diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bdb2032d4..dbe8ccd68 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14856,6 +14856,406 @@ "login", "marked" ] + }, + "EvaluateEscalationRequest": { + "type": "object", + "properties": { + "runStatus": { + "type": "string", + "enum": [ + "running", + "converged", + "abandoned", + "error" + ] + }, + "healthStatus": { + "type": "string", + "enum": [ + "healthy", + "degraded", + "critical" + ] + }, + "customerFlagged": { + "type": "boolean" + }, + "killRequested": { + "type": "boolean" + } + }, + "required": [ + "runStatus" + ] + }, + "EvaluateEscalationResponse": { + "type": "object", + "properties": { + "shouldEscalate": { + "type": "boolean" + }, + "action": { + "type": "string", + "enum": [ + "none", + "notify", + "human_review", + "stop" + ] + }, + "severity": { + "type": "string", + "enum": [ + "none", + "low", + "medium", + "high" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "BuildResultsPayloadRequest": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string", + "minLength": 1 + }, + "prNumber": { + "type": "integer", + "nullable": true + }, + "title": { + "type": "string" + }, + "changedFiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "additions": { + "type": "integer" + }, + "deletions": { + "type": "integer" + } + }, + "required": [ + "path" + ] + }, + "maxItems": 5000 + }, + "status": { + "type": "string", + "enum": [ + "open", + "merged", + "closed" + ] + } + }, + "required": [ + "repoFullName", + "title" + ] + }, + "BuildResultsPayloadResponse": { + "type": "object", + "properties": { + "prLink": { + "type": "string", + "nullable": true + }, + "summary": { + "type": "string" + }, + "diffPreview": { + "nullable": true + }, + "totals": { + "nullable": true + } + } + }, + "BuildProgressSnapshotRequest": { + "type": "object", + "properties": { + "iteration": { + "type": "integer" + }, + "maxIterations": { + "type": "integer", + "nullable": true + }, + "phase": { + "type": "string", + "enum": [ + "queued", + "claiming", + "coding", + "reviewing", + "submitting", + "done" + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "converged", + "abandoned", + "error" + ] + }, + "recentActivity": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "at": { + "type": "string" + } + }, + "required": [ + "step" + ] + }, + "maxItems": 1000 + } + }, + "required": [ + "iteration", + "phase", + "status" + ] + }, + "BuildProgressSnapshotResponse": { + "type": "object", + "properties": { + "phase": { + "type": "string" + }, + "status": { + "type": "string" + }, + "iteration": { + "type": "number" + }, + "maxIterations": { + "type": "number", + "nullable": true + }, + "percentComplete": { + "type": "number", + "nullable": true + }, + "recentActivity": { + "nullable": true + }, + "done": { + "type": "boolean" + } + } + }, + "IntakeIdeaRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "targetRepo": { + "type": "string" + }, + "constraints": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50 + }, + "acceptanceHints": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50 + }, + "priority": { + "type": "string" + }, + "decomposition": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50 + } + }, + "required": [ + "key", + "title", + "body" + ] + }, + "maxItems": 50 + } + } + }, + "IntakeIdeaResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "verdict": { + "type": "string", + "enum": [ + "go", + "raise", + "avoid" + ] + }, + "taskGraph": { + "nullable": true + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "ok" + ] + }, + "PlanIdeaClaimsRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "targetRepo": { + "type": "string" + }, + "constraints": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50 + }, + "acceptanceHints": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50 + }, + "priority": { + "type": "string" + }, + "decomposition": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50 + } + }, + "required": [ + "key", + "title", + "body" + ] + }, + "maxItems": 50 + } + } + }, + "PlanIdeaClaimsResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "verdict": { + "type": "string", + "enum": [ + "go", + "raise", + "avoid" + ] + }, + "claimPlan": { + "nullable": true + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "ok" + ] } }, "parameters": {}, @@ -19437,6 +19837,191 @@ } ] } + }, + "/v1/loop/evaluate-escalation": { + "post": { + "summary": "Evaluate whether a loop outcome should escalate — REST mirror of loopover_evaluate_escalation (#9309)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvaluateEscalationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Escalation decision over caller-supplied loop outcome + health signals — mirrors the loopover_evaluate_escalation MCP tool. Pure, source-free evaluator; it decides, the caller wires the action", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvaluateEscalationResponse" + } + } + } + }, + "400": { + "description": "Invalid evaluate-escalation request body" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/loop/results-payload": { + "post": { + "summary": "Compose a loop results-delivery payload — REST mirror of loopover_build_results_payload (#9309)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildResultsPayloadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Formatted results payload over caller-supplied iteration metadata — mirrors the loopover_build_results_payload MCP tool. Pure composer; it formats the result, it does not fetch, open, or deliver anything", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildResultsPayloadResponse" + } + } + } + }, + "400": { + "description": "Invalid results-payload request body" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/loop/progress-snapshot": { + "post": { + "summary": "Compose a running-loop progress snapshot — REST mirror of loopover_build_progress_snapshot (#9309)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildProgressSnapshotRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Formatted progress snapshot over caller-supplied loop state — mirrors the loopover_build_progress_snapshot MCP tool. Pure composer; it formats the snapshot, it does not fetch or stream anything", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildProgressSnapshotResponse" + } + } + } + }, + "400": { + "description": "Invalid progress-snapshot request body" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/loop/intake-idea": { + "post": { + "summary": "Validate an idea submission and assemble its task-graph — REST mirror of loopover_intake_idea (#9309)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntakeIdeaRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Idea verdict + assembled task-graph — mirrors the loopover_intake_idea MCP tool. Pure composer over the caller-supplied idea and optional decomposition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntakeIdeaResponse" + } + } + } + }, + "400": { + "description": "Invalid intake-idea request body, or an empty/malformed idea submission (actionable error list returned)" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/loop/plan-idea-claims": { + "post": { + "summary": "Disposition an idea's task-graph into a claim plan — REST mirror of loopover_plan_idea_claims (#9309)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanIdeaClaimsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Idea verdict + claim plan (which constituent issues can be claimed now vs. deferred) — mirrors the loopover_plan_idea_claims MCP tool. Pure composer over the caller-supplied idea and optional decomposition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanIdeaClaimsResponse" + } + } + } + }, + "400": { + "description": "Invalid plan-idea-claims request body, or an empty/malformed idea submission (actionable error list returned)" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 06a6285e9..84b39898f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1295,7 +1295,7 @@ const checkSlopRiskOutputSchema = { // owns the real bounds/format checks and returns the actionable error list — an empty/malformed submission // reaches the handler rather than being rejected upstream by the schema. `decomposition` is the optional // renter-reviewed idea→issues split (the one fuzzy step, supplied in); omit it for the single-issue baseline. -const intakeIdeaShape = { +export const intakeIdeaShape = { id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), @@ -1309,7 +1309,7 @@ const intakeIdeaShape = { .optional(), }; -const intakeIdeaOutputSchema = { +export const intakeIdeaOutputSchema = { ok: z.boolean(), verdict: z.enum(["go", "raise", "avoid"]).optional(), taskGraph: z.unknown().optional(), @@ -1318,7 +1318,7 @@ const intakeIdeaOutputSchema = { // Claim-plan hand-off (#4799): same idea input, but the output is the loop disposition — which constituent // issues the claim/code/submit loop can claim now vs. must defer or skip. -const planIdeaClaimsOutputSchema = { +export const planIdeaClaimsOutputSchema = { ok: z.boolean(), verdict: z.enum(["go", "raise", "avoid"]).optional(), claimPlan: z.unknown().optional(), @@ -1326,7 +1326,7 @@ const planIdeaClaimsOutputSchema = { }; // Loop results-delivery input (#4801): a completed iteration's already-computed metadata. -const buildResultsPayloadShape = { +export const buildResultsPayloadShape = { repoFullName: z.string().min(1), prNumber: z.number().int().nullable().optional(), title: z.string(), @@ -1337,7 +1337,7 @@ const buildResultsPayloadShape = { status: z.enum(["open", "merged", "closed"]).optional(), }; -const buildResultsPayloadOutputSchema = { +export const buildResultsPayloadOutputSchema = { prLink: z.string().nullable().optional(), summary: z.string().optional(), diffPreview: z.unknown().optional(), @@ -1345,7 +1345,7 @@ const buildResultsPayloadOutputSchema = { }; // Loop progress-snapshot input (#4800): a running loop's already-computed state. -const buildProgressSnapshotShape = { +export const buildProgressSnapshotShape = { iteration: z.number().int(), maxIterations: z.number().int().nullable().optional(), phase: z.enum(["queued", "claiming", "coding", "reviewing", "submitting", "done"]), @@ -1356,7 +1356,7 @@ const buildProgressSnapshotShape = { .optional(), }; -const buildProgressSnapshotOutputSchema = { +export const buildProgressSnapshotOutputSchema = { phase: z.string().optional(), status: z.string().optional(), iteration: z.number().optional(), @@ -1367,14 +1367,14 @@ const buildProgressSnapshotOutputSchema = { }; // Loop escalation evaluator input (#4806): an already-computed loop outcome + health tier + operator signals. -const evaluateEscalationShape = { +export const evaluateEscalationShape = { runStatus: z.enum(["running", "converged", "abandoned", "error"]), healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(), customerFlagged: z.boolean().optional(), killRequested: z.boolean().optional(), }; -const evaluateEscalationOutputSchema = { +export const evaluateEscalationOutputSchema = { shouldEscalate: z.boolean().optional(), action: z.enum(["none", "notify", "human_review", "stop"]).optional(), severity: z.enum(["none", "low", "medium", "high"]).optional(), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 79085ab1e..94b40b299 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1926,6 +1926,168 @@ export const GateConfigEffectiveResponseSchema = z }) .openapi("GateConfigEffectiveResponse"); +/** + * Request body for POST /v1/loop/evaluate-escalation. Field-level parity with `evaluateEscalationShape` + * (the `loopover_evaluate_escalation` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. + */ +export const EvaluateEscalationRequestSchema = z + .object({ + runStatus: z.enum(["running", "converged", "abandoned", "error"]), + healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(), + customerFlagged: z.boolean().optional(), + killRequested: z.boolean().optional(), + }) + .openapi("EvaluateEscalationRequest"); + +/** + * Response body for POST /v1/loop/evaluate-escalation. Field-level parity with `evaluateEscalationOutputSchema` + * (the `loopover_evaluate_escalation` MCP tool `outputSchema`) in src/mcp/server.ts — #9309. + */ +export const EvaluateEscalationResponseSchema = z + .object({ + shouldEscalate: z.boolean().optional(), + action: z.enum(["none", "notify", "human_review", "stop"]).optional(), + severity: z.enum(["none", "low", "medium", "high"]).optional(), + reasons: z.array(z.string()).optional(), + }) + .openapi("EvaluateEscalationResponse"); + +/** + * Request body for POST /v1/loop/results-payload. Field-level parity with `buildResultsPayloadShape` + * (the `loopover_build_results_payload` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. + */ +export const BuildResultsPayloadRequestSchema = z + .object({ + repoFullName: z.string().min(1), + prNumber: z.number().int().nullable().optional(), + title: z.string(), + changedFiles: z + .array(z.object({ path: z.string(), additions: z.number().int().optional(), deletions: z.number().int().optional() })) + .max(5000) + .optional(), + status: z.enum(["open", "merged", "closed"]).optional(), + }) + .openapi("BuildResultsPayloadRequest"); + +/** + * Response body for POST /v1/loop/results-payload. Field-level parity with `buildResultsPayloadOutputSchema` + * (the `loopover_build_results_payload` MCP tool `outputSchema`) in src/mcp/server.ts — #9309. `diffPreview` + * and `totals` are intentionally left as opaque `unknown` objects (the tool composes them from caller-supplied + * metadata; the spec does not re-derive their internal shape). + */ +export const BuildResultsPayloadResponseSchema = z + .object({ + prLink: z.string().nullable().optional(), + summary: z.string().optional(), + diffPreview: z.unknown().optional(), + totals: z.unknown().optional(), + }) + .openapi("BuildResultsPayloadResponse"); + +/** + * Request body for POST /v1/loop/progress-snapshot. Field-level parity with `buildProgressSnapshotShape` + * (the `loopover_build_progress_snapshot` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. + */ +export const BuildProgressSnapshotRequestSchema = z + .object({ + iteration: z.number().int(), + maxIterations: z.number().int().nullable().optional(), + phase: z.enum(["queued", "claiming", "coding", "reviewing", "submitting", "done"]), + status: z.enum(["running", "converged", "abandoned", "error"]), + recentActivity: z + .array(z.object({ step: z.string(), detail: z.string().optional(), at: z.string().optional() })) + .max(1000) + .optional(), + }) + .openapi("BuildProgressSnapshotRequest"); + +/** + * Response body for POST /v1/loop/progress-snapshot. Field-level parity with `buildProgressSnapshotOutputSchema` + * (the `loopover_build_progress_snapshot` MCP tool `outputSchema`) in src/mcp/server.ts — #9309. `recentActivity` + * is left as opaque `unknown` (the tool passes the caller-supplied activity list through untouched). + */ +export const BuildProgressSnapshotResponseSchema = z + .object({ + phase: z.string().optional(), + status: z.string().optional(), + iteration: z.number().optional(), + maxIterations: z.number().nullable().optional(), + percentComplete: z.number().nullable().optional(), + recentActivity: z.unknown().optional(), + done: z.boolean().optional(), + }) + .openapi("BuildProgressSnapshotResponse"); + +/** + * Request body for POST /v1/loop/intake-idea. Field-level parity with `intakeIdeaShape` + * (the `loopover_intake_idea` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. Fields are deliberately + * loose (matching the tool) so the engine's validateIdeaSubmission owns the real bounds/format checks. + */ +export const IntakeIdeaRequestSchema = z + .object({ + id: z.string().optional(), + title: z.string().optional(), + body: z.string().optional(), + targetRepo: z.string().optional(), + constraints: z.array(z.string()).max(50).optional(), + acceptanceHints: z.array(z.string()).max(50).optional(), + priority: z.string().optional(), + decomposition: z + .array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() })) + .max(50) + .optional(), + }) + .openapi("IntakeIdeaRequest"); + +/** + * Response body for POST /v1/loop/intake-idea. Field-level parity with `intakeIdeaOutputSchema` + * (the `loopover_intake_idea` MCP tool `outputSchema`) in src/mcp/server.ts — #9309. `taskGraph` is left as + * opaque `unknown` (the assembled task-graph structure is not re-derived in the spec). + */ +export const IntakeIdeaResponseSchema = z + .object({ + ok: z.boolean(), + verdict: z.enum(["go", "raise", "avoid"]).optional(), + taskGraph: z.unknown().optional(), + errors: z.array(z.string()).optional(), + }) + .openapi("IntakeIdeaResponse"); + +/** + * Request body for POST /v1/loop/plan-idea-claims. The `loopover_plan_idea_claims` MCP tool reuses the same + * `intakeIdeaShape` input as intake-idea (src/mcp/server.ts) — kept as its own component for a stable per-route + * contract — #9309. + */ +export const PlanIdeaClaimsRequestSchema = z + .object({ + id: z.string().optional(), + title: z.string().optional(), + body: z.string().optional(), + targetRepo: z.string().optional(), + constraints: z.array(z.string()).max(50).optional(), + acceptanceHints: z.array(z.string()).max(50).optional(), + priority: z.string().optional(), + decomposition: z + .array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() })) + .max(50) + .optional(), + }) + .openapi("PlanIdeaClaimsRequest"); + +/** + * Response body for POST /v1/loop/plan-idea-claims. Field-level parity with `planIdeaClaimsOutputSchema` + * (the `loopover_plan_idea_claims` MCP tool `outputSchema`) in src/mcp/server.ts — #9309. `claimPlan` is left + * as opaque `unknown` (the disposition structure is not re-derived in the spec). + */ +export const PlanIdeaClaimsResponseSchema = z + .object({ + ok: z.boolean(), + verdict: z.enum(["go", "raise", "avoid"]).optional(), + claimPlan: z.unknown().optional(), + errors: z.array(z.string()).optional(), + }) + .openapi("PlanIdeaClaimsResponse"); + export const BurdenForecastSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 67f085e8c..a8c3122fe 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -37,6 +37,16 @@ import { IssueQualityReportSchema, IssueQualityResponseSchema, GateConfigEffectiveResponseSchema, + EvaluateEscalationRequestSchema, + EvaluateEscalationResponseSchema, + BuildResultsPayloadRequestSchema, + BuildResultsPayloadResponseSchema, + BuildProgressSnapshotRequestSchema, + BuildProgressSnapshotResponseSchema, + IntakeIdeaRequestSchema, + IntakeIdeaResponseSchema, + PlanIdeaClaimsRequestSchema, + PlanIdeaClaimsResponseSchema, LabelAuditSchema, LaneAdviceSchema, LiveGateThresholdsResponseSchema, @@ -163,6 +173,16 @@ export function buildOpenApiSpec() { registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("GateConfigEffectiveResponse", GateConfigEffectiveResponseSchema); + registry.register("EvaluateEscalationRequest", EvaluateEscalationRequestSchema); + registry.register("EvaluateEscalationResponse", EvaluateEscalationResponseSchema); + registry.register("BuildResultsPayloadRequest", BuildResultsPayloadRequestSchema); + registry.register("BuildResultsPayloadResponse", BuildResultsPayloadResponseSchema); + registry.register("BuildProgressSnapshotRequest", BuildProgressSnapshotRequestSchema); + registry.register("BuildProgressSnapshotResponse", BuildProgressSnapshotResponseSchema); + registry.register("IntakeIdeaRequest", IntakeIdeaRequestSchema); + registry.register("IntakeIdeaResponse", IntakeIdeaResponseSchema); + registry.register("PlanIdeaClaimsRequest", PlanIdeaClaimsRequestSchema); + registry.register("PlanIdeaClaimsResponse", PlanIdeaClaimsResponseSchema); registry.register("LiveGateThresholdsResponse", LiveGateThresholdsResponseSchema); registry.register("BurdenForecast", BurdenForecastSchema); registry.register("ContributorScoringProfile", ContributorScoringProfileSchema); @@ -466,6 +486,96 @@ export function buildOpenApiSpec() { 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/loop/evaluate-escalation", + summary: "Evaluate whether a loop outcome should escalate — REST mirror of loopover_evaluate_escalation (#9309)", + request: { + body: { + content: { "application/json": { schema: EvaluateEscalationRequestSchema } }, + }, + }, + responses: { + 200: { + description: + "Escalation decision over caller-supplied loop outcome + health signals — mirrors the loopover_evaluate_escalation MCP tool. Pure, source-free evaluator; it decides, the caller wires the action", + content: { "application/json": { schema: EvaluateEscalationResponseSchema } }, + }, + 400: { description: "Invalid evaluate-escalation request body" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/loop/results-payload", + summary: "Compose a loop results-delivery payload — REST mirror of loopover_build_results_payload (#9309)", + request: { + body: { + content: { "application/json": { schema: BuildResultsPayloadRequestSchema } }, + }, + }, + responses: { + 200: { + description: + "Formatted results payload over caller-supplied iteration metadata — mirrors the loopover_build_results_payload MCP tool. Pure composer; it formats the result, it does not fetch, open, or deliver anything", + content: { "application/json": { schema: BuildResultsPayloadResponseSchema } }, + }, + 400: { description: "Invalid results-payload request body" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/loop/progress-snapshot", + summary: "Compose a running-loop progress snapshot — REST mirror of loopover_build_progress_snapshot (#9309)", + request: { + body: { + content: { "application/json": { schema: BuildProgressSnapshotRequestSchema } }, + }, + }, + responses: { + 200: { + description: + "Formatted progress snapshot over caller-supplied loop state — mirrors the loopover_build_progress_snapshot MCP tool. Pure composer; it formats the snapshot, it does not fetch or stream anything", + content: { "application/json": { schema: BuildProgressSnapshotResponseSchema } }, + }, + 400: { description: "Invalid progress-snapshot request body" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/loop/intake-idea", + summary: "Validate an idea submission and assemble its task-graph — REST mirror of loopover_intake_idea (#9309)", + request: { + body: { + content: { "application/json": { schema: IntakeIdeaRequestSchema } }, + }, + }, + responses: { + 200: { + description: + "Idea verdict + assembled task-graph — mirrors the loopover_intake_idea MCP tool. Pure composer over the caller-supplied idea and optional decomposition", + content: { "application/json": { schema: IntakeIdeaResponseSchema } }, + }, + 400: { description: "Invalid intake-idea request body, or an empty/malformed idea submission (actionable error list returned)" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/loop/plan-idea-claims", + summary: "Disposition an idea's task-graph into a claim plan — REST mirror of loopover_plan_idea_claims (#9309)", + request: { + body: { + content: { "application/json": { schema: PlanIdeaClaimsRequestSchema } }, + }, + }, + responses: { + 200: { + description: + "Idea verdict + claim plan (which constituent issues can be claimed now vs. deferred) — mirrors the loopover_plan_idea_claims MCP tool. Pure composer over the caller-supplied idea and optional decomposition", + content: { "application/json": { schema: PlanIdeaClaimsResponseSchema } }, + }, + 400: { description: "Invalid plan-idea-claims request body, or an empty/malformed idea submission (actionable error list returned)" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/live-gate-thresholds", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index f4afc434c..627b631ab 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "vitest"; import { buildOpenApiSpec } from "../../src/openapi/spec"; +import { + evaluateEscalationShape, + evaluateEscalationOutputSchema, + buildResultsPayloadShape, + buildResultsPayloadOutputSchema, + buildProgressSnapshotShape, + buildProgressSnapshotOutputSchema, + intakeIdeaShape, + intakeIdeaOutputSchema, + planIdeaClaimsOutputSchema, +} from "../../src/mcp/server"; describe("OpenAPI contract", () => { it("exports the modern private-beta backend contract only", () => { @@ -149,6 +160,72 @@ describe("OpenAPI contract", () => { } }); + // #9309: the five /v1/loop/* idea/task-graph composer routes are each backed by an MCP tool whose Zod + // input/output shapes already live in src/mcp/server.ts. Assert every route is a documented POST path whose + // request/response components stay field-for-field in parity with those tool shapes, so a future field added + // to a tool but not the spec fails loudly here. request-apr-transfer is intentionally excluded from this batch. + it("documents the /v1/loop/* idea/task-graph route family with tool-parity schemas (#9309)", () => { + const spec = buildOpenApiSpec(); + const schemas = spec.components?.schemas ?? {}; + + const propKeys = (name: string) => + Object.keys((schemas[name] as { properties?: Record }).properties ?? {}).sort(); + + const cases = [ + { + path: "/v1/loop/evaluate-escalation", + request: "EvaluateEscalationRequest", + response: "EvaluateEscalationResponse", + inputShape: evaluateEscalationShape, + outputShape: evaluateEscalationOutputSchema, + }, + { + path: "/v1/loop/results-payload", + request: "BuildResultsPayloadRequest", + response: "BuildResultsPayloadResponse", + inputShape: buildResultsPayloadShape, + outputShape: buildResultsPayloadOutputSchema, + }, + { + path: "/v1/loop/progress-snapshot", + request: "BuildProgressSnapshotRequest", + response: "BuildProgressSnapshotResponse", + inputShape: buildProgressSnapshotShape, + outputShape: buildProgressSnapshotOutputSchema, + }, + { + path: "/v1/loop/intake-idea", + request: "IntakeIdeaRequest", + response: "IntakeIdeaResponse", + inputShape: intakeIdeaShape, + outputShape: intakeIdeaOutputSchema, + }, + { + // plan-idea-claims reuses intakeIdeaShape as its input; its output is the claim-plan disposition. + path: "/v1/loop/plan-idea-claims", + request: "PlanIdeaClaimsRequest", + response: "PlanIdeaClaimsResponse", + inputShape: intakeIdeaShape, + outputShape: planIdeaClaimsOutputSchema, + }, + ]; + + for (const { path, request, response, inputShape, outputShape } of cases) { + const op = spec.paths[path]?.post; + expect(op, `${path} should be a documented POST path`).toBeDefined(); + expect(op?.requestBody, `${path} should document a request body`).toBeDefined(); + + expect(schemas[request], `${request} component should be registered`).toBeDefined(); + expect(schemas[response], `${response} component should be registered`).toBeDefined(); + + expect(propKeys(request)).toEqual(Object.keys(inputShape).sort()); + expect(propKeys(response)).toEqual(Object.keys(outputShape).sort()); + } + + // request-apr-transfer is explicitly out of scope for this batch and must stay undocumented here. + expect(spec.paths["/v1/loop/request-apr-transfer"]).toBeUndefined(); + }); + it("declares an `in: path` parameter for every {templated} path segment (Cloudflare schema-validation warning 30046)", () => { const spec = buildOpenApiSpec(); for (const [path, methods] of Object.entries(spec.paths ?? {})) {