From 2739fe7a101020a7f1ba6ebb68065b78f3dbba28 Mon Sep 17 00:00:00 2001 From: Kaito Date: Sat, 16 May 2026 20:34:49 +0700 Subject: [PATCH] fix(api): generate agent OpenAPI paths --- apps/web/app/api/openapi/route.ts | 488 +----------------------------- apps/web/lib/api/openapi.test.ts | 49 +++ apps/web/lib/api/openapi.ts | 387 +++++++++++++++++++++++ 3 files changed, 438 insertions(+), 486 deletions(-) create mode 100644 apps/web/lib/api/openapi.test.ts create mode 100644 apps/web/lib/api/openapi.ts diff --git a/apps/web/app/api/openapi/route.ts b/apps/web/app/api/openapi/route.ts index a511ef3..5d1d22f 100644 --- a/apps/web/app/api/openapi/route.ts +++ b/apps/web/app/api/openapi/route.ts @@ -1,490 +1,6 @@ import { NextResponse } from "next/server" -import swaggerJsdoc from "swagger-jsdoc" - -const openApiDocument = swaggerJsdoc({ - definition: { - openapi: "3.1.0", - info: { - title: "AgentBridge Agent API", - description: - "Coordinate AI agents, projects, and tasks through AgentBridge company-scoped API endpoints.", - version: "0.1.0", - }, - servers: [{ url: "/" }], - tags: [ - { - name: "Profile", - description: "Current authenticated agent profile.", - }, - { - name: "Agents", - description: "Manage agents in the authenticated agent's company.", - }, - { - name: "Projects", - description: "Manage projects in the authenticated agent's company.", - }, - { - name: "Tasks", - description: "Manage tasks in the authenticated agent's company.", - }, - { - name: "Health", - description: "Unauthenticated deployment smoke and readiness checks.", - }, - ], - components: { - securitySchemes: { - bearerAuth: { - type: "http", - scheme: "bearer", - description: "Company-level bearer token.", - }, - }, - schemas: { - Status: { - type: "string", - enum: ["todo", "inprogress", "done", "blocked"], - }, - Error: { - type: "object", - properties: { - statusCode: { type: "integer" }, - error: { type: "string" }, - }, - required: ["statusCode", "error"], - }, - HealthResponse: { - type: "object", - description: - "Non-secret unauthenticated readiness response for deployment smoke checks. The response never includes bearer tokens, environment values, user/company data, stack traces, or raw database errors.", - properties: { - statusCode: { type: "integer", enum: [200, 503] }, - status: { type: "string", enum: ["healthy", "degraded"] }, - checks: { - type: "object", - properties: { - app: { type: "string", enum: ["ok"] }, - database: { type: "string", enum: ["ok", "unavailable"] }, - }, - required: ["app", "database"], - }, - }, - required: ["statusCode", "status", "checks"], - }, - Company: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - description: { type: "string" }, - }, - required: ["id", "name", "description"], - }, - Agent: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - AgentId: { - type: "string", - description: "Stable API identifier used in the AgentId header, for example main or review-agent-01.", - examples: ["main", "review-agent-01"], - }, - name: { type: "string" }, - description: { type: "string" }, - position: { type: "string" }, - companyId: { type: "string", format: "uuid" }, - }, - required: ["id", "AgentId", "name", "description", "position", "companyId"], - }, - CompanyAgent: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - AgentId: { - type: "string", - description: "Stable API identifier used in the AgentId header, for example main or review-agent-01.", - examples: ["main", "review-agent-01"], - }, - name: { type: "string" }, - description: { type: "string" }, - position: { type: "string" }, - companyId: { type: "string", format: "uuid" }, - _count: { - type: "object", - properties: { tasks: { type: "integer" } }, - required: ["tasks"], - }, - }, - required: ["id", "AgentId", "name", "description", "position", "_count"], - }, - Task: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - job: { type: "string" }, - status: { $ref: "#/components/schemas/Status" }, - note: { - type: "string", - nullable: true, - description: - "Result note or completion summary. Agents should use this for concise implementation notes, QA handoff, or done-card summaries.", - }, - summaryUpdatedAt: { - type: "string", - format: "date-time", - nullable: true, - description: - "Stored timestamp for the latest note/summary content change. Null when note is null or when no summary timestamp has been stored; unchanged when note is omitted or submitted unchanged; updated when note content changes; cleared when note is blanked. Actor auditability comes from taskUpdated* fields and AuditLog entries.", - }, - readBy: { - type: "array", - items: { type: "string" }, - description: - "AgentId values that have read this task in its current status. Status or note changes clear current-status read markers unless readBy is explicitly supplied.", - }, - blockingReason: { type: "string", nullable: true }, - dependencyIds: { - type: "array", - items: { type: "string", format: "uuid" }, - description: - "Database task ids for active, non-archived tasks this task depends on. Archived dependency tasks are omitted from Agent API task payloads.", - }, - dependencies: { - type: "array", - items: { $ref: "#/components/schemas/TaskDependencySummary" }, - description: - "Active, non-archived dependency task summaries only. Archived dependency tasks are filtered out and dependency summaries expose id, name, and status only.", - }, - unblocks: { - type: "array", - items: { $ref: "#/components/schemas/TaskDependencySummary" }, - description: - "Active, non-archived task summaries that this task unblocks. Archived unblocked tasks are filtered out and summaries expose id, name, and status only.", - }, - isDependencyReady: { - type: "boolean", - description: - "True when the task has at least one active, non-archived dependency and every active dependency is done. Archived dependencies do not affect readiness.", - }, - archivedAt: { type: "string", format: "date-time", nullable: true }, - taskUpdatedAt: { type: "string", format: "date-time" }, - taskUpdatedById: { type: "string", format: "uuid", nullable: true }, - taskUpdatedByName: { type: "string", nullable: true }, - taskUpdatedByType: { - type: "string", - enum: ["agent", "user", "system"], - description: "Actor source for the most recent task mutation.", - }, - }, - required: [ - "id", - "name", - "job", - "status", - "note", - "summaryUpdatedAt", - "readBy", - "blockingReason", - "dependencyIds", - "dependencies", - "unblocks", - "isDependencyReady", - "archivedAt", - "taskUpdatedAt", - "taskUpdatedById", - "taskUpdatedByName", - "taskUpdatedByType", - ], - }, - TaskDependencySummary: { - type: "object", - description: - "Compact active task summary used by dependencies and unblocks. Archived tasks are omitted before serialization, and archivedAt is intentionally not exposed here.", - additionalProperties: false, - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - status: { $ref: "#/components/schemas/Status" }, - }, - required: ["id", "name", "status"], - }, - TaskWithProject: { - allOf: [ - { $ref: "#/components/schemas/Task" }, - { - type: "object", - properties: { - project: { - allOf: [ - { $ref: "#/components/schemas/Project" }, - { - type: "object", - properties: { - company: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - }, - required: ["id", "name"], - }, - }, - }, - ], - }, - }, - required: ["project"], - }, - ], - }, - Project: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - description: { type: "string" }, - }, - required: ["id", "name", "description"], - }, - ProjectAgent: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - AgentId: { - type: "string", - description: "Stable API identifier used in the AgentId header.", - }, - name: { type: "string" }, - position: { type: "string" }, - }, - required: ["id", "AgentId", "name", "position"], - }, - ProjectWithTasks: { - allOf: [ - { $ref: "#/components/schemas/Project" }, - { - type: "object", - properties: { - projectAgents: { - type: "array", - items: { $ref: "#/components/schemas/ProjectAgent" }, - description: "Agents linked to this project and available for new task assignments.", - }, - tasks: { - type: "array", - items: { - allOf: [ - { $ref: "#/components/schemas/Task" }, - { - type: "object", - properties: { - assigned: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - position: { type: "string" }, - }, - required: ["id", "name", "position"], - }, - }, - required: ["assigned"], - }, - ], - }, - }, - }, - required: ["projectAgents", "tasks"], - }, - ], - }, - AgentResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - agent: { - allOf: [ - { $ref: "#/components/schemas/Agent" }, - { - type: "object", - properties: { company: { $ref: "#/components/schemas/Company" } }, - required: ["company"], - }, - ], - }, - }, - required: ["statusCode", "agent"], - }, - AgentCreatedResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [201] }, - agent: { $ref: "#/components/schemas/Agent" }, - }, - required: ["statusCode", "agent"], - }, - CompanyAgentResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - agent: { $ref: "#/components/schemas/CompanyAgent" }, - }, - required: ["statusCode", "agent"], - }, - TaskResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - task: { $ref: "#/components/schemas/TaskWithProject" }, - }, - required: ["statusCode", "task"], - }, - TaskCreatedResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [201] }, - task: { - allOf: [ - { $ref: "#/components/schemas/Task" }, - { - type: "object", - properties: { - assigned: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - position: { type: "string" }, - }, - required: ["id", "name", "position"], - }, - }, - required: ["assigned"], - }, - ], - }, - }, - required: ["statusCode", "task"], - }, - TasksResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - tasks: { type: "array", items: { $ref: "#/components/schemas/TaskWithProject" } }, - }, - required: ["statusCode", "tasks"], - }, - AgentsResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - agents: { type: "array", items: { $ref: "#/components/schemas/CompanyAgent" } }, - }, - required: ["statusCode", "agents"], - }, - ProjectsResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - projects: { type: "array", items: { $ref: "#/components/schemas/ProjectWithTasks" } }, - }, - required: ["statusCode", "projects"], - }, - ProjectResponse: { - type: "object", - properties: { - statusCode: { type: "integer" }, - project: { $ref: "#/components/schemas/Project" }, - }, - required: ["statusCode", "project"], - }, - ProjectWithTasksResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - project: { $ref: "#/components/schemas/ProjectWithTasks" }, - }, - required: ["statusCode", "project"], - }, - DeleteAgentResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - agentId: { type: "string", format: "uuid" }, - }, - required: ["statusCode", "agentId"], - }, - DeleteProjectResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - projectId: { type: "string", format: "uuid" }, - }, - required: ["statusCode", "projectId"], - }, - DeleteTaskResponse: { - type: "object", - properties: { - statusCode: { type: "integer", enum: [200] }, - taskId: { type: "string", format: "uuid" }, - }, - required: ["statusCode", "taskId"], - }, - }, - responses: { - BadRequest: { - description: "Invalid request", - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }, - Unauthorized: { - description: "Missing or invalid bearer token", - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }, - NotFound: { - description: "Resource not found", - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }, - }, - parameters: { - TaskId: { - name: "taskId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - }, - AgentId: { - name: "AgentId", - in: "header", - required: true, - description: "Stable API identifier for the acting agent, such as main, ume, or review-agent-01. This is not the agent database UUID.", - schema: { type: "string", example: "main" }, - }, - PathAgentId: { - name: "agentId", - in: "path", - required: true, - description: "Agent database UUID. This path value is distinct from the API-facing AgentId string used in the header and agent payloads.", - schema: { type: "string", format: "uuid" }, - }, - ProjectId: { - name: "projectId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - }, - }, - }, - security: [{ bearerAuth: [] }], - }, - apis: [ - "./apps/web/app/api/agent/**/*.ts", - "./apps/web/app/api/agent/route.ts", - "./apps/web/app/api/health/route.ts", - ], -}) +import { createOpenApiDocument } from "@/lib/api/openapi" export function GET() { - return NextResponse.json(openApiDocument) + return NextResponse.json(createOpenApiDocument()) } diff --git a/apps/web/lib/api/openapi.test.ts b/apps/web/lib/api/openapi.test.ts new file mode 100644 index 0000000..87dd207 --- /dev/null +++ b/apps/web/lib/api/openapi.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { fileURLToPath } from "node:url" +import { describe, it } from "node:test" +import { createOpenApiDocument } from "./openapi" + +type OpenApiDocument = { + paths?: Record + components?: { + schemas?: Record + } +} + +describe("OpenAPI document", () => { + it("generates only Agent API paths from the web app working directory", () => { + const originalCwd = process.cwd() + process.chdir(fileURLToPath(new URL("../..", import.meta.url))) + + try { + const document = createOpenApiDocument() as OpenApiDocument + const paths = Object.keys(document.paths ?? {}).sort() + + assert.deepEqual(paths, [ + "/api/agent", + "/api/agent/agents", + "/api/agent/agents/{agentId}", + "/api/agent/projects", + "/api/agent/projects/{projectId}", + "/api/agent/tasks", + "/api/agent/tasks/{taskId}", + ]) + } finally { + process.chdir(originalCwd) + } + }) + + it("exposes current task note, read, and dependency fields", () => { + const document = createOpenApiDocument() as OpenApiDocument + const taskSchema = document.components?.schemas?.Task as { + properties?: Record + } + + assert.ok(taskSchema.properties?.note) + assert.ok(taskSchema.properties?.summaryUpdatedAt) + assert.ok(taskSchema.properties?.readBy) + assert.ok(taskSchema.properties?.blockingReason) + assert.ok(taskSchema.properties?.dependencies) + assert.ok(taskSchema.properties?.unblocks) + }) +}) diff --git a/apps/web/lib/api/openapi.ts b/apps/web/lib/api/openapi.ts new file mode 100644 index 0000000..403217f --- /dev/null +++ b/apps/web/lib/api/openapi.ts @@ -0,0 +1,387 @@ +import path from "node:path" +import swaggerJsdoc from "swagger-jsdoc" + +export function getAgentApiGlobs() { + return [ + path.join(process.cwd(), "app/api/agent/**/*.ts"), + path.join(process.cwd(), "app/api/agent/route.ts"), + ] +} + +export function createOpenApiDocument() { + return swaggerJsdoc({ + definition: { + openapi: "3.1.0", + info: { + title: "AgentBridge Agent API", + description: + "Coordinate AI agents, projects, and tasks through AgentBridge company-scoped API endpoints.", + version: "0.1.0", + }, + servers: [{ url: "/" }], + tags: [ + { + name: "Profile", + description: "Current authenticated agent profile.", + }, + { + name: "Agents", + description: "Manage agents in the authenticated agent's company.", + }, + { + name: "Projects", + description: "Manage projects in the authenticated agent's company.", + }, + { + name: "Tasks", + description: "Manage tasks in the authenticated agent's company.", + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + description: "Company-level bearer token.", + }, + }, + schemas: { + Status: { + type: "string", + enum: ["todo", "inprogress", "done", "blocked"], + }, + Error: { + type: "object", + properties: { + statusCode: { type: "integer" }, + error: { type: "string" }, + }, + required: ["statusCode", "error"], + }, + Project: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + description: { type: "string" }, + }, + required: ["id", "name", "description"], + }, + ProjectAgent: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + AgentId: { + type: "string", + description: "Stable API identifier used in the AgentId header.", + }, + name: { type: "string" }, + description: { type: "string" }, + position: { type: "string" }, + }, + required: ["id", "AgentId", "name", "description", "position"], + }, + Agent: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + AgentId: { + type: "string", + description: "Stable API identifier used in the AgentId header.", + }, + name: { type: "string" }, + description: { type: "string" }, + position: { type: "string" }, + projectIds: { + type: "array", + items: { type: "string", format: "uuid" }, + }, + projects: { + type: "array", + items: { $ref: "#/components/schemas/Project" }, + }, + }, + required: [ + "id", + "AgentId", + "name", + "description", + "position", + "projectIds", + "projects", + ], + }, + CurrentAgent: { + allOf: [ + { $ref: "#/components/schemas/ProjectAgent" }, + { + type: "object", + properties: { + company: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + description: { type: "string" }, + }, + required: ["id", "name", "description"], + }, + }, + required: ["company"], + }, + ], + }, + TaskSummary: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + status: { $ref: "#/components/schemas/Status" }, + }, + required: ["id", "name", "status"], + }, + Task: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + job: { type: "string" }, + status: { $ref: "#/components/schemas/Status" }, + note: { + type: ["string", "null"], + description: "Completion note or summary describing what changed.", + }, + summaryUpdatedAt: { + type: ["string", "null"], + format: "date-time", + description: "When note/summary was last explicitly changed.", + }, + taskUpdatedAt: { type: "string", format: "date-time" }, + taskUpdatedById: { type: ["string", "null"], format: "uuid" }, + taskUpdatedByName: { type: ["string", "null"] }, + taskUpdatedByType: { type: "string" }, + blockingReason: { type: ["string", "null"] }, + archivedAt: { type: ["string", "null"], format: "date-time" }, + project: { $ref: "#/components/schemas/Project" }, + assigned: { $ref: "#/components/schemas/ProjectAgent" }, + dependencies: { + type: "array", + items: { $ref: "#/components/schemas/TaskSummary" }, + }, + dependencyIds: { + type: "array", + items: { type: "string", format: "uuid" }, + }, + unblocks: { + type: "array", + items: { $ref: "#/components/schemas/TaskSummary" }, + }, + isDependencyReady: { type: "boolean" }, + readBy: { + type: "array", + description: "AgentId values that have read this task in its current status.", + items: { type: "string" }, + }, + }, + required: [ + "id", + "name", + "job", + "status", + "note", + "summaryUpdatedAt", + "taskUpdatedAt", + "taskUpdatedById", + "taskUpdatedByName", + "taskUpdatedByType", + "blockingReason", + "archivedAt", + "project", + "assigned", + "dependencies", + "dependencyIds", + "unblocks", + "isDependencyReady", + "readBy", + ], + }, + TaskWithProject: { + allOf: [ + { $ref: "#/components/schemas/Task" }, + { + type: "object", + properties: { + project: { $ref: "#/components/schemas/Project" }, + }, + required: ["project"], + }, + ], + }, + ProjectWithAgents: { + allOf: [ + { $ref: "#/components/schemas/Project" }, + { + type: "object", + properties: { + agents: { + type: "array", + items: { $ref: "#/components/schemas/ProjectAgent" }, + }, + agentIds: { + type: "array", + items: { type: "string", format: "uuid" }, + }, + }, + required: ["agents", "agentIds"], + }, + ], + }, + ProjectWithTasks: { + allOf: [ + { $ref: "#/components/schemas/Project" }, + { + type: "object", + properties: { + agents: { + type: "array", + items: { $ref: "#/components/schemas/ProjectAgent" }, + }, + agentIds: { + type: "array", + items: { type: "string", format: "uuid" }, + }, + tasks: { + type: "array", + items: { $ref: "#/components/schemas/TaskWithProject" }, + }, + }, + required: ["agents", "agentIds", "tasks"], + }, + ], + }, + AgentsResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + agents: { type: "array", items: { $ref: "#/components/schemas/Agent" } }, + }, + required: ["statusCode", "agents"], + }, + AgentResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + agent: { $ref: "#/components/schemas/Agent" }, + }, + required: ["statusCode", "agent"], + }, + AgentDeleteResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + agentId: { type: "string", format: "uuid" }, + }, + required: ["statusCode", "agentId"], + }, + ProjectsResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + projects: { + type: "array", + items: { $ref: "#/components/schemas/ProjectWithAgents" }, + }, + }, + required: ["statusCode", "projects"], + }, + ProjectResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + project: { $ref: "#/components/schemas/ProjectWithTasks" }, + }, + required: ["statusCode", "project"], + }, + ProfileResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + agent: { $ref: "#/components/schemas/CurrentAgent" }, + }, + required: ["statusCode", "agent"], + }, + TasksResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + tasks: { type: "array", items: { $ref: "#/components/schemas/TaskWithProject" } }, + }, + required: ["statusCode", "tasks"], + }, + TaskResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + task: { $ref: "#/components/schemas/TaskWithProject" }, + }, + required: ["statusCode", "task"], + }, + TaskDeleteResponse: { + type: "object", + properties: { + statusCode: { type: "integer", example: 200 }, + taskId: { type: "string", format: "uuid" }, + }, + required: ["statusCode", "taskId"], + }, + }, + responses: { + BadRequest: { + description: "Invalid request", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + Unauthorized: { + description: "Missing or invalid bearer token", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + NotFound: { + description: "Resource not found", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + }, + parameters: { + TaskId: { + name: "taskId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + AgentId: { + name: "AgentId", + in: "header", + required: true, + description: + "Stable API identifier for the acting agent, such as main, ume, or review-agent-01. This is not the agent database UUID.", + schema: { type: "string", example: "main" }, + }, + PathAgentId: { + name: "agentId", + in: "path", + required: true, + description: + "Agent database UUID. This path value is distinct from the API-facing AgentId string used in the header and agent payloads.", + schema: { type: "string", format: "uuid" }, + }, + ProjectId: { + name: "projectId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + }, + }, + security: [{ bearerAuth: [] }], + }, + apis: getAgentApiGlobs(), + }) +}