diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 893aea10bc9..c7fedd40f7f 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -26,6 +26,7 @@ const apiPrefixes: Record = { "company-skill-policy.ts": "/api", "costs.ts": "/api", "dashboard.ts": "/api", + "decision-queues.ts": "/api", "decisions.ts": "/api", "decision-training.ts": "/api", "environments.ts": "/api", @@ -190,6 +191,26 @@ describe("openapi routes", () => { expect(res.body.paths["/api/companies/{companyId}/folders/items/move"].post.summary).toBe( "Move an item into or out of a folder", ); + const createQueue = res.body.paths["/api/companies/{companyId}/decision-queues"].post; + expect(createQueue.security).toContainEqual({ AgentBearerAuth: [] }); + expect(createQueue.responses["200"]).toBeDefined(); + expect(createQueue.responses["201"]).toBeDefined(); + expect(createQueue.requestBody.content["application/json"].schema).toMatchObject({ + type: "object", + properties: { + key: { type: "string", minLength: 1, maxLength: 80 }, + title: { type: "string", minLength: 1, maxLength: 120 }, + }, + required: ["key", "title"], + }); + const updateTriage = res.body.paths[ + "/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}" + ].put; + expect(updateTriage.responses["422"]).toBeDefined(); + expect(updateTriage.requestBody.content["application/json"].schema.properties).toMatchObject({ + decideBy: { nullable: true }, + snoozedUntil: { type: "string", format: "date-time", nullable: true }, + }); expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools"].get)).not.toContain("sessionToken"); expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools/call"].post)).not.toContain("sessionToken"); }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a3e34e8cb60..a26cbb77a8a 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -48,8 +48,13 @@ import { companyArtifactsQuerySchema, companyArtifactsResponseSchema, // Decisions + addDecisionQueueItemSchema, + createDecisionQueueSchema, + decisionAttentionSourceKindSchema, decisionInputsSchema, decisionOptionsSchema, + updateDecisionQueueSchema, + updateDecisionTriageSchema, // Routine createRoutineSchema, updateRoutineSchema, @@ -3277,6 +3282,188 @@ registry.registerPath({ // ─── Decisions ────────────────────────────────────────────────────────────── +// Decision queues and triage + +const decisionQueueSeedRuleSchema = z.object({ + key: z.string(), + description: z.string(), + signal: z.enum([ + "issue_has_pull_request_work_product", + "plan_document_confirmation", + "ask_user_questions", + ]), +}).strict(); + +const decisionQueueSchema = z.object({ + id: z.string(), + companyId: z.string(), + key: z.string(), + title: z.string(), + description: z.string().nullable(), + createdByType: z.enum(["agent", "user", "system"]), + createdByAgentId: z.string().nullable(), + createdByUserId: z.string().nullable(), + createdByRunId: z.string().nullable(), + retentionDays: z.number().int().nullable(), + seedRules: z.array(decisionQueueSeedRuleSchema), + seedRulesEnabled: z.boolean(), + itemCount: z.number().int().nonnegative(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).strict(); + +const decisionQueueItemSchema = z.object({ + id: z.string(), + companyId: z.string(), + queueId: z.string(), + sourceKind: decisionAttentionSourceKindSchema, + sourceId: z.string(), + addedByType: z.enum(["agent", "user", "system"]), + addedByAgentId: z.string().nullable(), + addedByUserId: z.string().nullable(), + addedByRunId: z.string().nullable(), + responsibleUserId: z.string().nullable(), + createdAt: z.string().datetime(), +}).strict(); + +const decisionTriageSchema = z.object({ + id: z.string(), + companyId: z.string(), + sourceKind: decisionAttentionSourceKindSchema, + sourceId: z.string(), + decideBy: z.string().nullable(), + snoozedUntil: z.string().datetime().nullable(), + setByType: z.enum(["agent", "user"]), + setByAgentId: z.string().nullable(), + setByUserId: z.string().nullable(), + setByRunId: z.string().nullable(), + responsibleUserId: z.string().nullable(), + version: z.number().int().positive(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).strict(); + +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/decision-queue-seed-rules", + tags: ["decision-queues"], + summary: "List built-in decision queue seed rules", + responses: { 200: r.ok(z.array(decisionQueueSeedRuleSchema)), 401: r.unauthorized, 403: r.forbidden }, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/decision-queues", + tags: ["decision-queues"], + summary: "List decision queues", + responses: { 200: r.ok(z.array(decisionQueueSchema)), 401: r.unauthorized, 403: r.forbidden }, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/decision-queues", + tags: ["decision-queues"], + summary: "Create a decision queue", + body: createDecisionQueueSchema, + responses: { + 200: r.ok(decisionQueueSchema), + 201: r.ok(decisionQueueSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + }, +}); + +registerCurrentRoute({ + method: "patch", + path: "/api/companies/{companyId}/decision-queues/{key}", + tags: ["decision-queues"], + summary: "Update a decision queue", + body: updateDecisionQueueSchema, + responses: { + 200: r.ok(decisionQueueSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/decision-queues/{key}/items", + tags: ["decision-queues"], + summary: "List visible items in a decision queue", + responses: { + 200: r.ok(z.array(decisionQueueItemSchema)), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/decision-queues/{key}/items", + tags: ["decision-queues"], + summary: "Add an item to a decision queue", + body: addDecisionQueueItemSchema, + responses: { + 200: r.ok(decisionQueueItemSchema), + 201: r.ok(decisionQueueItemSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registerCurrentRoute({ + method: "delete", + path: "/api/companies/{companyId}/decision-queues/{key}/items/{sourceKind}/{sourceId}", + tags: ["decision-queues"], + summary: "Remove an item from a decision queue", + responses: { + 200: r.ok(decisionQueueItemSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}", + tags: ["decision-queues"], + summary: "Get decision triage for an attention source", + responses: { + 200: r.ok(decisionTriageSchema.nullable()), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registerCurrentRoute({ + method: "put", + path: "/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}", + tags: ["decision-queues"], + summary: "Set decision triage for an attention source", + body: updateDecisionTriageSchema, + responses: { + 200: r.ok(decisionTriageSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 422: r.unprocessable, + }, +}); + +// Decisions + const createDecisionBodySchema = z.object({ title: z.string().trim().min(1).max(500), body: z.string().max(100_000),