diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bfc3bf69df..648a9c199f 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14852,6 +14852,406 @@ "recommendation", "summary" ] + }, + "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" + } + } + } + }, + "BuildResultsPayloadResponse": { + "type": "object", + "properties": { + "prLink": { + "type": "string", + "nullable": true + }, + "summary": { + "type": "string" + }, + "diffPreview": { + "nullable": true + }, + "totals": { + "nullable": true + } + } + }, + "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" + } + } + }, + "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" + ] + }, + "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" + ] + }, + "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" + ] + }, + "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" + ] + }, + "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" + ] + }, + "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 + } + } + }, + "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 + } + } } }, "parameters": {}, @@ -19293,6 +19693,191 @@ } ] } + }, + "/v1/loop/evaluate-escalation": { + "post": { + "summary": "Evaluate whether a loop run should escalate (#6754)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvaluateEscalationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Escalation decision from the same evaluateEscalation function the loopover_evaluate_escalation MCP tool calls", + "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 (#6752)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildResultsPayloadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Results payload from the same buildResultsPayload function the loopover_build_results_payload MCP tool calls", + "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 loop progress snapshot (#6753)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildProgressSnapshotRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Progress snapshot from the same buildProgressSnapshot function the loopover_build_progress_snapshot MCP tool calls", + "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 (#6755)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntakeIdeaRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Validated idea with task graph (mirrors loopover_intake_idea MCP tool output)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntakeIdeaResponse" + } + } + } + }, + "400": { + "description": "Schema-invalid body, or engine validation errors (ok:false + errors)" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/loop/plan-idea-claims": { + "post": { + "summary": "Validate an idea and produce a claim disposition plan (#6756)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanIdeaClaimsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Claim plan from the same validate → task-graph → buildClaimPlan path the loopover_plan_idea_claims MCP tool uses", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanIdeaClaimsResponse" + } + } + } + }, + "400": { + "description": "Schema-invalid body, or engine validation errors (ok:false + errors)" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index d222f98739..843b25d1ea 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1926,6 +1926,126 @@ export const GateConfigEffectiveResponseSchema = z }) .openapi("GateConfigEffectiveResponse"); +// #9309: OpenAPI contracts for the /v1/loop/* idea/task-graph family (excl. request-apr-transfer). +// Field shapes mirror the MCP tool inputSchema/outputSchema constants in src/mcp/server.ts so the +// documented REST surface cannot drift from what each loopover_* tool already validates. + +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"); + +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"); + +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"); + +export const BuildResultsPayloadResponseSchema = z + .object({ + prLink: z.string().nullable().optional(), + summary: z.string().optional(), + diffPreview: z.unknown().optional(), + totals: z.unknown().optional(), + }) + .openapi("BuildResultsPayloadResponse"); + +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"); + +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"); + +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"); + +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"); + +/** Same fields as IntakeIdeaRequest — plan-idea-claims reuses the idea-intake input shape (#4799 / MCP). */ +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"); + +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 3c606361c2..9f9452e3bc 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,11 @@ export function buildOpenApiSpec() { registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("GateConfigEffectiveResponse", GateConfigEffectiveResponseSchema); + registry.register("EvaluateEscalationResponse", EvaluateEscalationResponseSchema); + registry.register("BuildResultsPayloadResponse", BuildResultsPayloadResponseSchema); + registry.register("BuildProgressSnapshotResponse", BuildProgressSnapshotResponseSchema); + registry.register("IntakeIdeaResponse", IntakeIdeaResponseSchema); + registry.register("PlanIdeaClaimsResponse", PlanIdeaClaimsResponseSchema); registry.register("LiveGateThresholdsResponse", LiveGateThresholdsResponseSchema); registry.register("BurdenForecast", BurdenForecastSchema); registry.register("ContributorScoringProfile", ContributorScoringProfileSchema); @@ -466,6 +481,82 @@ export function buildOpenApiSpec() { 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, }, }); + // #9309: REST mirrors of the five loopover_* idea/task-graph MCP tools (excl. request-apr-transfer). + registry.registerPath({ + method: "post", + path: "/v1/loop/evaluate-escalation", + summary: "Evaluate whether a loop run should escalate (#6754)", + request: { + body: { content: { "application/json": { schema: EvaluateEscalationRequestSchema } } }, + }, + responses: { + 200: { + description: "Escalation decision from the same evaluateEscalation function the loopover_evaluate_escalation MCP tool calls", + 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 (#6752)", + request: { + body: { content: { "application/json": { schema: BuildResultsPayloadRequestSchema } } }, + }, + responses: { + 200: { + description: "Results payload from the same buildResultsPayload function the loopover_build_results_payload MCP tool calls", + content: { "application/json": { schema: BuildResultsPayloadResponseSchema } }, + }, + 400: { description: "Invalid results-payload request body" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/loop/progress-snapshot", + summary: "Compose a loop progress snapshot (#6753)", + request: { + body: { content: { "application/json": { schema: BuildProgressSnapshotRequestSchema } } }, + }, + responses: { + 200: { + description: "Progress snapshot from the same buildProgressSnapshot function the loopover_build_progress_snapshot MCP tool calls", + 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 (#6755)", + request: { + body: { content: { "application/json": { schema: IntakeIdeaRequestSchema } } }, + }, + responses: { + 200: { + description: "Validated idea with task graph (mirrors loopover_intake_idea MCP tool output)", + content: { "application/json": { schema: IntakeIdeaResponseSchema } }, + }, + 400: { description: "Schema-invalid body, or engine validation errors (ok:false + errors)" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/loop/plan-idea-claims", + summary: "Validate an idea and produce a claim disposition plan (#6756)", + request: { + body: { content: { "application/json": { schema: PlanIdeaClaimsRequestSchema } } }, + }, + responses: { + 200: { + description: "Claim plan from the same validate → task-graph → buildClaimPlan path the loopover_plan_idea_claims MCP tool uses", + content: { "application/json": { schema: PlanIdeaClaimsResponseSchema } }, + }, + 400: { description: "Schema-invalid body, or engine validation errors (ok:false + errors)" }, + }, + }); 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 f4afc434cf..9fa18c474f 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -17,6 +17,12 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/repos/{owner}/{repo}/issue-quality"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/outcome-patterns"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gate-config/effective"]).toBeDefined(); + expect(spec.paths["/v1/loop/evaluate-escalation"]).toBeDefined(); + expect(spec.paths["/v1/loop/results-payload"]).toBeDefined(); + expect(spec.paths["/v1/loop/progress-snapshot"]).toBeDefined(); + expect(spec.paths["/v1/loop/intake-idea"]).toBeDefined(); + expect(spec.paths["/v1/loop/plan-idea-claims"]).toBeDefined(); + expect(spec.paths["/v1/loop/request-apr-transfer"]).toBeUndefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/registration-readiness"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gittensor-config-recommendation"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet"]).toBeDefined(); @@ -97,6 +103,12 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.RepoIntelligence).toBeDefined(); expect(spec.components?.schemas?.RepoOutcomePatterns).toBeDefined(); expect(spec.components?.schemas?.RegistrationReadiness).toBeDefined(); + expect(spec.components?.schemas?.GateConfigEffectiveResponse).toBeDefined(); + expect(spec.components?.schemas?.EvaluateEscalationResponse).toBeDefined(); + expect(spec.components?.schemas?.BuildResultsPayloadResponse).toBeDefined(); + expect(spec.components?.schemas?.BuildProgressSnapshotResponse).toBeDefined(); + expect(spec.components?.schemas?.IntakeIdeaResponse).toBeDefined(); + expect(spec.components?.schemas?.PlanIdeaClaimsResponse).toBeDefined(); expect(spec.components?.schemas?.GittensorConfigRecommendation).toBeDefined(); expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); @@ -162,4 +174,54 @@ describe("OpenAPI contract", () => { } } }); + + // #9309: the five /v1/loop/* idea/task-graph REST mirrors must appear in the generated spec with + // response schemas whose keys match each route's MCP tool outputSchema (src/mcp/server.ts). + it("#9309: documents the /v1/loop idea/task-graph family with MCP-aligned response schemas", () => { + const spec = buildOpenApiSpec(); + const expected: Array<{ path: string; schemaName: string; keys: string[] }> = [ + { + path: "/v1/loop/evaluate-escalation", + schemaName: "EvaluateEscalationResponse", + keys: ["shouldEscalate", "action", "severity", "reasons"], + }, + { + path: "/v1/loop/results-payload", + schemaName: "BuildResultsPayloadResponse", + keys: ["prLink", "summary", "diffPreview", "totals"], + }, + { + path: "/v1/loop/progress-snapshot", + schemaName: "BuildProgressSnapshotResponse", + keys: ["phase", "status", "iteration", "maxIterations", "percentComplete", "recentActivity", "done"], + }, + { + path: "/v1/loop/intake-idea", + schemaName: "IntakeIdeaResponse", + keys: ["ok", "verdict", "taskGraph", "errors"], + }, + { + path: "/v1/loop/plan-idea-claims", + schemaName: "PlanIdeaClaimsResponse", + keys: ["ok", "verdict", "claimPlan", "errors"], + }, + ]; + + for (const { path, schemaName, keys } of expected) { + expect(spec.paths[path]?.post, `${path} missing POST`).toBeDefined(); + expect(spec.paths[path]?.post?.summary?.trim(), `${path} missing summary`).not.toBe(""); + const responseSchema = (spec.paths[path]?.post?.responses as Record)?.[ + "200" + ]?.content?.["application/json"]?.schema; + expect(responseSchema?.$ref, `${path} 200 response should $ref ${schemaName}`).toBe(`#/components/schemas/${schemaName}`); + + const component = spec.components?.schemas?.[schemaName] as { properties?: Record } | undefined; + expect(component?.properties, `${schemaName} missing from components`).toBeDefined(); + for (const key of keys) { + expect(component!.properties![key], `${schemaName} missing property ${key}`).toBeDefined(); + } + } + + expect(spec.paths["/v1/loop/request-apr-transfer"]).toBeUndefined(); + }); });