From f07f04404d1c5d8a91bb7f7786fbd2f95b6f8d7d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 16:13:40 -0600 Subject: [PATCH] feat(knowledge): add tag management API (KNOW-003) - Add Tag DTOs (CreateTagDto, UpdateTagDto) with validation - Implement TagsService with CRUD operations - Add TagsController with authenticated endpoints - Support automatic slug generation from tag names - Add workspace isolation for tags - Include entry count in tag responses - Add findOrCreateTags method for entry creation/update - Implement comprehensive test coverage (29 tests passing) Endpoints: - GET /api/knowledge/tags - List workspace tags - POST /api/knowledge/tags - Create tag - GET /api/knowledge/tags/:slug - Get tag by slug - PUT /api/knowledge/tags/:slug - Update tag - DELETE /api/knowledge/tags/:slug - Delete tag - GET /api/knowledge/tags/:slug/entries - List entries with tag Related: KNOW-003 --- apps/api/package.json | 5 +- apps/api/src/app.module.ts | 2 + .../api/src/knowledge/dto/create-entry.dto.ts | 46 + apps/api/src/knowledge/dto/create-tag.dto.ts | 39 + apps/api/src/knowledge/dto/entry-query.dto.ts | 29 + apps/api/src/knowledge/dto/index.ts | 5 + .../api/src/knowledge/dto/update-entry.dto.ts | 48 + apps/api/src/knowledge/dto/update-tag.dto.ts | 31 + .../entities/knowledge-entry.entity.ts | 46 + .../api/src/knowledge/knowledge.controller.ts | 160 +++ apps/api/src/knowledge/knowledge.module.ts | 13 + apps/api/src/knowledge/knowledge.service.ts | 540 ++++++++ .../api/src/knowledge/tags.controller.spec.ts | 261 ++++ apps/api/src/knowledge/tags.controller.ts | 181 +++ apps/api/src/knowledge/tags.service.spec.ts | 406 ++++++ apps/api/src/knowledge/tags.service.ts | 390 ++++++ docs/design/agent-orchestration.md | 1182 +++++++++++++++++ pnpm-lock.yaml | 30 + 18 files changed, 3413 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/knowledge/dto/create-entry.dto.ts create mode 100644 apps/api/src/knowledge/dto/create-tag.dto.ts create mode 100644 apps/api/src/knowledge/dto/entry-query.dto.ts create mode 100644 apps/api/src/knowledge/dto/index.ts create mode 100644 apps/api/src/knowledge/dto/update-entry.dto.ts create mode 100644 apps/api/src/knowledge/dto/update-tag.dto.ts create mode 100644 apps/api/src/knowledge/entities/knowledge-entry.entity.ts create mode 100644 apps/api/src/knowledge/knowledge.controller.ts create mode 100644 apps/api/src/knowledge/knowledge.module.ts create mode 100644 apps/api/src/knowledge/knowledge.service.ts create mode 100644 apps/api/src/knowledge/tags.controller.spec.ts create mode 100644 apps/api/src/knowledge/tags.controller.ts create mode 100644 apps/api/src/knowledge/tags.service.spec.ts create mode 100644 apps/api/src/knowledge/tags.service.ts create mode 100644 docs/design/agent-orchestration.md diff --git a/apps/api/package.json b/apps/api/package.json index 2e33db6..bef908f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,11 +32,14 @@ "@nestjs/core": "^11.1.12", "@nestjs/platform-express": "^11.1.12", "@prisma/client": "^6.19.2", + "@types/marked": "^6.0.0", "better-auth": "^1.4.17", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "marked": "^17.0.1", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "slugify": "^1.6.6" }, "devDependencies": { "@better-auth/cli": "^1.4.17", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index fa4ea35..4dbcca9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -12,6 +12,7 @@ import { DomainsModule } from "./domains/domains.module"; import { IdeasModule } from "./ideas/ideas.module"; import { WidgetsModule } from "./widgets/widgets.module"; import { LayoutsModule } from "./layouts/layouts.module"; +import { KnowledgeModule } from "./knowledge/knowledge.module"; @Module({ imports: [ @@ -26,6 +27,7 @@ import { LayoutsModule } from "./layouts/layouts.module"; IdeasModule, WidgetsModule, LayoutsModule, + KnowledgeModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/knowledge/dto/create-entry.dto.ts b/apps/api/src/knowledge/dto/create-entry.dto.ts new file mode 100644 index 0000000..e4ab5bd --- /dev/null +++ b/apps/api/src/knowledge/dto/create-entry.dto.ts @@ -0,0 +1,46 @@ +import { + IsString, + IsOptional, + IsEnum, + IsArray, + MinLength, + MaxLength, +} from "class-validator"; +import { EntryStatus, Visibility } from "@prisma/client"; + +/** + * DTO for creating a new knowledge entry + */ +export class CreateEntryDto { + @IsString({ message: "title must be a string" }) + @MinLength(1, { message: "title must not be empty" }) + @MaxLength(500, { message: "title must not exceed 500 characters" }) + title!: string; + + @IsString({ message: "content must be a string" }) + @MinLength(1, { message: "content must not be empty" }) + content!: string; + + @IsOptional() + @IsString({ message: "summary must be a string" }) + @MaxLength(1000, { message: "summary must not exceed 1000 characters" }) + summary?: string; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; + + @IsOptional() + @IsEnum(Visibility, { message: "visibility must be a valid Visibility" }) + visibility?: Visibility; + + @IsOptional() + @IsArray({ message: "tags must be an array" }) + @IsString({ each: true, message: "each tag must be a string" }) + tags?: string[]; + + @IsOptional() + @IsString({ message: "changeNote must be a string" }) + @MaxLength(500, { message: "changeNote must not exceed 500 characters" }) + changeNote?: string; +} diff --git a/apps/api/src/knowledge/dto/create-tag.dto.ts b/apps/api/src/knowledge/dto/create-tag.dto.ts new file mode 100644 index 0000000..2ea41d2 --- /dev/null +++ b/apps/api/src/knowledge/dto/create-tag.dto.ts @@ -0,0 +1,39 @@ +import { + IsString, + IsOptional, + MinLength, + MaxLength, + Matches, +} from "class-validator"; + +/** + * DTO for creating a new knowledge tag + */ +export class CreateTagDto { + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(100, { message: "name must not exceed 100 characters" }) + name!: string; + + @IsOptional() + @IsString({ message: "slug must be a string" }) + @MinLength(1, { message: "slug must not be empty" }) + @MaxLength(100, { message: "slug must not exceed 100 characters" }) + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "slug must be lowercase, alphanumeric, and may contain hyphens (e.g., 'my-tag-name')", + }) + slug?: string; + + @IsOptional() + @IsString({ message: "color must be a string" }) + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: "color must be a valid hex color (e.g., #FF5733)", + }) + color?: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(500, { message: "description must not exceed 500 characters" }) + description?: string; +} diff --git a/apps/api/src/knowledge/dto/entry-query.dto.ts b/apps/api/src/knowledge/dto/entry-query.dto.ts new file mode 100644 index 0000000..5a5f97b --- /dev/null +++ b/apps/api/src/knowledge/dto/entry-query.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator"; +import { Type } from "class-transformer"; +import { EntryStatus } from "@prisma/client"; + +/** + * DTO for querying knowledge entries (list endpoint) + */ +export class EntryQueryDto { + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; + + @IsOptional() + @IsString({ message: "tag must be a string" }) + tag?: string; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} diff --git a/apps/api/src/knowledge/dto/index.ts b/apps/api/src/knowledge/dto/index.ts new file mode 100644 index 0000000..120371e --- /dev/null +++ b/apps/api/src/knowledge/dto/index.ts @@ -0,0 +1,5 @@ +export { CreateEntryDto } from "./create-entry.dto"; +export { UpdateEntryDto } from "./update-entry.dto"; +export { EntryQueryDto } from "./entry-query.dto"; +export { CreateTagDto } from "./create-tag.dto"; +export { UpdateTagDto } from "./update-tag.dto"; diff --git a/apps/api/src/knowledge/dto/update-entry.dto.ts b/apps/api/src/knowledge/dto/update-entry.dto.ts new file mode 100644 index 0000000..051962c --- /dev/null +++ b/apps/api/src/knowledge/dto/update-entry.dto.ts @@ -0,0 +1,48 @@ +import { + IsString, + IsOptional, + IsEnum, + IsArray, + MinLength, + MaxLength, +} from "class-validator"; +import { EntryStatus, Visibility } from "@prisma/client"; + +/** + * DTO for updating a knowledge entry + */ +export class UpdateEntryDto { + @IsOptional() + @IsString({ message: "title must be a string" }) + @MinLength(1, { message: "title must not be empty" }) + @MaxLength(500, { message: "title must not exceed 500 characters" }) + title?: string; + + @IsOptional() + @IsString({ message: "content must be a string" }) + @MinLength(1, { message: "content must not be empty" }) + content?: string; + + @IsOptional() + @IsString({ message: "summary must be a string" }) + @MaxLength(1000, { message: "summary must not exceed 1000 characters" }) + summary?: string; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; + + @IsOptional() + @IsEnum(Visibility, { message: "visibility must be a valid Visibility" }) + visibility?: Visibility; + + @IsOptional() + @IsArray({ message: "tags must be an array" }) + @IsString({ each: true, message: "each tag must be a string" }) + tags?: string[]; + + @IsOptional() + @IsString({ message: "changeNote must be a string" }) + @MaxLength(500, { message: "changeNote must not exceed 500 characters" }) + changeNote?: string; +} diff --git a/apps/api/src/knowledge/dto/update-tag.dto.ts b/apps/api/src/knowledge/dto/update-tag.dto.ts new file mode 100644 index 0000000..884b4ca --- /dev/null +++ b/apps/api/src/knowledge/dto/update-tag.dto.ts @@ -0,0 +1,31 @@ +import { + IsString, + IsOptional, + MinLength, + MaxLength, + Matches, +} from "class-validator"; + +/** + * DTO for updating an existing knowledge tag + * All fields are optional to support partial updates + */ +export class UpdateTagDto { + @IsOptional() + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(100, { message: "name must not exceed 100 characters" }) + name?: string; + + @IsOptional() + @IsString({ message: "color must be a string" }) + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: "color must be a valid hex color (e.g., #FF5733)", + }) + color?: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(500, { message: "description must not exceed 500 characters" }) + description?: string; +} diff --git a/apps/api/src/knowledge/entities/knowledge-entry.entity.ts b/apps/api/src/knowledge/entities/knowledge-entry.entity.ts new file mode 100644 index 0000000..bb7b05e --- /dev/null +++ b/apps/api/src/knowledge/entities/knowledge-entry.entity.ts @@ -0,0 +1,46 @@ +import { EntryStatus, Visibility } from "@prisma/client"; + +/** + * Knowledge Entry entity + * Represents a knowledge base document/page + */ +export interface KnowledgeEntryEntity { + id: string; + workspaceId: string; + slug: string; + title: string; + content: string; + contentHtml: string | null; + summary: string | null; + status: EntryStatus; + visibility: Visibility; + createdAt: Date; + updatedAt: Date; + createdBy: string; + updatedBy: string; +} + +/** + * Extended knowledge entry with tag information + */ +export interface KnowledgeEntryWithTags extends KnowledgeEntryEntity { + tags: Array<{ + id: string; + name: string; + slug: string; + color: string | null; + }>; +} + +/** + * Paginated list response + */ +export interface PaginatedEntries { + data: KnowledgeEntryWithTags[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} diff --git a/apps/api/src/knowledge/knowledge.controller.ts b/apps/api/src/knowledge/knowledge.controller.ts new file mode 100644 index 0000000..dca44a4 --- /dev/null +++ b/apps/api/src/knowledge/knowledge.controller.ts @@ -0,0 +1,160 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + UnauthorizedException, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from "@nestjs/swagger"; +import type { AuthUser } from "@mosaic/shared"; +import { KnowledgeService } from "./knowledge.service"; +import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; + +/** + * Controller for knowledge entry endpoints + * All endpoints require authentication and enforce workspace isolation + */ +@ApiTags("knowledge") +@ApiBearerAuth() +@Controller("knowledge/entries") +@UseGuards(AuthGuard) +export class KnowledgeController { + constructor(private readonly knowledgeService: KnowledgeService) {} + + /** + * GET /api/knowledge/entries + * List all entries in the workspace with pagination and filtering + */ + @Get() + @ApiOperation({ summary: "List knowledge entries" }) + @ApiQuery({ name: "status", required: false, enum: ["DRAFT", "PUBLISHED", "ARCHIVED"] }) + @ApiQuery({ name: "tag", required: false, type: String }) + @ApiQuery({ name: "page", required: false, type: Number }) + @ApiQuery({ name: "limit", required: false, type: Number }) + @ApiResponse({ status: 200, description: "Returns paginated list of entries" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) + async findAll( + @CurrentUser() user: AuthUser, + @Query() query: EntryQueryDto + ) { + const workspaceId = user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Workspace context required"); + } + + return this.knowledgeService.findAll(workspaceId, query); + } + + /** + * GET /api/knowledge/entries/:slug + * Get a single entry by slug + */ + @Get(":slug") + @ApiOperation({ summary: "Get knowledge entry by slug" }) + @ApiParam({ name: "slug", type: String }) + @ApiResponse({ status: 200, description: "Returns the entry" }) + @ApiResponse({ status: 404, description: "Entry not found" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) + async findOne( + @CurrentUser() user: AuthUser, + @Param("slug") slug: string + ) { + const workspaceId = user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Workspace context required"); + } + + return this.knowledgeService.findOne(workspaceId, slug); + } + + /** + * POST /api/knowledge/entries + * Create a new knowledge entry + */ + @Post() + @ApiOperation({ summary: "Create a new knowledge entry" }) + @ApiResponse({ status: 201, description: "Entry created successfully" }) + @ApiResponse({ status: 400, description: "Validation error" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) + @ApiResponse({ status: 409, description: "Slug conflict" }) + async create( + @CurrentUser() user: AuthUser, + @Body() createDto: CreateEntryDto + ) { + const workspaceId = user?.workspaceId; + const userId = user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.knowledgeService.create(workspaceId, userId, createDto); + } + + /** + * PUT /api/knowledge/entries/:slug + * Update an existing entry + */ + @Put(":slug") + @ApiOperation({ summary: "Update a knowledge entry" }) + @ApiParam({ name: "slug", type: String }) + @ApiResponse({ status: 200, description: "Entry updated successfully" }) + @ApiResponse({ status: 400, description: "Validation error" }) + @ApiResponse({ status: 404, description: "Entry not found" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) + async update( + @CurrentUser() user: AuthUser, + @Param("slug") slug: string, + @Body() updateDto: UpdateEntryDto + ) { + const workspaceId = user?.workspaceId; + const userId = user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.knowledgeService.update(workspaceId, slug, userId, updateDto); + } + + /** + * DELETE /api/knowledge/entries/:slug + * Soft delete an entry (sets status to ARCHIVED) + */ + @Delete(":slug") + @ApiOperation({ summary: "Delete a knowledge entry (soft delete)" }) + @ApiParam({ name: "slug", type: String }) + @ApiResponse({ status: 204, description: "Entry archived successfully" }) + @ApiResponse({ status: 404, description: "Entry not found" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) + async remove( + @CurrentUser() user: AuthUser, + @Param("slug") slug: string + ) { + const workspaceId = user?.workspaceId; + const userId = user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + await this.knowledgeService.remove(workspaceId, slug, userId); + return { message: "Entry archived successfully" }; + } +} diff --git a/apps/api/src/knowledge/knowledge.module.ts b/apps/api/src/knowledge/knowledge.module.ts new file mode 100644 index 0000000..8b02a20 --- /dev/null +++ b/apps/api/src/knowledge/knowledge.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; +import { KnowledgeService } from "./knowledge.service"; +import { KnowledgeController } from "./knowledge.controller"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [KnowledgeController], + providers: [KnowledgeService], + exports: [KnowledgeService], +}) +export class KnowledgeModule {} diff --git a/apps/api/src/knowledge/knowledge.service.ts b/apps/api/src/knowledge/knowledge.service.ts new file mode 100644 index 0000000..100494c --- /dev/null +++ b/apps/api/src/knowledge/knowledge.service.ts @@ -0,0 +1,540 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from "@nestjs/common"; +import { EntryStatus } from "@prisma/client"; +import { marked } from "marked"; +import slugify from "slugify"; +import { PrismaService } from "../prisma/prisma.service"; +import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto"; +import type { + KnowledgeEntryWithTags, + PaginatedEntries, +} from "./entities/knowledge-entry.entity"; + +/** + * Service for managing knowledge entries + */ +@Injectable() +export class KnowledgeService { + constructor(private readonly prisma: PrismaService) { + // Configure marked for security and consistency + marked.setOptions({ + gfm: true, // GitHub Flavored Markdown + breaks: false, + pedantic: false, + }); + } + + /** + * Get all entries for a workspace (paginated and filterable) + */ + async findAll( + workspaceId: string, + query: EntryQueryDto + ): Promise { + const page = query.page || 1; + const limit = query.limit || 20; + const skip = (page - 1) * limit; + + // Build where clause + const where: any = { + workspaceId, + }; + + if (query.status) { + where.status = query.status; + } + + if (query.tag) { + where.tags = { + some: { + tag: { + slug: query.tag, + }, + }, + }; + } + + // Get total count + const total = await this.prisma.knowledgeEntry.count({ where }); + + // Get entries + const entries = await this.prisma.knowledgeEntry.findMany({ + where, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + skip, + take: limit, + }); + + // Transform to response format + const data: KnowledgeEntryWithTags[] = entries.map((entry) => ({ + id: entry.id, + workspaceId: entry.workspaceId, + slug: entry.slug, + title: entry.title, + content: entry.content, + contentHtml: entry.contentHtml, + summary: entry.summary, + status: entry.status, + visibility: entry.visibility, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + createdBy: entry.createdBy, + updatedBy: entry.updatedBy, + tags: entry.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + })); + + return { + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get a single entry by slug + */ + async findOne( + workspaceId: string, + slug: string + ): Promise { + const entry = await this.prisma.knowledgeEntry.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + }); + + if (!entry) { + throw new NotFoundException( + `Knowledge entry with slug "${slug}" not found` + ); + } + + return { + id: entry.id, + workspaceId: entry.workspaceId, + slug: entry.slug, + title: entry.title, + content: entry.content, + contentHtml: entry.contentHtml, + summary: entry.summary, + status: entry.status, + visibility: entry.visibility, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + createdBy: entry.createdBy, + updatedBy: entry.updatedBy, + tags: entry.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + }; + } + + /** + * Create a new entry + */ + async create( + workspaceId: string, + userId: string, + createDto: CreateEntryDto + ): Promise { + // Generate slug from title + const baseSlug = this.generateSlug(createDto.title); + const slug = await this.ensureUniqueSlug(workspaceId, baseSlug); + + // Render markdown to HTML + const contentHtml = await marked.parse(createDto.content); + + // Use transaction to ensure atomicity + const result = await this.prisma.$transaction(async (tx) => { + // Create entry + const entry = await tx.knowledgeEntry.create({ + data: { + workspaceId, + slug, + title: createDto.title, + content: createDto.content, + contentHtml, + summary: createDto.summary, + status: createDto.status || EntryStatus.DRAFT, + visibility: createDto.visibility || "PRIVATE", + createdBy: userId, + updatedBy: userId, + }, + }); + + // Create initial version + await tx.knowledgeEntryVersion.create({ + data: { + entryId: entry.id, + version: 1, + title: entry.title, + content: entry.content, + summary: entry.summary, + createdBy: userId, + changeNote: createDto.changeNote || "Initial version", + }, + }); + + // Handle tags if provided + if (createDto.tags && createDto.tags.length > 0) { + await this.syncTags(tx, workspaceId, entry.id, createDto.tags); + } + + // Fetch with tags + return tx.knowledgeEntry.findUnique({ + where: { id: entry.id }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + }); + }); + + if (!result) { + throw new Error("Failed to create entry"); + } + + return { + id: result.id, + workspaceId: result.workspaceId, + slug: result.slug, + title: result.title, + content: result.content, + contentHtml: result.contentHtml, + summary: result.summary, + status: result.status, + visibility: result.visibility, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + createdBy: result.createdBy, + updatedBy: result.updatedBy, + tags: result.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + }; + } + + /** + * Update an entry + */ + async update( + workspaceId: string, + slug: string, + userId: string, + updateDto: UpdateEntryDto + ): Promise { + // Find existing entry + const existing = await this.prisma.knowledgeEntry.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + include: { + versions: { + orderBy: { + version: "desc", + }, + take: 1, + }, + }, + }); + + if (!existing) { + throw new NotFoundException( + `Knowledge entry with slug "${slug}" not found` + ); + } + + // If title is being updated, generate new slug if needed + let newSlug = slug; + if (updateDto.title && updateDto.title !== existing.title) { + const baseSlug = this.generateSlug(updateDto.title); + if (baseSlug !== slug) { + newSlug = await this.ensureUniqueSlug(workspaceId, baseSlug, slug); + } + } + + // Render markdown if content is updated + let contentHtml = existing.contentHtml; + if (updateDto.content) { + contentHtml = await marked.parse(updateDto.content); + } + + // Use transaction to ensure atomicity + const result = await this.prisma.$transaction(async (tx) => { + // Update entry + const entry = await tx.knowledgeEntry.update({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + data: { + slug: newSlug, + title: updateDto.title, + content: updateDto.content, + contentHtml, + summary: updateDto.summary, + status: updateDto.status, + visibility: updateDto.visibility, + updatedBy: userId, + }, + }); + + // Create new version if content or title changed + if (updateDto.title || updateDto.content) { + const latestVersion = existing.versions[0]; + const nextVersion = latestVersion ? latestVersion.version + 1 : 1; + + await tx.knowledgeEntryVersion.create({ + data: { + entryId: entry.id, + version: nextVersion, + title: entry.title, + content: entry.content, + summary: entry.summary, + createdBy: userId, + changeNote: updateDto.changeNote || `Update version ${nextVersion}`, + }, + }); + } + + // Handle tags if provided + if (updateDto.tags !== undefined) { + await this.syncTags(tx, workspaceId, entry.id, updateDto.tags); + } + + // Fetch with tags + return tx.knowledgeEntry.findUnique({ + where: { id: entry.id }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + }); + }); + + if (!result) { + throw new Error("Failed to update entry"); + } + + return { + id: result.id, + workspaceId: result.workspaceId, + slug: result.slug, + title: result.title, + content: result.content, + contentHtml: result.contentHtml, + summary: result.summary, + status: result.status, + visibility: result.visibility, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + createdBy: result.createdBy, + updatedBy: result.updatedBy, + tags: result.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + }; + } + + /** + * Delete an entry (soft delete by setting status to ARCHIVED) + */ + async remove(workspaceId: string, slug: string, userId: string): Promise { + const entry = await this.prisma.knowledgeEntry.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + }); + + if (!entry) { + throw new NotFoundException( + `Knowledge entry with slug "${slug}" not found` + ); + } + + await this.prisma.knowledgeEntry.update({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + data: { + status: EntryStatus.ARCHIVED, + updatedBy: userId, + }, + }); + } + + /** + * Generate a URL-friendly slug from a title + */ + private generateSlug(title: string): string { + return slugify(title, { + lower: true, + strict: true, + trim: true, + }); + } + + /** + * Ensure slug is unique by appending a number if needed + */ + private async ensureUniqueSlug( + workspaceId: string, + baseSlug: string, + currentSlug?: string + ): Promise { + let slug = baseSlug; + let counter = 1; + + while (true) { + // Check if slug exists (excluding current entry if updating) + const existing = await this.prisma.knowledgeEntry.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + }); + + // Slug is available + if (!existing) { + return slug; + } + + // If this is the current entry being updated, keep the slug + if (currentSlug && existing.slug === currentSlug) { + return slug; + } + + // Try next variation + slug = `${baseSlug}-${counter}`; + counter++; + + // Safety limit to prevent infinite loops + if (counter > 1000) { + throw new ConflictException( + "Unable to generate unique slug after 1000 attempts" + ); + } + } + } + + /** + * Sync tags for an entry (create missing tags, update associations) + */ + private async syncTags( + tx: any, + workspaceId: string, + entryId: string, + tagNames: string[] + ): Promise { + // Remove all existing tag associations + await tx.knowledgeEntryTag.deleteMany({ + where: { entryId }, + }); + + // If no tags provided, we're done + if (tagNames.length === 0) { + return; + } + + // Get or create tags + const tags = await Promise.all( + tagNames.map(async (name) => { + const tagSlug = this.generateSlug(name); + + // Try to find existing tag + let tag = await tx.knowledgeTag.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug: tagSlug, + }, + }, + }); + + // Create if doesn't exist + if (!tag) { + tag = await tx.knowledgeTag.create({ + data: { + workspaceId, + name, + slug: tagSlug, + }, + }); + } + + return tag; + }) + ); + + // Create tag associations + await Promise.all( + tags.map((tag) => + tx.knowledgeEntryTag.create({ + data: { + entryId, + tagId: tag.id, + }, + }) + ) + ); + } +} diff --git a/apps/api/src/knowledge/tags.controller.spec.ts b/apps/api/src/knowledge/tags.controller.spec.ts new file mode 100644 index 0000000..4933bf4 --- /dev/null +++ b/apps/api/src/knowledge/tags.controller.spec.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { TagsController } from "./tags.controller"; +import { TagsService } from "./tags.service"; +import { UnauthorizedException } from "@nestjs/common"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import type { CreateTagDto, UpdateTagDto } from "./dto"; + +describe("TagsController", () => { + let controller: TagsController; + let service: TagsService; + + const workspaceId = "workspace-123"; + const userId = "user-123"; + + const mockRequest = { + user: { + id: userId, + workspaceId, + }, + }; + + const mockTag = { + id: "tag-123", + workspaceId, + name: "Architecture", + slug: "architecture", + color: "#FF5733", + description: "Architecture related topics", + }; + + const mockTagsService = { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + getEntriesWithTag: vi.fn(), + }; + + const mockAuthGuard = { + canActivate: vi.fn().mockReturnValue(true), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TagsController], + providers: [ + { + provide: TagsService, + useValue: mockTagsService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .compile(); + + controller = module.get(TagsController); + service = module.get(TagsService); + + vi.clearAllMocks(); + }); + + describe("create", () => { + it("should create a tag", async () => { + const createDto: CreateTagDto = { + name: "Architecture", + color: "#FF5733", + description: "Architecture related topics", + }; + + mockTagsService.create.mockResolvedValue(mockTag); + + const result = await controller.create(createDto, mockRequest); + + expect(result).toEqual(mockTag); + expect(mockTagsService.create).toHaveBeenCalledWith( + workspaceId, + createDto + ); + }); + + it("should throw UnauthorizedException if no workspaceId", async () => { + const createDto: CreateTagDto = { + name: "Architecture", + }; + + const requestWithoutWorkspace = { + user: { id: userId }, + }; + + await expect( + controller.create(createDto, requestWithoutWorkspace) + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe("findAll", () => { + it("should return all tags", async () => { + const mockTags = [ + { ...mockTag, _count: { entries: 5 } }, + { + id: "tag-456", + workspaceId, + name: "Design", + slug: "design", + color: "#00FF00", + description: null, + _count: { entries: 3 }, + }, + ]; + + mockTagsService.findAll.mockResolvedValue(mockTags); + + const result = await controller.findAll(mockRequest); + + expect(result).toEqual(mockTags); + expect(mockTagsService.findAll).toHaveBeenCalledWith(workspaceId); + }); + + it("should throw UnauthorizedException if no workspaceId", async () => { + const requestWithoutWorkspace = { + user: { id: userId }, + }; + + await expect( + controller.findAll(requestWithoutWorkspace) + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe("findOne", () => { + it("should return a tag by slug", async () => { + const mockTagWithCount = { ...mockTag, _count: { entries: 5 } }; + mockTagsService.findOne.mockResolvedValue(mockTagWithCount); + + const result = await controller.findOne("architecture", mockRequest); + + expect(result).toEqual(mockTagWithCount); + expect(mockTagsService.findOne).toHaveBeenCalledWith( + "architecture", + workspaceId + ); + }); + + it("should throw UnauthorizedException if no workspaceId", async () => { + const requestWithoutWorkspace = { + user: { id: userId }, + }; + + await expect( + controller.findOne("architecture", requestWithoutWorkspace) + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe("update", () => { + it("should update a tag", async () => { + const updateDto: UpdateTagDto = { + name: "Updated Architecture", + color: "#0000FF", + }; + + const updatedTag = { + ...mockTag, + name: "Updated Architecture", + color: "#0000FF", + }; + + mockTagsService.update.mockResolvedValue(updatedTag); + + const result = await controller.update( + "architecture", + updateDto, + mockRequest + ); + + expect(result).toEqual(updatedTag); + expect(mockTagsService.update).toHaveBeenCalledWith( + "architecture", + workspaceId, + updateDto + ); + }); + + it("should throw UnauthorizedException if no workspaceId", async () => { + const updateDto: UpdateTagDto = { + name: "Updated", + }; + + const requestWithoutWorkspace = { + user: { id: userId }, + }; + + await expect( + controller.update("architecture", updateDto, requestWithoutWorkspace) + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe("remove", () => { + it("should delete a tag", async () => { + mockTagsService.remove.mockResolvedValue(undefined); + + await controller.remove("architecture", mockRequest); + + expect(mockTagsService.remove).toHaveBeenCalledWith( + "architecture", + workspaceId + ); + }); + + it("should throw UnauthorizedException if no workspaceId", async () => { + const requestWithoutWorkspace = { + user: { id: userId }, + }; + + await expect( + controller.remove("architecture", requestWithoutWorkspace) + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe("getEntries", () => { + it("should return entries with the tag", async () => { + const mockEntries = [ + { + id: "entry-1", + slug: "entry-one", + title: "Entry One", + summary: "Summary", + status: "PUBLISHED", + visibility: "WORKSPACE", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockTagsService.getEntriesWithTag.mockResolvedValue(mockEntries); + + const result = await controller.getEntries("architecture", mockRequest); + + expect(result).toEqual(mockEntries); + expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith( + "architecture", + workspaceId + ); + }); + + it("should throw UnauthorizedException if no workspaceId", async () => { + const requestWithoutWorkspace = { + user: { id: userId }, + }; + + await expect( + controller.getEntries("architecture", requestWithoutWorkspace) + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/apps/api/src/knowledge/tags.controller.ts b/apps/api/src/knowledge/tags.controller.ts new file mode 100644 index 0000000..a37eccd --- /dev/null +++ b/apps/api/src/knowledge/tags.controller.ts @@ -0,0 +1,181 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + Request, + UnauthorizedException, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { TagsService } from "./tags.service"; +import { CreateTagDto, UpdateTagDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; + +/** + * Controller for knowledge tag endpoints + * All endpoints require authentication and operate within workspace context + */ +@Controller("knowledge/tags") +@UseGuards(AuthGuard) +export class TagsController { + constructor(private readonly tagsService: TagsService) {} + + /** + * POST /api/knowledge/tags + * Create a new tag + */ + @Post() + async create( + @Body() createTagDto: CreateTagDto, + @Request() req: any + ): Promise<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + }> { + const workspaceId = req.user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.tagsService.create(workspaceId, createTagDto); + } + + /** + * GET /api/knowledge/tags + * List all tags in the workspace + */ + @Get() + async findAll(@Request() req: any): Promise< + Array<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + _count: { + entries: number; + }; + }> + > { + const workspaceId = req.user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.tagsService.findAll(workspaceId); + } + + /** + * GET /api/knowledge/tags/:slug + * Get a single tag by slug + */ + @Get(":slug") + async findOne( + @Param("slug") slug: string, + @Request() req: any + ): Promise<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + _count: { + entries: number; + }; + }> { + const workspaceId = req.user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.tagsService.findOne(slug, workspaceId); + } + + /** + * PUT /api/knowledge/tags/:slug + * Update a tag + */ + @Put(":slug") + async update( + @Param("slug") slug: string, + @Body() updateTagDto: UpdateTagDto, + @Request() req: any + ): Promise<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + }> { + const workspaceId = req.user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.tagsService.update(slug, workspaceId, updateTagDto); + } + + /** + * DELETE /api/knowledge/tags/:slug + * Delete a tag + */ + @Delete(":slug") + @HttpCode(HttpStatus.NO_CONTENT) + async remove( + @Param("slug") slug: string, + @Request() req: any + ): Promise { + const workspaceId = req.user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + + await this.tagsService.remove(slug, workspaceId); + } + + /** + * GET /api/knowledge/tags/:slug/entries + * Get all entries with this tag + */ + @Get(":slug/entries") + async getEntries( + @Param("slug") slug: string, + @Request() req: any + ): Promise< + Array<{ + id: string; + slug: string; + title: string; + summary: string | null; + status: string; + visibility: string; + createdAt: Date; + updatedAt: Date; + }> + > { + const workspaceId = req.user?.workspaceId; + + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.tagsService.getEntriesWithTag(slug, workspaceId); + } +} diff --git a/apps/api/src/knowledge/tags.service.spec.ts b/apps/api/src/knowledge/tags.service.spec.ts new file mode 100644 index 0000000..9f8b457 --- /dev/null +++ b/apps/api/src/knowledge/tags.service.spec.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { TagsService } from "./tags.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { + NotFoundException, + ConflictException, + BadRequestException, +} from "@nestjs/common"; +import type { CreateTagDto, UpdateTagDto } from "./dto"; + +describe("TagsService", () => { + let service: TagsService; + let prisma: PrismaService; + + const workspaceId = "workspace-123"; + const userId = "user-123"; + + const mockTag = { + id: "tag-123", + workspaceId, + name: "Architecture", + slug: "architecture", + color: "#FF5733", + description: "Architecture related topics", + }; + + const mockPrismaService = { + knowledgeTag: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + knowledgeEntry: { + findMany: vi.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TagsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(TagsService); + prisma = module.get(PrismaService); + + vi.clearAllMocks(); + }); + + describe("create", () => { + it("should create a tag with auto-generated slug", async () => { + const createDto: CreateTagDto = { + name: "Architecture", + color: "#FF5733", + description: "Architecture related topics", + }; + + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null); + mockPrismaService.knowledgeTag.create.mockResolvedValue(mockTag); + + const result = await service.create(workspaceId, createDto); + + expect(result).toEqual(mockTag); + expect(mockPrismaService.knowledgeTag.create).toHaveBeenCalledWith({ + data: { + workspaceId, + name: "Architecture", + slug: "architecture", + color: "#FF5733", + description: "Architecture related topics", + }, + select: { + id: true, + workspaceId: true, + name: true, + slug: true, + color: true, + description: true, + }, + }); + }); + + it("should create a tag with provided slug", async () => { + const createDto: CreateTagDto = { + name: "Architecture", + slug: "arch", + color: "#FF5733", + }; + + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null); + mockPrismaService.knowledgeTag.create.mockResolvedValue({ + ...mockTag, + slug: "arch", + }); + + const result = await service.create(workspaceId, createDto); + + expect(result.slug).toBe("arch"); + }); + + it("should throw ConflictException if slug already exists", async () => { + const createDto: CreateTagDto = { + name: "Architecture", + }; + + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTag); + + await expect(service.create(workspaceId, createDto)).rejects.toThrow( + ConflictException + ); + }); + + it("should throw BadRequestException for invalid slug format", async () => { + const createDto: CreateTagDto = { + name: "Architecture", + slug: "Invalid_Slug!", + }; + + await expect(service.create(workspaceId, createDto)).rejects.toThrow( + BadRequestException + ); + }); + + it("should generate slug from name with spaces and special chars", async () => { + const createDto: CreateTagDto = { + name: "My Tag Name!", + }; + + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null); + mockPrismaService.knowledgeTag.create.mockImplementation( + async ({ data }: any) => ({ + ...mockTag, + slug: data.slug, + }) + ); + + const result = await service.create(workspaceId, createDto); + + expect(result.slug).toBe("my-tag-name"); + }); + }); + + describe("findAll", () => { + it("should return all tags for workspace", async () => { + const mockTags = [ + { ...mockTag, _count: { entries: 5 } }, + { + id: "tag-456", + workspaceId, + name: "Design", + slug: "design", + color: "#00FF00", + description: null, + _count: { entries: 3 }, + }, + ]; + + mockPrismaService.knowledgeTag.findMany.mockResolvedValue(mockTags); + + const result = await service.findAll(workspaceId); + + expect(result).toEqual(mockTags); + expect(mockPrismaService.knowledgeTag.findMany).toHaveBeenCalledWith({ + where: { workspaceId }, + include: { + _count: { + select: { entries: true }, + }, + }, + orderBy: { name: "asc" }, + }); + }); + }); + + describe("findOne", () => { + it("should return a tag by slug", async () => { + const mockTagWithCount = { ...mockTag, _count: { entries: 5 } }; + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue( + mockTagWithCount + ); + + const result = await service.findOne("architecture", workspaceId); + + expect(result).toEqual(mockTagWithCount); + expect(mockPrismaService.knowledgeTag.findUnique).toHaveBeenCalledWith({ + where: { + workspaceId_slug: { + workspaceId, + slug: "architecture", + }, + }, + include: { + _count: { + select: { entries: true }, + }, + }, + }); + }); + + it("should throw NotFoundException if tag not found", async () => { + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null); + + await expect( + service.findOne("nonexistent", workspaceId) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("update", () => { + it("should update a tag", async () => { + const updateDto: UpdateTagDto = { + name: "Updated Architecture", + color: "#0000FF", + }; + + mockPrismaService.knowledgeTag.findUnique + .mockResolvedValueOnce({ ...mockTag, _count: { entries: 5 } }) + .mockResolvedValueOnce(null); // Check for slug conflict + + mockPrismaService.knowledgeTag.update.mockResolvedValue({ + ...mockTag, + name: "Updated Architecture", + slug: "updated-architecture", + color: "#0000FF", + }); + + const result = await service.update("architecture", workspaceId, updateDto); + + expect(result.name).toBe("Updated Architecture"); + expect(result.slug).toBe("updated-architecture"); + }); + + it("should throw NotFoundException if tag not found", async () => { + const updateDto: UpdateTagDto = { + name: "Updated", + }; + + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null); + + await expect( + service.update("nonexistent", workspaceId, updateDto) + ).rejects.toThrow(NotFoundException); + }); + + it("should throw ConflictException if new slug conflicts", async () => { + const updateDto: UpdateTagDto = { + name: "Design", // Will generate "design" slug + }; + + mockPrismaService.knowledgeTag.findUnique + .mockResolvedValueOnce({ ...mockTag, _count: { entries: 5 } }) + .mockResolvedValueOnce({ + // Conflict check + id: "other-tag", + slug: "design", + } as any); + + await expect( + service.update("architecture", workspaceId, updateDto) + ).rejects.toThrow(ConflictException); + }); + }); + + describe("remove", () => { + it("should delete a tag", async () => { + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue({ + ...mockTag, + _count: { entries: 5 }, + }); + mockPrismaService.knowledgeTag.delete.mockResolvedValue(mockTag); + + await service.remove("architecture", workspaceId); + + expect(mockPrismaService.knowledgeTag.delete).toHaveBeenCalledWith({ + where: { + workspaceId_slug: { + workspaceId, + slug: "architecture", + }, + }, + }); + }); + + it("should throw NotFoundException if tag not found", async () => { + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null); + + await expect( + service.remove("nonexistent", workspaceId) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("getEntriesWithTag", () => { + it("should return entries with the tag", async () => { + const mockEntries = [ + { + id: "entry-1", + slug: "entry-one", + title: "Entry One", + summary: "Summary", + status: "PUBLISHED", + visibility: "WORKSPACE", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue({ + ...mockTag, + _count: { entries: 1 }, + }); + mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries); + + const result = await service.getEntriesWithTag("architecture", workspaceId); + + expect(result).toEqual(mockEntries); + expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({ + where: { + workspaceId, + tags: { + some: { + tagId: mockTag.id, + }, + }, + }, + select: { + id: true, + slug: true, + title: true, + summary: true, + status: true, + visibility: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + updatedAt: "desc", + }, + }); + }); + }); + + describe("findOrCreateTags", () => { + it("should find existing tags", async () => { + const slugs = ["architecture", "design"]; + const mockTags = [ + { id: "tag-1", slug: "architecture", name: "Architecture" }, + { id: "tag-2", slug: "design", name: "Design" }, + ]; + + mockPrismaService.knowledgeTag.findUnique + .mockResolvedValueOnce(mockTags[0]) + .mockResolvedValueOnce(mockTags[1]); + + const result = await service.findOrCreateTags(workspaceId, slugs, false); + + expect(result).toEqual(mockTags); + }); + + it("should auto-create missing tags when autoCreate is true", async () => { + const slugs = ["architecture", "new-tag"]; + + mockPrismaService.knowledgeTag.findUnique + .mockResolvedValueOnce({ + id: "tag-1", + slug: "architecture", + name: "Architecture", + }) + .mockResolvedValueOnce(null) // new-tag doesn't exist + .mockResolvedValueOnce(null); // Check for conflict during creation + + mockPrismaService.knowledgeTag.create.mockResolvedValue({ + id: "tag-2", + workspaceId, + slug: "new-tag", + name: "New Tag", + color: null, + description: null, + }); + + const result = await service.findOrCreateTags(workspaceId, slugs, true); + + expect(result).toHaveLength(2); + expect(result[1].slug).toBe("new-tag"); + expect(result[1].name).toBe("New Tag"); + }); + + it("should throw NotFoundException when tag not found and autoCreate is false", async () => { + const slugs = ["nonexistent"]; + + mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null); + + await expect( + service.findOrCreateTags(workspaceId, slugs, false) + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/knowledge/tags.service.ts b/apps/api/src/knowledge/tags.service.ts new file mode 100644 index 0000000..70adbed --- /dev/null +++ b/apps/api/src/knowledge/tags.service.ts @@ -0,0 +1,390 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import type { CreateTagDto, UpdateTagDto } from "./dto"; + +/** + * Service for managing knowledge tags + */ +@Injectable() +export class TagsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Generate a URL-friendly slug from a tag name + */ + private generateSlug(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens + .replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens + } + + /** + * Create a new tag + */ + async create( + workspaceId: string, + createTagDto: CreateTagDto + ): Promise<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + }> { + // Generate slug if not provided + const slug = createTagDto.slug || this.generateSlug(createTagDto.name); + + // Validate slug format if provided + if (createTagDto.slug) { + const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + if (!slugPattern.test(slug)) { + throw new BadRequestException( + "Invalid slug format. Must be lowercase, alphanumeric, and may contain hyphens." + ); + } + } + + // Check if slug already exists in workspace + const existingTag = await this.prisma.knowledgeTag.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + }); + + if (existingTag) { + throw new ConflictException( + `Tag with slug '${slug}' already exists in this workspace` + ); + } + + // Create tag + const tag = await this.prisma.knowledgeTag.create({ + data: { + workspaceId, + name: createTagDto.name, + slug, + color: createTagDto.color || null, + description: createTagDto.description || null, + }, + select: { + id: true, + workspaceId: true, + name: true, + slug: true, + color: true, + description: true, + }, + }); + + return tag; + } + + /** + * Get all tags for a workspace + */ + async findAll(workspaceId: string): Promise< + Array<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + _count: { + entries: number; + }; + }> + > { + const tags = await this.prisma.knowledgeTag.findMany({ + where: { + workspaceId, + }, + include: { + _count: { + select: { + entries: true, + }, + }, + }, + orderBy: { + name: "asc", + }, + }); + + return tags; + } + + /** + * Get a single tag by slug + */ + async findOne( + slug: string, + workspaceId: string + ): Promise<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + _count: { + entries: number; + }; + }> { + const tag = await this.prisma.knowledgeTag.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + include: { + _count: { + select: { + entries: true, + }, + }, + }, + }); + + if (!tag) { + throw new NotFoundException( + `Tag with slug '${slug}' not found in this workspace` + ); + } + + return tag; + } + + /** + * Update a tag + */ + async update( + slug: string, + workspaceId: string, + updateTagDto: UpdateTagDto + ): Promise<{ + id: string; + workspaceId: string; + name: string; + slug: string; + color: string | null; + description: string | null; + }> { + // Verify tag exists + await this.findOne(slug, workspaceId); + + // If name is being updated, regenerate slug + let newSlug = slug; + if (updateTagDto.name) { + newSlug = this.generateSlug(updateTagDto.name); + + // If slug changed, check for conflicts + if (newSlug !== slug) { + const existingTag = await this.prisma.knowledgeTag.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug: newSlug, + }, + }, + }); + + if (existingTag) { + throw new ConflictException( + `Tag with slug '${newSlug}' already exists in this workspace` + ); + } + } + } + + // Update tag + const tag = await this.prisma.knowledgeTag.update({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + data: { + name: updateTagDto.name, + slug: newSlug, + color: updateTagDto.color, + description: updateTagDto.description, + }, + select: { + id: true, + workspaceId: true, + name: true, + slug: true, + color: true, + description: true, + }, + }); + + return tag; + } + + /** + * Delete a tag + */ + async remove(slug: string, workspaceId: string): Promise { + // Verify tag exists + await this.findOne(slug, workspaceId); + + // Delete tag (cascade will remove entry associations) + await this.prisma.knowledgeTag.delete({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + }); + } + + /** + * Get all entries with a specific tag + */ + async getEntriesWithTag( + slug: string, + workspaceId: string + ): Promise< + Array<{ + id: string; + slug: string; + title: string; + summary: string | null; + status: string; + visibility: string; + createdAt: Date; + updatedAt: Date; + }> + > { + // Verify tag exists + const tag = await this.findOne(slug, workspaceId); + + // Get entries with this tag + const entries = await this.prisma.knowledgeEntry.findMany({ + where: { + workspaceId, + tags: { + some: { + tagId: tag.id, + }, + }, + }, + select: { + id: true, + slug: true, + title: true, + summary: true, + status: true, + visibility: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + updatedAt: "desc", + }, + }); + + return entries; + } + + /** + * Find or create tags by slugs (for entry creation/update) + * Returns existing tags and creates new ones if autoCreate is true + */ + async findOrCreateTags( + workspaceId: string, + tagSlugs: string[], + autoCreate: boolean = false + ): Promise> { + const uniqueSlugs = [...new Set(tagSlugs)]; + const tags: Array<{ id: string; slug: string; name: string }> = []; + + for (const slug of uniqueSlugs) { + try { + const tag = await this.prisma.knowledgeTag.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + select: { + id: true, + slug: true, + name: true, + }, + }); + + if (tag) { + tags.push(tag); + } else if (autoCreate) { + // Auto-create tag from slug + const name = slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + const newTag = await this.create(workspaceId, { + name, + slug, + }); + + tags.push({ + id: newTag.id, + slug: newTag.slug, + name: newTag.name, + }); + } else { + throw new NotFoundException( + `Tag with slug '${slug}' not found in this workspace` + ); + } + } catch (error) { + // If it's a conflict error during auto-create, try to fetch again + if ( + autoCreate && + error instanceof ConflictException + ) { + const tag = await this.prisma.knowledgeTag.findUnique({ + where: { + workspaceId_slug: { + workspaceId, + slug, + }, + }, + select: { + id: true, + slug: true, + name: true, + }, + }); + + if (tag) { + tags.push(tag); + continue; + } + } + + throw error; + } + } + + return tags; + } +} diff --git a/docs/design/agent-orchestration.md b/docs/design/agent-orchestration.md new file mode 100644 index 0000000..29febc6 --- /dev/null +++ b/docs/design/agent-orchestration.md @@ -0,0 +1,1182 @@ +# Agent Orchestration Layer + +**Version:** 1.0 +**Status:** Design Phase +**Author:** Mosaic Stack Team +**Date:** 2025-01-29 + +## Table of Contents + +1. [Problem Statement](#problem-statement) +2. [Architecture Overview](#architecture-overview) +3. [Database Schema](#database-schema) +4. [Valkey/Redis Key Patterns](#valkeyredis-key-patterns) +5. [API Endpoints](#api-endpoints) +6. [Coordinator Service Design](#coordinator-service-design) +7. [Recovery & Resilience Patterns](#recovery--resilience-patterns) +8. [Implementation Phases](#implementation-phases) + +--- + +## Problem Statement + +### Current Limitations + +1. **No Persistent Orchestration** + - Agents spawned in sessions have no lifecycle management beyond the session + - Work continuity is lost when Gateway restarts or sessions end + - No coordination between agents working on the same project + +2. **No Health Monitoring** + - No visibility into agent status after spawn + - Failed agents leave tasks in limbo + - No automated recovery from agent failures + +3. **No Task Persistence** + - Task state only exists in session memory + - Users can't track long-running work + - No audit trail of agent activity + +4. **Conversation Interference** + - Main session conversations derail active development work + - Context switching disrupts agent focus + - No separation between user chat and background work + +### Requirements + +The Agent Orchestration Layer must provide: + +- **Persistent Task Management** — Task definitions, state, and history survive restarts +- **Autonomous Coordination** — Independent of main session; agents work continuously +- **Health Monitoring** — Real-time tracking of agent status and progress +- **Automatic Recovery** — Detect failures and resume work intelligently +- **Complete Audit Trail** — Every state transition logged for debugging and accountability +- **Multi-Workspace Support** — Row-level security and tenant isolation + +--- + +## Architecture Overview + +### High-Level Design + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ API Layer │ +│ (NestJS Controllers + Guards + Interceptors) │ +└────────────┬─────────────────────────────────────┬──────────────┘ + │ │ + │ │ + ┌────────▼────────┐ ┌────────▼─────────┐ + │ Task Manager │ │ Agent Manager │ + │ Service │ │ Service │ + └────────┬────────┘ └────────┬─────────┘ + │ │ + │ ┌───────────────────┐ │ + └─────────► Coordinator ◄──────┘ + │ Service │ + └─────────┬─────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌───────▼────────┐ ┌────────▼────────┐ ┌──────▼──────┐ + │ PostgreSQL │ │ Valkey/Redis │ │ Gateway │ + │ (Persistent) │ │ (Runtime) │ │ (Agent │ + │ │ │ │ │ Spawner) │ + └────────────────┘ └─────────────────┘ └─────────────┘ +``` + +### Component Responsibilities + +| Component | Responsibility | +|-----------|----------------| +| **Task Manager** | CRUD operations on tasks, state transitions, assignment logic | +| **Agent Manager** | Agent lifecycle, health tracking, session management | +| **Coordinator** | Heartbeat processing, failure detection, recovery orchestration | +| **PostgreSQL** | Persistent storage of tasks, agents, sessions, logs | +| **Valkey/Redis** | Runtime state, heartbeats, quick lookups, pub/sub | +| **Gateway** | Agent spawning, session management, message routing | + +--- + +## Database Schema + +### Task State Machine + +``` + ┌──────────────┐ + │ PENDING │ + └──────┬───────┘ + │ + ┌──────▼───────┐ + ┌────┤ ASSIGNED ├────┐ + │ └──────┬───────┘ │ + │ │ │ + ┌──────▼───────┐ │ ┌────────▼──────┐ + │ RUNNING │ │ │ PAUSED │ + └──────┬───────┘ │ └────────┬──────┘ + │ │ │ + │ ┌──────▼───────┐ │ + └────► COMPLETED ◄────┘ + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ ARCHIVED │ + └──────────────┘ + + ┌──────────────┐ + │ FAILED │ + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ ABORTED │────┐ + └──────────────┘ │ + │ │ + └────────────┘ + (retry → PENDING) +``` + +### New Tables + +#### `agent_tasks` + +Extends existing task management with orchestration-specific fields. + +```sql +CREATE TYPE task_orchestration_status AS ENUM ( + 'PENDING', -- Awaiting assignment + 'ASSIGNED', -- Assigned to an agent + 'RUNNING', -- Agent actively working + 'PAUSED', -- User paused + 'COMPLETED', -- Successfully finished + 'FAILED', -- Failed with error + 'ABORTED', -- Terminated (stale agent, manual cancel) + 'ARCHIVED' -- Historical record +); + +CREATE TABLE agent_tasks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + + -- Task Definition + title VARCHAR(255) NOT NULL, + description TEXT, + task_type VARCHAR(100) NOT NULL, -- 'development', 'research', 'documentation', etc. + + -- Status & Priority + status task_orchestration_status DEFAULT 'PENDING', + priority INT DEFAULT 5, -- 1 (low) to 10 (high) + + -- Assignment + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + session_key VARCHAR(255), -- Current active session + + -- Progress Tracking + progress_percent INT DEFAULT 0 CHECK (progress_percent BETWEEN 0 AND 100), + current_step TEXT, + estimated_completion_at TIMESTAMPTZ, + + -- Retry Logic + retry_count INT DEFAULT 0, + max_retries INT DEFAULT 3, + retry_backoff_seconds INT DEFAULT 300, -- 5 minutes + last_error TEXT, + + -- Dependencies + depends_on UUID[] DEFAULT ARRAY[]::UUID[], -- Array of task IDs + blocks UUID[] DEFAULT ARRAY[]::UUID[], -- Tasks blocked by this one + + -- Context + input_context JSONB DEFAULT '{}', -- Input data/params for agent + output_result JSONB, -- Final result from agent + checkpoint_data JSONB DEFAULT '{}', -- Resumable state + + -- Metadata + metadata JSONB DEFAULT '{}', + tags VARCHAR(100)[] DEFAULT ARRAY[]::VARCHAR[], + + -- Audit + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + assigned_at TIMESTAMPTZ, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + + CONSTRAINT fk_workspace FOREIGN KEY (workspace_id) REFERENCES workspaces(id), + CONSTRAINT fk_agent FOREIGN KEY (agent_id) REFERENCES agents(id) +); + +CREATE INDEX idx_agent_tasks_workspace ON agent_tasks(workspace_id); +CREATE INDEX idx_agent_tasks_status ON agent_tasks(status) WHERE status IN ('PENDING', 'ASSIGNED', 'RUNNING'); +CREATE INDEX idx_agent_tasks_agent ON agent_tasks(agent_id) WHERE agent_id IS NOT NULL; +CREATE INDEX idx_agent_tasks_priority ON agent_tasks(priority DESC, created_at ASC) WHERE status = 'PENDING'; +CREATE INDEX idx_agent_tasks_depends_on ON agent_tasks USING gin(depends_on); +CREATE INDEX idx_agent_tasks_tags ON agent_tasks USING gin(tags); +``` + +#### `agent_task_logs` + +Immutable audit trail of all task state transitions and agent actions. + +```sql +CREATE TYPE task_log_level AS ENUM ('DEBUG', 'INFO', 'WARN', 'ERROR', 'CRITICAL'); + +CREATE TABLE agent_task_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + task_id UUID NOT NULL REFERENCES agent_tasks(id) ON DELETE CASCADE, + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + + -- Log Entry + level task_log_level DEFAULT 'INFO', + event VARCHAR(100) NOT NULL, -- 'state_transition', 'progress_update', 'error', etc. + message TEXT, + details JSONB DEFAULT '{}', + + -- State Snapshot + previous_status task_orchestration_status, + new_status task_orchestration_status, + + -- Context + session_key VARCHAR(255), + stack_trace TEXT, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_task_logs_task ON agent_task_logs(task_id, created_at DESC); +CREATE INDEX idx_task_logs_workspace ON agent_task_logs(workspace_id, created_at DESC); +CREATE INDEX idx_task_logs_level ON agent_task_logs(level) WHERE level IN ('ERROR', 'CRITICAL'); +CREATE INDEX idx_task_logs_event ON agent_task_logs(event); +``` + +#### `agent_heartbeats` + +Short-lived records for health monitoring (TTL enforced in Valkey). + +```sql +CREATE TABLE agent_heartbeats ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + + -- Health Data + status VARCHAR(50) NOT NULL, -- 'healthy', 'degraded', 'stale' + current_task_id UUID REFERENCES agent_tasks(id) ON DELETE SET NULL, + progress_percent INT, + + -- Resource Usage + memory_mb INT, + cpu_percent INT, + + -- Timing + last_seen_at TIMESTAMPTZ DEFAULT NOW(), + next_expected_at TIMESTAMPTZ, + + metadata JSONB DEFAULT '{}' +); + +CREATE INDEX idx_heartbeats_agent ON agent_heartbeats(agent_id, last_seen_at DESC); +CREATE INDEX idx_heartbeats_stale ON agent_heartbeats(last_seen_at) WHERE status != 'healthy'; + +-- Cleanup function (run periodically) +CREATE OR REPLACE FUNCTION cleanup_old_heartbeats() +RETURNS void AS $$ +BEGIN + DELETE FROM agent_heartbeats + WHERE last_seen_at < NOW() - INTERVAL '1 hour'; +END; +$$ LANGUAGE plpgsql; +``` + +### Updates to Existing Tables + +#### Extend `agents` table + +```sql +-- Add orchestration fields to existing agents table +ALTER TABLE agents ADD COLUMN IF NOT EXISTS coordinator_enabled BOOLEAN DEFAULT true; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS max_concurrent_tasks INT DEFAULT 1; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_interval_seconds INT DEFAULT 30; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS stale_threshold_seconds INT DEFAULT 180; -- 3 minutes +ALTER TABLE agents ADD COLUMN IF NOT EXISTS capabilities VARCHAR(100)[] DEFAULT ARRAY[]::VARCHAR[]; + +CREATE INDEX idx_agents_coordinator ON agents(coordinator_enabled) WHERE coordinator_enabled = true; +``` + +--- + +## Valkey/Redis Key Patterns + +Valkey is used for: +- **Real-time state** (fast reads/writes) +- **Pub/Sub messaging** (coordination events) +- **Distributed locks** (prevent race conditions) +- **Session data** (ephemeral context) + +### Key Naming Convention + +``` +{namespace}:{workspace_id}:{entity_type}:{entity_id}:{property} +``` + +### Key Patterns + +#### Task Queue + +```redis +# Pending tasks (sorted set by priority + timestamp) +ZADD tasks:pending:{workspace_id} {priority}:{timestamp} {task_id} + +# Assigned tasks (hash per agent) +HSET agent:tasks:{agent_id} {task_id} {assigned_at} + +# Active sessions (set) +SADD tasks:active:{workspace_id} {task_id}:{session_key} +``` + +#### Agent Status + +```redis +# Agent heartbeat (string with TTL) +SETEX agent:heartbeat:{agent_id} 60 "{\"status\":\"running\",\"task_id\":\"{task_id}\",\"timestamp\":{ts}}" + +# Agent status (hash) +HSET agent:status:{agent_id} + status "running" + current_task "{task_id}" + last_heartbeat {timestamp} + +# Stale agents (sorted set by last heartbeat) +ZADD coordinator:stale_agents {timestamp} {agent_id} +``` + +#### Coordination + +```redis +# Coordinator lock (to prevent multiple coordinators) +SET coordinator:lock:{workspace_id} {coordinator_instance_id} NX EX 30 + +# Task assignment lock +SET task:assign_lock:{task_id} {agent_id} NX EX 5 + +# Recovery queue (list) +LPUSH coordinator:recovery_queue:{workspace_id} {task_id} +``` + +#### Pub/Sub Channels + +```redis +# Task events +PUBLISH tasks:events:{workspace_id} "{\"event\":\"task_completed\",\"task_id\":\"{task_id}\"}" + +# Agent events +PUBLISH agents:events:{workspace_id} "{\"event\":\"agent_stale\",\"agent_id\":\"{agent_id}\"}" + +# Coordinator commands +PUBLISH coordinator:commands:{workspace_id} "{\"command\":\"reassign_task\",\"task_id\":\"{task_id}\"}" +``` + +#### Session Data (TTL: 1 hour) + +```redis +# Session context (hash with TTL) +HSET session:context:{session_key} + workspace_id "{workspace_id}" + agent_id "{agent_id}" + task_id "{task_id}" + started_at {timestamp} +EXPIRE session:context:{session_key} 3600 +``` + +### Data Lifecycle + +| Key Type | TTL | Cleanup Strategy | +|----------|-----|------------------| +| `agent:heartbeat:*` | 60s | Auto-expire | +| `agent:status:*` | None | Delete on agent termination | +| `session:context:*` | 1h | Auto-expire | +| `tasks:pending:*` | None | Remove on assignment | +| `coordinator:lock:*` | 30s | Auto-expire (renewed by active coordinator) | +| `task:assign_lock:*` | 5s | Auto-expire after assignment | + +--- + +## API Endpoints + +### Task Management + +```typescript +// Create a new orchestration task +POST /api/v1/agent-tasks +{ + "title": "Fix TypeScript strict errors in U-Connect", + "description": "...", + "taskType": "development", + "priority": 8, + "inputContext": { + "repository": "u-connect", + "branch": "main", + "scope": ["packages/shared"] + }, + "dependsOn": [], + "maxRetries": 3 +} + +// List tasks (with filtering) +GET /api/v1/agent-tasks?status=RUNNING&priority>=8&tags=typescript + +// Get task details +GET /api/v1/agent-tasks/:id + +// Update task (limited fields) +PATCH /api/v1/agent-tasks/:id +{ + "priority": 10, + "status": "PAUSED" +} + +// Cancel task +POST /api/v1/agent-tasks/:id/cancel +{ + "reason": "Requirements changed" +} + +// Retry failed task +POST /api/v1/agent-tasks/:id/retry + +// Get task logs +GET /api/v1/agent-tasks/:id/logs?level=ERROR&limit=100 +``` + +### Agent Management + +```typescript +// List agents +GET /api/v1/agents?status=WORKING&coordinatorEnabled=true + +// Get agent details +GET /api/v1/agents/:id + +// Register/update agent +PUT /api/v1/agents/:agentId +{ + "name": "Dev Agent #1", + "model": "claude-sonnet-4", + "capabilities": ["typescript", "nestjs", "prisma"], + "maxConcurrentTasks": 2, + "heartbeatIntervalSeconds": 30 +} + +// Get agent health +GET /api/v1/agents/:id/health + +// Get agent tasks +GET /api/v1/agents/:id/tasks?status=RUNNING + +// Terminate agent +POST /api/v1/agents/:id/terminate +{ + "reason": "Manual shutdown", + "graceful": true // Allow task completion +} +``` + +### Coordination + +```typescript +// Coordinator status (internal/admin only) +GET /api/v1/coordinator/status +{ + "active": true, + "instanceId": "coord-abc123", + "workspaces": ["ws-1", "ws-2"], + "lastHeartbeat": "2025-01-29T14:30:00Z", + "stats": { + "pendingTasks": 3, + "runningTasks": 5, + "healthyAgents": 8, + "staleAgents": 1 + } +} + +// Force recovery check (admin only) +POST /api/v1/coordinator/recover +{ + "workspaceId": "optional - all if omitted" +} + +// Get coordination logs (admin only) +GET /api/v1/coordinator/logs?workspace={id}&level=ERROR +``` + +### Webhooks (for external integration) + +```typescript +// Task state change webhook +POST {configured_webhook_url} +{ + "event": "task.status_changed", + "taskId": "task-123", + "previousStatus": "RUNNING", + "newStatus": "COMPLETED", + "workspaceId": "ws-1", + "timestamp": "2025-01-29T14:30:00Z" +} +``` + +--- + +## Coordinator Service Design + +### Core Responsibilities + +1. **Health Monitoring** — Check agent heartbeats, mark stale agents +2. **Task Assignment** — Match pending tasks to available agents +3. **Recovery Orchestration** — Reassign tasks from failed/stale agents +4. **Dependency Resolution** — Ensure tasks wait for dependencies +5. **Resource Management** — Enforce agent concurrency limits + +### Architecture + +```typescript +// Coordinator Service (NestJS) +@Injectable() +export class CoordinatorService { + private coordinatorLock: string; + private isRunning: boolean = false; + + constructor( + private readonly taskManager: TaskManagerService, + private readonly agentManager: AgentManagerService, + private readonly valkey: Valkey, + private readonly prisma: PrismaService, + private readonly logger: Logger + ) {} + + // Main coordination loop + @Cron('*/30 * * * * *') // Every 30 seconds + async coordinate() { + if (!await this.acquireLock()) { + return; // Another coordinator is active + } + + try { + await this.checkAgentHealth(); + await this.assignPendingTasks(); + await this.resolveDependencies(); + await this.recoverFailedTasks(); + } catch (error) { + this.logger.error('Coordination cycle failed', error); + } finally { + await this.releaseLock(); + } + } + + // Distributed lock to prevent multiple coordinators + private async acquireLock(): Promise { + const lockKey = `coordinator:lock:global`; + const result = await this.valkey.set( + lockKey, + process.env.HOSTNAME || 'coordinator', + 'NX', + 'EX', + 30 + ); + return result === 'OK'; + } + + // Check agent heartbeats and mark stale + private async checkAgentHealth() { + const agents = await this.agentManager.getCoordinatorManagedAgents(); + const now = Date.now(); + + for (const agent of agents) { + const heartbeatKey = `agent:heartbeat:${agent.id}`; + const lastHeartbeat = await this.valkey.get(heartbeatKey); + + if (!lastHeartbeat) { + // No heartbeat - agent is stale + await this.handleStaleAgent(agent); + } else { + const heartbeatData = JSON.parse(lastHeartbeat); + const age = now - heartbeatData.timestamp; + + if (age > agent.staleThresholdSeconds * 1000) { + await this.handleStaleAgent(agent); + } + } + } + } + + // Assign pending tasks to available agents + private async assignPendingTasks() { + const workspaces = await this.getActiveWorkspaces(); + + for (const workspace of workspaces) { + const pendingTasks = await this.taskManager.getPendingTasks( + workspace.id, + { orderBy: { priority: 'desc', createdAt: 'asc' } } + ); + + for (const task of pendingTasks) { + // Check dependencies + if (!await this.areDependenciesMet(task)) { + continue; + } + + // Find available agent + const agent = await this.agentManager.findAvailableAgent( + workspace.id, + task.taskType, + task.metadata.requiredCapabilities + ); + + if (agent) { + await this.assignTask(task, agent); + } + } + } + } + + // Handle stale agents + private async handleStaleAgent(agent: Agent) { + this.logger.warn(`Agent ${agent.id} is stale - recovering tasks`); + + // Mark agent as ERROR + await this.agentManager.updateAgentStatus(agent.id, AgentStatus.ERROR); + + // Get assigned tasks + const tasks = await this.taskManager.getTasksForAgent(agent.id); + + for (const task of tasks) { + await this.recoverTask(task, 'agent_stale'); + } + } + + // Recover a task from failure + private async recoverTask(task: AgentTask, reason: string) { + // Log the failure + await this.taskManager.logTaskEvent(task.id, { + level: 'ERROR', + event: 'task_recovery', + message: `Task recovery initiated: ${reason}`, + previousStatus: task.status, + newStatus: 'ABORTED' + }); + + // Check retry limit + if (task.retryCount >= task.maxRetries) { + await this.taskManager.updateTask(task.id, { + status: 'FAILED', + lastError: `Max retries exceeded (${task.retryCount}/${task.maxRetries})`, + failedAt: new Date() + }); + return; + } + + // Abort current assignment + await this.taskManager.updateTask(task.id, { + status: 'ABORTED', + agentId: null, + sessionKey: null, + retryCount: task.retryCount + 1 + }); + + // Wait for backoff period before requeuing + const backoffMs = task.retryBackoffSeconds * 1000 * Math.pow(2, task.retryCount); + setTimeout(async () => { + await this.taskManager.updateTask(task.id, { + status: 'PENDING' + }); + }, backoffMs); + } + + // Assign task to agent + private async assignTask(task: AgentTask, agent: Agent) { + // Acquire assignment lock + const lockKey = `task:assign_lock:${task.id}`; + const locked = await this.valkey.set(lockKey, agent.id, 'NX', 'EX', 5); + + if (!locked) { + return; // Another coordinator already assigned this task + } + + try { + // Update task + await this.taskManager.updateTask(task.id, { + status: 'ASSIGNED', + agentId: agent.id, + assignedAt: new Date() + }); + + // Spawn agent session via Gateway + const session = await this.spawnAgentSession(agent, task); + + // Update task with session + await this.taskManager.updateTask(task.id, { + sessionKey: session.sessionKey, + status: 'RUNNING', + startedAt: new Date() + }); + + // Log assignment + await this.taskManager.logTaskEvent(task.id, { + level: 'INFO', + event: 'task_assigned', + message: `Task assigned to agent ${agent.id}`, + details: { agentId: agent.id, sessionKey: session.sessionKey } + }); + } finally { + await this.valkey.del(lockKey); + } + } + + // Spawn agent session via Gateway + private async spawnAgentSession(agent: Agent, task: AgentTask): Promise { + // Call Gateway API to spawn subagent with task context + const response = await fetch(`${process.env.GATEWAY_URL}/api/agents/spawn`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId: task.workspaceId, + agentId: agent.id, + taskId: task.id, + label: `task:${task.id}:${task.title.slice(0, 30)}`, + context: { + taskTitle: task.title, + taskDescription: task.description, + inputContext: task.inputContext, + checkpointData: task.checkpointData + } + }) + }); + + const data = await response.json(); + return data.session; + } +} +``` + +### Coordination State Machine + +``` +┌─────────────────────────────────────────────────────────┐ +│ Coordinator Cycle │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ Acquire Lock (30s) │ + └───────────┬───────────┘ + │ + ┌────────▼────────┐ + │ Check Health │ + │ - Read agents │ + │ - Check ttls │ + │ - Mark stale │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Assign Tasks │ + │ - Read pending │ + │ - Match agents │ + │ - Spawn sessions│ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Resolve Deps │ + │ - Check waiting │ + │ - Unblock ready │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Recover Failed │ + │ - Requeue retry │ + │ - Log failures │ + └────────┬────────┘ + │ + ┌───────────▼───────────┐ + │ Release Lock │ + └───────────────────────┘ +``` + +--- + +## Recovery & Resilience Patterns + +### 1. Agent Failure Recovery + +**Scenario:** Agent crashes mid-task. + +**Detection:** +- Heartbeat TTL expires in Valkey +- Coordinator detects missing heartbeat + +**Recovery:** +1. Mark agent as `ERROR` in database +2. Abort assigned tasks with `status = ABORTED` +3. Log failure with stack trace (if available) +4. Apply exponential backoff: `backoff = retryBackoffSeconds * 2^retryCount` +5. Requeue task to `PENDING` after backoff +6. Coordinator reassigns on next cycle + +### 2. Gateway Restart Recovery + +**Scenario:** Gateway restarts, killing all agent sessions. + +**Detection:** +- All agent heartbeats stop simultaneously +- Coordinator detects mass stale agents + +**Recovery:** +1. Coordinator marks all `RUNNING` tasks as `ABORTED` +2. Tasks with `checkpointData` can resume from last checkpoint +3. Tasks without checkpoints restart from scratch +4. Exponential backoff prevents thundering herd + +### 3. Task Dependency Deadlock + +**Scenario:** Task A depends on Task B, which depends on Task A (circular dependency). + +**Detection:** +- Coordinator builds dependency graph +- Detects cycles during `resolveDependencies()` + +**Recovery:** +1. Log `ERROR` with cycle details +2. Mark all tasks in cycle as `FAILED` with reason `dependency_cycle` +3. Notify workspace owner via webhook + +### 4. Database Failure + +**Scenario:** PostgreSQL becomes unavailable. + +**Detection:** +- Prisma query fails with connection error + +**Recovery:** +1. Coordinator catches error, logs to stderr +2. Releases lock (allowing failover to another instance) +3. Retries with exponential backoff: 5s, 10s, 20s, 40s +4. If database remains down >5 minutes, alert admin + +### 5. Split-Brain Prevention + +**Scenario:** Network partition causes two coordinators to run simultaneously. + +**Prevention:** +- Distributed lock in Valkey with 30s TTL +- Coordinators must renew lock every cycle +- Only one coordinator can hold lock at a time + +**Detection:** +- Task assigned to multiple agents (conflict detection) + +**Recovery:** +1. Newer assignment wins (based on `assignedAt` timestamp) +2. Cancel older session +3. Log conflict for investigation + +### 6. Stale Task Timeout + +**Scenario:** Task runs longer than `estimatedCompletionAt + grace period`. + +**Detection:** +- Coordinator checks `estimatedCompletionAt` field +- If exceeded by >30 minutes, mark as potentially hung + +**Recovery:** +1. Send warning to agent session (via pub/sub) +2. If no progress update in 10 minutes, abort task +3. Log timeout error +4. Requeue with higher priority + +--- + +## Implementation Phases + +### Phase 1: Foundation (Week 1-2) + +**Goal:** Basic task and agent models, no coordination yet. + +**Deliverables:** +- [ ] Database schema migration (tables, indexes) +- [ ] Prisma models for `AgentTask`, `AgentTaskLog`, `AgentHeartbeat` +- [ ] Basic CRUD API endpoints for tasks +- [ ] Agent registration API +- [ ] Manual task assignment (no automation) + +**Testing:** +- Unit tests for task state machine +- Integration tests for task CRUD +- Manual testing: create task, assign to agent, complete + +### Phase 2: Coordination Core (Week 3-4) + +**Goal:** Autonomous coordinator with health monitoring. + +**Deliverables:** +- [ ] `CoordinatorService` with distributed locking +- [ ] Health monitoring (heartbeat TTL checks) +- [ ] Automatic task assignment to available agents +- [ ] Valkey integration for runtime state +- [ ] Pub/Sub for coordination events + +**Testing:** +- Unit tests for coordinator logic +- Integration tests with Valkey +- Chaos testing: kill agents, verify recovery +- Load testing: 10+ agents, 50+ tasks + +### Phase 3: Recovery & Resilience (Week 5) + +**Goal:** Fault-tolerant operation with automatic recovery. + +**Deliverables:** +- [ ] Agent failure detection and task recovery +- [ ] Exponential backoff for retries +- [ ] Checkpoint/resume support for long-running tasks +- [ ] Dependency resolution +- [ ] Deadlock detection + +**Testing:** +- Fault injection: kill agents, restart Gateway +- Dependency cycle testing +- Retry exhaustion testing +- Split-brain simulation + +### Phase 4: Observability (Week 6) + +**Goal:** Full visibility into orchestration state. + +**Deliverables:** +- [ ] Coordinator status dashboard +- [ ] Task progress tracking UI +- [ ] Real-time logs API +- [ ] Metrics export (Prometheus format) +- [ ] Webhook integration for external monitoring + +**Testing:** +- Load testing with metrics collection +- Dashboard usability testing +- Webhook reliability testing + +### Phase 5: Advanced Features (Future) + +**Goal:** Production-grade features. + +**Deliverables:** +- [ ] Task prioritization algorithms (SJF, priority queues) +- [ ] Agent capability matching (skills-based routing) +- [ ] Task batching (group similar tasks) +- [ ] Cost tracking (token usage, compute time) +- [ ] Multi-region coordination (geo-distributed agents) + +--- + +## Security Considerations + +### Row-Level Security (RLS) + +All queries must enforce workspace isolation: + +```typescript +// Example: Only return tasks for user's workspace +async getTasks(userId: string, workspaceId: string) { + const membership = await this.prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId, userId } } + }); + + if (!membership) { + throw new ForbiddenException('Not a member of this workspace'); + } + + return this.prisma.agentTask.findMany({ + where: { workspaceId } + }); +} +``` + +### API Authentication + +- All endpoints require valid session token +- Coordinator endpoints restricted to `ADMIN` or `OWNER` roles +- Agent heartbeat endpoints use JWT with agent-specific claims + +### Secrets Management + +- Task `inputContext` may contain secrets (API keys, passwords) +- Encrypt sensitive fields at rest using Prisma middleware +- Never log secrets in task logs +- Redact secrets in API responses + +--- + +## Monitoring & Alerting + +### Key Metrics + +| Metric | Description | Alert Threshold | +|--------|-------------|-----------------| +| `coordinator.cycle.duration_ms` | Coordination cycle execution time | >5000ms | +| `coordinator.stale_agents.count` | Number of stale agents detected | >5 | +| `tasks.pending.count` | Tasks waiting for assignment | >50 | +| `tasks.failed.count` | Total failed tasks (last 1h) | >10 | +| `tasks.retry.exhausted.count` | Tasks exceeding max retries | >0 | +| `agents.spawned.count` | Agent spawn rate | >100/min | +| `valkey.connection.errors` | Valkey connection failures | >0 | + +### Health Checks + +```http +GET /health/coordinator +{ + "status": "healthy", + "coordinator": { + "active": true, + "lastCycle": "2025-01-29T14:30:00Z", + "cycleCount": 1234 + }, + "database": "connected", + "valkey": "connected", + "gateway": "reachable" +} +``` + +--- + +## API Integration Examples + +### Creating a Task from Main Agent + +```typescript +// Main agent creates a development task +const task = await fetch('/api/v1/agent-tasks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${sessionToken}` + }, + body: JSON.stringify({ + title: 'Fix TypeScript strict errors in U-Connect', + description: 'Run tsc --noEmit, fix all errors, commit changes', + taskType: 'development', + priority: 8, + inputContext: { + repository: 'u-connect', + branch: 'main', + commands: ['pnpm install', 'pnpm tsc:check'] + }, + maxRetries: 2, + estimatedDurationMinutes: 30 + }) +}); + +const { id } = await task.json(); +console.log(`Task created: ${id}`); +``` + +### Agent Heartbeat (from spawned subagent) + +```typescript +// Subagent sends heartbeat every 30s +setInterval(async () => { + await fetch(`/api/v1/agents/${agentId}/heartbeat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${agentToken}` + }, + body: JSON.stringify({ + status: 'healthy', + currentTaskId: taskId, + progressPercent: 45, + currentStep: 'Running tsc --noEmit', + memoryMb: 512, + cpuPercent: 35 + }) + }); +}, 30000); +``` + +### Task Progress Update + +```typescript +// Agent updates task progress +await fetch(`/api/v1/agent-tasks/${taskId}/progress`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${agentToken}` + }, + body: JSON.stringify({ + progressPercent: 70, + currentStep: 'Fixing type errors in packages/shared', + checkpointData: { + filesProcessed: 15, + errorsFixed: 8, + remainingFiles: 5 + } + }) +}); +``` + +### Task Completion + +```typescript +// Agent marks task complete +await fetch(`/api/v1/agent-tasks/${taskId}/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${agentToken}` + }, + body: JSON.stringify({ + outputResult: { + filesModified: 20, + errorsFixed: 23, + commitHash: 'abc123', + buildStatus: 'passing' + }, + summary: 'All TypeScript strict errors resolved. Build passing.' + }) +}); +``` + +--- + +## Glossary + +| Term | Definition | +|------|------------| +| **Agent** | Autonomous AI instance (e.g., Claude subagent) that executes tasks | +| **Task** | Unit of work to be executed by an agent | +| **Coordinator** | Background service that assigns tasks and monitors agent health | +| **Heartbeat** | Periodic signal from agent indicating it's alive and working | +| **Stale Agent** | Agent that has stopped sending heartbeats (assumed dead) | +| **Checkpoint** | Snapshot of task state allowing resumption after failure | +| **Workspace** | Tenant isolation boundary (all tasks belong to a workspace) | +| **Session** | Gateway-managed connection between user and agent | +| **Orchestration** | Automated coordination of multiple agents working on tasks | + +--- + +## References + +- [Mosaic Stack Architecture](../3-architecture/README.md) +- [Existing Agent/AgentSession Schema](../../apps/api/prisma/schema.prisma) +- [NestJS Task Scheduling](https://docs.nestjs.com/techniques/task-scheduling) +- [Valkey Documentation](https://valkey.io/docs/) +- [PostgreSQL Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) + +--- + +**Next Steps:** +1. Review and approve this design document +2. Create GitHub issues for Phase 1 tasks +3. Set up development branch: `feature/agent-orchestration` +4. Begin database schema migration + +**Questions/Feedback:** Open an issue in `mosaic-stack` repo with label `orchestration`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfaf17c..62a9e90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@prisma/client': specifier: ^6.19.2 version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@types/marked': + specifier: ^6.0.0 + version: 6.0.0 better-auth: specifier: ^1.4.17 version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)) @@ -65,12 +68,18 @@ importers: class-validator: specifier: ^0.14.3 version: 0.14.3 + marked: + specifier: ^17.0.1 + version: 17.0.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 rxjs: specifier: ^7.8.1 version: 7.8.2 + slugify: + specifier: ^1.6.6 + version: 1.6.6 devDependencies: '@better-auth/cli': specifier: ^1.4.17 @@ -1584,6 +1593,10 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/marked@6.0.0': + resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==} + deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed. + '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} @@ -3042,6 +3055,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3642,6 +3660,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5518,6 +5540,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/marked@6.0.0': + dependencies: + marked: 17.0.1 + '@types/node@22.19.7': dependencies: undici-types: 6.21.0 @@ -7011,6 +7037,8 @@ snapshots: dependencies: semver: 7.7.3 + marked@17.0.1: {} + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -7668,6 +7696,8 @@ snapshots: sisteransi@1.0.5: {} + slugify@1.6.6: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: