diff --git a/AGENTS.md b/AGENTS.md index 409016367..6ce56eb73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ apps/ coder-acp-copilot/ # GitHub Copilot worker (ACP SDK) coder-acp-claude-code/ # Claude Code worker (ACP SDK) report-generator/ # Post-run report generation + readout/ # Submission-level generated readout model-scanners/ # Feature detection for Copilot and Anthropic models version-checkers/ # Poll for new agent/tool releases key-updaters/ # GitHub auth cookie management for VS Code Web @@ -69,6 +70,7 @@ Each worker implements the same queue-processor interface but adapts a different | `coder-acp-copilot` | GitHub Copilot | ACP SDK v0.14.1 | | `coder-acp-claude-code` | Claude Code | ACP SDK v0.13.1 | | `report-generator` | — | Copilot SDK | +| `readout` | — | Copilot SDK | Workers consume tasks from Azure Storage Queues (named `queue-`) and write results to MongoDB and Blob Storage. Each has its own Dockerfile and docker-compose profile. diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index 4f20cc04b..444397731 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -889,6 +889,33 @@ Timeout for the Copilot SDK session used by the report-generator worker. If the Git commit hash embedded in reporter metadata. Automatically set during CI/CD builds. Used to track which version of the report-generator produced a given report. +## Submission Readout Handler Configuration + +### AZURE_STORAGE_QUEUE_READOUT +**Default:** `pp-readout-queue` +**Type:** string + +Azure Storage Queue name for submission readout jobs. The scheduler enqueues +`{ type: "readout", submissionId, submissionRunId }` only after +`pp-observations` is done for every exact request/run pair in the current +submission attempt. + +### READOUT_MODEL +**Default:** `gpt-5.4` +**Type:** string + +The Copilot SDK model used by the dedicated `pp-readout` worker to synthesize a +schema-validated structured submission readout. Markdown is derived from the +validated structured object. + +### READOUT_SESSION_TIMEOUT_MS +**Default:** `300000` (5 minutes) +**Type:** integer (milliseconds) + +Timeout for one readout-generation attempt. Exhausted generation or schema +validation marks the exact current submission attempt's `pp-readout` handler as +failed; historical and superseded attempts remain immutable. + ## Scheduler Configuration ### SCHEDULER_POLL_INTERVAL_MS @@ -936,13 +963,13 @@ TTL for the DAG dispatcher's in-memory cache of handler topology (the `services` **Default:** `post-processor-queue` **Type:** string -Azure Storage Queue name used by both the scheduler (to enqueue post-processing work) and the post-processor worker (to dequeue). Must match between the two services. The `pp-taxonomy` observation handler shares this queue. +Azure Storage Queue name used by both the scheduler (to enqueue post-processing work) and the post-processor worker (to dequeue). Must match between the two services. The `pp-observations` handler shares this queue. ### JUDGE_URL **Default:** falls back to `JUDGE_SERVICE_URL`, then `http://localhost:3002` **Type:** URL string -Base URL of the judge service used by the `pp-taxonomy` post-process handler to evaluate observation criteria per iteration (snapshot + ATIF trajectory → boolean + evidence). The handler reuses the shared `JudgeClient`, so `JUDGE_CLIENT_TIMEOUT` / `JUDGE_CLIENT_RETRIES` apply. In K8s the post-processor reads `JUDGE_SERVICE_URL` from the `worker-config` ConfigMap (`envFrom`); in docker-compose the `post-processor` service sets `JUDGE_SERVICE_URL: http://judge:80` explicitly (it uses the `common-env` anchor, which — unlike `worker-env` — does not carry it). See issue #1156 and [docs/architecture/post-processing.md](docs/architecture/post-processing.md). +Base URL of the judge service used by the `pp-observations` post-process handler to evaluate observation criteria per iteration (snapshot + ATIF trajectory → boolean + evidence). The handler reuses the shared `JudgeClient`, so `JUDGE_CLIENT_TIMEOUT` / `JUDGE_CLIENT_RETRIES` apply. In K8s the post-processor reads `JUDGE_SERVICE_URL` from the `worker-config` ConfigMap (`envFrom`); in docker-compose the `post-processor` service sets `JUDGE_SERVICE_URL: http://judge:80` explicitly (it uses the `common-env` anchor, which — unlike `worker-env` — does not carry it). See issue #1156 and [docs/architecture/post-processing.md](docs/architecture/post-processing.md). ### SCHEDULER_URL **Default:** (not set) diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap index 317154c30..293a09669 100644 --- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap +++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap @@ -906,6 +906,14 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "name": { "type": "string", }, + "subject": { + "default": "run", + "enum": [ + "run", + "submission", + ], + "type": "string", + }, "systemPrompt": { "$ref": "#/components/schemas/ReportTemplateSystemPrompt", }, @@ -2450,6 +2458,15 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "type": "string", }, + "structuredArtifact": { + "$ref": "#/components/schemas/StructuredReportArtifact", + }, + "submissionId": { + "type": "string", + }, + "submissionRunId": { + "type": "string", + }, "templateId": { "type": "string", }, @@ -2463,7 +2480,6 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "required": [ "_id", - "requestId", "status", "createdAt", "projectId", @@ -2531,6 +2547,14 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "projectId": { "type": "string", }, + "subject": { + "default": "run", + "enum": [ + "run", + "submission", + ], + "type": "string", + }, "systemPrompt": { "$ref": "#/components/schemas/ReportTemplateSystemPrompt", }, @@ -2817,9 +2841,27 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "type": "array", }, + "submissionExpectedRequestCount": { + "minimum": 1, + "type": "integer", + }, + "submissionFinalizedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, "submissionId": { "type": "string", }, + "submissionInitialRunId": { + "type": "string", + }, + "submissionRequestIndex": { + "minimum": 0, + "type": "integer", + }, "taskPromptId": { "type": "string", }, @@ -3655,208 +3697,688 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "type": "object", }, - "SuggestedPromptFeature": { + "StructuredReportArtifact": { "properties": { - "behavior": { + "contentType": { + "enum": [ + "application/json", + ], "type": "string", }, - "prompt": { + "schemaId": { "type": "string", }, - "suggestedId": { + "schemaVersion": { + "type": "string", + }, + "url": { "type": "string", }, }, "required": [ - "suggestedId", - "behavior", - "prompt", + "schemaId", + "schemaVersion", + "contentType", + "url", ], "type": "object", }, - "TaskPromptResponse": { + "SubmissionBlockingDetail": { "properties": { - "_id": { + "error": { "type": "string", }, - "contentBlobUrl": { + "handlerId": { "type": "string", }, - "createdAt": { - "format": "date-time", - "type": [ - "string", - "null", + "reason": { + "enum": [ + "request-missing", + "run-changed", + "dependency-not-ready", + "dependency-failed", + "dependency-blocked", ], + "type": "string", }, - "deletedAt": { + "requestId": { + "type": "string", + }, + "runId": { + "type": "string", + }, + }, + "required": [ + "requestId", + "runId", + "handlerId", + "reason", + ], + "type": "object", + }, + "SubmissionDependencyProjection": { + "properties": { + "error": { + "type": "string", + }, + "handlerId": { + "type": "string", + }, + "requestId": { + "type": "string", + }, + "runId": { + "type": "string", + }, + "state": { + "enum": [ + "waiting", + "ready", + "failed", + "blocked", + ], + "type": "string", + }, + "updatedAt": { "format": "date-time", "type": [ "string", "null", ], }, - "features": { - "items": { - "$ref": "#/components/schemas/PromptFeatureResult", - }, - "type": "array", + "version": { + "type": "number", }, - "featuresExtractedAt": { + }, + "required": [ + "requestId", + "runId", + "handlerId", + "state", + ], + "type": "object", + }, + "SubmissionHandlerStatus": { + "properties": { + "error": { + "type": "string", + }, + "status": { + "enum": [ + "queued", + "processing", + "done", + "failed", + ], + "type": "string", + }, + "updatedAt": { "format": "date-time", "type": [ "string", "null", ], }, - "keyId": { - "type": "string", - }, - "projectId": { - "type": "string", - }, - "text": { - "type": "string", + "version": { + "type": "number", }, - "type": { - "$ref": "#/components/schemas/PromptType", + }, + "required": [ + "status", + ], + "type": "object", + }, + "SubmissionListResponse": { + "properties": { + "submissions": { + "items": { + "$ref": "#/components/schemas/SubmissionResponse", + }, + "type": "array", }, }, "required": [ - "_id", - "keyId", - "createdAt", - "projectId", + "submissions", ], "type": "object", }, - "TokenUsage": { + "SubmissionReportPointer": { "properties": { - "completionTokens": { - "type": "number", + "reportId": { + "type": "string", }, - "promptTokens": { - "type": "number", + "updatedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], }, - "totalTokens": { - "type": "number", + "url": { + "type": "string", }, }, - "required": [ - "promptTokens", - "completionTokens", - "totalTokens", - ], "type": "object", }, - "TriggerReportsInput": { + "SubmissionRequestRef": { "properties": { "requestId": { "type": "string", }, + "runId": { + "type": "string", + }, }, "required": [ "requestId", + "runId", ], "type": "object", }, - "UpdateAgentInput": { + "SubmissionRequestSummary": { "properties": { - "available": { - "type": "boolean", - }, - "capabilities": { - "$ref": "#/components/schemas/AgentCapabilities", - }, - "defaultModel": { + "model": { "type": "string", }, - "description": { + "outcome": { "type": "string", }, - "modelProvider": { + "profileId": { "type": "string", }, - "name": { + "requestId": { "type": "string", }, - "supportedModels": { - "items": { - "type": "string", - }, - "type": "array", + "runId": { + "type": "string", }, - }, - "type": "object", - }, - "UpdateCodebaseInput": { - "properties": { - "defaultBranch": { + "status": { "type": "string", }, - "description": { + "task": { "type": "string", }, - "name": { - "minLength": 1, + "workerType": { "type": "string", }, }, + "required": [ + "requestId", + "runId", + "task", + "status", + "workerType", + ], "type": "object", }, - "UpdateCriteriaInput": { + "SubmissionResponse": { "properties": { - "dependsOn": { + "attemptNumber": { + "type": "integer", + }, + "blocking": { "items": { - "type": "string", + "$ref": "#/components/schemas/SubmissionBlockingDetail", }, "type": "array", }, - "gates": { + "createdAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "dependencyProjection": { "items": { - "enum": [ - "select", - "build", - "test", - "run", - "deploy", - ], - "type": "string", + "$ref": "#/components/schemas/SubmissionDependencyProjection", }, "type": "array", }, - "kind": { - "enum": [ - "gate", - "observation", - ], - "type": "string", - }, - "prompt": { - "type": "string", + "handlerProgress": { + "additionalProperties": { + "$ref": "#/components/schemas/SubmissionHandlerStatus", + }, + "type": "object", }, - "subject": { - "enum": [ - "run", - "iteration", - ], + "projectId": { "type": "string", }, - "taxonomyElementId": { + "readiness": { "enum": [ - "dimension:idiomatic-use", - "dimension:dependency-currency", - "dimension:configuration-correctness", + "waiting", + "ready", + "blocked", + "processing", + "done", + "failed", ], "type": "string", }, - }, - "type": "object", - }, - "UpdateExtensionInput": { - "properties": { - "description": { - "type": "string", + "report": { + "$ref": "#/components/schemas/SubmissionReportPointer", + }, + "requestIds": { + "items": { + "type": "string", + }, + "type": "array", + }, + "requests": { + "items": { + "$ref": "#/components/schemas/SubmissionRequestSummary", + }, + "type": "array", + }, + "submissionId": { + "type": "string", + }, + "submissionRunId": { + "type": "string", + }, + "supersededAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "supersededBySubmissionRunId": { + "type": "string", + }, + "supersededReason": { + "enum": [ + "request-vector-changed", + "manual-retry", + ], + "type": "string", + }, + "updatedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "submissionId", + "projectId", + "requestIds", + "createdAt", + "submissionRunId", + "attemptNumber", + "readiness", + "requests", + "dependencyProjection", + "blocking", + ], + "type": "object", + }, + "SubmissionRetryResponse": { + "properties": { + "attemptNumber": { + "type": "integer", + }, + "blocking": { + "items": { + "$ref": "#/components/schemas/SubmissionBlockingDetail", + }, + "type": "array", + }, + "readiness": { + "enum": [ + "waiting", + "ready", + "blocked", + "processing", + "done", + "failed", + ], + "type": "string", + }, + "submissionId": { + "type": "string", + }, + "submissionRunId": { + "type": "string", + }, + }, + "required": [ + "submissionId", + "submissionRunId", + "attemptNumber", + "readiness", + "blocking", + ], + "type": "object", + }, + "SubmissionRunResponse": { + "properties": { + "_id": { + "type": "string", + }, + "attemptNumber": { + "minimum": 1, + "type": "integer", + }, + "blocking": { + "default": [], + "items": { + "$ref": "#/components/schemas/SubmissionBlockingDetail", + }, + "type": "array", + }, + "createdAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "dependencyProjection": { + "default": [], + "items": { + "$ref": "#/components/schemas/SubmissionDependencyProjection", + }, + "type": "array", + }, + "finishedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "handlerStatus": { + "additionalProperties": { + "$ref": "#/components/schemas/SubmissionHandlerStatus", + }, + "type": "object", + }, + "projectId": { + "type": "string", + }, + "report": { + "$ref": "#/components/schemas/SubmissionReportPointer", + }, + "reportsTriggeredAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "requests": { + "items": { + "$ref": "#/components/schemas/SubmissionRequestRef", + }, + "type": "array", + }, + "startedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "submissionId": { + "type": "string", + }, + "supersededAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "supersededBySubmissionRunId": { + "type": "string", + }, + "supersededReason": { + "enum": [ + "request-vector-changed", + "manual-retry", + ], + "type": "string", + }, + "updatedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "vectorFingerprint": { + "type": "string", + }, + }, + "required": [ + "_id", + "attemptNumber", + "vectorFingerprint", + "requests", + "createdAt", + ], + "type": "object", + }, + "SuggestedPromptFeature": { + "properties": { + "behavior": { + "type": "string", + }, + "prompt": { + "type": "string", + }, + "suggestedId": { + "type": "string", + }, + }, + "required": [ + "suggestedId", + "behavior", + "prompt", + ], + "type": "object", + }, + "TaskPromptResponse": { + "properties": { + "_id": { + "type": "string", + }, + "contentBlobUrl": { + "type": "string", + }, + "createdAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "deletedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "features": { + "items": { + "$ref": "#/components/schemas/PromptFeatureResult", + }, + "type": "array", + }, + "featuresExtractedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "keyId": { + "type": "string", + }, + "projectId": { + "type": "string", + }, + "text": { + "type": "string", + }, + "type": { + "$ref": "#/components/schemas/PromptType", + }, + }, + "required": [ + "_id", + "keyId", + "createdAt", + "projectId", + ], + "type": "object", + }, + "TokenUsage": { + "properties": { + "completionTokens": { + "type": "number", + }, + "promptTokens": { + "type": "number", + }, + "totalTokens": { + "type": "number", + }, + }, + "required": [ + "promptTokens", + "completionTokens", + "totalTokens", + ], + "type": "object", + }, + "TriggerReportsInput": { + "properties": { + "requestId": { + "type": "string", + }, + }, + "required": [ + "requestId", + ], + "type": "object", + }, + "TriggerSubmissionReportsInput": { + "properties": { + "submissionId": { + "type": "string", + }, + "submissionRunId": { + "type": "string", + }, + }, + "required": [ + "submissionId", + "submissionRunId", + ], + "type": "object", + }, + "UpdateAgentInput": { + "properties": { + "available": { + "type": "boolean", + }, + "capabilities": { + "$ref": "#/components/schemas/AgentCapabilities", + }, + "defaultModel": { + "type": "string", + }, + "description": { + "type": "string", + }, + "modelProvider": { + "type": "string", + }, + "name": { + "type": "string", + }, + "supportedModels": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "type": "object", + }, + "UpdateCodebaseInput": { + "properties": { + "defaultBranch": { + "type": "string", + }, + "description": { + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + }, + "type": "object", + }, + "UpdateCriteriaInput": { + "properties": { + "dependsOn": { + "items": { + "type": "string", + }, + "type": "array", + }, + "gates": { + "items": { + "enum": [ + "select", + "build", + "test", + "run", + "deploy", + ], + "type": "string", + }, + "type": "array", + }, + "kind": { + "enum": [ + "gate", + "observation", + ], + "type": "string", + }, + "prompt": { + "type": "string", + }, + "subject": { + "enum": [ + "run", + "iteration", + ], + "type": "string", + }, + "taxonomyElementId": { + "enum": [ + "dimension:idiomatic-use", + "dimension:dependency-currency", + "dimension:configuration-correctness", + ], + "type": "string", + }, + }, + "type": "object", + }, + "UpdateExtensionInput": { + "properties": { + "description": { + "type": "string", }, "name": { "type": "string", @@ -3996,6 +4518,13 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "name": { "type": "string", }, + "subject": { + "enum": [ + "run", + "submission", + ], + "type": "string", + }, "systemPrompt": { "allOf": [ { @@ -8347,6 +8876,22 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "type": "string", }, }, + { + "in": "query", + "name": "submissionId", + "required": false, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "submissionRunId", + "required": false, + "schema": { + "type": "string", + }, + }, { "in": "query", "name": "projectId", @@ -8600,6 +9145,56 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, + "/api/v1/reports/trigger-submission": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerSubmissionReportsInput", + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "reports": { + "items": { + "$ref": "#/components/schemas/ReportResponse", + }, + "type": "array", + }, + "triggered": { + "type": "number", + }, + }, + "required": [ + "triggered", + "reports", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Submission attempt not found", + }, + "409": { + "description": "Submission attempt is not ready", + }, + }, + "summary": "Trigger submission reports", + "tags": [ + "Reports", + ], + }, + }, "/api/v1/reports/{id}": { "get": { "parameters": [ @@ -8757,13 +9352,39 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight already referenced by this report", }, }, - "summary": "Link insight to report", + "summary": "Link insight to report", + "tags": [ + "Reports", + ], + }, + }, + "/api/v1/reports/{id}/logs": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "Server-sent event stream of log entries", + }, + "404": { + "description": "Report not found", + }, + }, + "summary": "Stream report logs (SSE)", "tags": [ "Reports", ], }, }, - "/api/v1/reports/{id}/logs": { + "/api/v1/reports/{id}/structured": { "get": { "parameters": [ { @@ -8777,13 +9398,13 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "responses": { "200": { - "description": "Server-sent event stream of log entries", + "description": "Success", }, "404": { - "description": "Report not found", + "description": "Report or structured artifact not found", }, }, - "summary": "Stream report logs (SSE)", + "summary": "Download a report's structured JSON artifact", "tags": [ "Reports", ], @@ -11593,6 +12214,292 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, + "/api/v1/submissions": { + "get": { + "parameters": [ + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionListResponse", + }, + }, + }, + "description": "Success", + }, + }, + "summary": "List submissions", + "tags": [ + "Submissions", + ], + }, + }, + "/api/v1/submissions/{id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Submission not found", + }, + }, + "summary": "Get a submission", + "tags": [ + "Submissions", + ], + }, + }, + "/api/v1/submissions/{id}/reports": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ReportResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Submission not found", + }, + }, + "summary": "Get reports for submission", + "tags": [ + "Reports", + ], + }, + }, + "/api/v1/submissions/{id}/retry": { + "post": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "submissionRunId": { + "type": "string", + }, + }, + "required": [ + "submissionRunId", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionRetryResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Submission not found", + }, + "409": { + "description": "Submission attempt is stale or dependencies are blocked", + }, + }, + "summary": "Retry current submission processing", + "tags": [ + "Submissions", + ], + }, + }, + "/api/v1/submissions/{id}/runs": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "runs": { + "items": { + "$ref": "#/components/schemas/SubmissionRunResponse", + }, + "type": "array", + }, + }, + "required": [ + "runs", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Submission not found", + }, + }, + "summary": "List current and historical submission attempts", + "tags": [ + "Submissions", + ], + }, + }, + "/api/v1/submissions/{id}/runs/{submissionRunId}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", + "name": "submissionRunId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionRunResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Submission attempt not found", + }, + }, + "summary": "Get one submission attempt", + "tags": [ + "Submissions", + ], + }, + }, + "/api/v1/submissions/{id}/runs/{submissionRunId}/logs": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", + "name": "submissionRunId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "Server-sent event stream of log entries", + }, + "404": { + "description": "Submission or submission attempt not found", + }, + }, + "summary": "Stream logs for a submission attempt (SSE)", + "tags": [ + "Submissions", + ], + }, + }, "/api/v1/task-prompts": { "get": { "parameters": [ diff --git a/apps/api/src/archive-har.test.ts b/apps/api/src/archive-har.test.ts index 9d01fc261..f123b4b8c 100644 --- a/apps/api/src/archive-har.test.ts +++ b/apps/api/src/archive-har.test.ts @@ -99,6 +99,7 @@ describe("rewriteHarUrlsForArchive", () => { expect(result.run!.turns![1].toolCallsUrl).toBe("iteration-2.tool-calls.jsonl"); expect(result.run!.turns![2].toolCallsUrl).toBeUndefined(); }); + }); // --- blobNameFromSnapshotsUrl --- @@ -902,7 +903,6 @@ describe("packRunIntoTar", () => { await packRunIntoTar(p, run, container, "run-011", () => true); p.finalize(); await entriesPromise; - expect(run.codebase!.archiveUrl).toBe(blobUrl); }); }); diff --git a/apps/api/src/openapi/openapi.test.ts b/apps/api/src/openapi/openapi.test.ts index 87426de43..6ebe182c8 100644 --- a/apps/api/src/openapi/openapi.test.ts +++ b/apps/api/src/openapi/openapi.test.ts @@ -36,6 +36,7 @@ import { BulkCreateReportsInputSchema, BulkReportStatusInputSchema, TriggerReportsInputSchema, + TriggerSubmissionReportsInputSchema, BulkTriggerReportsInputSchema, SkillResponseSchema, SkillRevisionResponseSchema, @@ -433,6 +434,17 @@ apiRoute(testApp, registry, { successStatus: 201, errorResponses: { 400: { description: "Invalid input" }, 404: { description: "Run not found" } }, handler: noop, }); +apiRoute(testApp, registry, { + method: "post", path: "/api/v1/reports/trigger-submission", tags: ["Reports"], + summary: "Trigger submission reports", body: TriggerSubmissionReportsInputSchema, + response: z.object({ triggered: z.number(), reports: z.array(ReportResponseSchema) }), + successStatus: 201, + errorResponses: { + 404: { description: "Submission attempt not found" }, + 409: { description: "Submission attempt is not ready" }, + }, + handler: noop, +}); apiRoute(testApp, registry, { method: "post", path: "/api/v1/reports/bulk-trigger", tags: ["Reports"], summary: "Bulk trigger reports", body: BulkTriggerReportsInputSchema, diff --git a/apps/api/src/routes/report-templates.ts b/apps/api/src/routes/report-templates.ts index d0e2a98d1..4429420d7 100644 --- a/apps/api/src/routes/report-templates.ts +++ b/apps/api/src/routes/report-templates.ts @@ -119,7 +119,17 @@ apiRoute(ctx.app, ctx.registry, { }, handler: async (req, res, next) => { try { - const { id, name, description, userPrompt, systemPrompt, trigger, model, timeoutMs } = req.body; + const { + id, + name, + subject = "run", + description, + userPrompt, + systemPrompt, + trigger, + model, + timeoutMs, + } = req.body; const projectId = getQueryProjectId(req); if (!id || typeof id !== "string") { @@ -138,6 +148,16 @@ apiRoute(ctx.app, ctx.registry, { res.status(400).json({ error: "userPrompt is required and must be a string" }); return; } + if ( + subject === "submission" && + trigger !== undefined && + trigger.type !== "always" + ) { + res.status(400).json({ + error: "Submission report templates currently support the automatic trigger only", + }); + return; + } // Validate systemPrompt if provided if (systemPrompt !== undefined) { @@ -196,6 +216,7 @@ apiRoute(ctx.app, ctx.registry, { { $set: { name, + subject, ...(description !== undefined ? { description } : {}), userPrompt, ...(systemPrompt !== undefined ? { systemPrompt } : {}), @@ -214,6 +235,7 @@ apiRoute(ctx.app, ctx.registry, { projectId, id, name, + subject, ...(description ? { description } : {}), userPrompt, ...(systemPrompt ? { systemPrompt } : {}), @@ -248,7 +270,16 @@ apiRoute(ctx.app, ctx.registry, { try { const { id } = req.params; const projectId = getQueryProjectId(req); - const { name, description, userPrompt, systemPrompt, trigger, model, timeoutMs } = req.body; + const { + name, + subject, + description, + userPrompt, + systemPrompt, + trigger, + model, + timeoutMs, + } = req.body; const existing = await ctx.reportTemplateCollection.findOne({ projectId, id, deletedAt: { $exists: false } }); if (!existing) { @@ -258,6 +289,7 @@ apiRoute(ctx.app, ctx.registry, { const updateFields: Record = { updatedAt: new Date() }; if (name !== undefined) updateFields.name = name; + if (subject !== undefined) updateFields.subject = subject; if (description !== undefined) updateFields.description = description; if (userPrompt !== undefined) { if (typeof userPrompt !== "string" || !userPrompt.trim()) { @@ -292,6 +324,18 @@ apiRoute(ctx.app, ctx.registry, { res.status(400).json({ error: triggerError }); return; } + const effectiveSubject = subject ?? existing.subject ?? "run"; + const effectiveTrigger = trigger ?? existing.trigger; + if ( + effectiveSubject === "submission" && + effectiveTrigger !== undefined && + effectiveTrigger.type !== "always" + ) { + res.status(400).json({ + error: "Submission report templates currently support the automatic trigger only", + }); + return; + } updateFields.trigger = trigger; } } diff --git a/apps/api/src/routes/reports-structured.test.ts b/apps/api/src/routes/reports-structured.test.ts new file mode 100644 index 000000000..c2f45cef9 --- /dev/null +++ b/apps/api/src/routes/reports-structured.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import type { RouteContext } from "../route-context.js"; +import { registerReportsRoutes } from "./reports.js"; + +describe("structured report download", () => { + it("proxies the stored JSON artifact through the API", async () => { + const app = express(); + app.use(express.json()); + const downloadJson = vi.fn().mockResolvedValue({ + meta: { submissionRunId: "submission-run-1" }, + }); + const ctx = { + app, + registry: new OpenAPIRegistry(), + reportCollection: { + findOne: vi.fn().mockResolvedValue({ + _id: "report-1", + projectId: "project-1", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + status: "completed", + structuredArtifact: { + schemaId: "scope.submission-readout", + schemaVersion: "1.0", + contentType: "application/json", + url: "https://storage.example/structured.json", + }, + createdAt: new Date(), + }), + }, + blobStorage: { downloadJson }, + } as unknown as RouteContext; + registerReportsRoutes(ctx); + app.use(( + error: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => res.status(500).json({ error: error.message })); + + const response = await request(app).get("/api/v1/reports/report-1/structured"); + + expect(response.status).toBe(200); + expect(response.headers["content-disposition"]).toContain( + "report-1.structured.json", + ); + expect(response.body).toEqual({ + meta: { submissionRunId: "submission-run-1" }, + }); + expect(downloadJson).toHaveBeenCalledWith( + "https://storage.example/structured.json", + ); + }); + + it("filters submission reports by the exact submission run", async () => { + const app = express(); + app.use(express.json()); + const toArray = vi.fn().mockResolvedValue([{ + _id: "report-1", + projectId: "project-1", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + status: "completed", + createdAt: new Date("2026-10-13T12:00:00.000Z"), + }]); + const find = vi.fn(() => ({ + sort: vi.fn(() => ({ toArray })), + })); + const ctx = { + app, + registry: new OpenAPIRegistry(), + reportCollection: { find }, + requestCollection: { + find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })), + }, + } as unknown as RouteContext; + registerReportsRoutes(ctx); + + const response = await request(app).get( + "/api/v1/reports?submissionId=submission-1&submissionRunId=submission-run-1", + ); + + expect(response.status).toBe(200); + expect(find).toHaveBeenCalledWith({ + submissionId: "submission-1", + submissionRunId: "submission-run-1", + }); + expect(response.body[0]).toMatchObject({ + id: "report-1", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + }); + }); +}); diff --git a/apps/api/src/routes/reports-submission-trigger.test.ts b/apps/api/src/routes/reports-submission-trigger.test.ts new file mode 100644 index 000000000..0dada00ed --- /dev/null +++ b/apps/api/src/routes/reports-submission-trigger.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import express from "express"; +import type { Collection } from "mongodb"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import type { + ReportDocument, + ReportTemplateDocument, + SubmissionDocument, +} from "shared"; +import type { RouteContext } from "../route-context.js"; +import { registerReportsRoutes } from "./reports.js"; + +function queryResult(items: T[]) { + return { + sort: () => queryResult(items), + toArray: async () => items, + }; +} + +function buildApp(input: { + existingReport?: ReportDocument; + submissionRunId?: string; +}) { + const submission: SubmissionDocument = { + _id: "submission-1", + submissionId: "submission-1", + projectId: "project-1", + requestIds: ["request-1"], + createdAt: new Date(), + run: { + _id: input.submissionRunId ?? "submission-run-1", + attemptNumber: 1, + vectorFingerprint: "vector", + requests: [{ requestId: "request-1", runId: "run-1" }], + dependencyProjection: [], + blocking: [], + handlerStatus: { + "pp-readout": { status: "done", version: 1 }, + }, + report: { reportId: "readout-report" }, + createdAt: new Date(), + }, + }; + const template: ReportTemplateDocument = { + _id: "template-document-1", + projectId: "project-1", + id: "executive-summary", + name: "Executive Summary", + subject: "submission", + userPrompt: "Summarize this submission.", + trigger: { type: "always" }, + createdAt: new Date(), + }; + const insertOne = vi.fn(async () => ({ acknowledged: true })); + const sendMessage = vi.fn(async () => ({ messageId: "queued" })); + const ctx = { + app: express(), + registry: new OpenAPIRegistry(), + submissionCollection: { + findOne: vi.fn(async (filter: Record) => + filter._id === submission._id && + filter["run._id"] === submission.run._id + ? submission + : null, + ), + } as unknown as Collection, + reportTemplateCollection: { + find: vi.fn(() => queryResult([template])), + } as unknown as Collection, + reportCollection: { + findOne: vi.fn(async () => input.existingReport ?? null), + insertOne, + } as unknown as Collection, + reportQueueClient: { sendMessage }, + } as unknown as RouteContext; + ctx.app.use(express.json()); + registerReportsRoutes(ctx); + ctx.app.use( + ( + error: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => res.status(500).json({ error: error.message }), + ); + return { app: ctx.app, insertOne, sendMessage }; +} + +describe("submission report template trigger", () => { + it("queues one report for each matching submission template", async () => { + const { app, insertOne, sendMessage } = buildApp({}); + + const response = await request(app) + .post("/api/v1/reports/trigger-submission") + .send({ + submissionId: "submission-1", + submissionRunId: "submission-run-1", + }); + + expect(response.status).toBe(201); + expect(response.body.triggered).toBe(1); + expect(insertOne).toHaveBeenCalledWith( + expect.objectContaining({ + submissionId: "submission-1", + submissionRunId: "submission-run-1", + templateId: "executive-summary", + status: "pending", + }), + ); + expect(sendMessage).toHaveBeenCalledOnce(); + }); + + it("rejects a stale submission attempt", async () => { + const { app, insertOne } = buildApp({ + submissionRunId: "submission-run-current", + }); + + const response = await request(app) + .post("/api/v1/reports/trigger-submission") + .send({ + submissionId: "submission-1", + submissionRunId: "submission-run-stale", + }); + + expect(response.status).toBe(404); + expect(insertOne).not.toHaveBeenCalled(); + }); + + it("does not create a duplicate report for the same attempt and template", async () => { + const { app, insertOne, sendMessage } = buildApp({ + existingReport: { + _id: "existing-report", + projectId: "project-1", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + templateId: "executive-summary", + status: "completed", + createdAt: new Date(), + }, + }); + + const response = await request(app) + .post("/api/v1/reports/trigger-submission") + .send({ + submissionId: "submission-1", + submissionRunId: "submission-run-1", + }); + + expect(response.status).toBe(201); + expect(response.body.triggered).toBe(0); + expect(insertOne).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index d6dd7aa32..4a1d71664 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -14,7 +14,9 @@ import { InsightResponseSchema, ReportResponseSchema, TriggerReportsInputSchema, + TriggerSubmissionReportsInputSchema, evaluateTrigger, + withRetry, } from "shared"; import type { TaskPromptDocument } from "shared"; import { apiRoute } from "../openapi/api-route.js"; @@ -108,18 +110,29 @@ apiRoute(ctx.app, ctx.registry, { path: "/api/v1/reports", tags: ["Reports"], summary: "List reports", - query: z.object({ requestId: z.string().optional(), projectId: z.string().optional() }), + query: z.object({ + requestId: z.string().optional(), + submissionId: z.string().optional(), + submissionRunId: z.string().optional(), + projectId: z.string().optional(), + }), response: z.array(ReportResponseSchema), handler: async (req, res, next) => { try { const requestIdFilter = req.query.requestId as string; + const submissionIdFilter = req.query.submissionId as string; + const submissionRunIdFilter = req.query.submissionRunId as string; const filter: Record = {}; if (requestIdFilter) { // Parent-scoped: the run id already pins the project. filter.requestId = requestIdFilter; + } else if (submissionIdFilter) { + filter.submissionId = submissionIdFilter; + if (submissionRunIdFilter) filter.submissionRunId = submissionRunIdFilter; } else { // Top-level reports list requires an explicit project (400 if absent). filter.projectId = getQueryProjectId(req); + if (submissionRunIdFilter) filter.submissionRunId = submissionRunIdFilter; } const reports = await ctx.reportCollection @@ -128,13 +141,21 @@ apiRoute(ctx.app, ctx.registry, { .toArray(); // Enrich reports with the task from their associated run - const requestIds = [...new Set(reports.map(r => r.requestId))]; + const requestIds = [ + ...new Set(reports.flatMap((report) => report.requestId ? [report.requestId] : [])), + ]; const runs = requestIds.length > 0 ? await ctx.requestCollection.find({ _id: { $in: requestIds } as any }, { projection: { _id: 1, "scenario.task": 1 } }).toArray() : []; const taskByRequestId = new Map(runs.map(r => [r._id, r.scenario?.task])); - res.json(reports.map(r => ({ ...r, id: r._id, task: taskByRequestId.get(r.requestId) }))); + res.json(reports.map((report) => ({ + ...report, + id: report._id, + ...(report.requestId + ? { task: taskByRequestId.get(report.requestId) } + : {}), + }))); } catch (error) { next(error); } @@ -201,6 +222,39 @@ apiRoute(ctx.app, ctx.registry, { }, }); +apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/reports/:id/structured", + tags: ["Reports"], + summary: "Download a report's structured JSON artifact", + params: z.object({ id: z.string() }), + response: z.any(), + rawResponse: true, + errorResponses: { + 404: { description: "Report or structured artifact not found" }, + }, + handler: async (req, res, next) => { + try { + const report = await ctx.reportCollection.findOne({ _id: req.params.id }); + if (!report?.structuredArtifact) { + res.status(404).json({ error: "Structured report artifact not found" }); + return; + } + const structured = await withRetry(() => + ctx.blobStorage.downloadJson(report.structuredArtifact!.url) + ); + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${req.params.id}.structured.json"`, + ); + res.json(structured); + } catch (error) { + next(error); + } + }, +}); + apiRoute(ctx.app, ctx.registry, { method: "post", path: "/api/v1/reports/bulk-status", @@ -229,7 +283,7 @@ apiRoute(ctx.app, ctx.registry, { // Build a map of requestId → latest report status const statusMap: Record = {}; for (const report of reports) { - if (!statusMap[report.requestId]) { + if (report.requestId && !statusMap[report.requestId]) { statusMap[report.requestId] = { reportId: report._id, status: report.status, @@ -510,7 +564,11 @@ apiRoute(ctx.app, ctx.registry, { // Load all active report templates const templates = await ctx.reportTemplateCollection - .find({ deletedAt: { $exists: false } }) + .find({ + projectId: run.projectId, + deletedAt: { $exists: false }, + $or: [{ subject: "run" }, { subject: { $exists: false } }], + }) .toArray(); // Evaluate each template's trigger against the run @@ -551,6 +609,89 @@ apiRoute(ctx.app, ctx.registry, { }, }); +// POST /api/v1/reports/trigger-submission — trigger submission-scoped templates +// once the exact current attempt and its generated evidence are complete. +apiRoute(ctx.app, ctx.registry, { + method: "post", + path: "/api/v1/reports/trigger-submission", + tags: ["Reports"], + summary: "Trigger submission reports", + body: TriggerSubmissionReportsInputSchema, + response: z.object({ + triggered: z.number(), + reports: z.array(ReportResponseSchema), + }), + successStatus: 201, + errorResponses: { + 404: { description: "Submission attempt not found" }, + 409: { description: "Submission attempt is not ready" }, + }, + handler: async (req, res, next) => { + try { + const { submissionId, submissionRunId } = req.body; + const submission = await ctx.submissionCollection.findOne({ + _id: submissionId, + "run._id": submissionRunId, + }); + if (!submission) { + res.status(404).json({ error: "Current submission attempt not found" }); + return; + } + if ( + submission.run.handlerStatus?.["pp-readout"]?.status !== "done" || + !submission.run.report?.reportId + ) { + res.status(409).json({ + error: "Submission attempt evidence is not complete", + }); + return; + } + + const templates = await ctx.reportTemplateCollection + .find({ + projectId: submission.projectId, + subject: "submission", + deletedAt: { $exists: false }, + }) + .toArray(); + const created: ReportDocument[] = []; + for (const template of templates) { + if (template.trigger && template.trigger.type !== "always") continue; + const existing = await ctx.reportCollection.findOne({ + submissionId, + submissionRunId, + templateId: template.id, + }); + if (existing) continue; + + const reportDoc: ReportDocument = { + _id: uuidv4(), + projectId: submission.projectId, + submissionId, + submissionRunId, + templateId: template.id, + status: "pending", + createdAt: new Date(), + }; + await ctx.reportCollection.insertOne(reportDoc); + await ctx.reportQueueClient.sendMessage( + Buffer.from(JSON.stringify({ reportId: reportDoc._id })).toString( + "base64", + ), + ); + created.push(reportDoc); + } + + res.status(201).json({ + triggered: created.length, + reports: created, + }); + } catch (error) { + next(error); + } + }, +}); + // POST /api/v1/reports/bulk-trigger — evaluate report templates for multiple runs at once apiRoute(ctx.app, ctx.registry, { method: "post", @@ -578,8 +719,13 @@ apiRoute(ctx.app, ctx.registry, { const notFound = requestIds.filter(id => !foundIds.has(id)); // Load all active templates + const projectIds = [...new Set(runs.map((run) => run.projectId))]; const templates = await ctx.reportTemplateCollection - .find({ deletedAt: { $exists: false } }) + .find({ + projectId: { $in: projectIds }, + deletedAt: { $exists: false }, + $or: [{ subject: "run" }, { subject: { $exists: false } }], + }) .toArray(); const created: Array<{ reportId: string; requestId: string; templateId: string }> = []; @@ -592,6 +738,7 @@ apiRoute(ctx.app, ctx.registry, { } for (const template of templates) { + if (template.projectId !== run.projectId) continue; const triggerResult = evaluateTrigger( template.trigger as any, run as any, diff --git a/apps/api/src/routes/submissions.test.ts b/apps/api/src/routes/submissions.test.ts index 984bdd358..8a2ed2340 100644 --- a/apps/api/src/routes/submissions.test.ts +++ b/apps/api/src/routes/submissions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Collection, Db } from "mongodb"; import type { RequestDocument, + ReportDocument, ServiceHandlerDocument, SubmissionDocument, SubmissionRunHistoryDocument, @@ -28,6 +29,13 @@ function buildApp(input: { requests: RequestDocument[]; submission: SubmissionDocument; history?: SubmissionRunHistoryDocument[]; + reports?: ReportDocument[]; + logs?: Array<{ + timestamp: string; + level: "info" | "warn" | "error" | "debug"; + source: string; + message: string; + }>; }) { let submission = structuredClone(input.submission); const services: ServiceHandlerDocument[] = [ @@ -90,6 +98,22 @@ function buildApp(input: { requestCollection: { find: vi.fn(() => queryResult(input.requests)), } as unknown as Collection, + reportCollection: { + find: vi.fn((filter: Record) => + queryResult((input.reports ?? []).filter((report) => + Object.entries(filter).every(([key, value]) => + report[key as keyof ReportDocument] === value + ) + )), + ), + findOne: vi.fn(async (filter: Record) => + (input.reports ?? []).find((report) => + Object.entries(filter).every(([key, value]) => + report[key as keyof ReportDocument] === value + ) + ) ?? null + ), + } as unknown as Collection, submissionCollection, submissionRunsCollection: { find: vi.fn(() => queryResult(input.history ?? [])), @@ -97,6 +121,9 @@ function buildApp(input: { (input.history ?? []).find((run) => run._id === filter._id) ?? null, ), } as unknown as Collection, + blobStorage: { + getLogEvents: vi.fn(async () => input.logs ?? []), + }, } as unknown as RouteContext; ctx.app.use(express.json()); registerSubmissionsRoutes(ctx); @@ -250,4 +277,80 @@ describe("submission routes", () => { }, ]); }); + + it("lists current and historical reports for the submission", async () => { + const currentReport: ReportDocument = { + _id: "report-current", + projectId: "project-1", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + status: "completed", + content: "# Current", + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + const historicalReport: ReportDocument = { + ...currentReport, + _id: "report-historical", + submissionRunId: "submission-run-0", + content: "# Historical", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + const response = await request( + buildApp({ + requests: [requestDocument], + submission: { + ...submissionDocument, + run: { + ...submissionDocument.run, + report: { reportId: "report-current" }, + }, + }, + reports: [currentReport, historicalReport], + }), + ).get("/api/v1/submissions/submission-1/reports"); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject([ + { + _id: "report-current", + submissionRunId: "submission-run-1", + }, + { + _id: "report-historical", + submissionRunId: "submission-run-0", + }, + ]); + }); + + it("replays logs for the exact submission attempt", async () => { + const response = await request( + buildApp({ + requests: [requestDocument], + submission: { + ...submissionDocument, + run: { + ...submissionDocument.run, + handlerStatus: { + "pp-readout": { status: "done", version: 1 }, + }, + }, + }, + logs: [ + { + timestamp: "2026-01-01T00:00:00.000Z", + level: "info", + source: "pp-readout", + message: "Submission report completed", + }, + ], + }), + ).get( + "/api/v1/submissions/submission-1/runs/submission-run-1/logs?fromStart=true", + ); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toContain("text/event-stream"); + expect(response.text).toContain("Submission report completed"); + expect(response.text).toContain("event: done"); + }); }); diff --git a/apps/api/src/routes/submissions.ts b/apps/api/src/routes/submissions.ts index 201aae5bd..a593c05c3 100644 --- a/apps/api/src/routes/submissions.ts +++ b/apps/api/src/routes/submissions.ts @@ -2,9 +2,11 @@ // Licensed under the MIT License. import { z } from "zod"; +import type { Response } from "express"; import { SubmissionCoordinator, SubmissionListResponseSchema, + ReportResponseSchema, SubmissionResponseSchema, SubmissionRetryResponseSchema, SubmissionRunResponseSchema, @@ -15,6 +17,8 @@ import type { SubmissionDocument } from "../route-context.js"; import type { RouteContext } from "../route-context.js"; import { apiRoute } from "../openapi/api-route.js"; import { ProjectIdQuerySchema, getQueryProjectId } from "../utils/project-scope.js"; +import { subscribeClient, unsubscribeClient } from "../utils/sse.js"; +import type { SSEClient } from "../utils/sse.js"; function coordinator(ctx: RouteContext): SubmissionCoordinator { return new SubmissionCoordinator({ @@ -49,6 +53,159 @@ function retryBlocking(submission: SubmissionDocument) { })); } +async function streamSubmissionRunLogs( + ctx: RouteContext, + req: import("express").Request, + res: Response, + submission: SubmissionDocument, + submissionRunId: string, + fromStart: boolean, +): Promise { + const isCurrent = submission.run._id === submissionRunId; + const historical = isCurrent + ? undefined + : await ctx.submissionRunsCollection.findOne({ + _id: submissionRunId, + submissionId: submission._id, + }); + const attempt = isCurrent ? submission.run : historical; + if (!attempt) { + res.status(404).json({ error: "Submission attempt not found" }); + return; + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + if (fromStart) { + try { + const pastLogs = await ctx.blobStorage.getLogEvents( + submission._id, + submissionRunId, + ); + for (const log of pastLogs) { + res.write(`data: ${JSON.stringify(log)}\n\n`); + } + } catch (error) { + console.error( + `Failed to replay logs for submission ${submission._id}/${submissionRunId}:`, + error, + ); + res.write( + `event: error\ndata: ${JSON.stringify({ message: "Cannot connect to log storage" })}\n\n`, + ); + res.end(); + return; + } + } + + const readiness = deriveSubmissionReadiness(attempt); + if ( + !isCurrent || + readiness === "done" || + readiness === "failed" || + readiness === "blocked" + ) { + res.write( + `event: done\ndata: ${JSON.stringify({ status: readiness })}\n\n`, + ); + res.end(); + return; + } + + let cleaned = false; + let redisSubscribed = false; + let changeStream: ReturnType | null = + null; + const heartbeat = setInterval(() => { + if (!cleaned) res.write(`:\n\n`); + }, 30_000); + + const client: SSEClient = { + res, + accepts: (event) => event.data?.runId === submissionRunId, + cleanup: () => { + if (cleaned) return; + cleaned = true; + clearInterval(heartbeat); + if (changeStream) { + changeStream.close().catch((error: unknown) => { + console.error("Error closing submission log change stream:", error); + }); + } + if (redisSubscribed) unsubscribeClient(submission._id, client); + res.end(); + }, + }; + + if (process.env.REDIS_HOST) { + try { + await subscribeClient(submission._id, client); + redisSubscribed = true; + } catch (error) { + console.error( + `Redis subscription failed for submission ${submission._id}:`, + error, + ); + } + } + + try { + changeStream = ctx.submissionCollection.watch( + [ + { + $match: { + "documentKey._id": submission._id, + operationType: "update", + }, + }, + ], + { fullDocument: "updateLookup" }, + ); + changeStream.on("change", (change: { + operationType?: string; + fullDocument?: SubmissionDocument; + }) => { + const updated = change.fullDocument; + if (change.operationType !== "update" || !updated) return; + if (updated.run._id !== submissionRunId) { + res.write( + `event: done\ndata: ${JSON.stringify({ status: "superseded" })}\n\n`, + ); + client.cleanup(); + return; + } + const updatedReadiness = deriveSubmissionReadiness(updated.run); + if ( + updatedReadiness === "done" || + updatedReadiness === "failed" || + updatedReadiness === "blocked" + ) { + res.write( + `event: done\ndata: ${JSON.stringify({ status: updatedReadiness })}\n\n`, + ); + client.cleanup(); + } + }); + changeStream.on("error", (error: unknown) => { + console.error( + `Submission log change stream failed for ${submission._id}:`, + error, + ); + }); + } catch (error) { + console.error( + `Failed to create submission log change stream for ${submission._id}:`, + error, + ); + } + + req.on("close", () => client.cleanup()); +} + async function toResponse(ctx: RouteContext, submission: SubmissionDocument) { const requestDocs = await ctx.requestCollection .find({ @@ -123,6 +280,68 @@ export function registerSubmissionsRoutes(ctx: RouteContext): void { }, }); + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/submissions/:id/runs/:submissionRunId/logs", + tags: ["Submissions"], + summary: "Stream logs for a submission attempt (SSE)", + params: z.object({ + id: z.string(), + submissionRunId: z.string(), + }), + response: z.any(), + rawResponse: true, + responseDescription: "Server-sent event stream of log entries", + errorResponses: { + 404: { description: "Submission or submission attempt not found" }, + }, + handler: async (req, res) => { + const submission = await ctx.submissionCollection.findOne({ + _id: req.params.id, + }); + if (!submission) { + res.status(404).json({ error: "Submission not found" }); + return; + } + await streamSubmissionRunLogs( + ctx, + req, + res, + submission, + req.params.submissionRunId, + req.query.fromStart === "true", + ); + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/submissions/:id/reports", + tags: ["Reports"], + summary: "Get reports for submission", + params: z.object({ id: z.string() }), + response: z.array(ReportResponseSchema), + errorResponses: { 404: { description: "Submission not found" } }, + handler: async (req, res, next) => { + try { + const submission = await ctx.submissionCollection.findOne({ + _id: req.params.id, + }); + if (!submission) { + res.status(404).json({ error: "Submission not found" }); + return; + } + const reports = await ctx.reportCollection.find({ + projectId: submission.projectId, + submissionId: submission._id, + }).sort({ createdAt: -1 }).toArray(); + res.json(reports.map((report) => ({ ...report, id: report._id }))); + } catch (error) { + next(error); + } + }, + }); + apiRoute(ctx.app, ctx.registry, { method: "get", path: "/api/v1/submissions/:id", diff --git a/apps/api/src/utils/sse.ts b/apps/api/src/utils/sse.ts index 2e073662d..093faa2ff 100644 --- a/apps/api/src/utils/sse.ts +++ b/apps/api/src/utils/sse.ts @@ -13,6 +13,7 @@ export type SSEClient = { res: Response; cleanup: () => void; onActivity?: () => void; + accepts?: (event: LogEvent) => boolean; }; const sseClients: Map> = new Map(); @@ -50,20 +51,24 @@ function getOrCreateSubscriber(): InstanceType { sharedSubscriber.on("message", (channel: string, message: string) => { const requestId = channel.replace("logs:", ""); const clients = sseClients.get(requestId); + let logEvent: LogEvent | undefined; + try { + logEvent = JSON.parse(message) as LogEvent; + } catch { + // Preserve legacy forwarding for clients that do not filter events. + } if (clients) { for (const client of clients) { + if (client.accepts && (!logEvent || !client.accepts(logEvent))) { + continue; + } client.res.write(`data: ${message}\n\n`); client.onActivity?.(); // Check for completion - try { - const logEvent = JSON.parse(message) as LogEvent; - if (logEvent.data?.final === true) { - client.res.write(`event: done\ndata: {"status":"done"}\n\n`); - client.cleanup(); - } - } catch { - // Ignore parse errors + if (logEvent?.data?.final === true) { + client.res.write(`event: done\ndata: {"status":"done"}\n\n`); + client.cleanup(); } } } diff --git a/apps/cli/src/commands/report.test.ts b/apps/cli/src/commands/report.test.ts new file mode 100644 index 000000000..9fd85335b --- /dev/null +++ b/apps/cli/src/commands/report.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { registerReportCommands } from "./report.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + process.exitCode = undefined; +}); + +describe("report get", () => { + it("downloads Structured JSON through the standard report-by-ID command", async () => { + const urls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (request: Request) => { + urls.push(request.url); + return { + ok: true, + status: 200, + json: async () => ({ + meta: { schemaId: "scope.submission-readout" }, + }), + }; + })); + const output: string[] = []; + vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + output.push(parts.map(String).join(" ")); + }); + const program = new Command(); + registerReportCommands(program); + + await program.parseAsync( + [ + "report", + "get", + "-i", + "report-1", + "--structured", + "-u", + "http://localhost:3100", + ], + { from: "user" }, + ); + + expect(urls).toEqual([ + "http://localhost:3100/api/v1/reports/report-1/structured", + ]); + expect(output.join("\n")).toContain('"schemaId": "scope.submission-readout"'); + }); + + it("renders submission subject metadata through standard report retrieval", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + id: "report-1", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + status: "completed", + createdAt: "2026-01-01T00:00:00.000Z", + }), + }))); + const output: string[] = []; + vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + output.push(parts.map(String).join(" ")); + }); + const program = new Command(); + registerReportCommands(program); + + await program.parseAsync( + [ + "report", + "get", + "-i", + "report-1", + "-o", + "json", + "-u", + "http://localhost:3100", + ], + { from: "user" }, + ); + + expect(output.join("\n")).toContain('"submissionId": "submission-1"'); + expect(output.join("\n")).toContain( + '"submissionRunId": "submission-run-1"', + ); + }); +}); + +describe("report list", () => { + it("passes submission filters to the global reports collection", async () => { + const urls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (request: Request) => { + urls.push(request.url); + return { + ok: true, + status: 200, + json: async () => [], + }; + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const program = new Command(); + registerReportCommands(program); + + await program.parseAsync( + [ + "report", + "list", + "--submission", + "submission-1", + "--submission-run", + "submission-run-1", + "--project", + "project-1", + "-o", + "json", + "-u", + "http://localhost:3100", + ], + { from: "user" }, + ); + + expect(urls).toEqual([ + "http://localhost:3100/api/v1/reports?submissionId=submission-1&submissionRunId=submission-run-1&projectId=project-1", + ]); + }); +}); diff --git a/apps/cli/src/commands/report.ts b/apps/cli/src/commands/report.ts index 1dce6f3b6..058f047ef 100644 --- a/apps/cli/src/commands/report.ts +++ b/apps/cli/src/commands/report.ts @@ -16,7 +16,7 @@ export function registerReportCommands(program: Command): void { const report = program .command("report") - .description("Generate, view, and monitor run reports") + .description("Generate, view, and monitor reports") .action(() => { report.help(); }); @@ -106,11 +106,26 @@ report .command("get") .description("Get a report by ID") .requiredOption("-i, --id ", "Report ID") + .option("--structured", "Get the report's Structured JSON artifact") .option("-u, --url ", "API base URL", getDefaultApiUrl()) , ['markdown']) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { + if (options.structured) { + const structuredResponse = await apiFetch( + options.url, + `/reports/${encodeURIComponent(options.id)}/structured`, + ); + if (!structuredResponse.ok) { + const error = await structuredResponse.json(); + console.error(errorText("Error:"), error.error || JSON.stringify(error)); + process.exitCode = 1; + return; + } + console.log(JSON.stringify(await structuredResponse.json(), null, 2)); + return; + } const response = await apiFetch(options.url, `/reports/${options.id}`); if (!response.ok) { @@ -121,7 +136,9 @@ report const report = await response.json() as { id: string; - requestId: string; + requestId?: string; + submissionId?: string; + submissionRunId?: string; status: string; reporter?: { id: string; name: string; model: string; agentId: string; agentVersion: string }; content?: string; @@ -139,6 +156,8 @@ report const fields: DisplayField[] = [ { key: 'id', label: 'ID' }, { key: 'requestId', label: 'Run' }, + { key: 'submissionId', label: 'Submission' }, + { key: 'submissionRunId', label: 'Submission Run' }, { key: 'status', label: 'Status' }, { key: 'reporter', label: 'Reporter', formatter: (r: any) => r.reporter ? `${r.reporter.name} (${r.reporter.agentId}@${r.reporter.agentVersion})` : '' }, { key: 'model', label: 'Model', formatter: (r: any) => r.reporter?.model || '' }, @@ -152,7 +171,15 @@ report } console.log(`${label('Report:')} ${value(report.id)}`); - console.log(`${label('Run:')} ${value(report.requestId)}`); + if (report.requestId) { + console.log(`${label('Run:')} ${value(report.requestId)}`); + } + if (report.submissionId) { + console.log(`${label('Submission:')} ${value(report.submissionId)}`); + } + if (report.submissionRunId) { + console.log(`${label('Attempt:')} ${value(report.submissionRunId)}`); + } console.log(`${label('Status:')} ${colorLevel(report.status === "completed" ? "info" : report.status === "failed" ? "error" : "warn")} ${report.status}`); console.log(`${label('Created:')} ${dimTimestamp(new Date(report.createdAt).toLocaleString())}`); if (report.updatedAt) { @@ -181,8 +208,10 @@ report withProjectOption(withOutputOption( report .command("list") - .description("List all reports (optionally filter by run)") + .description("List all reports") .option("-r, --run ", "Filter by run ID") + .option("-s, --submission ", "Filter by submission ID") + .option("--submission-run ", "Filter by submission run ID") .option("-u, --url ", "API base URL", getDefaultApiUrl()) )) .action(async (options) => { @@ -191,6 +220,10 @@ report try { const params = new URLSearchParams(); if (options.run) params.set("requestId", options.run); + if (options.submission) params.set("submissionId", options.submission); + if (options.submissionRun) { + params.set("submissionRunId", options.submissionRun); + } const qs = params.toString(); const response = await apiFetch(options.url, `/reports${qs ? `?${qs}` : ""}`, { projectId }); @@ -202,7 +235,9 @@ report const reports = await response.json() as Array<{ id: string; - requestId: string; + requestId?: string; + submissionId?: string; + submissionRunId?: string; status: string; reporter?: { model: string }; createdAt: string; @@ -222,7 +257,13 @@ report tableFormatter: (r: any) => value(r.id), }, { key: 'requestId', label: 'Run ID', - tableFormatter: (r: any) => dimTimestamp(r.requestId), + tableFormatter: (r: any) => r.requestId ? dimTimestamp(r.requestId) : 'N/A', + }, + { key: 'submissionId', label: 'Submission', + tableFormatter: (r: any) => r.submissionId ? dimTimestamp(r.submissionId) : 'N/A', + }, + { key: 'submissionRunId', label: 'Submission Run', + tableFormatter: (r: any) => r.submissionRunId ? dimTimestamp(r.submissionRunId) : 'N/A', }, { key: 'status', label: 'Status', tableFormatter: (r: any) => { diff --git a/apps/cli/src/commands/submission.test.ts b/apps/cli/src/commands/submission.test.ts new file mode 100644 index 000000000..c7a43069e --- /dev/null +++ b/apps/cli/src/commands/submission.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { registerSubmissionCommands } from "./submission.js"; + +interface MockResponse { + ok: boolean; + status: number; + json: () => Promise; +} + +function makeProgram(): Command { + const program = new Command(); + registerSubmissionCommands(program); + return program; +} + +function response(body: unknown): MockResponse { + return { + ok: true, + status: 200, + json: async () => body, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + process.exitCode = undefined; +}); + +describe("submission report", () => { + it("lists current and historical reports through the plural resource", async () => { + const urls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (request: Request) => { + urls.push(request.url); + return response([ + { + id: "report-current", + submissionRunId: "submission-run-2", + status: "completed", + createdAt: "2026-01-02T00:00:00.000Z", + }, + { + id: "report-historical", + submissionRunId: "submission-run-1", + status: "failed", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); + })); + const output: string[] = []; + vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + output.push(parts.map(String).join(" ")); + }); + + await makeProgram().parseAsync( + [ + "submission", + "reports", + "submission-1", + "-u", + "http://localhost:3100", + "-o", + "json", + ], + { from: "user" }, + ); + + expect(urls).toEqual([ + "http://localhost:3100/api/v1/submissions/submission-1/reports", + ]); + expect(output.join("\n")).toContain('"id": "report-current"'); + expect(output.join("\n")).toContain('"id": "report-historical"'); + }); +}); diff --git a/apps/cli/src/commands/submission.ts b/apps/cli/src/commands/submission.ts index 835e7f3ed..00cdbeb8e 100644 --- a/apps/cli/src/commands/submission.ts +++ b/apps/cli/src/commands/submission.ts @@ -33,6 +33,7 @@ interface SubmissionSummary { runId: string; reason: string; }>; + report?: { reportId?: string; url?: string; updatedAt?: string }; } async function responseJson(response: Response): Promise { return response.json() as Promise; @@ -108,6 +109,9 @@ export function registerSubmissionCommands(program: Command): void { console.log(`${label("Attempt:")} ${value(String(doc.attemptNumber))}`); console.log(`${label("Readiness:")} ${value(doc.readiness)}`); console.log(`${label("Submission run:")} ${value(doc.submissionRunId)}`); + if (doc.report?.reportId) { + console.log(`${label("Report:")} ${value(doc.report.reportId)}`); + } for (const request of doc.requests) { console.log( ` ${value(request.requestId)} ${request.status}` + @@ -168,6 +172,51 @@ export function registerSubmissionCommands(program: Command): void { } }); + withOutputOption( + submission + .command("reports") + .description("List reports for a submission") + .argument("", "Submission ID") + .option("-u, --url ", "API base URL", getDefaultApiUrl()), + ).action(async (id: string, options) => { + try { + const response = await apiFetch( + options.url, + `/submissions/${encodeURIComponent(id)}/reports`, + ); + if (!response.ok) await throwApiError(response); + const reports = await responseJson>(response); + const reportFields: DisplayField[] = [ + { key: "id", label: "Report" }, + { key: "submissionRunId", label: "Submission Run" }, + { key: "status", label: "Status" }, + { + key: "model", + label: "Model", + formatter: (row: Record) => + (row.reporter as { model?: string } | undefined)?.model ?? "", + }, + { key: "createdAt", label: "Created" }, + ]; + console.log( + formatData( + reports, + reportFields, + (options.output ?? "table") as OutputFormat, + ), + ); + } catch (error) { + console.error(errorText("Error:"), error instanceof Error ? error.message : error); + process.exitCode = 1; + } + }); + submission .command("retry") .description("Retry the current submission processing attempt") diff --git a/apps/portal/src/components/SubmissionLink.stories.tsx b/apps/portal/src/components/SubmissionLink.stories.tsx new file mode 100644 index 000000000..0df3ea96b --- /dev/null +++ b/apps/portal/src/components/SubmissionLink.stories.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { SubmissionLink } from "./SubmissionLink"; + +const meta = { + title: "Submissions/SubmissionLink", + component: SubmissionLink, + args: { + submissionId: "submission-12345678", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Labeled: Story = { + play: async ({ canvas }) => { + await expect( + canvas.getByRole("link", { + name: "Open submission submission-12345678", + }), + ).toBeVisible(); + await expect(canvas.getByText("Submission")).toBeVisible(); + }, +}; + +export const Compact: Story = { + args: { compact: true }, +}; diff --git a/apps/portal/src/components/SubmissionLink.test.tsx b/apps/portal/src/components/SubmissionLink.test.tsx new file mode 100644 index 000000000..358959353 --- /dev/null +++ b/apps/portal/src/components/SubmissionLink.test.tsx @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { SubmissionLink } from "./SubmissionLink"; + +describe("SubmissionLink", () => { + it("provides a labeled, accessible path to Submission Detail", () => { + render( + + + , + ); + + const link = screen.getByRole("link", { + name: "Open submission submission-123", + }); + expect(link.getAttribute("href")).toBe("/submissions/submission-123"); + expect(screen.getByText("Submission")).toBeDefined(); + }); +}); diff --git a/apps/portal/src/components/SubmissionLink.tsx b/apps/portal/src/components/SubmissionLink.tsx new file mode 100644 index 000000000..243bc5268 --- /dev/null +++ b/apps/portal/src/components/SubmissionLink.tsx @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Layers3 } from "lucide-react"; +import { Link } from "react-router-dom"; +import { ShortId } from "@/components/ShortId"; +import { cn } from "@/lib/utils"; + +export function SubmissionLink({ + submissionId, + className, + compact = false, +}: { + submissionId: string; + className?: string; + compact?: boolean; +}) { + return ( + event.stopPropagation()} + > + + {!compact && Submission} + + + ); +} diff --git a/apps/portal/src/components/SubmissionReports.stories.tsx b/apps/portal/src/components/SubmissionReports.stories.tsx new file mode 100644 index 000000000..6e1bcca02 --- /dev/null +++ b/apps/portal/src/components/SubmissionReports.stories.tsx @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { + SubmissionReports, + reportId, +} from "@/pages/SubmissionDetail"; +import type { + Report, + SubmissionRunAttempt, +} from "@/types"; + +const attempt: SubmissionRunAttempt = { + _id: "submission-run-current", + attemptNumber: 2, + vectorFingerprint: "vector-current", + requests: [{ requestId: "request-1", runId: "run-1" }], + dependencyProjection: [], + blocking: [], + handlerStatus: { + "pp-readout": { status: "done", version: 1 }, + }, + report: { reportId: "report-readout" }, + createdAt: "2026-01-02T00:00:00.000Z", +}; + +const readoutReport: Report = { + _id: "report-readout", + id: "report-readout", + submissionId: "submission-1", + submissionRunId: attempt._id, + status: "completed", + content: "# Submission report\n\nFive runs completed successfully.", + reporter: { + id: "pp-readout", + name: "Submission Readout", + gitHash: "abc", + model: "gpt-5.4", + agentId: "copilot", + agentVersion: "1.0.0", + }, + structuredArtifact: { + schemaId: "scope.submission-readout", + schemaVersion: "1.0", + contentType: "application/json", + url: "reports/report-readout/structured.json", + }, + createdAt: "2026-01-02T00:00:00.000Z", +}; + +const templateReport: Report = { + _id: "report-executive", + id: "report-executive", + submissionId: "submission-1", + submissionRunId: attempt._id, + templateId: "executive-summary", + status: "completed", + content: "# Executive summary\n\nA concise template-based view.", + createdAt: "2026-01-02T01:00:00.000Z", +}; + +function ReportTypesStory({ reports }: { reports: Report[] }) { + const [selected, setSelected] = useState(reports[0]); + return ( + undefined} + /> + ); +} + +const meta = { + title: "Submissions/Submission Reports", + component: ReportTypesStory, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OneReportType: Story = { + args: { reports: [readoutReport] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("tab", { name: "Submission Readout" }), + ).toBeVisible(); + await expect( + canvas.getByRole("heading", { name: "Submission report" }), + ).toBeVisible(); + }, +}; + +export const MultipleReportTypes: Story = { + args: { reports: [readoutReport, templateReport] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const executiveTab = canvas.getByRole("tab", { name: "Executive Summary" }); + await expect(executiveTab).toHaveClass("rounded-b-none"); + await userEvent.click(executiveTab); + await expect(executiveTab).toHaveAttribute("data-state", "active"); + await expect( + canvas.getByRole("heading", { name: "Executive summary" }), + ).toBeVisible(); + }, +}; diff --git a/apps/portal/src/components/SubmissionStatusCard.stories.tsx b/apps/portal/src/components/SubmissionStatusCard.stories.tsx index 0c465e7e9..bf08bb7ef 100644 --- a/apps/portal/src/components/SubmissionStatusCard.stories.tsx +++ b/apps/portal/src/components/SubmissionStatusCard.stories.tsx @@ -41,7 +41,7 @@ const submission: Submission = { { requestId: "request-2", runId: "run-2", - handlerId: "pp-observations", + handlerId: "security-scan", state: "ready", }, ], @@ -59,7 +59,32 @@ const meta = { export default meta; type Story = StoryObj; -export const Processing: Story = {}; +export const GeneratingReport: Story = {}; + +export const Retryable: Story = { + args: { + submission: { + ...submission, + readiness: "ready", + handlerProgress: { + "pp-readout": { status: "failed", version: 1, error: "Model timeout" }, + }, + }, + }, +}; + +export const ReportReady: Story = { + args: { + submission: { + ...submission, + readiness: "done", + report: { reportId: "report-123" }, + handlerProgress: { + "pp-readout": { status: "done", version: 1 }, + }, + }, + }, +}; export const Blocked: Story = { args: { @@ -70,7 +95,7 @@ export const Blocked: Story = { { requestId: "request-2", runId: "run-2", - handlerId: "pp-observations", + handlerId: "security-scan", reason: "dependency-failed", }, ], diff --git a/apps/portal/src/components/SubmissionStatusCard.test.tsx b/apps/portal/src/components/SubmissionStatusCard.test.tsx index b735e762e..4a1477b9f 100644 --- a/apps/portal/src/components/SubmissionStatusCard.test.tsx +++ b/apps/portal/src/components/SubmissionStatusCard.test.tsx @@ -10,7 +10,7 @@ import { SubmissionStatusCard } from "./SubmissionStatusCard"; afterEach(cleanup); describe("SubmissionStatusCard", () => { - it("renders readiness and exact observation progress", () => { + it("renders readiness and arbitrary dependency progress", () => { const submission: Submission = { submissionId: "submission-1", projectId: "project-1", @@ -45,7 +45,7 @@ describe("SubmissionStatusCard", () => { { requestId: "request-2", runId: "run-2", - handlerId: "pp-observations", + handlerId: "security-scan", state: "failed", }, ], @@ -53,7 +53,7 @@ describe("SubmissionStatusCard", () => { { requestId: "request-2", runId: "run-2", - handlerId: "pp-observations", + handlerId: "security-scan", reason: "dependency-failed", }, ], @@ -61,8 +61,45 @@ describe("SubmissionStatusCard", () => { render(); - expect(screen.getByText("blocked")).toBeDefined(); + expect(screen.getByText("Report generation")).toBeDefined(); + expect(screen.getByText("Generation blocked")).toBeDefined(); expect(screen.getByText("3")).toBeDefined(); + expect(screen.getByText("Dependencies ready")).toBeDefined(); expect(screen.getByText("1/2")).toBeDefined(); + expect(screen.getByText("Observations 1/1")).toBeDefined(); + expect(screen.getByText("Security Scan 0/1")).toBeDefined(); + }); + + it.each([ + ["waiting", "Waiting on dependencies"], + ["ready", "Ready to generate"], + ["processing", "Generating report"], + ["done", "Report ready"], + ["failed", "Generation failed"], + ] as const)("renders %s as %s", (readiness, label) => { + const submission: Submission = { + submissionId: "submission-1", + projectId: "project-1", + requestIds: ["request-1"], + createdAt: new Date().toISOString(), + submissionRunId: "submission-run-1", + attemptNumber: 1, + readiness, + requests: [ + { + requestId: "request-1", + runId: "run-1", + task: "Task", + status: "done", + workerType: "worker", + }, + ], + dependencyProjection: [], + blocking: [], + }; + + render(); + + expect(screen.getByText(label)).toBeDefined(); }); }); diff --git a/apps/portal/src/components/SubmissionStatusCard.tsx b/apps/portal/src/components/SubmissionStatusCard.tsx index f711bdd1c..b227d11be 100644 --- a/apps/portal/src/components/SubmissionStatusCard.tsx +++ b/apps/portal/src/components/SubmissionStatusCard.tsx @@ -17,6 +17,24 @@ const readinessVariant: Record< failed: "destructive", }; +const readinessLabel: Record = { + waiting: "Waiting on dependencies", + ready: "Ready to generate", + blocked: "Generation blocked", + processing: "Generating report", + done: "Report ready", + failed: "Generation failed", +}; + +export function submissionHandlerLabel(handlerId: string): string { + return handlerId + .replace(/^pp-/, "") + .split(/[-_]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + export function SubmissionStatusCard({ submission, }: { @@ -25,12 +43,27 @@ export function SubmissionStatusCard({ const ready = submission.dependencyProjection.filter( (dependency) => dependency.state === "ready", ).length; + const dependencyProgress = Array.from( + submission.dependencyProjection.reduce( + (progress, dependency) => { + const current = progress.get(dependency.handlerId) ?? { + ready: 0, + total: 0, + }; + current.total += 1; + if (dependency.state === "ready") current.ready += 1; + progress.set(dependency.handlerId, current); + return progress; + }, + new Map(), + ), + ); return ( - Processing + Report generation - {submission.readiness} + {readinessLabel[submission.readiness]} @@ -39,15 +72,24 @@ export function SubmissionStatusCard({
{submission.attemptNumber}
-
Observations ready
+
Dependencies ready
- {ready}/{submission.requests.length} + {ready}/{submission.dependencyProjection.length}
Blocking runs
{submission.blocking.length}
+ {dependencyProgress.length > 0 && ( +
+ {dependencyProgress.map(([handlerId, progress]) => ( + + {submissionHandlerLabel(handlerId)} {progress.ready}/{progress.total} + + ))} +
+ )}
); diff --git a/apps/portal/src/components/ui/stories.play.test.tsx b/apps/portal/src/components/ui/stories.play.test.tsx index 6ed917da2..5137d9b6d 100644 --- a/apps/portal/src/components/ui/stories.play.test.tsx +++ b/apps/portal/src/components/ui/stories.play.test.tsx @@ -5,6 +5,7 @@ import { describe, it, afterEach } from "vitest"; import { cleanup, render, within } from "@testing-library/react"; import { composeStories } from "@storybook/react-vite"; +import { MemoryRouter } from "react-router-dom"; import * as alert from "./alert.stories"; import * as alertDialog from "./alert-dialog.stories"; @@ -24,11 +25,13 @@ import * as tabs from "./tabs.stories"; import * as textarea from "./textarea.stories"; import * as tooltip from "./tooltip.stories"; import * as sonner from "./sonner.stories"; +import * as submissionLink from "../SubmissionLink.stories"; +import * as submissionReports from "../SubmissionReports.stories"; const modules: Record> = { alert, alertDialog, checkbox, dialog, dropdownMenu, label, popover, scrollArea, select, separator, sheet, skeleton, switch: sw, table, tabs, textarea, - tooltip, sonner, + tooltip, sonner, submissionLink, submissionReports, }; afterEach(() => cleanup()); @@ -41,7 +44,11 @@ for (const [name, mod] of Object.entries(modules)) { it(`${storyName} renders and play passes`, async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const S = Story as any; - const { container } = render(); + const { container } = render( + + + , + ); // The story play functions only depend on `canvas` (userEvent/screen/expect // are imported directly in the story files). Invoke the raw play with a // canvas bound to the rendered container to avoid needing the vitest addon. diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index 1258ac38e..fa7013541 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Run, RunState, CriteriaDocument, CriteriaGraphData, GeneratePromptResponse, AnalysisResponse, PromptFeatureDocument, Report, BulkReportStatus, BulkReportSummary, ReportTemplate, ReportTrigger, ReportTemplateSystemPrompt, KeyDocument, KeyValidationResult, CreateKeyRequest, UpdateKeyRequest, CodingAgent, AgentVersion, McpServerDocument, CreateMcpServerRequest, UpdateMcpServerRequest, BulkResubmitOverrides, Insight, InsightWithReference, TaskPrompt, TaskPromptFeatureExtractionResult, Model, FeatureFlag, SkillDocument, SkillSearchResult, SkillDiscoveryResult, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, CodebaseSourceType, ResourceDocument, ResourceRevisionDocument, ResourceBindingSpec, CreateResourceBody, CreateResourceRevisionBody, ExtensionDocument, ExtensionSearchResult, ExtensionVersionInfo, MdpResponse, AccountDocument, CreateAccountRequest, UpdateAccountRequest, ProfileWithVersion, ProfileVersionDocument, ProfileDocument, RunGroup, RunFacetsResponse, CursorPaginatedResponse, IterationOp, GateConfig, GateId, PromptType, RunSortField, RunSortDir, CriterionKind, TaxonomyElementId, CriterionSubject, Submission } from "@/types"; +import type { Run, RunState, CriteriaDocument, CriteriaGraphData, GeneratePromptResponse, AnalysisResponse, PromptFeatureDocument, Report, BulkReportStatus, BulkReportSummary, ReportTemplate, ReportTrigger, ReportTemplateSystemPrompt, KeyDocument, KeyValidationResult, CreateKeyRequest, UpdateKeyRequest, CodingAgent, AgentVersion, BulkResubmitOverrides, Insight, InsightWithReference, TaskPrompt, TaskPromptFeatureExtractionResult, Model, FeatureFlag, SkillDocument, SkillSearchResult, SkillDiscoveryResult, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, CodebaseSourceType, ResourceDocument, ResourceRevisionDocument, ResourceBindingSpec, CreateResourceBody, CreateResourceRevisionBody, ExtensionDocument, ExtensionSearchResult, ExtensionVersionInfo, MdpResponse, AccountDocument, CreateAccountRequest, UpdateAccountRequest, ProfileWithVersion, ProfileVersionDocument, ProfileDocument, RunGroup, RunFacetsResponse, CursorPaginatedResponse, IterationOp, GateConfig, GateId, PromptType, RunSortField, RunSortDir, CriterionKind, TaxonomyElementId, CriterionSubject, Submission, SubmissionRunAttempt } from "@/types"; import type { Project, CreateProjectRequest, UpdateProjectRequest } from "@/types"; import { qs } from "./url"; @@ -246,6 +246,28 @@ export const api = { getSubmission: (id: string): Promise => request(`/submissions/${id}`), + getSubmissionRuns: (id: string): Promise<{ + runs: SubmissionRunAttempt[]; + }> => request(`/submissions/${id}/runs`), + + getSubmissionRun: ( + id: string, + submissionRunId: string, + ): Promise => + request( + `/submissions/${encodeURIComponent(id)}/runs/${encodeURIComponent(submissionRunId)}`, + ), + + getSubmissionReports: (id: string): Promise => + request(`/submissions/${encodeURIComponent(id)}/reports`), + + submissionRunLogsUrl: ( + id: string, + submissionRunId: string, + fromStart = true, + ): string => + `${BASE}/submissions/${encodeURIComponent(id)}/runs/${encodeURIComponent(submissionRunId)}/logs?fromStart=${fromStart}`, + retrySubmission: ( id: string, submissionRunId: string, @@ -782,6 +804,9 @@ export const api = { return request(`/reports/${id}`); }, + reportStructuredUrl: (id: string): string => + `${BASE}/reports/${encodeURIComponent(id)}/structured`, + /** Get reports for a specific run */ getRunReports: (requestId: string): Promise => { return request(`/requests/${requestId}/reports`); @@ -834,6 +859,7 @@ export const api = { createReportTemplate: (body: { id: string; name: string; + subject?: "run" | "submission"; userPrompt: string; description?: string; systemPrompt?: ReportTemplateSystemPrompt; @@ -850,6 +876,7 @@ export const api = { /** Update an existing report template */ updateReportTemplate: (id: string, body: { name?: string; + subject?: "run" | "submission"; description?: string; userPrompt?: string; systemPrompt?: ReportTemplateSystemPrompt | null; diff --git a/apps/portal/src/pages/CreateReportTemplate.test.tsx b/apps/portal/src/pages/CreateReportTemplate.test.tsx new file mode 100644 index 000000000..f751f16a6 --- /dev/null +++ b/apps/portal/src/pages/CreateReportTemplate.test.tsx @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api", () => ({ + api: { + listAvailableReportModels: vi.fn(async () => []), + createReportTemplate: vi.fn(async (body: Record) => ({ + _id: "template-document-1", + projectId: "project-1", + createdAt: "2026-01-01T00:00:00.000Z", + ...body, + })), + }, +})); + +import { api } from "@/lib/api"; +import { CreateReportTemplate } from "./CreateReportTemplate"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("CreateReportTemplate", () => { + it("creates an automatically triggered submission-scoped template", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + + } + /> + Template created} + /> + + + , + ); + + await userEvent.type(screen.getByLabelText("Name *"), "Submission Summary"); + await userEvent.type( + screen.getByLabelText("User Prompt *"), + "Summarize the exact submission attempt.", + ); + await userEvent.click(screen.getByRole("combobox", { name: "Report scope" })); + await userEvent.click( + within(await screen.findByRole("listbox")).getByText( + "Submission attempt", + ), + ); + + expect( + screen.getByText( + "Runs automatically once the submission attempt and its generated evidence are complete.", + ), + ).toBeDefined(); + expect( + screen.getByRole("combobox", { name: "Trigger" }).textContent, + ).toContain("Always"); + + await userEvent.click( + screen.getByRole("button", { name: "Create Template" }), + ); + + expect(api.createReportTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "submission-summary", + name: "Submission Summary", + subject: "submission", + userPrompt: "Summarize the exact submission attempt.", + }), + ); + expect(await screen.findByText("Template created")).toBeDefined(); + }); +}); diff --git a/apps/portal/src/pages/CreateReportTemplate.tsx b/apps/portal/src/pages/CreateReportTemplate.tsx index 50f412460..da3a23f33 100644 --- a/apps/portal/src/pages/CreateReportTemplate.tsx +++ b/apps/portal/src/pages/CreateReportTemplate.tsx @@ -66,6 +66,7 @@ export function CreateReportTemplate() { const [id, setId] = useState(""); const [idManuallyEdited, setIdManuallyEdited] = useState(false); const [description, setDescription] = useState(""); + const [subject, setSubject] = useState<"run" | "submission">("run"); const [userPrompt, setUserPrompt] = useState(""); const [sysMode, setSysMode] = useState<"none" | "append" | "override">("none"); const [sysContent, setSysContent] = useState(""); @@ -106,6 +107,7 @@ export function CreateReportTemplate() { const body: Parameters[0] = { id: id.trim(), name: name.trim(), + subject, userPrompt: userPrompt.trim(), }; @@ -123,7 +125,7 @@ export function CreateReportTemplate() { } // Trigger - if (triggerType !== "always") { + if (subject === "run" && triggerType !== "always") { const idsArray = triggerIds.split(",").map(s => s.trim()).filter(Boolean); if (triggerType === "criteria") { body.trigger = { type: "criteria", criteriaIds: triggerCriteriaIds, match: triggerMatch }; @@ -192,6 +194,29 @@ export function CreateReportTemplate() { /> +
+ + +

+ Submission templates run once after the exact submission attempt + and its generated evidence are complete. +

+
+ {/* User Prompt */} @@ -250,7 +275,7 @@ export function CreateReportTemplate() {
setTriggerType(v as any)}> - + setEditDescription(e.target.value)} placeholder="Optional description" />
+
+ + +
@@ -320,7 +344,13 @@ export function ReportTemplateDetail() {
- + setEditTriggerType(value) + } + disabled={editSubject === "submission"} + > diff --git a/apps/portal/src/pages/ReportTemplateList.tsx b/apps/portal/src/pages/ReportTemplateList.tsx index cd7259799..f3fc575bf 100644 --- a/apps/portal/src/pages/ReportTemplateList.tsx +++ b/apps/portal/src/pages/ReportTemplateList.tsx @@ -112,6 +112,7 @@ export function ReportTemplateList() { const columnOptions: CustomizeColumnsOption[] = [ { id: "id", label: "ID", required: true }, { id: "name", label: "Name" }, + { id: "subject", label: "Scope" }, { id: "trigger", label: "Trigger" }, { id: "model", label: "Model" }, { id: "systemPrompt", label: "Sys Prompt" }, @@ -133,6 +134,17 @@ export function ReportTemplateList() { hidden: columnVisibility.isHidden("name"), cell: (t) => {t.name}, }, + { + id: "subject", + header: "Scope", + width: "120px", + hidden: columnVisibility.isHidden("subject"), + cell: (template) => ( + + {template.subject === "submission" ? "Submission" : "Run"} + + ), + }, { id: "trigger", header: "Trigger", diff --git a/apps/portal/src/pages/ReportsList.tsx b/apps/portal/src/pages/ReportsList.tsx index b382614c6..d4b40f074 100644 --- a/apps/portal/src/pages/ReportsList.tsx +++ b/apps/portal/src/pages/ReportsList.tsx @@ -89,7 +89,7 @@ export function ReportsList() { const columnOptions: CustomizeColumnsOption[] = [ { id: "id", label: "ID", required: true }, { id: "task", label: "Task" }, - { id: "runId", label: "Run ID" }, + { id: "runId", label: "Subject" }, { id: "status", label: "Status" }, { id: "template", label: "Template" }, { id: "model", label: "Model" }, @@ -116,17 +116,30 @@ export function ReportsList() { }, { id: "runId", - header: "Run ID", + header: "Subject", width: "120px", hidden: columnVisibility.isHidden("runId"), - cell: (r) => ( + cell: (r) => r.submissionId ? ( { e.stopPropagation(); navigate(`/runs/${r.requestId}`); }} + onClick={(event) => { + event.stopPropagation(); + navigate(`/submissions/${r.submissionId}`); + }} > - {formatId(r.requestId)} + Submission {formatId(r.submissionId)} - ), + ) : r.requestId ? ( + { + event.stopPropagation(); + navigate(`/runs/${r.requestId}`); + }} + > + Run {formatId(r.requestId)} + + ) : —, }, { id: "status", @@ -150,7 +163,9 @@ export function ReportsList() { {r.templateId} ) : ( - default + + {r.submissionId ? "template-less" : "default"} + ), }, { @@ -190,7 +205,7 @@ export function ReportsList() {
("grid"); const [reportsFilter, setReportsFilter] = useState<"latest" | "all">("latest"); @@ -705,9 +706,10 @@ export function RunDetail() { if (error || !run) { return (
- +
@@ -804,9 +806,18 @@ export function RunDetail() {
{/* Back link + header */}
- + diff --git a/apps/portal/src/pages/RunsList.tsx b/apps/portal/src/pages/RunsList.tsx index c7257fc97..aeeb2f4a1 100644 --- a/apps/portal/src/pages/RunsList.tsx +++ b/apps/portal/src/pages/RunsList.tsx @@ -28,6 +28,7 @@ import { StatusBadge, OutcomeBadge } from "@/components/StatusBadge"; import { TaskPromptBadge } from "@/components/TaskPromptBadge"; import { AgentBadge } from "@/components/AgentBadge"; import { ShortId } from "@/components/ShortId"; +import { SubmissionLink } from "@/components/SubmissionLink"; import { ListLayout, FilterRail, @@ -1346,16 +1347,11 @@ export function RunsList() { { id: "submission", header: "Submission", - width: "120px", + width: "190px", hidden: columnVisibility.isHidden("submission"), cell: (r) => r.submissionId ? ( - event.stopPropagation()} - > - - + ) : ( — ), @@ -2511,6 +2507,12 @@ export function RunsList() { switch (column.id) { case "submission": + if ( + groupBy === "submissionId" && + groupKey !== "no-submission" + ) { + return ; + } return distinct("submissions", (r) => r.submissionId); case "task": return distinct("tasks", (r) => r.scenario?.task); @@ -2689,12 +2691,7 @@ export function RunsList() {
{groupBy === "submissionId" && groupKey !== "no-submission" && ( - - View submission - + )} {runs.length} run{runs.length !== 1 ? "s" : ""} diff --git a/apps/portal/src/pages/SubmissionDetail.test.tsx b/apps/portal/src/pages/SubmissionDetail.test.tsx new file mode 100644 index 000000000..db0d60fbb --- /dev/null +++ b/apps/portal/src/pages/SubmissionDetail.test.tsx @@ -0,0 +1,469 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigate, +} from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + Report, + Submission, + SubmissionRunAttempt, +} from "@/types"; + +const submission: Submission = { + submissionId: "submission-1", + projectId: "project-1", + requestIds: ["request-1", "request-2"], + createdAt: "2026-01-01T00:00:00.000Z", + submissionRunId: "submission-run-2", + attemptNumber: 2, + readiness: "done", + requests: [ + { + requestId: "request-1", + runId: "run-current-1", + task: "Create an API", + status: "done", + outcome: "succeeded", + workerType: "coder-acp-copilot", + model: "gpt-5.4", + }, + { + requestId: "request-2", + runId: "run-current-2", + task: "Document the API", + status: "done", + outcome: "succeeded", + workerType: "coder-acp-copilot", + model: "gpt-5.4", + }, + ], + dependencyProjection: [ + { + requestId: "request-1", + runId: "run-current-1", + handlerId: "pp-observations", + state: "ready", + }, + { + requestId: "request-2", + runId: "run-current-2", + handlerId: "security-scan", + state: "ready", + }, + ], + blocking: [], + handlerProgress: { + "pp-readout": { status: "done", version: 1 }, + }, + report: { reportId: "report-current" }, +}; + +const currentAttempt: SubmissionRunAttempt = { + _id: "submission-run-2", + attemptNumber: 2, + vectorFingerprint: "vector-current", + requests: [ + { requestId: "request-1", runId: "run-current-1" }, + { requestId: "request-2", runId: "run-current-2" }, + ], + dependencyProjection: submission.dependencyProjection, + blocking: [], + handlerStatus: submission.handlerProgress, + report: { reportId: "report-current" }, + createdAt: "2026-01-02T00:00:00.000Z", +}; + +const historicalAttempt: SubmissionRunAttempt = { + _id: "submission-run-1", + attemptNumber: 1, + vectorFingerprint: "vector-historical", + requests: [ + { requestId: "request-1", runId: "run-historical-1" }, + { requestId: "request-2", runId: "run-historical-2" }, + ], + dependencyProjection: [ + { + requestId: "request-1", + runId: "run-historical-1", + handlerId: "pp-observations", + state: "ready", + }, + { + requestId: "request-2", + runId: "run-historical-2", + handlerId: "security-scan", + state: "ready", + }, + ], + blocking: [], + handlerStatus: { + "pp-readout": { status: "done", version: 1 }, + }, + report: { reportId: "report-historical" }, + createdAt: "2026-01-01T00:00:00.000Z", + supersededAt: "2026-01-02T00:00:00.000Z", + supersededBySubmissionRunId: "submission-run-2", + supersededReason: "request-vector-changed", +}; + +const reports: Report[] = [ + { + _id: "report-current", + id: "report-current", + submissionId: "submission-1", + submissionRunId: "submission-run-2", + status: "completed", + content: "# Current generated report", + reporter: { + id: "pp-readout", + name: "Submission Readout", + gitHash: "abc", + model: "gpt-5.4", + agentId: "copilot", + agentVersion: "1.0.0", + }, + structuredArtifact: { + schemaId: "scope.submission-readout", + schemaVersion: "1.0", + contentType: "application/json", + url: "reports/report-current/structured.json", + }, + createdAt: "2026-01-02T00:00:00.000Z", + }, + { + _id: "report-current-older-duplicate", + id: "report-current-older-duplicate", + submissionId: "submission-1", + submissionRunId: "submission-run-2", + status: "completed", + content: "# Stale duplicate report", + reporter: { + id: "pp-readout", + name: "Submission Readout", + gitHash: "abc", + model: "gpt-5.4", + agentId: "copilot", + agentVersion: "1.0.0", + }, + createdAt: "2026-01-01T12:00:00.000Z", + }, + { + _id: "report-template-current", + id: "report-template-current", + submissionId: "submission-1", + submissionRunId: "submission-run-2", + templateId: "executive-summary", + status: "completed", + content: "# Executive summary report", + createdAt: "2026-01-02T01:00:00.000Z", + }, + { + _id: "report-historical", + id: "report-historical", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + status: "completed", + content: "# Historical generated report", + reporter: { + id: "pp-readout", + name: "Submission Readout", + gitHash: "abc", + model: "gpt-5.4", + agentId: "copilot", + agentVersion: "1.0.0", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }, +]; + +vi.mock("@/lib/api", () => ({ + api: { + getSubmission: vi.fn(async () => submission), + getSubmissionRuns: vi.fn(async () => ({ + runs: [currentAttempt, historicalAttempt], + })), + getSubmissionReports: vi.fn(async () => reports), + retrySubmission: vi.fn(), + reportStructuredUrl: vi.fn( + (id: string) => `/api/v1/reports/${id}/structured`, + ), + submissionRunLogsUrl: vi.fn( + (_id: string, attemptId: string) => + `/api/v1/submissions/submission-1/runs/${attemptId}/logs`, + ), + }, +})); + +vi.mock("@/hooks/use-log-stream", () => ({ + useLogStream: vi.fn(() => ({ + logs: [], + isConnected: false, + isDone: true, + error: null, + clear: vi.fn(), + })), +})); + +import { SubmissionDetail } from "./SubmissionDetail"; +import { api } from "@/lib/api"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function renderDetail(initialEntry = "/submissions/submission-1") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + + + } + /> + Run detail destination
} /> + + + , + ); +} + +function LocationProbe() { + const location = useLocation(); + const navigate = useNavigate(); + return ( +
+ {location.pathname}{location.search} + + +
+ ); +} + +describe("SubmissionDetail", () => { + it("defaults to Reports and shows attempt-scoped report types", async () => { + renderDetail(); + + expect( + await screen.findByRole("heading", { name: "Current generated report" }), + ).toBeDefined(); + expect( + screen.getByRole("tab", { name: "Reports (2)" }).getAttribute("data-state"), + ).toBe("active"); + expect( + screen.getByRole("tab", { name: "Submission Readout" }), + ).toBeDefined(); + expect( + screen.getByRole("tab", { name: "Executive Summary" }), + ).toBeDefined(); + expect(screen.getByText("Current")).toBeDefined(); + expect( + screen.getByRole("link", { name: "Structured JSON" }).getAttribute("href"), + ).toBe("/api/v1/reports/report-current/structured"); + + await userEvent.click( + screen.getByRole("tab", { name: "Executive Summary" }), + ); + expect( + await screen.findByRole("heading", { name: "Executive summary report" }), + ).toBeDefined(); + expect(screen.getByText("This attempt")).toBeDefined(); + }); + + it("selects the newest report when one attempt has duplicate report types", async () => { + renderDetail( + "/submissions/submission-1?tab=reports&attempt=submission-run-2&report=report-current-older-duplicate", + ); + + expect( + await screen.findByRole("heading", { name: "Current generated report" }), + ).toBeDefined(); + expect( + screen.getByRole("link", { name: "Structured JSON" }).getAttribute("href"), + ).toBe("/api/v1/reports/report-current/structured"); + expect(screen.getByLabelText("Current URL").textContent).toContain( + "report=report-current", + ); + }); + + it("deep-links to a report type and preserves browser back and forward navigation", async () => { + renderDetail( + "/submissions/submission-1?tab=reports&attempt=submission-run-2&report=report-template-current", + ); + + expect( + await screen.findByRole("heading", { name: "Executive summary report" }), + ).toBeDefined(); + expect( + screen.getByRole("tab", { name: "Executive Summary" }).getAttribute( + "data-state", + ), + ).toBe("active"); + + await userEvent.click( + screen.getByRole("tab", { name: "Submission Readout" }), + ); + expect( + await screen.findByRole("heading", { name: "Current generated report" }), + ).toBeDefined(); + expect(screen.getByLabelText("Current URL").textContent).toContain( + "report=report-current", + ); + + await userEvent.click(screen.getByRole("button", { name: "History back" })); + expect( + await screen.findByRole("heading", { name: "Executive summary report" }), + ).toBeDefined(); + expect(screen.getByLabelText("Current URL").textContent).toContain( + "report=report-template-current", + ); + + await userEvent.click( + screen.getByRole("button", { name: "History forward" }), + ); + expect( + await screen.findByRole("heading", { name: "Current generated report" }), + ).toBeDefined(); + }); + + it("renders the immutable member vector with the shared table and run navigation", async () => { + renderDetail(); + await screen.findByRole("heading", { name: "Current generated report" }); + + await userEvent.click(screen.getByRole("tab", { name: "Runs (2)" })); + + expect( + screen.getByRole("heading", { name: "Immutable member run vector" }), + ).toBeDefined(); + expect(screen.getByRole("columnheader", { name: "Request" })).toBeDefined(); + expect( + screen.getByRole("columnheader", { name: "Exact run attempt" }), + ).toBeDefined(); + expect( + screen.getByRole("columnheader", { name: "Dependencies" }), + ).toBeDefined(); + const requestLink = screen + .getAllByRole("link") + .find((link) => link.getAttribute("href")?.includes("run-current-1")); + expect(requestLink).toBeDefined(); + expect(requestLink!.getAttribute("href")).toContain( + "runId=run-current-1", + ); + expect(requestLink!.getAttribute("href")).toContain( + "fromSubmission=submission-1", + ); + }); + + it("switches attempts through the picker and scopes Processing to history", async () => { + renderDetail("/submissions/submission-1?tab=processing"); + await screen.findByText("Report ready"); + + const picker = screen.getByRole("combobox", { name: "Submission attempt" }); + await userEvent.click(picker); + const listbox = await screen.findByRole("listbox"); + await userEvent.click( + within(listbox).getByText(/Attempt 1 · Superseded/), + ); + + expect( + await screen.findByText(/Viewing attempt 1 \(historical and read-only\)/), + ).toBeDefined(); + expect(screen.getByText("Dependencies ready")).toBeDefined(); + expect(screen.getByText("Observations 1/1")).toBeDefined(); + expect(screen.getByText("Security Scan 1/1")).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Attempt processing logs" }), + ).toBeDefined(); + }); + + it("scopes report types to the selected historical attempt", async () => { + renderDetail(); + await screen.findByRole("heading", { name: "Current generated report" }); + + const picker = screen.getByRole("combobox", { name: "Submission attempt" }); + await userEvent.click(picker); + const listbox = await screen.findByRole("listbox"); + await userEvent.click(within(listbox).getByText(/Attempt 1 · Superseded/)); + + expect( + await screen.findByRole("heading", { + name: "Historical generated report", + }), + ).toBeDefined(); + expect( + screen.getByRole("tab", { name: "Reports (1)" }), + ).toBeDefined(); + expect( + screen.getByRole("tab", { name: "Submission Readout" }), + ).toBeDefined(); + expect(screen.getByText("Historical")).toBeDefined(); + }); + + it("renders one report-type tab for a single template-less report", async () => { + vi.mocked(api.getSubmissionReports).mockResolvedValueOnce([reports[0]!]); + + renderDetail(); + + expect( + await screen.findByRole("heading", { name: "Current generated report" }), + ).toBeDefined(); + expect( + screen.getByRole("tab", { name: "Reports (1)" }), + ).toBeDefined(); + expect( + screen.getByRole("tab", { name: "Submission Readout" }), + ).toBeDefined(); + }); + + it("shows current attempt progress when only historical reports exist", async () => { + vi.mocked(api.getSubmission).mockResolvedValueOnce({ + ...submission, + readiness: "processing", + report: undefined, + handlerProgress: { + "pp-readout": { status: "processing", version: 1 }, + }, + }); + vi.mocked(api.getSubmissionRuns).mockResolvedValueOnce({ + runs: [ + { + ...currentAttempt, + report: undefined, + handlerStatus: { + "pp-readout": { status: "processing", version: 1 }, + }, + }, + historicalAttempt, + ], + }); + vi.mocked(api.getSubmissionReports).mockResolvedValueOnce([reports[3]!]); + + renderDetail(); + + expect( + await screen.findByRole("tab", { name: "Reports" }), + ).toBeDefined(); + expect(screen.getByText("Report not available")).toBeDefined(); + expect( + screen.getByText("A report is being generated for this attempt."), + ).toBeDefined(); + }); +}); diff --git a/apps/portal/src/pages/SubmissionDetail.tsx b/apps/portal/src/pages/SubmissionDetail.tsx index a4a7b67bf..79d90fe70 100644 --- a/apps/portal/src/pages/SubmissionDetail.tsx +++ b/apps/portal/src/pages/SubmissionDetail.tsx @@ -1,20 +1,362 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { + useCallback, + useEffect, + useMemo, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, FileText, RotateCcw } from "lucide-react"; -import { Link, useParams } from "react-router-dom"; +import { + ArrowLeft, + ArrowRight, + Clock, + Download, + FileText, + ListChecks, + RotateCcw, + ScrollText, +} from "lucide-react"; +import { + Link, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; import { toast } from "sonner"; -import { SubmissionStatusCard } from "@/components/SubmissionStatusCard"; +import { LogViewer } from "@/components/LogViewer"; +import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { ShortId } from "@/components/ShortId"; +import { + SubmissionStatusCard, + submissionHandlerLabel, +} from "@/components/SubmissionStatusCard"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + DataTable, + type DataTableColumn, +} from "@/components/list-layout"; +import { useLogStream } from "@/hooks/use-log-stream"; import { api } from "@/lib/api"; import { formatDate } from "@/lib/utils"; +import type { + Report, + Submission, + SubmissionReadiness, + SubmissionRequestSummary, + SubmissionRunAttempt, +} from "@/types"; + +type SubmissionTab = "reports" | "runs" | "processing"; + +interface SubmissionMemberRow extends SubmissionRequestSummary { + dependencyStates: Array<{ + handlerId: string; + state: string; + }>; + historical: boolean; +} + +function deriveAttemptReadiness( + attempt: SubmissionRunAttempt, +): SubmissionReadiness { + if (attempt.blocking.length > 0) return "blocked"; + const handlers = Object.values(attempt.handlerStatus ?? {}); + if (handlers.some((handler) => handler.status === "failed")) return "failed"; + if (handlers.some((handler) => handler.status === "processing")) { + return "processing"; + } + if (handlers.length > 0 && handlers.every((handler) => handler.status === "done")) { + return "done"; + } + if ( + attempt.dependencyProjection.length > 0 && + attempt.dependencyProjection.every((dependency) => dependency.state === "ready") + ) { + return "ready"; + } + return "waiting"; +} + +export function reportId(report: Report): string { + return report.id || report._id; +} + +export function submissionReportTypeKey(report: Report): string { + if (report.templateId) return `template:${report.templateId}`; + if (report.reporter?.id) return `reporter:${report.reporter.id}`; + return `report:${reportId(report)}`; +} + +export function submissionReportTypeLabel(report: Report): string { + if (report.templateId) return submissionHandlerLabel(report.templateId); + if (report.reporter?.name.trim()) return report.reporter.name; + if (report.reporter?.id) return submissionHandlerLabel(report.reporter.id); + return "Report"; +} + +export function newestSubmissionReportsByType( + reports: Report[], +): Array<{ key: string; label: string; report: Report }> { + const reportsByType = new Map(); + for (const report of reports) { + const key = submissionReportTypeKey(report); + const existing = reportsByType.get(key); + if ( + !existing || + report.createdAt > existing.createdAt || + (report.createdAt === existing.createdAt && + reportId(report).localeCompare(reportId(existing)) > 0) + ) { + reportsByType.set(key, report); + } + } + return Array.from(reportsByType, ([key, report]) => ({ + key, + label: submissionReportTypeLabel(report), + report, + })).sort((left, right) => left.label.localeCompare(right.label)); +} + +function attemptLabel( + attempt: SubmissionRunAttempt, + currentAttemptId: string, +): string { + const state = + attempt._id === currentAttemptId + ? "Current" + : attempt.supersededAt + ? "Superseded" + : "Historical"; + return `Attempt ${attempt.attemptNumber} · ${state} · ${formatDate(attempt.createdAt)}`; +} + +function SubmissionAttemptPicker({ + attempts, + currentAttemptId, + selectedAttemptId, + onSelect, +}: { + attempts: SubmissionRunAttempt[]; + currentAttemptId: string; + selectedAttemptId: string; + onSelect: (attemptId: string) => void; +}) { + return ( +
+ + +
+ ); +} + +export function SubmissionReports({ + reports, + selectedReport, + currentReportId, + isCurrentAttempt, + readiness, + onSelect, + onShowProcessing, +}: { + reports: Report[]; + selectedReport?: Report; + currentReportId?: string; + isCurrentAttempt: boolean; + readiness: SubmissionReadiness; + onSelect: (report: Report) => void; + onShowProcessing: () => void; +}) { + const unavailableMessage = + readiness === "blocked" + ? "Report generation is blocked by one or more dependencies." + : readiness === "failed" + ? "Report generation failed and can be retried from the current attempt." + : readiness === "processing" + ? "A report is being generated for this attempt." + : "No report has been generated for this attempt yet."; + const unavailable = ( + + +
+

Report not available

+

+ {unavailableMessage} +

+
+ +
+
+ ); + if (reports.length === 0) return unavailable; + const reportTypes = newestSubmissionReportsByType(reports); + const selectedType = + reportTypes.find( + (option) => + selectedReport && + submissionReportTypeKey(selectedReport) === option.key, + ) ?? reportTypes[0]; + const selectedTypeReport = selectedType?.report; + + return ( + { + const option = reportTypes.find((candidate) => candidate.key === key); + if (option) onSelect(option.report); + }} + > + + {reportTypes.map((option) => ( + + {option.label} + + ))} + + + {selectedTypeReport ? ( + + <> + +
+
+
+ Generated report + + {reportId(selectedTypeReport) === currentReportId + ? "Current" + : isCurrentAttempt + ? "This attempt" + : "Historical"} + + {selectedTypeReport.status} +
+
+ + Report{" "} + + + {selectedTypeReport.submissionRunId && ( + + Attempt{" "} + + + )} + Created {formatDate(selectedTypeReport.createdAt)} + {selectedTypeReport.updatedAt && ( + Updated {formatDate(selectedTypeReport.updatedAt)} + )} +
+ {selectedTypeReport.reporter && ( +

+ {selectedTypeReport.reporter.name} ·{" "} + {selectedTypeReport.reporter.model} ·{" "} + {selectedTypeReport.reporter.agentId}@ + {selectedTypeReport.reporter.agentVersion} +

+ )} +
+ {selectedTypeReport.structuredArtifact && ( + + )} +
+
+ + {selectedTypeReport.content ? ( +
+ + {selectedTypeReport.content} + +
+ ) : selectedTypeReport.error ? ( +

+ {selectedTypeReport.error} +

+ ) : ( +

+ This report does not have rendered Markdown content. +

+ )} +
+ +
+ ) : unavailable} +
+ ); +} export function SubmissionDetail() { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const submissionQuery = useQuery({ queryKey: ["submission", id], @@ -22,11 +364,35 @@ export function SubmissionDetail() { enabled: Boolean(id), refetchInterval: (query) => { const readiness = query.state.data?.readiness; - return readiness === "done" || readiness === "failed" || readiness === "blocked" + return readiness === "done" || + readiness === "failed" || + readiness === "blocked" ? false : 5_000; }, }); + const attemptsQuery = useQuery({ + queryKey: ["submission-runs", id], + queryFn: () => api.getSubmissionRuns(id!), + enabled: Boolean(id), + refetchInterval: + submissionQuery.data?.readiness === "done" || + submissionQuery.data?.readiness === "failed" || + submissionQuery.data?.readiness === "blocked" + ? false + : 5_000, + }); + const reportsQuery = useQuery({ + queryKey: ["submission-reports", id], + queryFn: () => api.getSubmissionReports(id!), + enabled: Boolean(id), + refetchInterval: + submissionQuery.data?.readiness === "done" || + submissionQuery.data?.readiness === "failed" || + submissionQuery.data?.readiness === "blocked" + ? false + : 5_000, + }); const retry = useMutation({ mutationFn: () => api.retrySubmission( @@ -34,28 +400,296 @@ export function SubmissionDetail() { submissionQuery.data!.submissionRunId, ), onSuccess: () => { - toast.success("Submission processing restarted"); + toast.success("Report generation restarted"); void queryClient.invalidateQueries({ queryKey: ["submission", id] }); + void queryClient.invalidateQueries({ queryKey: ["submission-runs", id] }); + void queryClient.invalidateQueries({ queryKey: ["submission-reports", id] }); }, onError: (error) => { toast.error(error instanceof Error ? error.message : "Retry failed"); }, }); - if (submissionQuery.isLoading) { - return
Loading submission…
; + const submission = submissionQuery.data; + const attempts = attemptsQuery.data?.runs ?? []; + const selectedAttemptId = + searchParams.get("attempt") ?? submission?.submissionRunId ?? ""; + const selectedAttempt = + attempts.find((attempt) => attempt._id === selectedAttemptId) ?? + attempts.find((attempt) => attempt._id === submission?.submissionRunId); + const isCurrentAttempt = + selectedAttempt?._id === submission?.submissionRunId; + const readiness = + submission && selectedAttempt + ? isCurrentAttempt + ? submission.readiness + : deriveAttemptReadiness(selectedAttempt) + : "waiting"; + const reports = reportsQuery.data ?? []; + const selectedAttemptReports = useMemo( + () => + selectedAttempt + ? reports.filter( + (report) => report.submissionRunId === selectedAttempt._id, + ) + : [], + [reports, selectedAttempt], + ); + const selectedAttemptReportTypes = useMemo( + () => newestSubmissionReportsByType(selectedAttemptReports), + [selectedAttemptReports], + ); + const requestedTab = searchParams.get("tab"); + const activeTab: SubmissionTab = + requestedTab === "reports" || + requestedTab === "runs" || + requestedTab === "processing" + ? requestedTab + : selectedAttemptReportTypes.length > 0 || readiness !== "waiting" + ? "reports" + : "runs"; + + const requestedReportId = searchParams.get("report"); + const defaultReportId = + selectedAttempt?.report?.reportId ?? + (isCurrentAttempt ? submission?.report?.reportId : undefined); + const requestedReport = selectedAttemptReports.find( + (report) => reportId(report) === requestedReportId, + ); + const defaultReport = selectedAttemptReports.find( + (report) => reportId(report) === defaultReportId, + ); + const selectedReportTypeKey = requestedReport + ? submissionReportTypeKey(requestedReport) + : defaultReport + ? submissionReportTypeKey(defaultReport) + : undefined; + const selectedReport = + selectedAttemptReportTypes.find( + (option) => option.key === selectedReportTypeKey, + )?.report ?? selectedAttemptReportTypes[0]?.report; + + useEffect(() => { + if (!requestedReportId || !selectedReport) return; + const canonicalReportId = reportId(selectedReport); + if (requestedReportId === canonicalReportId) return; + const next = new URLSearchParams(searchParams); + next.set("report", canonicalReportId); + setSearchParams(next, { replace: true }); + }, [ + requestedReportId, + searchParams, + selectedReport, + setSearchParams, + ]); + + const memberRows = useMemo(() => { + if (!submission || !selectedAttempt) return []; + const summaryById = new Map( + submission.requests.map((request) => [request.requestId, request]), + ); + return selectedAttempt.requests.map((reference) => { + const summary = summaryById.get(reference.requestId); + return { + requestId: reference.requestId, + runId: reference.runId, + task: summary?.task ?? "Unknown task", + status: + isCurrentAttempt && summary?.runId === reference.runId + ? summary.status + : "historical", + ...(isCurrentAttempt && + summary?.runId === reference.runId && + summary.outcome + ? { outcome: summary.outcome } + : {}), + workerType: summary?.workerType ?? "unknown", + ...(summary?.model ? { model: summary.model } : {}), + ...(summary?.profileId ? { profileId: summary.profileId } : {}), + dependencyStates: selectedAttempt.dependencyProjection + .filter( + (dependency) => + dependency.requestId === reference.requestId && + dependency.runId === reference.runId, + ) + .map((dependency) => ({ + handlerId: dependency.handlerId, + state: dependency.state, + })), + historical: !isCurrentAttempt, + }; + }); + }, [isCurrentAttempt, selectedAttempt, submission]); + + const memberColumns = useMemo[]>( + () => [ + { + id: "request", + header: "Request", + width: "130px", + cell: (row) => ( + event.stopPropagation()} + > + + + ), + }, + { + id: "run", + header: "Exact run attempt", + width: "150px", + cell: (row) => , + }, + { + id: "task", + header: "Task", + cell: (row) => {row.task}, + }, + { + id: "status", + header: "Status", + width: "110px", + cell: (row) => ( + + {row.status} + + ), + }, + { + id: "outcome", + header: "Outcome", + width: "110px", + cell: (row) => + row.outcome ? ( + + {row.outcome} + + ) : ( + — + ), + }, + { + id: "worker", + header: "Worker / model", + cell: (row) => ( +
+
{row.workerType}
+ {row.model && ( +
{row.model}
+ )} +
+ ), + }, + { + id: "dependencies", + header: "Dependencies", + cell: (row) => + row.dependencyStates.length > 0 ? ( +
+ {row.dependencyStates.map((dependency) => ( + + {submissionHandlerLabel(dependency.handlerId)} ·{" "} + {dependency.state} + + ))} +
+ ) : ( + — + ), + }, + { + id: "open", + header: "", + width: "44px", + align: "right", + cell: () => , + }, + ], + [submission?.submissionId], + ); + + const logUrlBuilder = useCallback( + (_attemptId: string, fromStart: boolean) => + api.submissionRunLogsUrl(id!, selectedAttemptId, fromStart), + [id, selectedAttemptId], + ); + const logStream = useLogStream({ + id: selectedAttemptId, + enabled: Boolean(id && selectedAttemptId && activeTab === "processing"), + fromStart: true, + attemptNumber: selectedAttempt?.attemptNumber, + urlBuilder: logUrlBuilder, + }); + + if (submissionQuery.isLoading || attemptsQuery.isLoading) { + return ( +
+ Loading submission… +
+ ); } - if (submissionQuery.isError || !submissionQuery.data) { + if (submissionQuery.isError || !submission) { return (

Submission could not be loaded.

); } - const submission = submissionQuery.data; + if (!selectedAttempt) { + return ( +
+

Submission attempt could not be loaded.

+
+ ); + } + + const statusSubmission: Submission = { + ...submission, + submissionRunId: selectedAttempt._id, + attemptNumber: selectedAttempt.attemptNumber, + readiness, + dependencyProjection: selectedAttempt.dependencyProjection, + blocking: selectedAttempt.blocking, + handlerProgress: selectedAttempt.handlerStatus, + report: selectedAttempt.report, + requests: memberRows, + }; + + const setTab = (tab: SubmissionTab) => { + const next = new URLSearchParams(searchParams); + next.set("tab", tab); + setSearchParams(next); + }; + const selectAttempt = (attemptId: string) => { + const next = new URLSearchParams(searchParams); + if (attemptId === submission.submissionRunId) next.delete("attempt"); + else next.set("attempt", attemptId); + next.delete("report"); + setSearchParams(next); + }; + const selectReport = (report: Report) => { + const next = new URLSearchParams(searchParams); + next.set("tab", "reports"); + next.set("report", reportId(report)); + setSearchParams(next); + }; + return ( -
-
+
+

- Created {formatDate(submission.createdAt)} + Created {formatDate(submission.createdAt)} · {submission.requests.length}{" "} + member run{submission.requests.length === 1 ? "" : "s"}

- +
+ + +
- - - {submission.blocking.length > 0 && ( - - - Blocking dependencies - - - {submission.blocking.map((blocking) => ( -
- - {blocking.requestId.slice(0, 8)} - - {blocking.reason} - - {blocking.runId} - -
- ))} -
-
+ {!isCurrentAttempt && ( +
+ + + Viewing attempt {selectedAttempt.attemptNumber} (historical and + read-only). + + +
)} - - - Runs - - - {submission.requests.map((request) => { - const dependency = submission.dependencyProjection.find( - (item) => - item.requestId === request.requestId && item.runId === request.runId, - ); - return ( - -
-
- - {request.task} -
-
- {request.requestId} · {request.runId} + setTab(value as SubmissionTab)}> + + + + Reports{" "} + {selectedAttemptReportTypes.length > 0 + ? `(${selectedAttemptReportTypes.length})` + : ""} + + + + Runs ({memberRows.length}) + + + + Processing + + + + + setTab("processing")} + /> + + + + + + + Immutable member run vector + +

+ Each row preserves the exact request and run-attempt identity for + submission attempt {selectedAttempt.attemptNumber}. +

+
+ + `${row.requestId}:${row.runId}`} + density="compact" + onRowClick={(row) => + navigate( + `/runs/${row.requestId}?runId=${row.runId}&fromSubmission=${submission.submissionId}`, + ) + } + emptyState={ +
+ This attempt has no member runs.
-
-
- {request.status} - + + + + + + + + {selectedAttempt.blocking.length > 0 && ( + + + Blocking dependencies + + + {selectedAttempt.blocking.map((blocking) => ( +
- {dependency?.state ?? "waiting"} - -
- - ); - })} -
-
+ + {submissionHandlerLabel(blocking.handlerId)} + + + {blocking.reason.replaceAll("-", " ")} + + + request {blocking.requestId.slice(0, 8)} + + + run {blocking.runId} + + {blocking.error && ( + + {blocking.error} + + )} +
+ ))} + + + )} + + {selectedAttempt.handlerStatus && + Object.keys(selectedAttempt.handlerStatus).length > 0 && ( + + + Processing stages + + + {Object.entries(selectedAttempt.handlerStatus).map( + ([handlerId, status]) => ( + + {submissionHandlerLabel(handlerId)} · {status.status} + + ), + )} + + + )} + + + + Attempt processing logs + + + + + + +
); } diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index 1ee0d61f0..29ca91bc6 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -250,6 +250,7 @@ export interface SubmissionBlockingDetail { reason: | "request-missing" | "run-changed" + | "dependency-not-ready" | "dependency-failed" | "dependency-blocked"; error?: string; @@ -266,6 +267,24 @@ export interface SubmissionRequestSummary { profileId?: string; } +export interface SubmissionRunAttempt { + _id: string; + attemptNumber: number; + vectorFingerprint: string; + requests: Array<{ requestId: string; runId: string }>; + createdAt: string; + updatedAt?: string; + startedAt?: string; + finishedAt?: string; + dependencyProjection: SubmissionDependencyProjection[]; + handlerStatus?: Submission["handlerProgress"]; + blocking: SubmissionBlockingDetail[]; + report?: { reportId?: string; url?: string; updatedAt?: string }; + supersededAt?: string; + supersededBySubmissionRunId?: string; + supersededReason?: "request-vector-changed" | "manual-retry"; +} + export interface Submission { submissionId: string; projectId: string; @@ -566,10 +585,18 @@ export interface Reporter { export interface Report { _id: string; id: string; - requestId: string; + requestId?: string; + submissionId?: string; + submissionRunId?: string; task?: string; reporter?: Reporter; content?: string; + structuredArtifact?: { + schemaId: string; + schemaVersion: string; + contentType: "application/json"; + url: string; + }; status: ReportStatus; error?: string; insightReferences?: InsightReference[]; @@ -643,6 +670,7 @@ export interface ReportTemplate { _id: string; id: string; name: string; + subject?: "run" | "submission"; description?: string; userPrompt: string; systemPrompt?: ReportTemplateSystemPrompt; diff --git a/apps/scheduler/src/index.ts b/apps/scheduler/src/index.ts index 9fe0fbf79..adb6629e0 100644 --- a/apps/scheduler/src/index.ts +++ b/apps/scheduler/src/index.ts @@ -207,6 +207,8 @@ async function main(): Promise { db, createQueueClient, ppPollIntervalMs, + 30, + API_URL, ); submissionDispatcher.start(); console.log(`[Scheduler] Submission dispatcher started (poll=${ppPollIntervalMs}ms)`); diff --git a/apps/scheduler/src/submission-dispatcher.test.ts b/apps/scheduler/src/submission-dispatcher.test.ts new file mode 100644 index 000000000..db6dbffe9 --- /dev/null +++ b/apps/scheduler/src/submission-dispatcher.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Collection, Db } from "mongodb"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + RequestDocument, + SubmissionDocument, + SubmissionRunHistoryDocument, +} from "shared"; +import { SubmissionDispatcher } from "./submission-dispatcher.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("SubmissionDispatcher report template triggering", () => { + it("claims and triggers the exact completed submission attempt once", async () => { + const submission: SubmissionDocument = { + _id: "submission-1", + submissionId: "submission-1", + projectId: "project-1", + requestIds: ["request-1"], + createdAt: new Date(), + run: { + _id: "submission-run-1", + attemptNumber: 1, + vectorFingerprint: "vector", + requests: [{ requestId: "request-1", runId: "run-1" }], + dependencyProjection: [], + blocking: [], + handlerStatus: { + "pp-readout": { status: "done", version: 1 }, + }, + report: { reportId: "readout-report" }, + createdAt: new Date(), + }, + }; + const findOneAndUpdate = vi.fn(async () => submission); + const dispatcher = new SubmissionDispatcher( + {} as Collection, + { + findOneAndUpdate, + updateOne: vi.fn(), + } as unknown as Collection, + {} as Collection, + { collection: vi.fn() } as unknown as Db, + vi.fn(), + 30_000, + 30, + "http://api.test", + ); + const reconcile = vi.fn(async () => ({ + submission, + readiness: "done" as const, + rolled: false, + dispatched: false, + })); + ( + dispatcher as unknown as { + coordinator: { reconcile: typeof reconcile }; + } + ).coordinator = { reconcile }; + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 201, + text: async () => "", + })); + vi.stubGlobal("fetch", fetchMock); + + await ( + dispatcher as unknown as { + reconcileAndTrigger: (submissionId: string) => Promise; + } + ).reconcileAndTrigger("submission-1"); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + _id: "submission-1", + "run._id": "submission-run-1", + }), + expect.any(Object), + { returnDocument: "after" }, + ); + expect(fetchMock).toHaveBeenCalledWith( + "http://api.test/api/v1/reports/trigger-submission", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + submissionId: "submission-1", + submissionRunId: "submission-run-1", + }), + }), + ); + }); +}); diff --git a/apps/scheduler/src/submission-dispatcher.ts b/apps/scheduler/src/submission-dispatcher.ts index cd2914853..2282d86bf 100644 --- a/apps/scheduler/src/submission-dispatcher.ts +++ b/apps/scheduler/src/submission-dispatcher.ts @@ -11,6 +11,7 @@ import { type SubmissionQueueMessagePayload, type SubmissionRunHistoryDocument, type ServiceHandlerDocument, + withRetry, } from "shared"; export class SubmissionDispatcher { @@ -28,6 +29,7 @@ export class SubmissionDispatcher { private readonly createQueueClient: (queueName: string) => QueueClient, private readonly pollIntervalMs = 30_000, private readonly batchSize = 30, + private readonly apiUrl = "http://api:3000", ) { this.coordinator = new SubmissionCoordinator( { @@ -63,7 +65,7 @@ export class SubmissionDispatcher { { projection: { submissionId: 1 } }, ); if (request?.submissionId) { - await this.coordinator.reconcile(request.submissionId); + await this.reconcileAndTrigger(request.submissionId); } } @@ -87,7 +89,7 @@ export class SubmissionDispatcher { ? candidates.at(-1)?._id ?? null : null; for (const candidate of candidates) { - await this.coordinator.reconcile(candidate._id); + await this.reconcileAndTrigger(candidate._id); } } catch (error) { console.error("[SubmissionDispatcher] Poll failed:", error); @@ -96,6 +98,64 @@ export class SubmissionDispatcher { } } + private async reconcileAndTrigger(submissionId: string): Promise { + const reconciled = await this.coordinator.reconcile(submissionId); + if (!reconciled || reconciled.readiness !== "done") return; + const submissionRunId = reconciled.submission.run._id; + if (reconciled.submission.run.reportsTriggeredAt) return; + + const now = new Date(); + const claimed = await this.submissions.findOneAndUpdate( + { + _id: submissionId, + "run._id": submissionRunId, + "run.reportsTriggeredAt": { $exists: false }, + }, + { + $set: { + "run.reportsTriggeredAt": now, + "run.updatedAt": now, + updatedAt: now, + }, + }, + { returnDocument: "after" }, + ); + if (!claimed) return; + + const url = `${this.apiUrl}/api/v1/reports/trigger-submission`; + try { + await withRetry(async () => { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ submissionId, submissionRunId }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `POST ${url} -> ${response.status} ${detail}`.trim(), + ); + } + }); + console.log( + `[SubmissionDispatcher] Triggered report templates for ${submissionId}/${submissionRunId}`, + ); + } catch (error) { + const failedAt = new Date(); + await this.submissions.updateOne( + { _id: submissionId, "run._id": submissionRunId }, + { + $unset: { "run.reportsTriggeredAt": "" }, + $set: { + "run.updatedAt": failedAt, + updatedAt: failedAt, + }, + }, + ); + throw error; + } + } + private async enqueue( service: SubmissionHandlerServiceDocument, message: SubmissionQueueMessagePayload, diff --git a/apps/workers/readout/Dockerfile b/apps/workers/readout/Dockerfile new file mode 100644 index 000000000..9a2a63675 --- /dev/null +++ b/apps/workers/readout/Dockerfile @@ -0,0 +1,60 @@ +FROM node:22.22.0-slim AS builder + +# The bundled Copilot CLI trusts the OS system CA store. Without this bundle, +# SDK token validation fails with an opaque "fetch failed" error. +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ +ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ + COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} +RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY tsconfig.base.json ./ +COPY packages/shared/package.json ./packages/shared/ +COPY apps/workers/readout/package.json ./apps/workers/readout/ + +RUN pnpm install --frozen-lockfile + +COPY packages/shared/tsconfig.json ./packages/shared/ +COPY packages/shared/src ./packages/shared/src/ +COPY apps/workers/readout/tsconfig.json ./apps/workers/readout/ +COPY apps/workers/readout/src ./apps/workers/readout/src/ +COPY apps/workers/readout/handler.yaml ./apps/workers/readout/ + +RUN pnpm --filter shared build && pnpm --filter readout-handler build + +# --- Dev stage: hot reload with tsx watch --- +FROM builder AS dev +ENV NODE_ENV=development +ENV SERVICE_DIR=workers/readout +COPY scripts/dev-entrypoint.sh /app/scripts/dev-entrypoint.sh +EXPOSE 80 +ENV PORT=80 +CMD ["/app/scripts/dev-entrypoint.sh"] + +# --- Production stage --- +FROM node:22.22.0-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ +ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ + COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} +RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY packages/shared/package.json ./packages/shared/ +COPY apps/workers/readout/package.json ./apps/workers/readout/ + +RUN pnpm install --frozen-lockfile --prod + +COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ +COPY --from=builder /app/apps/workers/readout/dist ./apps/workers/readout/dist/ +COPY --from=builder /app/apps/workers/readout/handler.yaml ./apps/workers/readout/handler.yaml + +WORKDIR /app/apps/workers/readout + +CMD ["node", "dist/index.js"] diff --git a/apps/workers/readout/handler.yaml b/apps/workers/readout/handler.yaml new file mode 100644 index 000000000..967490565 --- /dev/null +++ b/apps/workers/readout/handler.yaml @@ -0,0 +1,12 @@ +# Submission handler registration metadata. +# Read by the generic register-handler script and POSTed to the +# scheduler's POST /handlers/register endpoint on deploy. +_id: pp-readout +type: submission-handler +version: 1 +queue: pp-readout-queue +selector: readout +autoBackfill: false +dependsOn: + - pp-observations +dependencyMode: all-runs diff --git a/apps/workers/readout/package.json b/apps/workers/readout/package.json new file mode 100644 index 000000000..8e4b87b7f --- /dev/null +++ b/apps/workers/readout/package.json @@ -0,0 +1,24 @@ +{ + "name": "readout-handler", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint src/" + }, + "dependencies": { + "shared": "workspace:*", + "@azure/storage-queue": "^12.25.0", + "@github/copilot-sdk": "1.0.1", + "dotenv": "^16.4.5", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "tsx": "^4.19.0", + "typescript": "^5.6.2" + } +} diff --git a/apps/workers/readout/src/index.ts b/apps/workers/readout/src/index.ts new file mode 100644 index 000000000..e9ed516eb --- /dev/null +++ b/apps/workers/readout/src/index.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import "dotenv/config"; +import { + ReadoutQueueProcessor, + type ReadoutQueueProcessorConfig, +} from "./readout-queue-processor.js"; + +const config: ReadoutQueueProcessorConfig = { + mongoUri: + process.env.MONGO_CONNECTION_STRING || + process.env.AZURE_COSMOS_CONNECTION_STRING || + "mongodb://localhost:27017", + mongoDatabase: process.env.MONGO_DATABASE || "requests-db", + mongoCollection: "submissions", + storageAccountName: + process.env.AZURE_STORAGE_ACCOUNT_NAME || "devstoreaccount1", + storageConnectionString: + process.env.STORAGE_CONNECTION_STRING || + process.env.AZURE_STORAGE_CONNECTION_STRING || + undefined, + queueName: process.env.AZURE_STORAGE_QUEUE_READOUT || "pp-readout-queue", + batchSize: Number(process.env.BATCH_SIZE) || 1, + pollIntervalMs: Number(process.env.POLL_INTERVAL_MS) || 5000, + redisHost: process.env.REDIS_HOST || "localhost", + redisPort: Number(process.env.REDIS_PORT) || 6379, + redisPassword: process.env.REDIS_PASSWORD || "", + model: process.env.READOUT_MODEL || "gpt-5.4", + sessionTimeoutMs: + Number(process.env.READOUT_SESSION_TIMEOUT_MS) || 5 * 60 * 1000, + tokenManagerUrl: process.env.TOKEN_MANAGER_URL, +}; + +const processor = new ReadoutQueueProcessor(config); +processor.start().catch((error) => { + console.error("[readout-handler] Fatal error:", error); + process.exit(1); +}); diff --git a/apps/workers/readout/src/prompt.ts b/apps/workers/readout/src/prompt.ts new file mode 100644 index 000000000..0841817b8 --- /dev/null +++ b/apps/workers/readout/src/prompt.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { SubmissionEvidence } from "./readout-builder.js"; + +export const READOUT_SYSTEM_PROMPT = `You synthesize a submission-level benchmark readout from an exact immutable vector of Scope runs. + +The user message contains JSON evidence for every request/run pair in that vector: request metadata, coding outcome, profile metadata, criteria definitions, run-level and per-iteration observations, and ATIF trajectories. + +Return raw JSON only with this shape: +{ + "title": "string", + "executiveSummary": "string", + "profiles": [ + { "profileId": "optional string", "requestIds": ["string"], "summary": "string" } + ], + "runs": [ + { "requestId": "string", "runId": "string", "trajectoryHighlights": ["string"] } + ], + "findings": [ + { + "id": "string", + "title": "string", + "description": "string", + "severity": "critical|high|medium|low|info", + "evidence": [ + { + "requestId": "string", + "runId": "string", + "iteration": 0, + "criterionId": "optional string", + "excerpt": "string" + } + ] + } + ], + "actions": [ + { + "id": "string", + "title": "string", + "priority": "critical|high|medium|low", + "description": "string", + "relatedFindingIds": ["string"] + } + ] +} + +Rules: +- Use only requestId, runId, iteration, criterionId, and profileId values present in the evidence. +- Treat failed coding outcomes as valid evidence. +- Treat observation results with passed:false as evidence, not as missing data. +- An empty selected-observation set is valid; summarize trajectory and outcome evidence without inventing observations. +- Include every exact-vector run once in runs and every request once in a profile group. +- Preserve disagreements and mixed results instead of averaging them away. +- Ground every finding in at least one evidence reference. +- Return no Markdown fences or commentary.`; + +export function composeReadoutUserPrompt(evidence: SubmissionEvidence): string { + return [ + "Generate the submission readout synthesis for this exact immutable evidence vector.", + JSON.stringify(evidence), + ].join("\n\n"); +} diff --git a/apps/workers/readout/src/readout-builder.test.ts b/apps/workers/readout/src/readout-builder.test.ts new file mode 100644 index 000000000..fa377becb --- /dev/null +++ b/apps/workers/readout/src/readout-builder.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import type { + CriteriaDocument, + RequestDocument, + RunState, +} from "shared"; +import { + composeSubmissionReadout, + renderSubmissionReadoutMarkdown, + type SubmissionEvidence, +} from "./readout-builder.js"; + +function evidence(): SubmissionEvidence { + const criterion: CriteriaDocument = { + id: "uses-current-api", + projectId: "project-1", + prompt: "Uses the current API", + kind: "observation", + subject: "run", + taxonomyElementId: "dimension:dependency-currency", + createdAt: new Date(), + }; + const request = { + _id: "request-1", + projectId: "project-1", + workerType: "copilot", + profileId: "profile-1", + profileVersionId: "profile-version-1", + observations: [criterion.id], + scenario: { task: "Update the API", criteria: [] }, + createdAt: new Date(), + priority: 0, + } satisfies RequestDocument; + const run = { + _id: "run-1", + attemptNumber: 1, + status: "done", + outcome: "failed", + observationResults: [{ + criterionId: criterion.id, + passed: false, + feedback: "The deprecated API remained.", + evaluated: true, + }], + turns: [{ + iteration: 1, + judgeFeedback: "", + snapshotUrl: "https://example.test/snapshot", + passed: false, + timestamp: new Date("2026-10-13T12:00:00.000Z"), + observationResults: [{ + criterionId: criterion.id, + passed: true, + feedback: "The first iteration used the current API.", + evaluated: true, + }], + }], + } satisfies RunState; + return { + submissionId: "submission-1", + submissionRunId: "submission-run-1", + runs: [{ request, run, criteria: [criterion], trajectories: [] }], + }; +} + +describe("submission readout composition", () => { + it("preserves mixed run/iteration observations, profiles, and provenance", () => { + const readout = composeSubmissionReadout(evidence(), { + title: "Submission readout", + executiveSummary: "The run failed after mixed observation evidence.", + profiles: [{ + profileId: "profile-1", + requestIds: ["request-1"], + summary: "One profiled run.", + }], + runs: [{ + requestId: "request-1", + runId: "run-1", + trajectoryHighlights: ["The agent changed approach between iterations."], + }], + findings: [{ + id: "finding-1", + title: "Regression", + description: "The final state regressed.", + severity: "high", + evidence: [{ + requestId: "request-1", + runId: "run-1", + iteration: 1, + criterionId: "uses-current-api", + excerpt: "The deprecated API remained.", + }], + }], + actions: [{ + id: "action-1", + title: "Prevent regression", + priority: "high", + description: "Add a final-state check.", + relatedFindingIds: ["finding-1"], + }], + }, new Date("2026-10-13T12:00:00.000Z")); + + expect(readout.runs[0].observations).toEqual([ + expect.objectContaining({ subject: "run", passed: false }), + expect.objectContaining({ subject: "iteration", passed: true }), + ]); + expect(readout.findings[0].evidence[0]).toMatchObject({ + submissionRunId: "submission-run-1", + requestId: "request-1", + runId: "run-1", + }); + expect(readout.profiles).toEqual([{ + profileId: "profile-1", + requestIds: ["request-1"], + summary: "One profiled run.", + }]); + expect(readout.summary.failedRuns).toBe(1); + expect(readout.summary.passedObservations).toBe(1); + expect(readout.summary.failedObservations).toBe(1); + expect(renderSubmissionReadoutMarkdown(readout)).toContain( + "Schema: `scope.submission-readout@1.0`", + ); + }); + + it("rejects evidence outside the exact vector", () => { + expect(() => composeSubmissionReadout(evidence(), { + title: "Submission readout", + executiveSummary: "Invalid evidence.", + profiles: [{ requestIds: ["request-1"], summary: "One run." }], + runs: [{ + requestId: "request-1", + runId: "run-1", + trajectoryHighlights: [], + }], + findings: [{ + id: "finding-1", + title: "Invalid", + description: "References a newer run.", + severity: "high", + evidence: [{ + requestId: "request-1", + runId: "run-newer", + excerpt: "Not in the vector.", + }], + }], + actions: [], + })).toThrow("non-vector run"); + }); + + it("rejects findings that cite a missing iteration", () => { + expect(() => composeSubmissionReadout(evidence(), { + title: "Submission readout", + executiveSummary: "Invalid iteration evidence.", + profiles: [], + runs: [], + findings: [{ + id: "finding-1", + title: "Invalid", + description: "References a missing iteration.", + severity: "high", + evidence: [{ + requestId: "request-1", + runId: "run-1", + iteration: 2, + excerpt: "Not in the vector.", + }], + }], + actions: [], + })).toThrow("unknown iteration 2"); + }); + + it("canonically retains every exact-vector profile when synthesis omits it", () => { + const readout = composeSubmissionReadout(evidence(), { + title: "Submission readout", + executiveSummary: "Profile synthesis was omitted.", + profiles: [], + runs: [], + findings: [], + actions: [], + }); + + expect(readout.profiles).toEqual([{ + profileId: "profile-1", + requestIds: ["request-1"], + summary: "1 exact-vector run.", + }]); + }); +}); diff --git a/apps/workers/readout/src/readout-builder.ts b/apps/workers/readout/src/readout-builder.ts new file mode 100644 index 000000000..0143188b9 --- /dev/null +++ b/apps/workers/readout/src/readout-builder.ts @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; +import { + SUBMISSION_READOUT_SCHEMA_ID, + SUBMISSION_READOUT_SCHEMA_VERSION, + submissionReadoutSchema, + type CriteriaDocument, + type CriterionResult, + type RequestDocument, + type RunState, + type SubmissionReadout, +} from "shared"; + +export interface ExactRunEvidence { + request: RequestDocument; + run: RunState; + criteria: CriteriaDocument[]; + trajectories: Array<{ iteration: number; trajectory: unknown }>; +} + +export interface SubmissionEvidence { + submissionId: string; + submissionRunId: string; + runs: ExactRunEvidence[]; +} + +const evidenceReferenceSchema = z.object({ + requestId: z.string().min(1), + runId: z.string().min(1), + iteration: z.number().int().min(0).optional(), + criterionId: z.string().min(1).optional(), + excerpt: z.string().min(1), +}); + +export const readoutSynthesisSchema = z.object({ + title: z.string().min(1), + executiveSummary: z.string().min(1), + profiles: z.array(z.object({ + profileId: z.string().optional(), + requestIds: z.array(z.string().min(1)).min(1), + summary: z.string().min(1), + })), + runs: z.array(z.object({ + requestId: z.string().min(1), + runId: z.string().min(1), + trajectoryHighlights: z.array(z.string().min(1)), + })), + findings: z.array(z.object({ + id: z.string().min(1), + title: z.string().min(1), + description: z.string().min(1), + severity: z.enum(["critical", "high", "medium", "low", "info"]), + evidence: z.array(evidenceReferenceSchema).min(1), + })), + actions: z.array(z.object({ + id: z.string().min(1), + title: z.string().min(1), + priority: z.enum(["critical", "high", "medium", "low"]), + description: z.string().min(1), + relatedFindingIds: z.array(z.string().min(1)), + })), +}); + +export type ReadoutSynthesis = z.infer; + +function observationsForRun(evidence: ExactRunEvidence) { + const criteriaById = new Map(evidence.criteria.map((criterion) => [criterion.id, criterion])); + const mapResult = ( + result: CriterionResult, + subject: "run" | "iteration", + ) => { + const criterion = criteriaById.get(result.criterionId); + return { + criterionId: result.criterionId, + ...(criterion?.taxonomyElementId + ? { taxonomyElementId: criterion.taxonomyElementId } + : {}), + subject, + passed: result.passed, + feedback: result.feedback, + }; + }; + return [ + ...(evidence.run.observationResults ?? []).map((result) => + mapResult(result, "run") + ), + ...(evidence.run.turns ?? []).flatMap((turn) => + (turn.observationResults ?? []).map((result) => + mapResult(result, "iteration") + ) + ), + ]; +} + +export function composeSubmissionReadout( + evidence: SubmissionEvidence, + synthesis: ReadoutSynthesis, + generatedAt = new Date(), +): SubmissionReadout { + const exactRuns = new Map( + evidence.runs.map((entry) => [ + `${entry.request._id}:${entry.run._id}`, + entry, + ]), + ); + const highlights = new Map( + synthesis.runs.map((entry) => [ + `${entry.requestId}:${entry.runId}`, + entry.trajectoryHighlights, + ]), + ); + for (const key of highlights.keys()) { + if (!exactRuns.has(key)) { + throw new Error(`Readout synthesis referenced non-vector run ${key}`); + } + } + + const criterionIds = new Set( + evidence.runs.flatMap((entry) => entry.criteria.map((criterion) => criterion.id)), + ); + for (const finding of synthesis.findings) { + for (const reference of finding.evidence) { + const key = `${reference.requestId}:${reference.runId}`; + const exactRun = exactRuns.get(key); + if (!exactRun) { + throw new Error(`Readout finding ${finding.id} referenced non-vector run ${key}`); + } + if ( + reference.iteration !== undefined && + !(exactRun.run.turns ?? []).some( + (turn) => turn.iteration === reference.iteration, + ) + ) { + throw new Error( + `Readout finding ${finding.id} referenced unknown iteration ${reference.iteration} for ${key}`, + ); + } + if (reference.criterionId && !criterionIds.has(reference.criterionId)) { + throw new Error( + `Readout finding ${finding.id} referenced unknown criterion ${reference.criterionId}`, + ); + } + } + } + + const runs = evidence.runs.map((entry) => ({ + requestId: entry.request._id, + runId: entry.run._id, + task: entry.request.scenario.task, + workerType: entry.request.workerType, + ...(entry.request.model ? { model: entry.request.model } : {}), + ...(entry.request.profileId ? { profileId: entry.request.profileId } : {}), + ...(entry.request.profileVersionId + ? { profileVersionId: entry.request.profileVersionId } + : {}), + status: entry.run.status, + ...(entry.run.outcome ? { outcome: entry.run.outcome } : {}), + observations: observationsForRun(entry), + trajectoryHighlights: + highlights.get(`${entry.request._id}:${entry.run._id}`) ?? [], + })); + const allObservations = runs.flatMap((run) => run.observations); + const synthesisProfiles = new Map( + synthesis.profiles.map((profile) => [profile.profileId ?? "", profile]), + ); + const requestIdsByProfile = new Map(); + for (const entry of evidence.runs) { + const key = entry.request.profileId ?? ""; + requestIdsByProfile.set(key, [ + ...(requestIdsByProfile.get(key) ?? []), + entry.request._id, + ]); + } + const profiles = [...requestIdsByProfile].map(([profileId, requestIds]) => ({ + ...(profileId ? { profileId } : {}), + requestIds, + summary: + synthesisProfiles.get(profileId)?.summary ?? + `${requestIds.length} exact-vector run${requestIds.length === 1 ? "" : "s"}.`, + })); + const findingIds = new Set(synthesis.findings.map((finding) => finding.id)); + for (const action of synthesis.actions) { + const unknown = action.relatedFindingIds.find((id) => !findingIds.has(id)); + if (unknown) { + throw new Error(`Readout action ${action.id} referenced unknown finding ${unknown}`); + } + } + + return submissionReadoutSchema.parse({ + meta: { + schemaId: SUBMISSION_READOUT_SCHEMA_ID, + schemaVersion: SUBMISSION_READOUT_SCHEMA_VERSION, + generatedAt: generatedAt.toISOString(), + submissionId: evidence.submissionId, + submissionRunId: evidence.submissionRunId, + }, + summary: { + title: synthesis.title, + executiveSummary: synthesis.executiveSummary, + totalRuns: runs.length, + succeededRuns: runs.filter((run) => run.outcome === "succeeded").length, + failedRuns: runs.filter((run) => run.outcome === "failed").length, + passedObservations: allObservations.filter((result) => result.passed).length, + failedObservations: allObservations.filter((result) => !result.passed).length, + }, + profiles, + runs, + findings: synthesis.findings.map((finding) => ({ + ...finding, + evidence: finding.evidence.map((reference) => ({ + submissionRunId: evidence.submissionRunId, + ...reference, + })), + })), + actions: synthesis.actions, + }); +} + +export function renderSubmissionReadoutMarkdown(readout: SubmissionReadout): string { + const lines = [ + `# ${readout.summary.title}`, + "", + readout.summary.executiveSummary, + "", + "## Summary", + "", + `- Runs: ${readout.summary.totalRuns}`, + `- Succeeded: ${readout.summary.succeededRuns}`, + `- Failed: ${readout.summary.failedRuns}`, + `- Passed observations: ${readout.summary.passedObservations}`, + `- Failed observations: ${readout.summary.failedObservations}`, + "", + "## Runs", + "", + ]; + for (const run of readout.runs) { + lines.push( + `### ${run.task || run.requestId}`, + "", + `- Request: \`${run.requestId}\``, + `- Run: \`${run.runId}\``, + `- Status: ${run.status}${run.outcome ? ` / ${run.outcome}` : ""}`, + `- Profile: ${run.profileId ?? "none"}`, + "", + ); + if (run.observations.length > 0) { + lines.push("| Observation | Subject | Result | Evidence |", "| --- | --- | --- | --- |"); + for (const observation of run.observations) { + lines.push( + `| \`${observation.criterionId}\` | ${observation.subject} | ${observation.passed ? "Passed" : "Failed"} | ${observation.feedback.replaceAll("|", "\\|")} |`, + ); + } + lines.push(""); + } + if (run.trajectoryHighlights.length > 0) { + lines.push( + "**Trajectory highlights**", + "", + ...run.trajectoryHighlights.map((highlight) => `- ${highlight}`), + "", + ); + } + } + lines.push("## Findings", ""); + if (readout.findings.length === 0) { + lines.push("No cross-run findings were identified.", ""); + } else { + for (const finding of readout.findings) { + lines.push( + `### ${finding.title}`, + "", + `**Severity:** ${finding.severity}`, + "", + finding.description, + "", + ...finding.evidence.map( + (evidence) => + `- \`${evidence.requestId}/${evidence.runId}\`${evidence.iteration === undefined ? "" : ` iteration ${evidence.iteration}`}: ${evidence.excerpt}`, + ), + "", + ); + } + } + lines.push("## Actions", ""); + if (readout.actions.length === 0) { + lines.push("No follow-up actions were generated.", ""); + } else { + for (const action of readout.actions) { + lines.push( + `- **${action.title}** (${action.priority}): ${action.description}`, + ); + } + lines.push(""); + } + lines.push( + "---", + `Schema: \`${readout.meta.schemaId}@${readout.meta.schemaVersion}\``, + `Submission run: \`${readout.meta.submissionRunId}\``, + ); + return lines.join("\n"); +} diff --git a/apps/workers/readout/src/readout-generator.ts b/apps/workers/readout/src/readout-generator.ts new file mode 100644 index 000000000..c30f2d76b --- /dev/null +++ b/apps/workers/readout/src/readout-generator.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CopilotClient, type PermissionHandler, SessionEvent } from "@github/copilot-sdk"; +import type { LogEvent } from "shared"; +import { + composeSubmissionReadout, + readoutSynthesisSchema, + type SubmissionEvidence, +} from "./readout-builder.js"; +import { + READOUT_SYSTEM_PROMPT, + composeReadoutUserPrompt, +} from "./prompt.js"; + +const approveNoTools: PermissionHandler = () => ({ + kind: "denied-by-rules", + rules: [], +}); + +type ReadoutLog = ( + level: LogEvent["level"], + message: string, + data?: Record, +) => Promise | void; + +export async function generateSubmissionReadout(input: { + evidence: SubmissionEvidence; + model: string; + githubToken: string; + timeoutMs: number; + log: ReadoutLog; +}) { + const client = new CopilotClient({ gitHubToken: input.githubToken }); + let latestMessage = ""; + let streamedResponse = ""; + try { + const session = await client.createSession({ + model: input.model, + streaming: true, + availableTools: [], + excludedTools: ["builtin:*", "mcp:*"], + onPermissionRequest: approveNoTools, + systemMessage: { mode: "replace", content: READOUT_SYSTEM_PROMPT }, + }); + session.on((event: SessionEvent) => { + if (event.type === "assistant.message_delta") { + streamedResponse += event.data.deltaContent; + } else if (event.type === "assistant.message") { + latestMessage = event.data.content; + } else if (event.type === "session.error") { + void input.log("error", event.data.message, { + errorType: event.data.errorType, + }); + } + }); + + let prompt = composeReadoutUserPrompt(input.evidence); + for (let attempt = 1; attempt <= 3; attempt += 1) { + streamedResponse = ""; + latestMessage = ""; + await input.log("info", `Generating submission readout (${attempt}/3)`); + const response = await session.sendAndWait({ prompt }, input.timeoutMs); + const text = + response?.data.content?.trim() || + latestMessage.trim() || + streamedResponse.trim(); + if (!text) throw new Error("Copilot SDK returned an empty readout response"); + const jsonText = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); + let parsed: unknown; + try { + parsed = JSON.parse(jsonText) as unknown; + } catch (error) { + if (attempt === 3) { + throw new Error(`Readout response was not valid JSON: ${String(error)}`); + } + prompt = "Return the complete corrected readout synthesis as raw JSON only."; + continue; + } + const result = readoutSynthesisSchema.safeParse(parsed); + if (result.success) { + return composeSubmissionReadout(input.evidence, result.data); + } + if (attempt === 3) { + throw new Error( + `Readout synthesis failed schema validation: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, + ); + } + prompt = [ + "Return the complete corrected readout synthesis as raw JSON only.", + `Validation errors: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, + ].join("\n"); + } + throw new Error("Readout generation ended without a valid result"); + } finally { + await client.stop().catch(async (error: unknown) => { + await input.log("warn", "Failed to stop Copilot SDK client cleanly", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } +} diff --git a/apps/workers/readout/src/readout-queue-processor.test.ts b/apps/workers/readout/src/readout-queue-processor.test.ts new file mode 100644 index 000000000..ee5c1ab91 --- /dev/null +++ b/apps/workers/readout/src/readout-queue-processor.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { DequeuedMessageItem } from "@azure/storage-queue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CriteriaDocument, + SubmissionDocument, + VisibilityHeartbeat, +} from "shared"; +import type { RequestDocument, RunHistoryDocument } from "shared"; + +const coordination = vi.hoisted(() => ({ + claim: vi.fn(), + complete: vi.fn(), + isCurrent: vi.fn(), +})); +const generate = vi.hoisted(() => vi.fn()); + +vi.mock("shared", async (importOriginal) => ({ + ...(await importOriginal()), + claimSubmissionHandler: coordination.claim, + completeSubmissionHandler: coordination.complete, + isCurrentSubmissionRun: coordination.isCurrent, +})); +vi.mock("./readout-generator.js", () => ({ + generateSubmissionReadout: generate, +})); + +import { ReadoutQueueProcessor } from "./readout-queue-processor.js"; + +const submission: SubmissionDocument = { + _id: "submission-1", + submissionId: "submission-1", + projectId: "project-1", + requestIds: ["request-1"], + run: { + _id: "submission-run-1", + attemptNumber: 1, + vectorFingerprint: "request-1:run-1", + requests: [{ requestId: "request-1", runId: "run-1" }], + dependencyProjection: [], + handlerStatus: { + "pp-readout": { + version: 1, + status: "processing", + updatedAt: new Date("2026-10-13T12:00:00.000Z"), + }, + }, + blocking: [], + createdAt: new Date("2026-10-13T12:00:00.000Z"), + startedAt: new Date("2026-10-13T12:00:00.000Z"), + updatedAt: new Date("2026-10-13T12:00:00.000Z"), + }, + createdAt: new Date("2026-10-13T12:00:00.000Z"), + updatedAt: new Date("2026-10-13T12:00:00.000Z"), +}; + +function processor() { + const instance = new ReadoutQueueProcessor({ + mongoUri: "mongodb://localhost:27017", + mongoDatabase: "scope", + mongoCollection: "submissions", + storageAccountName: "devstoreaccount1", + storageConnectionString: "UseDevelopmentStorage=true", + queueName: "pp-readout-queue", + batchSize: 1, + pollIntervalMs: 100, + redisHost: "localhost", + redisPort: 6379, + redisPassword: "", + model: "test-model", + sessionTimeoutMs: 1_000, + }); + const insertOne = vi.fn().mockResolvedValue({ acknowledged: true }); + Object.assign(instance as object, { + collection: {}, + db: { + collection: vi.fn((name: string) => + name === "reports" ? { insertOne } : {} + ), + }, + loadEvidence: vi.fn().mockResolvedValue({ + submissionId: submission._id, + submissionRunId: submission.run._id, + runs: [], + }), + tokenClient: { + acquireToken: vi.fn().mockResolvedValue("token"), + }, + blobStorage: { + uploadJson: vi.fn().mockImplementation((path: string) => + Promise.resolve(`https://storage.example/${path}`) + ), + }, + safeDeleteMessage: vi.fn().mockResolvedValue(undefined), + }); + return { instance, insertOne }; +} + +const message = { messageId: "message-1" } as DequeuedMessageItem; +const heartbeat = { + stop: vi.fn().mockResolvedValue(undefined), +} as unknown as VisibilityHeartbeat; +const payload = { + type: "readout", + submissionId: submission._id, + submissionRunId: submission.run._id, +}; +type LogFn = ReturnType; +type ReadoutPayload = typeof payload; + +async function handle(instance: ReadoutQueueProcessor, log: LogFn = vi.fn()) { + await (instance as unknown as { + handleRequest( + submission: SubmissionDocument, + message: DequeuedMessageItem, + heartbeat: VisibilityHeartbeat, + log: LogFn, + payload: ReadoutPayload, + ): Promise; + }).handleRequest(submission, message, heartbeat, log, payload); +} + +describe("readout queue orchestration", () => { + beforeEach(() => { + vi.clearAllMocks(); + coordination.isCurrent.mockResolvedValue(true); + generate.mockResolvedValue({ + meta: { + schemaId: "scope.submission-readout", + schemaVersion: "1.0", + generatedAt: "2026-10-13T12:00:00.000Z", + submissionId: submission._id, + submissionRunId: submission.run._id, + }, + summary: { + title: "Submission readout", + executiveSummary: "Summary", + totalRuns: 0, + succeededRuns: 0, + failedRuns: 0, + passedObservations: 0, + failedObservations: 0, + }, + profiles: [], + runs: [], + findings: [], + actions: [], + }); + }); + + it("discards stale or duplicate exact-run messages before generation", async () => { + coordination.claim.mockResolvedValue(null); + const { instance, insertOne } = processor(); + + await handle(instance); + + expect(generate).not.toHaveBeenCalled(); + expect(insertOne).not.toHaveBeenCalled(); + expect((instance as unknown as { safeDeleteMessage: ReturnType }) + .safeDeleteMessage).toHaveBeenCalledOnce(); + }); + + it("keeps superseded completions historical and uses new report ids on retry", async () => { + coordination.claim.mockResolvedValue(submission); + coordination.complete.mockResolvedValue(false); + const { instance, insertOne } = processor(); + const log = vi.fn().mockResolvedValue(undefined); + + await handle(instance, log); + await handle(instance, log); + + expect(insertOne).toHaveBeenCalledTimes(2); + const reports = insertOne.mock.calls.map(([report]) => report); + expect(reports[0]._id).not.toBe(reports[1]._id); + expect(reports).toEqual([ + expect.objectContaining({ + submissionId: submission._id, + submissionRunId: submission.run._id, + }), + expect.objectContaining({ + submissionId: submission._id, + submissionRunId: submission.run._id, + }), + ]); + expect(coordination.complete).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenCalledWith( + "warn", + "Readout report remained historical after supersession", + expect.objectContaining({ + submissionId: submission._id, + submissionRunId: submission.run._id, + }), + ); + }); + + it("does not publish when the submission is superseded during upload", async () => { + coordination.claim.mockResolvedValue(submission); + coordination.isCurrent + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const { instance, insertOne } = processor(); + + await handle(instance); + + expect(generate).toHaveBeenCalledOnce(); + expect(insertOne).not.toHaveBeenCalled(); + expect(coordination.complete).not.toHaveBeenCalled(); + }); + + it("loads the archived run named by the immutable exact vector", async () => { + const { instance } = processor(); + delete (instance as unknown as Record).loadEvidence; + const currentRequest = { + _id: "request-1", + projectId: "project-1", + workerType: "coder-acp-copilot", + observations: ["observation-criterion"], + scenario: { task: "Task", criteria: ["task-criterion"] }, + createdAt: new Date("2026-10-13T12:00:00.000Z"), + priority: 0, + run: { + _id: "run-newer", + attemptNumber: 2, + status: "done", + outcome: "succeeded", + }, + } satisfies RequestDocument; + const archivedRun = { + _id: "run-1", + requestId: "request-1", + projectId: "project-1", + attemptNumber: 1, + status: "done", + outcome: "failed", + observationResults: [{ + criterionId: "result-criterion", + evaluated: true, + passed: false, + feedback: "Archived evidence", + }], + } satisfies RunHistoryDocument; + const historyFindOne = vi.fn().mockResolvedValue(archivedRun); + const criteria = [ + { + id: "task-criterion", + prompt: "Task criterion", + projectId: "project-1", + createdAt: new Date("2026-10-13T12:00:00.000Z"), + }, + { + id: "observation-criterion", + prompt: "Configured observation", + projectId: "project-1", + createdAt: new Date("2026-10-13T12:00:00.000Z"), + }, + { + id: "result-criterion", + prompt: "Archived observation result", + projectId: "project-1", + createdAt: new Date("2026-10-13T12:00:00.000Z"), + }, + ] satisfies CriteriaDocument[]; + const criteriaFind = vi.fn(() => ({ + toArray: vi.fn().mockResolvedValue(criteria), + })); + Object.assign(instance as object, { + db: { + collection: vi.fn((name: string) => { + if (name === "requests") { + return { + find: vi.fn(() => ({ + toArray: vi.fn().mockResolvedValue([currentRequest]), + })), + }; + } + if (name === "runs") return { findOne: historyFindOne }; + if (name === "criteria") { + return { find: criteriaFind }; + } + return {}; + }), + }, + }); + + const evidence = await (instance as unknown as { + loadEvidence(value: SubmissionDocument): Promise<{ + runs: Array<{ + run: RunHistoryDocument | RequestDocument["run"]; + criteria: CriteriaDocument[]; + }>; + }>; + }).loadEvidence(submission); + + expect(historyFindOne).toHaveBeenCalledWith({ + _id: "run-1", + requestId: "request-1", + projectId: "project-1", + }); + expect(evidence.runs[0].run?._id).toBe("run-1"); + expect(evidence.runs[0].criteria.map((criterion) => criterion.id)).toEqual([ + "task-criterion", + "observation-criterion", + "result-criterion", + ]); + expect(criteriaFind).toHaveBeenCalledWith({ + projectId: "project-1", + id: { + $in: expect.arrayContaining([ + "task-criterion", + "observation-criterion", + "result-criterion", + ]), + }, + }); + }); +}); diff --git a/apps/workers/readout/src/readout-queue-processor.ts b/apps/workers/readout/src/readout-queue-processor.ts new file mode 100644 index 000000000..a33d260e3 --- /dev/null +++ b/apps/workers/readout/src/readout-queue-processor.ts @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { DequeuedMessageItem } from "@azure/storage-queue"; +import { randomUUID } from "node:crypto"; +import { + BaseQueueProcessor, + BlobStorage, + type BaseQueueProcessorConfig, + type CriteriaDocument, + type LogEvent, + type ReportDocument, + type RequestDocument, + type RunHistoryDocument, + type SubmissionDocument, + type SubmissionQueueMessagePayload, + TokenManagerClient, + claimSubmissionHandler, + completeSubmissionHandler, + isCurrentSubmissionRun, + SUBMISSION_READOUT_SCHEMA_ID, + SUBMISSION_READOUT_SCHEMA_VERSION, + type VisibilityHeartbeat, + withRetry, +} from "shared"; +import { + renderSubmissionReadoutMarkdown, + type ExactRunEvidence, + type SubmissionEvidence, +} from "./readout-builder.js"; +import { generateSubmissionReadout } from "./readout-generator.js"; + +const HANDLER_ID = "pp-readout"; +const HANDLER_VERSION = 1; + +export interface ReadoutQueueProcessorConfig extends BaseQueueProcessorConfig { + model: string; + sessionTimeoutMs: number; + tokenManagerUrl?: string; +} + +function isReadoutMessage( + payload: Record | undefined, +): payload is Record & SubmissionQueueMessagePayload { + return payload?.type === "readout" && + typeof payload.submissionId === "string" && + typeof payload.submissionRunId === "string"; +} + +export class ReadoutQueueProcessor extends BaseQueueProcessor { + private readonly readoutConfig: ReadoutQueueProcessorConfig; + private readonly tokenClient: TokenManagerClient; + private readonly blobStorage: BlobStorage; + + constructor(config: ReadoutQueueProcessorConfig) { + super(config, "readout-handler"); + this.readoutConfig = config; + this.tokenClient = new TokenManagerClient(config.tokenManagerUrl); + this.blobStorage = new BlobStorage({ + storageAccountName: config.storageAccountName, + storageConnectionString: config.storageConnectionString, + }); + } + + protected override extractDocumentId(payload: Record): string { + return typeof payload.submissionId === "string" ? payload.submissionId : ""; + } + + protected override async handleRequest( + _submission: SubmissionDocument, + message: DequeuedMessageItem, + heartbeat: VisibilityHeartbeat, + log: ( + level: LogEvent["level"], + message: string, + data?: Record, + ) => Promise, + payload?: Record, + ): Promise { + if (!isReadoutMessage(payload)) { + await log("warn", "Discarding unsupported readout queue payload", { payload }); + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + return; + } + const { submissionId, submissionRunId } = payload; + const claimed = await withRetry(() => + claimSubmissionHandler({ + submissions: this.collection, + submissionId, + submissionRunId, + handlerId: HANDLER_ID, + version: HANDLER_VERSION, + }) + ); + if (!claimed) { + await log("info", "Discarding stale or duplicate readout message", { + submissionId, + submissionRunId, + }); + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + return; + } + + try { + if (!(await this.isCurrent(submissionId, submissionRunId))) return; + const evidence = await this.loadEvidence(claimed); + + if (!(await this.isCurrent(submissionId, submissionRunId))) return; + const githubToken = await this.tokenClient.acquireToken("copilot-sdk"); + + if (!(await this.isCurrent(submissionId, submissionRunId))) return; + const readout = await generateSubmissionReadout({ + evidence, + model: this.readoutConfig.model, + githubToken, + timeoutMs: this.readoutConfig.sessionTimeoutMs, + log, + }); + const markdown = renderSubmissionReadoutMarkdown(readout); + + if (!(await this.isCurrent(submissionId, submissionRunId))) return; + const reportId = randomUUID(); + const structuredUrl = await this.blobStorage.uploadJson( + `reports/${reportId}/structured.json`, + readout, + ); + + if (!(await this.isCurrent(submissionId, submissionRunId))) return; + const now = new Date(); + const report: ReportDocument = { + _id: reportId, + projectId: claimed.projectId, + submissionId, + submissionRunId, + reporter: { + id: HANDLER_ID, + name: "Submission Readout", + gitHash: process.env.GIT_COMMIT || "unknown", + model: this.readoutConfig.model, + agentId: "copilot-sdk", + agentVersion: process.env.COPILOT_SDK_VERSION || "unknown", + }, + content: markdown, + structuredArtifact: { + schemaId: SUBMISSION_READOUT_SCHEMA_ID, + schemaVersion: SUBMISSION_READOUT_SCHEMA_VERSION, + contentType: "application/json", + url: structuredUrl, + }, + status: "completed", + createdAt: now, + updatedAt: now, + }; + await withRetry(() => + this.db.collection("reports").insertOne(report) + ); + + const completed = await withRetry(() => + completeSubmissionHandler({ + submissions: this.collection, + submissionId, + submissionRunId, + handlerId: HANDLER_ID, + version: HANDLER_VERSION, + status: "done", + report: { + reportId, + url: `/api/v1/reports/${reportId}`, + updatedAt: now, + }, + }) + ); + if (!completed) { + await log("warn", "Readout report remained historical after supersession", { + submissionId, + submissionRunId, + reportId, + }); + } else { + await log("info", "Submission readout completed", { + submissionId, + submissionRunId, + reportId, + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (await this.isCurrent(submissionId, submissionRunId)) { + await withRetry(() => + completeSubmissionHandler({ + submissions: this.collection, + submissionId, + submissionRunId, + handlerId: HANDLER_ID, + version: HANDLER_VERSION, + status: "failed", + error: errorMessage, + }) + ); + } + await log("error", `Submission readout failed: ${errorMessage}`, { + submissionId, + submissionRunId, + }); + } finally { + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + } + } + + private isCurrent( + submissionId: string, + submissionRunId: string, + ): Promise { + return withRetry(() => + isCurrentSubmissionRun({ + submissions: this.collection, + submissionId, + submissionRunId, + }) + ); + } + + private async loadEvidence( + submission: SubmissionDocument, + ): Promise { + const requests = await withRetry(() => + this.db.collection("requests").find({ + _id: { $in: submission.run.requests.map((ref) => ref.requestId) }, + projectId: submission.projectId, + }).toArray() + ); + const requestById = new Map(requests.map((request) => [request._id, request])); + const history = this.db.collection("runs"); + const exactRuns: Array<{ request: RequestDocument; run: RunHistoryDocument | RequestDocument["run"] }> = []; + for (const ref of submission.run.requests) { + const request = requestById.get(ref.requestId); + if (!request) throw new Error(`Exact-vector request ${ref.requestId} was not found`); + const run = request.run?._id === ref.runId + ? request.run + : await withRetry(() => history.findOne({ + _id: ref.runId, + requestId: ref.requestId, + projectId: submission.projectId, + })); + if (!run) { + throw new Error(`Exact-vector run ${ref.requestId}/${ref.runId} was not found`); + } + exactRuns.push({ request, run }); + } + + const criterionIds = [ + ...new Set( + exactRuns.flatMap(({ request, run }) => [ + ...request.scenario.criteria, + ...(request.observations ?? []), + ...(run?.observationResults ?? []).map((result) => result.criterionId), + ...(run?.turns ?? []).flatMap((turn) => + (turn.observationResults ?? []).map((result) => result.criterionId) + ), + ]), + ), + ]; + const criteria = criterionIds.length === 0 + ? [] + : await withRetry(() => + this.db.collection("criteria").find({ + projectId: submission.projectId, + id: { $in: criterionIds }, + }).toArray() + ); + const criteriaById = new Map(criteria.map((criterion) => [criterion.id, criterion])); + + const runs: ExactRunEvidence[] = []; + for (const { request, run } of exactRuns) { + if (!run) continue; + const runCriterionIds = new Set([ + ...request.scenario.criteria, + ...(request.observations ?? []), + ...(run.observationResults ?? []).map((result) => result.criterionId), + ...(run.turns ?? []).flatMap((turn) => + (turn.observationResults ?? []).map((result) => result.criterionId) + ), + ]); + const trajectories = await Promise.all( + (run.turns ?? []).flatMap((turn) => + turn.atifUrl + ? [withRetry(() => + this.blobStorage.downloadJson(turn.atifUrl!) + ).then((trajectory) => ({ + iteration: turn.iteration, + trajectory, + }))] + : [], + ), + ); + runs.push({ + request, + run, + criteria: [...runCriterionIds].flatMap((id) => { + const criterion = criteriaById.get(id); + return criterion ? [criterion] : []; + }), + trajectories, + }); + } + return { + submissionId: submission._id, + submissionRunId: submission.run._id, + runs, + }; + } +} diff --git a/apps/workers/readout/tsconfig.json b/apps/workers/readout/tsconfig.json new file mode 100644 index 000000000..bf012ecbb --- /dev/null +++ b/apps/workers/readout/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/apps/workers/report-generator/src/report-queue-processor.ts b/apps/workers/report-generator/src/report-queue-processor.ts index 7ecaac8f8..5c0cb5252 100644 --- a/apps/workers/report-generator/src/report-queue-processor.ts +++ b/apps/workers/report-generator/src/report-queue-processor.ts @@ -13,11 +13,13 @@ import { ReportDocument, ReportTemplateDocument, Reporter, + SUBMISSION_REPORT_SYSTEM_PROMPT, TokenManagerClient, type VisibilityHeartbeat, } from "shared"; import { trackMetric, trackEvent } from "telemetry"; import { createReportTools } from "./tools.js"; +import { createSubmissionReportTools } from "./submission-tools.js"; import { REPORT_SYSTEM_PROMPT, withRetry } from "shared"; export interface ReportQueueProcessorConfig extends BaseQueueProcessorConfig { @@ -36,21 +38,22 @@ export interface ResolvedReportPrompts { export function resolveReportPrompts( template: Pick, - requestId: string, + subjectId: string, + defaultSystemPrompt = REPORT_SYSTEM_PROMPT, ): ResolvedReportPrompts { const userPrompt = template.userPrompt.replace( - /\{\{requestId\}\}|\{requestId\}/g, - requestId, + /\{\{requestId\}\}|\{requestId\}|\{\{submissionId\}\}|\{submissionId\}/g, + subjectId, ); if (!template.systemPrompt) { - return { userPrompt, systemPrompt: REPORT_SYSTEM_PROMPT }; + return { userPrompt, systemPrompt: defaultSystemPrompt }; } if (template.systemPrompt.mode === "override") { return { userPrompt, systemPrompt: template.systemPrompt.content }; } return { userPrompt, - systemPrompt: `${REPORT_SYSTEM_PROMPT}\n\n${template.systemPrompt.content}`, + systemPrompt: `${defaultSystemPrompt}\n\n${template.systemPrompt.content}`, }; } @@ -88,14 +91,33 @@ export class ReportQueueProcessor extends BaseQueueProcessor { const generationStart = Date.now(); const reportId = doc._id; const requestId = doc.requestId; + const submissionId = doc.submissionId; + const submissionRunId = doc.submissionRunId; + if (!requestId && !(submissionId && submissionRunId)) { + throw new Error( + `Report ${reportId} has no run or submission subject`, + ); + } if (!ReportQueueProcessor.coldStartTracked) { ReportQueueProcessor.coldStartTracked = true; trackMetric({ name: "report_generator.cold_start_ms", value: process.uptime() * 1000, properties: { service: "report-generator" } }); } - trackEvent({ name: "report_generator.generation_started", properties: { requestId, reportId, templateId: doc.templateId || "unknown" } }); - await log("info", `Starting report generation for run ${requestId}`); + trackEvent({ + name: "report_generator.generation_started", + properties: { + requestId: requestId ?? "submission", + reportId, + templateId: doc.templateId || "unknown", + }, + }); + await log( + "info", + requestId + ? `Starting report generation for run ${requestId}` + : `Starting report generation for submission ${submissionId}/${submissionRunId}`, + ); // --- Prepare snapshots temp directory --- const snapshotsDir = join(tmpdir(), `report-${reportId}`); @@ -103,12 +125,18 @@ export class ReportQueueProcessor extends BaseQueueProcessor { try { // --- Create Copilot SDK tools --- - const tools = createReportTools( - this.reportConfig.apiBaseUrl, - requestId, - snapshotsDir, - reportId - ); + const tools = requestId + ? createReportTools( + this.reportConfig.apiBaseUrl, + requestId, + snapshotsDir, + reportId, + ) + : createSubmissionReportTools( + this.reportConfig.apiBaseUrl, + submissionId!, + submissionRunId!, + ); await log("info", "Initialized tools, starting Copilot SDK session"); @@ -151,18 +179,19 @@ export class ReportQueueProcessor extends BaseQueueProcessor { await log("info", `Reporter: ${reporter.agentId}@${reporter.agentVersion}, model: ${reporter.model}`); - const { - userPrompt: resolvedUserPrompt, - systemPrompt: resolvedSystemPrompt, - } = resolveReportPrompts(template, requestId); + const prompts = resolveReportPrompts( + template, + requestId ?? submissionId!, + requestId ? REPORT_SYSTEM_PROMPT : SUBMISSION_REPORT_SYSTEM_PROMPT, + ); // --- Run Copilot SDK session --- const resolvedTimeoutMs = template.timeoutMs ?? this.reportConfig.sessionTimeoutMs ?? 5 * 60 * 1000; const llmStart = Date.now(); const content = await this.runCopilotSession( tools, - resolvedUserPrompt, - resolvedSystemPrompt, + prompts.userPrompt, + prompts.systemPrompt, resolvedModel, resolvedTimeoutMs, log @@ -183,7 +212,10 @@ export class ReportQueueProcessor extends BaseQueueProcessor { await log("info", `Report completed (${content.length} chars)`, { final: true }); trackMetric({ name: "report_generator.generation_duration_ms", value: Date.now() - generationStart, properties: { service: "report-generator" } }); - trackEvent({ name: "report_generator.generation_completed", properties: { requestId, reportId } }); + trackEvent({ + name: "report_generator.generation_completed", + properties: { requestId: requestId ?? "submission", reportId }, + }); } finally { // Clean up extracted snapshot files if (existsSync(snapshotsDir)) { @@ -211,7 +243,9 @@ export class ReportQueueProcessor extends BaseQueueProcessor { * real-time progress on the Logs tab. */ private async runCopilotSession( - tools: ReturnType, + tools: + | ReturnType + | ReturnType, userPrompt: string, systemPrompt: string, model: string, diff --git a/apps/workers/report-generator/src/submission-tools.test.ts b/apps/workers/report-generator/src/submission-tools.test.ts new file mode 100644 index 000000000..f7fa32a4b --- /dev/null +++ b/apps/workers/report-generator/src/submission-tools.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSubmissionReportTools } from "./submission-tools.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("createSubmissionReportTools", () => { + it("loads structured evidence for the exact submission attempt", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + _id: "readout-report", + submissionRunId: "submission-run-1", + reporter: { id: "pp-readout" }, + structuredArtifact: { url: "reports/readout-report/structured.json" }, + createdAt: "2026-01-01T00:00:00.000Z", + }, + { + _id: "other-attempt", + submissionRunId: "submission-run-2", + reporter: { id: "pp-readout" }, + structuredArtifact: { url: "reports/other/structured.json" }, + createdAt: "2026-01-02T00:00:00.000Z", + }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ submissionRunId: "submission-run-1", runs: [] }), + }); + vi.stubGlobal("fetch", fetchMock); + const tools = createSubmissionReportTools( + "http://api.test", + "submission-1", + "submission-run-1", + ); + const evidenceTool = tools.find( + (tool) => tool.name === "get_submission_evidence", + ); + + expect(evidenceTool).toBeDefined(); + const result = await ( + evidenceTool as unknown as { + handler: ( + args: Record, + context: Record, + ) => Promise; + } + ).handler({}, {}); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "http://api.test/api/v1/submissions/submission-1/reports", + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "http://api.test/api/v1/reports/readout-report/structured", + ); + expect(result).toEqual({ + reportId: "readout-report", + evidence: { submissionRunId: "submission-run-1", runs: [] }, + }); + }); +}); diff --git a/apps/workers/report-generator/src/submission-tools.ts b/apps/workers/report-generator/src/submission-tools.ts new file mode 100644 index 000000000..e00ecfca3 --- /dev/null +++ b/apps/workers/report-generator/src/submission-tools.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineTool } from "@github/copilot-sdk"; +import type { ReportDocument, SubmissionRunState } from "shared"; + +interface SubmissionAttemptResponse extends SubmissionRunState { + submissionId?: string; + projectId?: string; +} + +export function createSubmissionReportTools( + apiBaseUrl: string, + submissionId: string, + submissionRunId: string, +) { + const getSubmissionSummary = defineTool("get_submission_summary", { + description: + "Get the exact immutable submission attempt, including its request/run vector, dependency projection, handler states, and report pointer.", + parameters: { type: "object", properties: {} }, + handler: async () => { + const response = await fetch( + `${apiBaseUrl}/api/v1/submissions/${encodeURIComponent(submissionId)}/runs/${encodeURIComponent(submissionRunId)}`, + ); + if (!response.ok) { + return { + error: `Failed to fetch submission attempt: ${response.status} ${response.statusText}`, + }; + } + const attempt: SubmissionAttemptResponse = await response.json(); + return { + submissionId, + submissionRunId, + attemptNumber: attempt.attemptNumber, + requests: attempt.requests, + dependencyProjection: attempt.dependencyProjection, + handlerStatus: attempt.handlerStatus, + blocking: attempt.blocking, + createdAt: attempt.createdAt, + finishedAt: attempt.finishedAt, + }; + }, + }); + + const getSubmissionEvidence = defineTool("get_submission_evidence", { + description: + "Get the schema-validated structured evidence for this exact submission attempt, including provenance, outcomes, profiles, criteria, trajectories, and observations.", + parameters: { type: "object", properties: {} }, + handler: async () => { + const reportsResponse = await fetch( + `${apiBaseUrl}/api/v1/submissions/${encodeURIComponent(submissionId)}/reports`, + ); + if (!reportsResponse.ok) { + return { + error: `Failed to list submission reports: ${reportsResponse.status} ${reportsResponse.statusText}`, + }; + } + const reports: ReportDocument[] = await reportsResponse.json(); + const evidenceReport = reports + .filter( + (report) => + report.submissionRunId === submissionRunId && + report.reporter?.id === "pp-readout" && + report.structuredArtifact, + ) + .sort( + (left, right) => + new Date(right.createdAt).getTime() - + new Date(left.createdAt).getTime() || + right._id.localeCompare(left._id), + )[0]; + if (!evidenceReport) { + return { error: "Structured submission evidence is not available" }; + } + const response = await fetch( + `${apiBaseUrl}/api/v1/reports/${encodeURIComponent(evidenceReport._id)}/structured`, + ); + if (!response.ok) { + return { + error: `Failed to fetch structured evidence: ${response.status} ${response.statusText}`, + }; + } + const evidence: unknown = await response.json(); + return { reportId: evidenceReport._id, evidence }; + }, + }); + + return [getSubmissionSummary, getSubmissionEvidence].map((tool) => ({ + ...tool, + skipPermission: true, + })); +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 411c8fc2e..0a22780d0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -217,6 +217,22 @@ services: - action: rebuild path: ./pnpm-lock.yaml + readout-handler: + build: + target: dev + develop: + watch: + - action: sync + path: ./apps/workers/readout/src + target: /app/apps/workers/readout/src + - action: sync + path: ./packages/shared/src + target: /app/packages/shared/src + - action: rebuild + path: ./apps/workers/readout/package.json + - action: rebuild + path: ./pnpm-lock.yaml + register-handler-post-processor: build: target: dev @@ -225,6 +241,10 @@ services: build: target: dev + register-handler-readout: + build: + target: dev + post-processor: build: target: dev diff --git a/docker-compose.yml b/docker-compose.yml index 221dd5c03..33105e7c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ # docker compose --profile vscode-web up # + VS Code Web worker # docker compose --profile claude-code --profile copilot up # multiple workers # docker compose --profile report up # + Report Generator worker +# docker compose --profile readout up # + Submission readout worker # docker compose up mongodb redis azurite # infra only (run app services natively) # # Host-native debugging: @@ -869,6 +870,51 @@ services: api: condition: service_started + readout-handler: + build: + context: . + dockerfile: apps/workers/readout/Dockerfile + args: + <<: *npm-build-args + profiles: [readout, all] + environment: + <<: *common-env + AZURE_STORAGE_QUEUE_READOUT: pp-readout-queue + READOUT_MODEL: ${READOUT_MODEL:-gpt-5.4} + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + TOKEN_MANAGER_URL: http://token-manager:80 + GIT_COMMIT: ${GIT_COMMIT:-development} + READOUT_SESSION_TIMEOUT_MS: ${READOUT_SESSION_TIMEOUT_MS:-300000} + depends_on: + mongodb: + condition: service_healthy + required: false + redis: + condition: service_healthy + azurite: + condition: service_healthy + scheduler: + condition: service_started + register-handler-readout: + condition: service_completed_successfully + + register-handler-readout: + build: + context: . + dockerfile: apps/workers/readout/Dockerfile + args: + <<: *npm-build-args + profiles: [readout, all] + environment: + <<: *common-env + HANDLER_YAML_PATH: apps/workers/readout/handler.yaml + SCHEDULER_URL: http://scheduler:8080 + depends_on: + scheduler: + condition: service_started + restart: "no" + entrypoint: ["npx", "tsx", "packages/shared/src/scripts/register-handler.ts"] + register-handler-post-processor: build: context: . @@ -892,6 +938,8 @@ services: build: context: . dockerfile: apps/workers/post-processor/Dockerfile + args: + <<: *npm-build-args profiles: [post-processor] environment: <<: *common-env diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md index 9ff0c010f..ff2b81973 100644 --- a/docs/architecture/app-design.md +++ b/docs/architecture/app-design.md @@ -528,12 +528,25 @@ discard a message when `claimSubmissionHandler()` returns `null`. Final writes use `completeSubmissionHandler()`, whose compare-and-swap rejects stale publication after supersession. +`pp-readout` writes a normal append-only `ReportDocument` directly; it does not +invoke report templates or the report-generator worker. Run reports retain +`requestId`. Submission reports instead carry `submissionId` and the exact +`submissionRunId`, with no report-kind discriminator. Their optional structured +artifact reference identifies `scope.submission-readout@1.0`, +`application/json`, and the blob URL. The Markdown `content` is rendered from +that validated object. Each successful regeneration uses a new report id, while +the current embedded submission run points only to its latest successful +report. The internal `pp-readout` handler and `scope.submission-readout` schema +names do not define the public submission API resource name. + The API exposes: - `GET /api/v1/submissions?projectId=...` - `GET /api/v1/submissions/:id` +- `GET /api/v1/submissions/:id/reports` - `GET /api/v1/submissions/:id/runs` - `GET /api/v1/submissions/:id/runs/:submissionRunId` +- `GET /api/v1/submissions/:id/runs/:submissionRunId/logs` - `POST /api/v1/submissions/:id/retry` Responses expose readiness, exact blocking request/run pairs, handler progress, @@ -542,6 +555,32 @@ pointer without queue names or scheduler claim fields. Portal submit flows navigate to Submission Detail, and the CLI mirrors list/get/history/retry capabilities under `scope submission`. +Submission Detail uses an attempt-scoped view rather than rendering every +attempt at once. Its attempt picker switches the exact immutable request/run +vector, dependency projection, processing logs, and report subject together. +Horizontal Reports, Runs, and Processing tabs keep member runs one click away +while a long report is open. Reports are first scoped to the selected attempt, +then grouped by report type: template-based reports use `templateId`, while +template-less handler reports use `reporter.id`. A second horizontal tab row +uses browser-style tabs connected to the report panel, shows the newest report +for each type, and deliberately does not expose same-type version history. The +`attempt` and `report` query parameters form the stable deep-link contract. +Selecting an attempt or report adds a browser-history entry; stale duplicate +report ids are canonicalized to the newest report of that type for the selected +attempt. Only the report referenced by the embedded current attempt is labeled +current. + +Report templates have a `subject` of `run` (the backward-compatible default) or +`submission`. Run templates retain their criteria/task/prompt-feature triggers. +Submission templates currently use the automatic trigger: after `pp-readout` +finishes for the exact current `submissionRunId`, the scheduler atomically +claims `run.reportsTriggeredAt` and asks the API to create at most one +template-based report per template and attempt. The normal report-generator +worker consumes that report, reads the exact attempt plus its validated +structured readout evidence, and stores Markdown as a sibling report type. A +failed trigger clears the exact-attempt claim so polling can retry; archived +attempts are never trigger targets. + ## Runs List Query API `GET /api/v1/requests` powers the Portal **Runs** list and `scope run list`. All diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e6b179e56..a71c60123 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -80,6 +80,7 @@ flowchart TB | `workers/coder-acp-copilot` | GitHub Copilot agent via Agent Client Protocol (ACP) | | `workers/report-generator` | Copilot SDK worker that generates post-run markdown reports | | `workers/post-processor` | DAG-based post-run handlers: `pp-atif` (trajectory ATIF) and `pp-observations` (boolean observations via the judge, per-iteration or whole-run by criterion `subject`) | +| `workers/readout` | Copilot SDK submission handler that generates schema-validated structured JSON and derived Markdown for an exact submission attempt | | `gateway` | AI Gateway — shared Rust TLS-intercepting proxy with plugin architecture (HAR capture, future: token refresh, rate limiting) | | `evaluations/static-prompts` | Developer-run static prompt quality and user-controlled prompt red-team suites | diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 4518ef391..285531042 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -34,28 +34,16 @@ Two paths trigger post-processing: - Updates `run.turns.$.atifUrl` in the request document 4. On success, stamps `run.handlerStatus["pp-atif"] = { status: "done", version: N }` (while legacy `run.postProcessorVersion` / `run.postProcessorStatus` may still be mirrored during the transition) -5. Triggers any downstream handlers once the scheduler sees the dependency graph is satisfied. (Reports are **not** a DAG handler — see [Report Triggering](#report-triggering-on-dag-drain).) +5. Triggers `pp-observations` once the scheduler sees the dependency graph is + satisfied. Reports are not run-level DAG handlers. -### Blob Storage Layout - -```javascript -{requestId}/ - runs/{runId}/ - iteration-1/ - atif.trajectory.json - iteration-2/ - atif.trajectory.json -``` - -### API Endpoints +ATIF downloads remain available for the latest or an exact historical attempt: | Endpoint | Description | | --- | --- | | `GET /api/v1/requests/:id/atif?iteration=N` | Download ATIF for the latest run | | `GET /api/v1/requests/:id/runs/:runId/atif?iteration=N` | Download ATIF for a specific run | -The `iteration` query parameter is mandatory for the ATIF endpoints. ATIF files are also included in archive exports as `iteration-{N}.atif.trajectory.json`. - ## Taxonomy Observations (`pp-observations`) The `pp-observations` handler records **observation criteria** — boolean outcomes + @@ -88,11 +76,15 @@ Gate criteria are always `subject: "iteration"` (the API rejects `"run"` for gat 3. **Iteration-subject:** for each iteration with a `snapshotUrl`, calls the existing judge via the shared `JudgeClient` (`POST /api/v1/evaluate`) with `snapshotUrl` + `atifUrl` + the iteration-subject ids. Writes the returned `{criterionId, passed, feedback}` to that turn's **`turn.observationResults`**. 4. **Run-subject:** evaluates **once** with `snapshotUrl` = final turn's snapshot, `atifUrls` = every turn's `atifUrl` (the judge downloads + merges them into one `AgentTrajectory`), and the full `conversationHistory`. Writes the verdicts to **`run.observationResults`** (run level). - In all judge calls there is no persona, no gate, no taxonomyElementId (the classifier lives on the criterion). ATIF supersedes per-turn `toolCallsUrl`, so only ATIF is sent. - - Result shape/storage mirrors gate `criteriaResults`. No new blob, no `run.taxonomyUrl`. + - Result shape/storage mirrors gate `criteriaResults`; no separate run artifact is created. - **Runs exactly once per run:** the scheduler dispatches `pp-observations` a single time (it never sets `message.iteration`), so the run-subject branch needs no idempotency guard. 5. Stamps `run.handlerStatus["pp-observations"] = { status: "done", version: N }` and notifies the scheduler. -Dimension grouping is **derived at read time** by joining each result's `criterionId` → criterion `taxonomyElementId` (the three R&A Readout dimensions; unset = unclassified). It never feeds back to the agent and never affects pass/fail. (#1053 later extends this worker with a richer scorecard artifact.) +Dimension grouping is **derived at read time** by joining each result's +`criterionId` → criterion `taxonomyElementId` (the three R&A Readout +dimensions; unset = unclassified). It never feeds back to the agent and never +affects pass/fail. Submission report synthesis consumes these classifications +after every exact-vector dependency is ready. ## Handler Interface @@ -165,7 +157,9 @@ The `AtifHandler` has two complementary test suites: The post-processor uses a version-based re-processing scheme: -- Each handler is registered independently in the `services` collection using a `post-process-handler` document keyed by handler ID (for example `pp-atif`) +- Each run handler is registered independently in the `services` collection + using a `post-process-handler` document keyed by handler ID (for example + `pp-atif` or `pp-observations`) - Each registration stores the handler `version`, queue name, selector, `autoBackfill` behavior, and `dependsOn` DAG edges - The scheduler compares each request's per-handler status/version state (for example `run.handlerStatus["pp-atif"]`) against the registered handler entry for that selector - Requests with missing or outdated handler state are re-dispatched for that specific handler once its dependencies are satisfied @@ -174,17 +168,20 @@ This allows deploying handler improvements and having them automatically applied ## Handler Registration -Each post-process handler declares its DAG metadata in a `handler.yaml` file colocated with the worker (e.g. `apps/workers/post-processor/handler.yaml`). This mirrors the coding-agent `agent.yaml` registration pattern. +Each post-process handler declares its DAG metadata in a `handler.yaml` file +colocated with the worker. This mirrors the coding-agent `agent.yaml` +registration pattern. ```yaml -# apps/workers/post-processor/handler.yaml -_id: pp-atif +# apps/workers/post-processor/handler.taxonomy.yaml +_id: pp-observations type: post-process-handler version: 1 queue: post-processor-queue -selector: atif -autoBackfill: true -dependsOn: [] +selector: observations +autoBackfill: false +dependsOn: + - pp-atif ``` On deploy, a generic registration script — `packages/shared/src/scripts/register-handler.ts` — reads the `handler.yaml` pointed to by `HANDLER_YAML_PATH`, waits for the scheduler's `/health`, and POSTs the parsed document to `${SCHEDULER_URL}/handlers/register` (with retry). The scheduler validates the document, runs a cycle check against the existing topology, and upserts it into the `services` collection via `HandlerDispatcher.registerHandler`. **Workers never write to the `services` collection directly** — the scheduler is the sole owner. @@ -205,6 +202,14 @@ flowchart LR - **Docker Compose:** one `register-handler-` init service per handler (e.g. `pp-atif`) runs the generic script via `tsx`, gated on the scheduler being up. The worker waits for its registration service to complete. - **Kubernetes:** a `register-handler-post-processor` Job runs the compiled script (`node packages/shared/dist/scripts/register-handler.js`) on each deploy. `pp-atif` and `pp-observations` are registered in K8s via their respective registration Jobs (`register-handler-taxonomy` points its `HANDLER_YAML_PATH` at `handler.taxonomy.yaml`). The scheduler exposes a `Service` (`scheduler.scoped.svc.cluster.local:8080`) so Jobs and workers can reach `/handlers/register` and the notify endpoints. +Compose registration jobs that execute TypeScript source must use the worker's +`dev` image target so their working directory and source tree match the generic +registration entrypoint. Image builds also forward the configured npm registry +through `x-npm-build-args`. Workers that invoke the Copilot SDK, including +`pp-readout`, install the operating-system CA bundle in both development and +production stages because the bundled Copilot CLI validates credentials against +the system trust store. + ## Submission handlers Submission handlers use the same `services` registration endpoint but declare @@ -213,7 +218,7 @@ name run-level DAG handlers, not other submission handlers. The scheduler reconciles each submission's exact request/run vector, projects those named dependencies, and enqueues only the current embedded submission attempt. -The first consumer is `pp-readout`, which depends on `pp-observations` for every +The submission consumer is `pp-readout`, which depends on `pp-observations` for every run in the vector. Its queue contract is: ```json @@ -232,12 +237,44 @@ remains at-least-once; the embedded current attempt is the only dispatchable state, and the scheduler poll repairs missed notifications, stale projections, request-vector drift, and interrupted rollover/finalization. +The dedicated worker in `apps/workers/readout/` loads the exact embedded vector +from current or archived request attempts; it never substitutes a newer current +run. It aggregates ATIF trajectories, run- and iteration-level observations, +criteria definitions, taxonomy element IDs, outcomes, profiles, and request +metadata. The model returns schema-limited synthesis fields. Code then composes +canonical counts and provenance, validates the complete +`scope.submission-readout@1.0` object, and derives Markdown from that object. + +Each successful generation creates a new report document without a template or +report kind. The JSON artifact is uploaded to +`reports/{reportId}/structured.json`; Markdown stays inline on the report. +`SubmissionRunState.report` is compare-and-swap updated only for the exact +current attempt. Reports from previous generations and superseded attempts +remain append-only history. + +The public submission resource is generic: +`GET /api/v1/submissions/:id/reports` returns current and historical reports in +newest-first order, matching the run-level reports collection convention. The +embedded current attempt's `reportId` is resolved through +`GET /api/v1/reports/:reportId`; Structured JSON is downloaded through the +report artifact proxy. Internal handler, queue, and schema identifiers retain +`readout` because they identify this specific synthesis implementation rather +than the public resource. + +Submission-handler logs are persisted under the exact submission attempt and +streamed through +`GET /api/v1/submissions/:id/runs/:submissionRunId/logs`. Queue processors use +the message's `submissionRunId` as the log attempt key, so superseded attempts +remain independently replayable and cannot inherit logs from a newer current +attempt. + To **add or change a handler**, edit its `handler.yaml` (bump `version`, adjust `dependsOn`, etc.) — no code or migration changes are needed for registration. ## Report Triggering on DAG Drain -Reports are intentionally **not** modeled as a DAG handler. The report-generator -worker is keyed by `reportId` and requires a `templateId`, whereas the DAG +Run reports are intentionally **not** modeled as a DAG handler. The +report-generator worker is keyed by `reportId` and requires a `templateId`, +whereas the run DAG dispatches a generic `{ type, requestId, runId }` message — so a report node could never process a DAG message. Reports are also fundamentally different from post-process handlers: they are parameterized (per `templateId`), N-per-run (one @@ -250,7 +287,7 @@ active template's trigger and creates the properly-templated `ReportDocument`(s) ```mermaid flowchart LR - RT["run done"] --> DAG["scheduler dispatches DAG
(pp-atif → …)"] + RT["run done"] --> DAG["scheduler dispatches DAG
(pp-atif → pp-observations)"] DAG -->|"all handlers terminal"| DR{DAG drained?} DR -->|yes| TRIG["POST /api/v1/reports/trigger
{ requestId }"] TRIG --> API["API evaluates each template trigger
→ ReportDocument + report-queue {reportId}"] @@ -263,7 +300,7 @@ flowchart LR registered handler is terminal. A run with **zero** registered handlers is vacuously drained, so reports trigger immediately on run completion. -**Trigger regardless of handler success.** A flaky handler must not block report +**Trigger regardless of handler success.** A flaky unrelated handler must not block report generation (reports analyze the run/ATIF), so the scheduler triggers reports whenever the DAG drains — not only when every handler succeeded. @@ -307,19 +344,20 @@ stateDiagram-v2 | Environment Variable | Default | Description | | --- | --- | --- | | `AZURE_STORAGE_QUEUE_POSTPROCESSOR` | `post-processor-queue` | Queue name for ATIF post-processor messages | +| `AZURE_STORAGE_QUEUE_READOUT` | `pp-readout-queue` | Queue name for exact-attempt submission readouts | | `SCHEDULER_PP_POLL_INTERVAL_MS` | `30000` | Scheduler backfill polling interval | | `BATCH_SIZE` | `1` | Messages to process per poll (worker-side) | | `POLL_INTERVAL_MS` | `5000` | Worker queue polling interval | -| `SCOPE_MT_API_URL` / `API_BASE_URL` | `http://localhost:3001` | API URL used by report Copilot tools | -| `SESSION_TIMEOUT_MS` | `300000` | Timeout for report Copilot SDK sessions | +| `READOUT_MODEL` | `gpt-5.4` | Copilot SDK model used by the submission readout worker | +| `READOUT_SESSION_TIMEOUT_MS` | `300000` | Timeout for one readout generation attempt | | `SCHEDULER_URL` | _(unset)_ | Scheduler base URL — used by handlers to notify completion and by the register-handler script to reach `/handlers/register` | | `API_URL` | `http://api:80` | API base URL the **scheduler** uses to call `POST /api/v1/reports/trigger` when a run's handler DAG drains | | `HANDLER_YAML_PATH` | _(unset)_ | Path to a handler's `handler.yaml`, read by the generic `register-handler` script | ## Infrastructure -- **Queues:** Azure Storage Queues (`post-processor-queue` for ATIF, `report-queue` for reports) -- **Blob Storage:** `snapshots` container (shared with HAR, tool-calls, and other artifacts) -- **Database:** MongoDB `requests` collection (`run.handlerStatus` map keyed by handler ID, with deprecated `run.postProcessorStatus` / `run.postProcessorVersion` compatibility fields during migration) +- **Queues:** Azure Storage Queues (`post-processor-queue` for ATIF and observations, `pp-readout-queue` for submission readouts, `report-queue` for templated run reports) +- **Blob Storage:** `snapshots` container, including `reports/{reportId}/structured.json` +- **Database:** MongoDB `requests`, `runs`, `submissions`, `submission-runs`, and append-only `reports` collections - **KEDA:** ScaledObject scales the worker to zero when queue is empty - **Handler registration:** declarative `handler.yaml` per worker → generic `register-handler` script → scheduler `POST /handlers/register` (sole writer to the `services` collection). A Kubernetes Job runs the script on each deploy; Docker Compose uses per-handler init services. See [Handler Registration](#handler-registration). \ No newline at end of file diff --git a/docs/architecture/prompt-evaluations.md b/docs/architecture/prompt-evaluations.md index 1bdc0636a..e6a3cc3d6 100644 --- a/docs/architecture/prompt-evaluations.md +++ b/docs/architecture/prompt-evaluations.md @@ -30,7 +30,7 @@ families: | Prompt-feature extraction | Positive, negative, ambiguous, ancestor/descendant, and empty-feature cases | | Judge instructions | Bundled and independent strategies; file, tool-history, response, conflicting, missing, and prior-pass evidence | | Developer feedback | Default persona, custom persona, descendant guard on/off, and multiple root failures | -| Run reports | Default and appended system prompts; override as a control where the default prompt is intentionally absent | +| Run reports and submission readouts | Default and appended run-report system prompts; override as a control where the default prompt is intentionally absent; exact-vector submission readout synthesis; submission-template report composition from exact-attempt summary and structured evidence tools | Judge and report tool descriptions are static AI-facing instructions, but they are evaluated through their owning end-to-end judge and report families rather diff --git a/evaluations/static-prompts/adapter/adapter.test.ts b/evaluations/static-prompts/adapter/adapter.test.ts index 4cc89ef9f..33841fa18 100644 --- a/evaluations/static-prompts/adapter/adapter.test.ts +++ b/evaluations/static-prompts/adapter/adapter.test.ts @@ -2,7 +2,11 @@ // Licensed under the MIT License. import { describe, expect, it } from "vitest"; -import { REPORT_SYSTEM_PROMPT } from "../../../packages/shared/src/index.js"; +import { + REPORT_SYSTEM_PROMPT, + SUBMISSION_REPORT_SYSTEM_PROMPT, +} from "../../../packages/shared/src/index.js"; +import { READOUT_SYSTEM_PROMPT } from "../../../apps/workers/readout/src/prompt.js"; import { buildCriteriaAuthoringRequest, buildCriteriaDependencySuggestionRequest, @@ -66,7 +70,13 @@ describe("static prompt adapter registry", () => { }, { family: "run-report", - variants: ["default", "append", "override-control"], + variants: [ + "default", + "append", + "override-control", + "submission-readout", + "submission-template", + ], }, ]); expect(listPromptTargets().map(({ family }) => family)).toEqual([ @@ -95,6 +105,16 @@ describe("static prompt adapter registry", () => { family: "run-report", variant: "override-control", }); + expect(targets).toContainEqual({ + adapterId: "run-report/submission-readout", + family: "run-report", + variant: "submission-readout", + }); + expect(targets).toContainEqual({ + adapterId: "run-report/submission-template", + family: "run-report", + variant: "submission-template", + }); expect(JSON.parse(JSON.stringify(targets))).toEqual(targets); }); }); @@ -422,4 +442,57 @@ describe("session prompt adapters", () => { ]), ); }); + + it("composes the exact submission-readout prompt without tools", async () => { + const evidence = { + submissionId: "submission-1", + submissionRunId: "submission-run-1", + runs: [], + }; + const result = await getPromptTarget( + "run-report", + "submission-readout", + ).run( + { evidence }, + "submission-readout", + fakeContext(() => "{}"), + ); + + expect(result.request.messages).toEqual([ + { role: "system", content: READOUT_SYSTEM_PROMPT }, + { + role: "user", + content: [ + "Generate the submission readout synthesis for this exact immutable evidence vector.", + JSON.stringify(evidence), + ].join("\n\n"), + }, + ]); + expect(result.request.tools).toEqual([]); + }); + + it("composes submission-template prompts with exact-attempt tools", async () => { + const result = await getPromptTarget( + "run-report", + "submission-template", + ).run( + { + submissionId: "submission-1", + submissionRunId: "submission-run-1", + reportId: "report-1", + userPrompt: "Summarize {submissionId}.", + }, + "submission-template", + fakeContext(() => "# Submission report"), + ); + + expect(result.request.messages).toEqual([ + { role: "system", content: SUBMISSION_REPORT_SYSTEM_PROMPT }, + { role: "user", content: "Summarize submission-1." }, + ]); + expect(result.request.tools?.map((tool) => tool.name)).toEqual([ + "get_submission_summary", + "get_submission_evidence", + ]); + }); }); diff --git a/evaluations/static-prompts/adapter/targets/reports.ts b/evaluations/static-prompts/adapter/targets/reports.ts index 696ff31b3..721cd4fce 100644 --- a/evaluations/static-prompts/adapter/targets/reports.ts +++ b/evaluations/static-prompts/adapter/targets/reports.ts @@ -3,7 +3,13 @@ import type { ReportTemplateSystemPrompt } from "../../../../packages/shared/src/index.js"; import { createReportTools } from "../../../../apps/workers/report-generator/src/tools.js"; +import { createSubmissionReportTools } from "../../../../apps/workers/report-generator/src/submission-tools.js"; import { resolveReportPrompts } from "../../../../apps/workers/report-generator/src/report-queue-processor.js"; +import { SUBMISSION_REPORT_SYSTEM_PROMPT } from "../../../../packages/shared/src/index.js"; +import { + READOUT_SYSTEM_PROMPT, + composeReadoutUserPrompt, +} from "../../../../apps/workers/readout/src/prompt.js"; import type { CompletionOptions, ComposedPromptRequest, @@ -14,6 +20,8 @@ import { normalizeTools } from "./common.js"; interface ReportInput { requestId: string; + submissionId?: string; + submissionRunId?: string; reportId: string; userPrompt: string; systemPrompt?: ReportTemplateSystemPrompt; @@ -50,7 +58,22 @@ function parseReportInput(input: unknown, variant: string): ReportInput { ); } return { - requestId: requiredString(value.requestId, "input.requestId"), + requestId: + variant === "submission-template" + ? optionalString(value.requestId, "input.requestId") ?? "" + : requiredString(value.requestId, "input.requestId"), + ...(variant === "submission-template" + ? { + submissionId: requiredString( + value.submissionId, + "input.submissionId", + ), + submissionRunId: requiredString( + value.submissionRunId, + "input.submissionRunId", + ), + } + : {}), reportId: optionalString(value.reportId, "input.reportId") ?? "prompt-eval-report", userPrompt: requiredString(value.userPrompt, "input.userPrompt"), @@ -80,9 +103,10 @@ function parseReportInput(input: unknown, variant: string): ReportInput { function createFixtureToolOptions( evidenceContext: Record | undefined, ): CompletionOptions { - const request = evidenceContext - ? record(evidenceContext.request, "input.evidenceContext.request") - : {}; + const request = + evidenceContext?.request === undefined + ? {} + : record(evidenceContext.request, "input.evidenceContext.request"); const turns = Array.isArray(request.turns) ? request.turns.filter( (turn): turn is Record => @@ -98,6 +122,13 @@ function createFixtureToolOptions( ? (arguments_ as Record) : {}; switch (name) { + case "get_submission_summary": + return evidenceContext?.submission ?? {}; + case "get_submission_evidence": + return { + reportId: "prompt-eval-readout", + evidence: evidenceContext?.evidence ?? {}, + }; case "get_run_summary": return request; case "list_turns": @@ -176,19 +207,30 @@ function composeParsedReportRequest( variant: string, defaultModel: string, ): ComposedPromptRequest { + const submissionTemplate = variant === "submission-template"; + const subjectId = submissionTemplate + ? parsed.submissionId! + : parsed.requestId; const prompts = resolveReportPrompts( { userPrompt: parsed.userPrompt, systemPrompt: parsed.systemPrompt, }, - parsed.requestId, - ); - const tools = createReportTools( - parsed.apiBaseUrl, - parsed.requestId, - parsed.snapshotsDir, - parsed.reportId, + subjectId, + submissionTemplate ? SUBMISSION_REPORT_SYSTEM_PROMPT : undefined, ); + const tools = submissionTemplate + ? createSubmissionReportTools( + parsed.apiBaseUrl, + parsed.submissionId!, + parsed.submissionRunId!, + ) + : createReportTools( + parsed.apiBaseUrl, + parsed.requestId, + parsed.snapshotsDir, + parsed.reportId, + ); return { messages: [ { role: "system", content: prompts.systemPrompt }, @@ -200,8 +242,12 @@ function composeParsedReportRequest( adapterId: `run-report/${variant}`, productionSources: [ "apps/workers/report-generator/src/report-queue-processor.ts#resolveReportPrompts", - "apps/workers/report-generator/src/tools.ts#createReportTools", - "packages/shared/src/report-templates/prompt.ts#REPORT_SYSTEM_PROMPT", + submissionTemplate + ? "apps/workers/report-generator/src/submission-tools.ts#createSubmissionReportTools" + : "apps/workers/report-generator/src/tools.ts#createReportTools", + submissionTemplate + ? "packages/shared/src/report-templates/prompt.ts#SUBMISSION_REPORT_SYSTEM_PROMPT" + : "packages/shared/src/report-templates/prompt.ts#REPORT_SYSTEM_PROMPT", ], fidelity: "exact", }, @@ -213,6 +259,32 @@ export function composeReportRequest( variant: string, defaultModel: string, ): ComposedPromptRequest { + if (variant === "submission-readout") { + const value = record(input); + const evidence = record(value.evidence, "input.evidence"); + return { + messages: [ + { role: "system", content: READOUT_SYSTEM_PROMPT }, + { + role: "user", + content: composeReadoutUserPrompt( + evidence as unknown as Parameters[0], + ), + }, + ], + model: + optionalString(value.model, "input.model") ?? defaultModel, + tools: [], + metadata: { + adapterId: "run-report/submission-readout", + productionSources: [ + "apps/workers/readout/src/prompt.ts#READOUT_SYSTEM_PROMPT", + "apps/workers/readout/src/readout-generator.ts#composeReadoutUserPrompt", + ], + fidelity: "exact", + }, + }; + } return composeParsedReportRequest( parseReportInput(input, variant), variant, @@ -222,8 +294,24 @@ export function composeReportRequest( export const reportAdapter: PromptTargetAdapter = { family: "run-report", - variants: ["default", "append", "override-control"], + variants: [ + "default", + "append", + "override-control", + "submission-readout", + "submission-template", + ], async run(input, variant, context) { + if (variant === "submission-readout") { + const request = composeReportRequest(input, variant, context.model); + const completion = await context.complete(request); + return { + request, + rawResponse: completion.content, + output: completion.content.trim(), + invocationMetadata: completion.metadata, + }; + } const parsed = parseReportInput(input, variant); const request = composeParsedReportRequest(parsed, variant, context.model); const completion = await context.complete( diff --git a/evaluations/static-prompts/datasets/manifest.json b/evaluations/static-prompts/datasets/manifest.json index 06fcc0e6c..489fe7a17 100644 --- a/evaluations/static-prompts/datasets/manifest.json +++ b/evaluations/static-prompts/datasets/manifest.json @@ -36,7 +36,7 @@ "prompt-feature-extraction": 30, "judge-instructions": 30, "developer-feedback": 25, - "run-report": 30 + "run-report": 32 } }, "files": [ @@ -90,6 +90,14 @@ "run-report" ] }, + { + "path": "v1/readouts.jsonl", + "sha256": "7f5b6f50f66e5f182a265996817a910763722c01e68766c95bc9d2ba9abadda7", + "rows": 2, + "families": [ + "run-report" + ] + }, { "path": "v1/task-prompts.jsonl", "sha256": "58c8fd827afddcc197bef8013817d2aea0fd68667bcc0e71bb4ad6055c3aed06", diff --git a/evaluations/static-prompts/datasets/v1/readouts.jsonl b/evaluations/static-prompts/datasets/v1/readouts.jsonl new file mode 100644 index 000000000..753c46325 --- /dev/null +++ b/evaluations/static-prompts/datasets/v1/readouts.jsonl @@ -0,0 +1,2 @@ +{"schemaVersion":1,"id":"run-report-0123456789abcdef","family":"run-report","variant":"submission-readout","smoke":true,"input":{"evidence":{"submissionId":"submission-1","submissionRunId":"submission-run-1","runs":[{"request":{"_id":"request-1","projectId":"project-1","workerType":"coder-acp-copilot","profileId":"profile-1","observations":["uses-current-api"],"scenario":{"task":"Update the API","criteria":[]},"createdAt":"2026-10-13T12:00:00.000Z","priority":0},"run":{"_id":"run-1","attemptNumber":1,"status":"done","outcome":"failed","observationResults":[{"criterionId":"uses-current-api","passed":false,"feedback":"The deprecated API remained.","evaluated":true}],"turns":[]},"criteria":[{"id":"uses-current-api","projectId":"project-1","prompt":"Use the current API","kind":"observation","subject":"run","taxonomyElementId":"dimension:dependency-currency","createdAt":"2026-10-13T12:00:00.000Z"}],"trajectories":[]}]}},"expected":{"referenceReport":"{\"title\":\"Submission readout\",\"executiveSummary\":\"The exact run failed and did not satisfy the selected observation.\",\"profiles\":[{\"profileId\":\"profile-1\",\"requestIds\":[\"request-1\"],\"summary\":\"One profiled run.\"}],\"runs\":[{\"requestId\":\"request-1\",\"runId\":\"run-1\",\"trajectoryHighlights\":[]}],\"findings\":[{\"id\":\"finding-1\",\"title\":\"Deprecated API remained\",\"description\":\"The final run evidence failed the selected observation.\",\"severity\":\"high\",\"evidence\":[{\"requestId\":\"request-1\",\"runId\":\"run-1\",\"criterionId\":\"uses-current-api\",\"excerpt\":\"The deprecated API remained.\"}]}],\"actions\":[{\"id\":\"action-1\",\"title\":\"Update the API usage\",\"priority\":\"high\",\"description\":\"Replace the deprecated API and verify the final state.\",\"relatedFindingIds\":[\"finding-1\"]}]}"},"evaluators":["generation_success","output_schema","non_empty_report","no_fabricated_references","relevance","coherence","groundedness","task_adherence","report_quality"],"tags":["submission-readout","failed"],"provenance":{"kind":"synthetic-reviewed","sourceEndpoints":[],"projectId":"synthetic-project","sourceEntityIds":["submission-1","submission-run-1","request-1","run-1"],"harvestedAt":"2026-10-13T12:00:00.000Z","selectionSeed":"scope-static-prompts-v1","sourceHash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","note":"Reviewed synthetic exact-vector case covering failed coding outcome and passed:false observation evidence."},"review":{"status":"approved","method":"manual-contract-review-v1"}} +{"schemaVersion":1,"id":"run-report-fedcba9876543210","family":"run-report","variant":"submission-template","smoke":true,"input":{"submissionId":"submission-1","submissionRunId":"submission-run-1","reportId":"template-report-1","userPrompt":"Create an executive summary of this exact submission attempt. Compare run outcomes and cite only supplied evidence.","evidenceContext":{"submission":{"submissionId":"submission-1","submissionRunId":"submission-run-1","attemptNumber":1,"requests":[{"requestId":"request-1","runId":"run-1"},{"requestId":"request-2","runId":"run-2"}]},"evidence":{"submissionId":"submission-1","submissionRunId":"submission-run-1","runs":[{"requestId":"request-1","runId":"run-1","outcome":"succeeded"},{"requestId":"request-2","runId":"run-2","outcome":"failed"}],"findings":[{"title":"Mixed outcomes","description":"One of two exact runs failed."}]}}},"expected":{"referenceReport":"# Executive summary\n\nThe exact submission attempt contains two runs with mixed outcomes: `request-1` / `run-1` succeeded, while `request-2` / `run-2` failed.\n\n## Key finding\n\nOne of the two runs failed, so the submission should be reviewed before treating the result as consistently successful."},"evaluators":["generation_success","non_empty_report","valid_markdown","no_fabricated_references","report_quality","relevance","coherence","groundedness","task_adherence","tool_call_accuracy"],"tags":["submission-template","mixed-outcomes"],"provenance":{"kind":"synthetic-reviewed","sourceEndpoints":["/api/v1/submissions/:id/runs/:submissionRunId","/api/v1/submissions/:id/reports","/api/v1/reports/:id/structured"],"projectId":"synthetic-project","sourceEntityIds":["submission-1","submission-run-1","request-1","run-1","request-2","run-2"],"harvestedAt":"2026-10-14T00:00:00.000Z","selectionSeed":"scope-static-prompts-v1","sourceHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","note":"Reviewed synthetic case for submission-scoped template composition and exact-attempt evidence tools."},"review":{"status":"approved","method":"manual-contract-review-v1"}} diff --git a/evaluations/static-prompts/evaluation-manifest.yaml b/evaluations/static-prompts/evaluation-manifest.yaml index e35c81865..f82b7dbe9 100644 --- a/evaluations/static-prompts/evaluation-manifest.yaml +++ b/evaluations/static-prompts/evaluation-manifest.yaml @@ -19,4 +19,4 @@ qualityFamilies: - id: developer-feedback variants: [default, persona, descendant-guard] - id: run-report - variants: [default, append, override-control] + variants: [default, append, override-control, submission-readout, submission-template] diff --git a/evaluations/static-prompts/scripts/dataset-adapter-contract.test.ts b/evaluations/static-prompts/scripts/dataset-adapter-contract.test.ts index 7a8771043..943edc312 100644 --- a/evaluations/static-prompts/scripts/dataset-adapter-contract.test.ts +++ b/evaluations/static-prompts/scripts/dataset-adapter-contract.test.ts @@ -57,6 +57,17 @@ function fakeResponse(row: QualityCaseRow, request: ComposedPromptRequest): stri case "developer-feedback": return "Implement the missing requirement and rerun the relevant check."; case "run-report": + if (row.variant === "submission-readout") { + expect(request.tools).toEqual([]); + return JSON.stringify({ + title: "Submission readout", + executiveSummary: "The exact vector was reviewed.", + profiles: [], + runs: [], + findings: [], + actions: [], + }); + } expect(request.tools?.length).toBeGreaterThan(0); return "# Run report\n\nThe run evidence was reviewed."; } diff --git a/evaluations/static-prompts/scripts/validate-coverage.test.ts b/evaluations/static-prompts/scripts/validate-coverage.test.ts index 66cd57fca..f08588162 100644 --- a/evaluations/static-prompts/scripts/validate-coverage.test.ts +++ b/evaluations/static-prompts/scripts/validate-coverage.test.ts @@ -6,6 +6,6 @@ import { validateCoverage } from "./validate-coverage.js"; it("covers every quality manifest family and variant", async () => { await expect(validateCoverage()).resolves.toEqual({ - qualityTargets: 15, + qualityTargets: 17, }); }); diff --git a/evaluations/static-prompts/scripts/validate-dataset.ts b/evaluations/static-prompts/scripts/validate-dataset.ts index 395c09cb8..f066b0672 100644 --- a/evaluations/static-prompts/scripts/validate-dataset.ts +++ b/evaluations/static-prompts/scripts/validate-dataset.ts @@ -181,9 +181,18 @@ function validateFamilyContract(row: DatasetCase, prefix: string, errors: string assert(typeof input.includeDescendantGuard === "boolean", `${prefix}: input.includeDescendantGuard must be boolean`, errors); break; case "run-report": - assert(stringField(input, "requestId"), `${prefix}: input.requestId is required`, errors); - assert(stringField(input, "reportId"), `${prefix}: input.reportId is required`, errors); - assert(stringField(input, "userPrompt"), `${prefix}: input.userPrompt is required`, errors); + if (row.variant === "submission-readout") { + assert(isRecord(input.evidence), `${prefix}: input.evidence is required`, errors); + } else if (row.variant === "submission-template") { + assert(stringField(input, "submissionId"), `${prefix}: input.submissionId is required`, errors); + assert(stringField(input, "submissionRunId"), `${prefix}: input.submissionRunId is required`, errors); + assert(stringField(input, "reportId"), `${prefix}: input.reportId is required`, errors); + assert(stringField(input, "userPrompt"), `${prefix}: input.userPrompt is required`, errors); + } else { + assert(stringField(input, "requestId"), `${prefix}: input.requestId is required`, errors); + assert(stringField(input, "reportId"), `${prefix}: input.reportId is required`, errors); + assert(stringField(input, "userPrompt"), `${prefix}: input.userPrompt is required`, errors); + } if (row.variant === "append" || row.variant === "override-control") { assert(stringField(input, "systemPromptContent"), `${prefix}: input.systemPromptContent is required for ${row.variant}`, errors); } @@ -345,9 +354,14 @@ export async function validateDataset( assert( syntheticCases.every((row) => row.family === "judge-instructions" - || (row.family === "run-report" && (row.variant === "default" || row.variant === "override-control")) + || (row.family === "run-report" && ( + row.variant === "default" + || row.variant === "override-control" + || row.variant === "submission-readout" + || row.variant === "submission-template" + )) || (row.family === "developer-feedback" && row.variant === "persona")), - "synthetic cases are only permitted for unavailable judge evidence, feedback persona, and report override controls", + "synthetic cases are only permitted for unavailable judge evidence, feedback persona, report override controls, submission readouts, and submission templates", errors, ); const hasTag = (family: QualityFamily, tag: string): boolean => diff --git a/package.json b/package.json index 635bb66ae..d12b00825 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:coder-acp-claude-code": "pnpm --filter coder-acp-claude-code dev", "dev:coder-acp-copilot": "pnpm --filter coder-acp-copilot dev", "dev:report-generator": "pnpm --filter report-generator dev", + "dev:readout": "pnpm --filter readout-handler dev", "docker:up": "scripts/worktree-env.sh && scripts/dev-compose.sh up --build", "docker:up:claude-code": "scripts/worktree-env.sh && SCOPE_REGISTER_CLAUDE_CODE_AGENT=true scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile report up --build", "docker:up:copilot": "scripts/worktree-env.sh && SCOPE_REGISTER_COPILOT_AGENT=true scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --profile copilot --profile report up --build", @@ -26,9 +27,9 @@ "docker:up:portal": "scripts/worktree-env.sh && scripts/dev-compose.sh --profile auth --profile portal up --build", "docker:dev": "scripts/worktree-env.sh && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh -f docker-compose.yml -f docker-compose.dev.yml up --build --watch", "docker:dev:claude-code": "scripts/worktree-env.sh && SCOPE_REGISTER_CLAUDE_CODE_AGENT=true GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile report --profile post-processor --profile auth --profile portal up --build --watch", - "docker:dev:copilot": "scripts/worktree-env.sh && SCOPE_REGISTER_COPILOT_AGENT=true GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile post-processor --profile auth --profile portal up --build --watch", + "docker:dev:copilot": "scripts/worktree-env.sh && SCOPE_REGISTER_COPILOT_AGENT=true GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile post-processor --profile readout --profile auth --profile portal up --build --watch", "docker:dev:portal": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh -f docker-compose.yml -f docker-compose.dev.yml --profile auth --profile portal up --build --watch", - "docker:dev:all": "scripts/worktree-env.sh && SCOPE_REGISTER_COPILOT_AGENT=true SCOPE_REGISTER_CLAUDE_CODE_AGENT=true GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile post-processor --profile auth --profile portal up --build --watch", + "docker:dev:all": "scripts/worktree-env.sh && SCOPE_REGISTER_COPILOT_AGENT=true SCOPE_REGISTER_CLAUDE_CODE_AGENT=true GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile post-processor --profile readout --profile auth --profile portal up --build --watch", "docker:dev:build": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile auth --profile portal build", "docker:down": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile copilot --profile report --profile portal down --volumes", "docker:build": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile copilot --profile report build", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fae65d02f..4308a9a4d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -29,5 +29,6 @@ export * from "./har/index.js"; export * from "./utils/index.js"; export * from "./schemas/index.js"; export * from "./auth/index.js"; +export * from "./schemas/readout.js"; export * from "./cursor.js"; export * from "./run-duration.js"; diff --git a/packages/shared/src/logging/log-publisher.test.ts b/packages/shared/src/logging/log-publisher.test.ts index d332ec8ad..bd8808355 100644 --- a/packages/shared/src/logging/log-publisher.test.ts +++ b/packages/shared/src/logging/log-publisher.test.ts @@ -68,6 +68,7 @@ describe("LogPublisher", () => { expect(parsed.message).toBe("hello"); expect(parsed.level).toBe("info"); expect(parsed.source).toBe("test-worker"); + expect(parsed.data.runId).toBe("attempt-1"); }); it("persists the log event via appendLogEvent under the per-attempt path", async () => { diff --git a/packages/shared/src/logging/log-publisher.ts b/packages/shared/src/logging/log-publisher.ts index 88c0dbe68..ef85f9184 100644 --- a/packages/shared/src/logging/log-publisher.ts +++ b/packages/shared/src/logging/log-publisher.ts @@ -87,9 +87,13 @@ export class LogPublisher { // The Redis channel is keyed on requestId so SSE listeners receive logs // for whichever attempt is currently running. const channel = `logs:${requestId}`; + const redisLogEvent: LogEvent = { + ...logEvent, + data: { ...data, runId }, + }; try { await this.redisBreaker.execute(() => - this.redis.publish(channel, JSON.stringify(logEvent)) + this.redis.publish(channel, JSON.stringify(redisLogEvent)) ); } catch (error) { // Silently ignore - circuit breaker handles logging state changes diff --git a/packages/shared/src/queue/base-queue-processor.test.ts b/packages/shared/src/queue/base-queue-processor.test.ts index 00d485be4..a8ebf9d71 100644 --- a/packages/shared/src/queue/base-queue-processor.test.ts +++ b/packages/shared/src/queue/base-queue-processor.test.ts @@ -162,8 +162,10 @@ describe("BaseQueueProcessor visibility heartbeat lifecycle", () => { vi.useRealTimers(); }); - function makeMessage(): DequeuedMessageItem { - const payload = Buffer.from(JSON.stringify({ requestId: "doc-1" })).toString("base64"); + function makeMessage( + messagePayload: Record = { requestId: "doc-1" }, + ): DequeuedMessageItem { + const payload = Buffer.from(JSON.stringify(messagePayload)).toString("base64"); return { messageId: "msg-1", popReceipt: "receipt-0", @@ -171,7 +173,16 @@ describe("BaseQueueProcessor visibility heartbeat lifecycle", () => { } as unknown as DequeuedMessageItem; } - function setupProcessor(handleRequest: (heartbeat: VisibilityHeartbeat) => Promise | void) { + function setupProcessor( + handleRequest: ( + heartbeat: VisibilityHeartbeat, + log: ( + level: LogEvent["level"], + message: string, + data?: Record, + ) => Promise, + ) => Promise | void, + ) { class HBProcessor extends BaseQueueProcessor<{ _id: string }> { public capturedHeartbeat: VisibilityHeartbeat | undefined; public handleRequestCalls = 0; @@ -181,11 +192,24 @@ describe("BaseQueueProcessor visibility heartbeat lifecycle", () => { _doc: { _id: string }, _message: DequeuedMessageItem, heartbeat: VisibilityHeartbeat, + log: ( + level: LogEvent["level"], + message: string, + data?: Record, + ) => Promise, ): Promise { this.capturedHeartbeat = heartbeat; this.popReceiptAtHandleStart = heartbeat.popReceipt; this.handleRequestCalls++; - await handleRequest(heartbeat); + await handleRequest(heartbeat, log); + } + + protected override extractDocumentId( + payload: Record, + ): string { + return typeof payload.submissionId === "string" + ? payload.submissionId + : super.extractDocumentId(payload); } protected override async cleanup(): Promise { @@ -203,11 +227,12 @@ describe("BaseQueueProcessor visibility heartbeat lifecycle", () => { findOne: vi.fn().mockResolvedValue({ _id: "doc-1", run: { _id: "run-1" } }), updateOne: vi.fn().mockResolvedValue({ matchedCount: 1 }), }; - (proc as any).logPublisher = { + const logPublisher = { publish: vi.fn().mockResolvedValue(undefined), evictRun: vi.fn(), }; - return { proc, updateMessage, deleteMessage }; + (proc as any).logPublisher = logPublisher; + return { proc, updateMessage, deleteMessage, logPublisher }; } it("starts the heartbeat before handleRequest runs", async () => { @@ -223,6 +248,26 @@ describe("BaseQueueProcessor visibility heartbeat lifecycle", () => { expect(updateMessage).not.toHaveBeenCalled(); }); + it("persists submission worker logs under the exact submission attempt", async () => { + const { proc, logPublisher } = setupProcessor(async (_heartbeat, log) => { + await log("info", "Submission processing started"); + }); + await (proc as any).processMessage( + makeMessage({ + submissionId: "doc-1", + submissionRunId: "submission-run-1", + }), + ); + + expect(logPublisher.publish).toHaveBeenCalledWith( + "doc-1", + "submission-run-1", + "info", + "Submission processing started", + undefined, + ); + }); + it("the heartbeat passed to handleRequest exposes the live pop receipt", async () => { let observedAfterTick: string | undefined; const { proc } = setupProcessor(async (heartbeat) => { diff --git a/packages/shared/src/queue/base-queue-processor.ts b/packages/shared/src/queue/base-queue-processor.ts index 08bc35d51..5a0d3772b 100644 --- a/packages/shared/src/queue/base-queue-processor.ts +++ b/packages/shared/src/queue/base-queue-processor.ts @@ -242,8 +242,13 @@ export abstract class BaseQueueProcessor { it('is a non-empty string', () => { @@ -10,6 +13,15 @@ describe('REPORT_SYSTEM_PROMPT', () => { expect(REPORT_SYSTEM_PROMPT.length).toBeGreaterThan(0); }); + describe('SUBMISSION_REPORT_SYSTEM_PROMPT', () => { + it('requires exact submission-attempt evidence', () => { + expect(SUBMISSION_REPORT_SYSTEM_PROMPT).toContain('exact, immutable submission attempt'); + expect(SUBMISSION_REPORT_SYSTEM_PROMPT).toContain('get_submission_summary'); + expect(SUBMISSION_REPORT_SYSTEM_PROMPT).toContain('get_submission_evidence'); + expect(SUBMISSION_REPORT_SYSTEM_PROMPT).toContain('Never substitute newer runs'); + }); + }); + it('describes the report generation task', () => { expect(REPORT_SYSTEM_PROMPT).toContain('report'); expect(REPORT_SYSTEM_PROMPT).toContain('agentic coding session'); diff --git a/packages/shared/src/report-templates/prompt.ts b/packages/shared/src/report-templates/prompt.ts index c812e5816..4502646df 100644 --- a/packages/shared/src/report-templates/prompt.ts +++ b/packages/shared/src/report-templates/prompt.ts @@ -71,3 +71,9 @@ Keep insight titles concise (one line). Use markdown in descriptions for detail. - \`> [!WARNING]\` for potential issues or pitfalls - \`> [!CAUTION]\` for critical problems or regressions `; + +export const SUBMISSION_REPORT_SYSTEM_PROMPT = `Your job is to analyze one exact, immutable submission attempt containing multiple agentic coding runs and produce a Markdown report. + +Use get_submission_summary to understand the selected attempt and its exact request/run membership. Use get_submission_evidence to inspect the schema-validated evidence generated for that same attempt. Preserve the distinction between individual run outcomes and cross-run patterns. Never substitute newer runs or evidence from another submission attempt. + +The report must follow the user's template instructions, remain grounded in the supplied evidence, and use GitHub Flavored Markdown. Do not mention tool use or internal processing in the report body.`; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 52a7bed3f..036f45347 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -23,3 +23,4 @@ export * from "./feature-flag.js"; export * from "./profile.js"; export * from "./project.js"; export * from "./submission.js"; +export * from "./readout.js"; diff --git a/packages/shared/src/schemas/readout.test.ts b/packages/shared/src/schemas/readout.test.ts new file mode 100644 index 000000000..503dcfc8c --- /dev/null +++ b/packages/shared/src/schemas/readout.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { + SUBMISSION_READOUT_SCHEMA_ID, + SUBMISSION_READOUT_SCHEMA_VERSION, + submissionReadoutSchema, +} from "./readout.js"; + +describe("submissionReadoutSchema", () => { + it("accepts a one-request readout with empty selected observations", () => { + expect(submissionReadoutSchema.parse({ + meta: { + schemaId: SUBMISSION_READOUT_SCHEMA_ID, + schemaVersion: SUBMISSION_READOUT_SCHEMA_VERSION, + generatedAt: "2026-10-13T12:00:00.000Z", + submissionId: "submission-1", + submissionRunId: "submission-run-1", + }, + summary: { + title: "Submission readout", + executiveSummary: "The single run completed without selected observations.", + totalRuns: 1, + succeededRuns: 1, + failedRuns: 0, + passedObservations: 0, + failedObservations: 0, + }, + profiles: [{ + requestIds: ["request-1"], + summary: "One unprofiled request.", + }], + runs: [{ + requestId: "request-1", + runId: "run-1", + task: "Update the endpoint", + workerType: "copilot", + status: "done", + outcome: "succeeded", + observations: [], + trajectoryHighlights: [], + }], + findings: [], + actions: [], + })).toBeTruthy(); + }); +}); diff --git a/packages/shared/src/schemas/readout.ts b/packages/shared/src/schemas/readout.ts new file mode 100644 index 000000000..f8e85ca7e --- /dev/null +++ b/packages/shared/src/schemas/readout.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; + +export const SUBMISSION_READOUT_SCHEMA_ID = "scope.submission-readout"; +export const SUBMISSION_READOUT_SCHEMA_VERSION = "1.0"; + +export const readoutObservationSchema = z.object({ + criterionId: z.string().min(1), + taxonomyElementId: z.string().optional(), + subject: z.enum(["run", "iteration"]), + passed: z.boolean(), + feedback: z.string(), +}); + +export const readoutEvidenceSchema = z.object({ + submissionRunId: z.string().min(1), + requestId: z.string().min(1), + runId: z.string().min(1), + iteration: z.number().int().min(0).optional(), + criterionId: z.string().min(1).optional(), + excerpt: z.string().min(1), +}); + +export const readoutRunSchema = z.object({ + requestId: z.string().min(1), + runId: z.string().min(1), + task: z.string(), + workerType: z.string().min(1), + model: z.string().optional(), + profileId: z.string().optional(), + profileVersionId: z.string().optional(), + status: z.string(), + outcome: z.string().optional(), + observations: z.array(readoutObservationSchema), + trajectoryHighlights: z.array(z.string().min(1)), +}); + +export const readoutFindingSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + description: z.string().min(1), + severity: z.enum(["critical", "high", "medium", "low", "info"]), + evidence: z.array(readoutEvidenceSchema).min(1), +}); + +export const readoutActionSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + priority: z.enum(["critical", "high", "medium", "low"]), + description: z.string().min(1), + relatedFindingIds: z.array(z.string().min(1)), +}); + +export const submissionReadoutSchema = z.object({ + meta: z.object({ + schemaId: z.literal(SUBMISSION_READOUT_SCHEMA_ID), + schemaVersion: z.literal(SUBMISSION_READOUT_SCHEMA_VERSION), + generatedAt: z.string().datetime({ offset: true }), + submissionId: z.string().min(1), + submissionRunId: z.string().min(1), + }), + summary: z.object({ + title: z.string().min(1), + executiveSummary: z.string().min(1), + totalRuns: z.number().int().min(1), + succeededRuns: z.number().int().min(0), + failedRuns: z.number().int().min(0), + passedObservations: z.number().int().min(0), + failedObservations: z.number().int().min(0), + }), + profiles: z.array(z.object({ + profileId: z.string().optional(), + requestIds: z.array(z.string().min(1)).min(1), + summary: z.string().min(1), + })), + runs: z.array(readoutRunSchema).min(1), + findings: z.array(readoutFindingSchema), + actions: z.array(readoutActionSchema), +}); + +export type SubmissionReadout = z.infer; +export type ReadoutRun = z.infer; diff --git a/packages/shared/src/schemas/report-template.ts b/packages/shared/src/schemas/report-template.ts index 333e079cd..10bc44669 100644 --- a/packages/shared/src/schemas/report-template.ts +++ b/packages/shared/src/schemas/report-template.ts @@ -47,6 +47,7 @@ export const CreateReportTemplateInputSchema = z .object({ id: z.string(), name: z.string(), + subject: z.enum(["run", "submission"]).default("run"), description: z.string().optional(), userPrompt: z.string(), systemPrompt: ReportTemplateSystemPromptSchema.optional(), @@ -59,6 +60,7 @@ export const CreateReportTemplateInputSchema = z export const UpdateReportTemplateInputSchema = z .object({ name: z.string().optional(), + subject: z.enum(["run", "submission"]).optional(), description: z.string().optional(), userPrompt: z.string().optional(), systemPrompt: ReportTemplateSystemPromptSchema.nullable().optional(), @@ -73,6 +75,7 @@ export const ReportTemplateResponseSchema = z _id: z.string(), id: z.string(), name: z.string(), + subject: z.enum(["run", "submission"]).default("run"), description: z.string().optional(), userPrompt: z.string(), systemPrompt: ReportTemplateSystemPromptSchema.optional(), diff --git a/packages/shared/src/schemas/report.ts b/packages/shared/src/schemas/report.ts index c9aa697db..d3cbbe0e5 100644 --- a/packages/shared/src/schemas/report.ts +++ b/packages/shared/src/schemas/report.ts @@ -33,13 +33,25 @@ export const ReportStatusSchema = z.enum([ "failed", ]); +export const StructuredReportArtifactSchema = z + .object({ + schemaId: z.string(), + schemaVersion: z.string(), + contentType: z.literal("application/json"), + url: z.string(), + }) + .openapi("StructuredReportArtifact"); + export const ReportResponseSchema = z .object({ _id: z.string(), - requestId: z.string(), + requestId: z.string().optional(), + submissionId: z.string().optional(), + submissionRunId: z.string().optional(), templateId: z.string().optional(), reporter: ReporterSchema.optional(), content: z.string().optional(), + structuredArtifact: StructuredReportArtifactSchema.optional(), status: ReportStatusSchema, error: z.string().optional(), insightReferences: z.array(InsightReferenceSchema).optional(), @@ -47,6 +59,18 @@ export const ReportResponseSchema = z updatedAt: z.coerce.date().optional(), projectId: z.string(), }) + .superRefine((report, context) => { + if ( + !report.requestId && + !(report.submissionId && report.submissionRunId) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Report must identify either a request or an exact submission attempt", + }); + } + }) .openapi("ReportResponse"); export const CreateReportInputSchema = z @@ -75,6 +99,13 @@ export const TriggerReportsInputSchema = z }) .openapi("TriggerReportsInput"); +export const TriggerSubmissionReportsInputSchema = z + .object({ + submissionId: z.string(), + submissionRunId: z.string(), + }) + .openapi("TriggerSubmissionReportsInput"); + export const BulkTriggerReportsInputSchema = z .object({ requestIds: z.array(z.string()), diff --git a/packages/shared/src/schemas/submission.ts b/packages/shared/src/schemas/submission.ts index 3558ae603..6e6a80c36 100644 --- a/packages/shared/src/schemas/submission.ts +++ b/packages/shared/src/schemas/submission.ts @@ -87,6 +87,7 @@ export const SubmissionRunStateSchema = z handlerStatus: z.record(z.string(), SubmissionHandlerStatusSchema).optional(), blocking: z.array(SubmissionBlockingDetailSchema).default([]), report: SubmissionReportPointerSchema.optional(), + reportsTriggeredAt: z.coerce.date().optional(), reconcileAfter: z.coerce.date().optional(), rollover: SubmissionRolloverClaimSchema.optional(), supersededAt: z.coerce.date().optional(), diff --git a/packages/shared/src/scripts/register-handler.test.ts b/packages/shared/src/scripts/register-handler.test.ts index 162916404..7d873e452 100644 --- a/packages/shared/src/scripts/register-handler.test.ts +++ b/packages/shared/src/scripts/register-handler.test.ts @@ -51,4 +51,18 @@ describe("parseHandlerDocument", () => { it("rejects a non-object input", () => { expect(() => parseHandlerDocument("nope")).toThrow(/mapping/); }); + + it("accepts the pp-readout submission handler contract", () => { + const readout = { + _id: "pp-readout", + type: "submission-handler", + version: 1, + queue: "pp-readout-queue", + selector: "readout", + autoBackfill: false, + dependsOn: ["pp-observations"], + dependencyMode: "all-runs", + }; + expect(parseHandlerDocument(readout)).toEqual(readout); + }); }); diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 5f8315307..0c86fa6e4 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -651,6 +651,8 @@ export interface SubmissionRunState { handlerStatus?: Record; blocking: SubmissionBlockingDetail[]; report?: SubmissionReportPointer; + /** Exactly-once guard for automatic submission-scoped report templates. */ + reportsTriggeredAt?: Date; /** Internal scheduler hold used by bulk request retry so one completed * replacement batch produces one vector rollover. Expiry self-heals crashes. */ reconcileAfter?: Date; @@ -939,14 +941,24 @@ export interface Reporter { agentVersion: string; // @github/copilot-sdk package version } +export interface StructuredReportArtifact { + schemaId: string; + schemaVersion: string; + contentType: "application/json"; + url: string; +} + /** Report document stored in MongoDB */ export interface ReportDocument { _id: string; // UUID - requestId: string; // FK → RequestDocument._id + requestId?: string; // FK → RequestDocument._id for run reports + submissionId?: string; // FK → SubmissionDocument._id for submission reports + submissionRunId?: string; // Exact immutable submission attempt projectId: string; // FK → ProjectDocument._id (denormalized from request) templateId?: string; // FK → ReportTemplateDocument.id (slug of template that generated this report) reporter?: Reporter; // Set by the worker when it picks up the job content?: string; // Generated markdown report + structuredArtifact?: StructuredReportArtifact; status: ReportStatus; error?: string; insightReferences?: InsightReference[]; // Insights discovered/referenced by this report @@ -1005,6 +1017,7 @@ export interface ReportTemplateDocument { projectId: string; // FK → ProjectDocument._id (immutable scope) id: string; // Human-readable slug (e.g. "default", "failure-analysis") name: string; // Display name + subject?: "run" | "submission"; // Defaults to "run" for legacy templates description?: string; userPrompt: string; // REQUIRED — the instruction sent to the agent systemPrompt?: ReportTemplateSystemPrompt; // OPTIONAL — customize base system prompt diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4a29c36a..75f306a3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -759,6 +759,34 @@ importers: specifier: ^5.6.2 version: 5.9.3 + apps/workers/readout: + dependencies: + '@azure/storage-queue': + specifier: ^12.25.0 + version: 12.29.0 + '@github/copilot-sdk': + specifier: 1.0.1 + version: 1.0.1(typescript@5.9.3) + dotenv: + specifier: 16.6.1 + version: 16.6.1 + shared: + specifier: workspace:* + version: link:../../../packages/shared + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.7.0 + version: 22.19.10 + tsx: + specifier: ^4.19.0 + version: 4.21.0 + typescript: + specifier: ^5.6.2 + version: 5.9.3 + apps/workers/report-generator: dependencies: '@azure/storage-queue': @@ -4782,7 +4810,7 @@ packages: engines: {node: '>=12'} dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + resolution: {integrity: sha1-dz8OaVJ6gxXHKF1e5zxEWdIKgCA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dotenv/-/dotenv-16.6.1.tgz} engines: {node: '>=12'} dunder-proto@1.0.1: diff --git a/scripts/readout-container.test.ts b/scripts/readout-container.test.ts new file mode 100644 index 000000000..80d07532f --- /dev/null +++ b/scripts/readout-container.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +const root = new URL("../", import.meta.url); + +async function read(path: string): Promise { + return readFile(new URL(path, root), "utf8"); +} + +describe("readout container configuration", () => { + it("installs the system CA bundle in development and production stages", async () => { + const dockerfile = await read("apps/workers/readout/Dockerfile"); + const installs = dockerfile.match( + /apt-get install -y --no-install-recommends ca-certificates/g, + ); + + expect(installs).toHaveLength(2); + }); + + it("forwards the configured npm registry to readout image builds", async () => { + const compose = await read("docker-compose.yml"); + + for (const service of ["readout-handler", "register-handler-readout"]) { + expect(compose).toMatch( + new RegExp( + `\\n ${service}:\\n[\\s\\S]*?dockerfile: apps/workers/readout/Dockerfile\\n[\\s\\S]*?args:\\n\\s+<<: \\*npm-build-args`, + ), + ); + } + }); + + it("uses the development image for both post-processor registration jobs", async () => { + const compose = await read("docker-compose.dev.yml"); + + for (const service of [ + "register-handler-post-processor", + "register-handler-taxonomy", + ]) { + expect(compose).toMatch( + new RegExp(`\\n ${service}:\\n\\s+build:\\n\\s+target: dev`), + ); + } + }); +}); diff --git a/website/src/openapi/scope-openapi.json b/website/src/openapi/scope-openapi.json index eb0e8dfd8..5c6583cda 100644 --- a/website/src/openapi/scope-openapi.json +++ b/website/src/openapi/scope-openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Scope API","version":"1.0.0","description":"REST API for the Scope platform — benchmarking AI coding agents"},"servers":[{"url":"/","description":"Current server"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Use the unchanged IdP access token, without the Bearer prefix."}},"schemas":{"KeyResponse":{"type":"object","properties":{},"additionalProperties":{}},"KeyInput":{"type":"object","properties":{},"additionalProperties":{}},"ValidateKeyInput":{"type":"object","properties":{"token":{"type":"string"}},"required":["token"]},"AccountResponse":{"type":"object","properties":{},"additionalProperties":{}},"AccountInput":{"type":"object","properties":{},"additionalProperties":{}},"UserMeResponse":{"type":"object","properties":{"id":{"type":"string"},"role":{"type":"string"},"email":{"type":"string"},"displayName":{"type":"string"},"idp":{"type":"string"},"idpTenant":{"type":"string"}},"required":["id"]},"ProjectResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","createdAt"]},"CreateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"creator":{"type":"string"}},"required":["name"]},"UpdateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"SubmissionListResponse":{"type":"object","properties":{"submissions":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionResponse"}}},"required":["submissions"]},"SubmissionResponse":{"type":"object","properties":{"submissionId":{"type":"string"},"projectId":{"type":"string"},"requestIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"submissionRunId":{"type":"string"},"attemptNumber":{"type":"integer"},"readiness":{"type":"string","enum":["waiting","ready","blocked","processing","done","failed"]},"requests":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionRequestSummary"}},"dependencyProjection":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionDependencyProjection"}},"blocking":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionBlockingDetail"}},"handlerProgress":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/SubmissionHandlerStatus"}},"report":{"$ref":"#/components/schemas/SubmissionReportPointer"},"supersededAt":{"type":["string","null"],"format":"date-time"},"supersededBySubmissionRunId":{"type":"string"},"supersededReason":{"type":"string","enum":["request-vector-changed","manual-retry"]}},"required":["submissionId","projectId","requestIds","createdAt","submissionRunId","attemptNumber","readiness","requests","dependencyProjection","blocking"]},"SubmissionRequestSummary":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"task":{"type":"string"},"status":{"type":"string"},"outcome":{"type":"string"},"workerType":{"type":"string"},"model":{"type":"string"},"profileId":{"type":"string"}},"required":["requestId","runId","task","status","workerType"]},"SubmissionDependencyProjection":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"handlerId":{"type":"string"},"state":{"type":"string","enum":["waiting","ready","failed","blocked"]},"version":{"type":"number"},"updatedAt":{"type":["string","null"],"format":"date-time"},"error":{"type":"string"}},"required":["requestId","runId","handlerId","state"]},"SubmissionBlockingDetail":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"handlerId":{"type":"string"},"reason":{"type":"string","enum":["request-missing","run-changed","dependency-not-ready","dependency-failed","dependency-blocked"]},"error":{"type":"string"}},"required":["requestId","runId","handlerId","reason"]},"SubmissionHandlerStatus":{"type":"object","properties":{"status":{"type":"string","enum":["queued","processing","done","failed"]},"version":{"type":"number"},"updatedAt":{"type":["string","null"],"format":"date-time"},"error":{"type":"string"}},"required":["status"]},"SubmissionReportPointer":{"type":"object","properties":{"reportId":{"type":"string"},"url":{"type":"string"},"updatedAt":{"type":["string","null"],"format":"date-time"}}},"SubmissionRunResponse":{"type":"object","properties":{"_id":{"type":"string"},"attemptNumber":{"type":"integer","minimum":1},"vectorFingerprint":{"type":"string"},"requests":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionRequestRef"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"dependencyProjection":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionDependencyProjection"},"default":[]},"handlerStatus":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/SubmissionHandlerStatus"}},"blocking":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionBlockingDetail"},"default":[]},"report":{"$ref":"#/components/schemas/SubmissionReportPointer"},"supersededAt":{"type":["string","null"],"format":"date-time"},"supersededBySubmissionRunId":{"type":"string"},"supersededReason":{"type":"string","enum":["request-vector-changed","manual-retry"]},"submissionId":{"type":"string"},"projectId":{"type":"string"}},"required":["_id","attemptNumber","vectorFingerprint","requests","createdAt"]},"SubmissionRequestRef":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"}},"required":["requestId","runId"]},"SubmissionRetryResponse":{"type":"object","properties":{"submissionId":{"type":"string"},"submissionRunId":{"type":"string"},"attemptNumber":{"type":"integer"},"readiness":{"type":"string","enum":["waiting","ready","blocked","processing","done","failed"]},"blocking":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionBlockingDetail"}}},"required":["submissionId","submissionRunId","attemptNumber","readiness","blocking"]},"RequestResponse":{"type":"object","properties":{"_id":{"type":"string"},"scenario":{"$ref":"#/components/schemas/Scenario"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"maxIterations":{"type":"number"},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"deletedAt":{"type":["string","null"],"format":"date-time"},"taskPromptId":{"type":"string"},"agentsMdPromptId":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBinding"}},"extensions":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"profileId":{"type":"string"},"profileVersionId":{"type":"string"},"submissionId":{"type":"string"},"submissionExpectedRequestCount":{"type":"integer","minimum":1},"submissionRequestIndex":{"type":"integer","minimum":0},"submissionInitialRunId":{"type":"string"},"submissionFinalizedAt":{"type":["string","null"],"format":"date-time"},"priority":{"type":"integer","default":0},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}},"observations":{"type":"array","items":{"type":"string"}},"gateSummaries":{"type":"array","items":{"$ref":"#/components/schemas/GateRunSummary"}},"run":{"$ref":"#/components/schemas/RunState"},"projectId":{"type":"string"}},"required":["_id","scenario","workerType","createdAt","projectId"]},"Scenario":{"type":"object","properties":{"version":{"type":"string","enum":["v1","v2"]},"task":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}}},"required":["task","criteria"]},"Persona":{"type":"object","properties":{"personality":{"type":"string","enum":["demanding","friendly"]},"experience":{"type":"string","enum":["junior","senior"]},"verbosity":{"type":"string","enum":["brief","moderate"]},"type":{"type":"string","enum":["traditional","ai_assisted","vibe"]}},"required":["personality","experience","verbosity","type"]},"ResourceBinding":{"type":"object","properties":{"ref":{"type":"string"},"revisionId":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref","revisionId","params"]},"GateConfig":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"promptId":{"type":"string"},"promptText":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}},"maxIterations":{"type":"integer","minimum":1,"maximum":50}},"required":["gate","criteria"]},"GateRunSummary":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"status":{"type":"string","enum":["passed","failed","skipped"]},"iterations":{"type":"integer","minimum":0}},"required":["gate","status","iterations"]},"RunState":{"type":"object","properties":{"_id":{"type":"string"},"attemptNumber":{"type":"integer","minimum":1},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"queuedQueueName":{"type":"string"},"outcome":{"type":"string","enum":["succeeded","failed","finished"]},"result":{"type":"string"},"error":{"type":"string"},"logsUrl":{"type":"string"},"updatedAt":{"type":["string","null"],"format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"turns":{"type":"array","items":{"$ref":"#/components/schemas/ConversationTurn"}},"workerVersion":{"type":"string"},"os":{"type":"object","properties":{"platform":{"type":"string"},"release":{"type":"string"},"arch":{"type":"string"}},"required":["platform","release","arch"]},"lastHeartbeatAt":{"type":["string","null"],"format":"date-time"},"worker":{"type":"object","properties":{"instanceId":{"type":"string"},"podName":{"type":"string"}},"required":["instanceId"]},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"setupVideoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"aiCallCount":{"type":"number"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRunOutcome"}},"mcpRegistered":{"type":"boolean"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"pausedAt":{"type":["string","null"],"format":"date-time"},"resumedAt":{"type":["string","null"],"format":"date-time"},"observationResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}}},"required":["_id","attemptNumber","status"]},"ConversationTurn":{"type":"object","properties":{"iteration":{"type":"number"},"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"codingAgentResponse":{"type":"string"},"judgeFeedback":{"type":"string"},"snapshotUrl":{"type":"string"},"passed":{"type":"boolean"},"timestamp":{"type":["string","null"],"format":"date-time"},"criteriaResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}},"observationResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"startedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"toolCalls":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"arguments":{"type":"object","additionalProperties":{}},"response":{"type":"string"},"timestamp":{"type":"string"}},"required":["id","name","arguments"]}},"toolCallsUrl":{"type":"string"},"toolCallCount":{"type":"number"},"aiCallCount":{"type":"number"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"chatResultUrl":{"type":"string"},"chatResultFormat":{"type":"string"}},"required":["iteration","judgeFeedback","snapshotUrl","passed","timestamp"]},"CriterionResult":{"type":"object","properties":{"criterionId":{"type":"string"},"passed":{"type":"boolean"},"feedback":{"type":"string"},"evaluated":{"type":"boolean"}},"required":["criterionId","passed","feedback","evaluated"]},"TokenUsage":{"type":"object","properties":{"promptTokens":{"type":"number"},"completionTokens":{"type":"number"},"totalTokens":{"type":"number"}},"required":["promptTokens","completionTokens","totalTokens"]},"ResourceRunOutcome":{"type":"object","properties":{"ref":{"type":"string"},"slug":{"type":"string"},"revisionId":{"type":"string"},"setupSucceeded":{"type":"boolean"},"published":{"type":"array","items":{"type":"string"}},"params":{"type":"object","additionalProperties":{"type":"string"}},"setupDurationMs":{"type":"number"},"error":{"type":"string"},"teardownRan":{"type":"boolean"}},"required":["ref","slug","revisionId","setupSucceeded","published"]},"ResourceBindingSpec":{"anyOf":[{"type":"string"},{"type":"object","properties":{"ref":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref"]}]},"CreateRequestInput":{"type":"object","properties":{"scenario":{"$ref":"#/components/schemas/Scenario"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"maxIterations":{"type":"integer","minimum":1,"maximum":50},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}},"profileId":{"type":"string"},"profileVariations":{"type":"array","items":{"type":"string"}},"priority":{"type":"integer"},"agentsMd":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}},"observations":{"type":"array","items":{"type":"string"}}},"required":["scenario"]},"RunFacetsResponse":{"type":"object","properties":{"total":{"type":"number"},"facets":{"type":"object","properties":{"workerType":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"status":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"outcome":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"model":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"os":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"priority":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"agentVersion":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"profileId":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}}},"required":["workerType","status","outcome","model","os","priority","agentVersion","profileId"]}},"required":["total","facets"]},"RunFacetBucket":{"type":"object","properties":{"value":{"type":"string"},"count":{"type":"number"}},"required":["value","count"]},"PaginatedRunsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"Cursors":{"type":"object","properties":{"next":{"type":["string","null"]},"prev":{"type":["string","null"]}},"required":["next","prev"]},"PaginatedRunGroupsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RunGroup"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"RunGroup":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"runIds":{"type":"array","items":{"type":"string"}},"aggregates":{"$ref":"#/components/schemas/GroupAggregates"},"uniform":{"$ref":"#/components/schemas/GroupUniformValues"}},"required":["key","label","runIds","aggregates","uniform"]},"GroupAggregates":{"type":"object","properties":{"count":{"type":"number"},"turns":{"$ref":"#/components/schemas/AggregateStats"},"duration":{"$ref":"#/components/schemas/AggregateStats"},"promptTokens":{"$ref":"#/components/schemas/AggregateStats"},"completionTokens":{"$ref":"#/components/schemas/AggregateStats"},"statusCounts":{"type":"object","additionalProperties":{"type":"number"}},"outcomeCounts":{"type":"object","additionalProperties":{"type":"number"}}},"required":["count","turns","duration","promptTokens","completionTokens","statusCounts","outcomeCounts"]},"AggregateStats":{"type":["object","null"],"properties":{"min":{"type":"number"},"max":{"type":"number"},"mean":{"type":"number"},"stdDev":{"type":"number"}},"required":["min","max","mean","stdDev"]},"GroupUniformValues":{"type":"object","properties":{"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":"string"},"platform":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"submissionId":{"type":"string"},"task":{"type":"string"}}},"BulkResubmitInput":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"count":{"type":"integer","minimum":1,"maximum":10,"default":1},"overrides":{"type":"object","properties":{"profileId":{"type":["string","null"]},"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":["string","null"]},"reasoningEffort":{"type":["string","null"]},"maxIterations":{"type":["number","null"]},"mcpServers":{"type":["array","null"],"items":{"type":"string"}},"skillRevisions":{"type":["array","null"],"items":{"type":"string"}},"extensions":{"type":["array","null"],"items":{"type":"string"}}}}},"required":["ids"]},"ReportResponse":{"type":"object","properties":{"_id":{"type":"string"},"requestId":{"type":"string"},"templateId":{"type":"string"},"reporter":{"$ref":"#/components/schemas/Reporter"},"content":{"type":"string"},"status":{"type":"string","enum":["pending","generating","completed","failed"]},"error":{"type":"string"},"insightReferences":{"type":"array","items":{"$ref":"#/components/schemas/InsightReference"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","requestId","status","createdAt","projectId"]},"Reporter":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"gitHash":{"type":"string"},"model":{"type":"string"},"agentId":{"type":"string"},"agentVersion":{"type":"string"}},"required":["id","name","gitHash","model","agentId","agentVersion"]},"InsightReference":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]},"CreateCriteriaInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"],"default":"gate"},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]}},"required":["id","prompt"]},"CriteriaResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"]},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"CriteriaGraph":{"type":"object","properties":{"nodes":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphNode"}},"edges":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphEdge"}}},"required":["nodes","edges"]},"CriteriaGraphNode":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"]},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]}},"required":["id","prompt"]},"CriteriaGraphEdge":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"]},"UpdateCriteriaInput":{"type":"object","properties":{"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"]},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]}}},"CreatePromptFeatureInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["id","prompt"]},"PromptType":{"type":"string","enum":["select","build","test","run","deploy","agents.md"]},"PromptFeatureResult":{"type":"object","properties":{"featureId":{"type":"string"},"detected":{"type":"boolean"},"evaluated":{"type":"boolean"}},"required":["featureId","detected","evaluated"]},"SuggestedPromptFeature":{"type":"object","properties":{"suggestedId":{"type":"string"},"behavior":{"type":"string"},"prompt":{"type":"string"}},"required":["suggestedId","behavior","prompt"]},"PromptFeatureResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"UpdatePromptFeatureInput":{"type":"object","properties":{"prompt":{"type":"string"}}},"TaskPromptResponse":{"type":"object","properties":{"_id":{"type":"string"},"keyId":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"text":{"type":"string"},"contentBlobUrl":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","keyId","createdAt","projectId"]},"CreateTaskPromptInput":{"type":"object","properties":{"text":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["text"]},"PatchTaskPromptFeatureInput":{"type":"object","properties":{"detected":{"type":"boolean"}},"required":["detected"]},"CreateReportInput":{"type":"object","properties":{"requestId":{"type":"string"},"templateId":{"type":"string"}},"required":["requestId"]},"BulkCreateReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}},"templateId":{"type":"string"}},"required":["requestIds"]},"BulkReportStatusInput":{"type":"object","properties":{"reportIds":{"type":"array","items":{"type":"string"}}},"required":["reportIds"]},"BulkReportSummaryResponse":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/ReportSummary"}},"ReportSummary":{"type":"object","properties":{"total":{"type":"number"},"pending":{"type":"number"},"generating":{"type":"number"},"completed":{"type":"number"},"failed":{"type":"number"}},"required":["total","pending","generating","completed","failed"]},"BulkReportSummaryInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"TriggerReportsInput":{"type":"object","properties":{"requestId":{"type":"string"}},"required":["requestId"]},"BulkTriggerReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"InsightResponse":{"type":"object","properties":{"_id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"upvotes":{"type":"number"},"downvotes":{"type":"number"},"blocked":{"type":"boolean"},"referenceCount":{"type":"number"},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","title","description","upvotes","downvotes","blocked","referenceCount","createdBy","createdAt","projectId"]},"ProfileWithVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"version":{"$ref":"#/components/schemas/ProfileVersionResponse"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","version","projectId"]},"ProfileVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"profileId":{"type":"string"},"version":{"type":"number"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","profileId","version","workerType","model","createdAt","projectId"]},"CreateProfileInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["name","workerType","model"]},"ProfileResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","projectId"]},"UpdateProfileIdentity":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512}}},"ReportTemplateResponse":{"type":"object","properties":{"_id":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","id","name","userPrompt","createdAt","projectId"]},"ReportTemplateSystemPrompt":{"type":"object","properties":{"mode":{"type":"string","enum":["append","override"]},"content":{"type":"string"}},"required":["mode","content"]},"ReportTrigger":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["always"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["criteria"]},"criteriaIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","criteriaIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["taskPrompt"]},"taskPromptIds":{"type":"array","items":{"type":"string"}}},"required":["type","taskPromptIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["promptFeature"]},"featureIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","featureIds"]}]},"CreateReportTemplateInput":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0}},"required":["id","name","userPrompt"]},"UpdateReportTemplateInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"allOf":[{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},{"type":["object","null"]}]},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":["string","null"]},"timeoutMs":{"type":["integer","null"],"exclusiveMinimum":0}}},"AgentResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"},"versions":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","supportedModels","createdAt"]},"AgentCapabilities":{"type":"object","properties":{"supportsReasoningEffort":{"type":"boolean"},"supportsMcpServers":{"type":"boolean"},"supportsSkills":{"type":"boolean"},"supportsExtensions":{"type":"boolean"},"supportsResources":{"type":"boolean"}}},"AgentVersion":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string"},"status":{"type":"string","enum":["active","retired"]},"createdAt":{"type":["string","null"],"format":"date-time"}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","status","createdAt"]},"CreateAgentInput":{"type":"object","properties":{"_id":{"type":"string","minLength":1},"name":{"type":"string","minLength":1},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}},"required":["_id","name"]},"UpdateAgentInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}}},"RegisterAgentVersionInput":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string","minLength":1}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","queueName"]},"PatchAgentVersionInput":{"type":"object","properties":{"status":{"type":"string","enum":["active","retired"]}},"required":["status"]},"ModelResponse":{"type":"object","properties":{"_id":{"type":"string"},"modelId":{"type":"string"},"provider":{"type":"string"},"agentId":{"type":"string"},"firstSeenAt":{"type":["string","null"],"format":"date-time"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"disappearedAt":{"type":["string","null"],"format":"date-time"},"providerAvailableFrom":{"type":["string","null"],"format":"date-time"},"providerEndOfLife":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"$ref":"#/components/schemas/ModelCapabilities"}},"required":["_id","modelId","provider","agentId","firstSeenAt","lastSeenAt"]},"ModelCapabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}},"McpServerResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","type","createdAt","projectId"]},"McpServerHeader":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]},"UpdateMcpServerInput":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}}},"SkillResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","source","skillName","name","origin","createdAt","projectId"]},"SkillSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"source":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"installs":{"type":"number"}},"required":["id","name","source","internal"]},"SkillDiscoveryResult":{"type":"object","properties":{"skillName":{"type":"string"},"skillPath":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"existsInLibrary":{"type":"boolean"},"currentRevisionCommitSha":{"type":"string"},"latestUpstreamCommitSha":{"type":"string"},"updateAvailable":{"type":"boolean"},"lastImportedAt":{"type":"string"}},"required":["skillName","skillPath"]},"SkillRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"ref":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"skillPath":{"type":"string"},"commitHash":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"name":{"type":"string"},"description":{"type":"string"},"license":{"type":"string"},"compatibility":{"type":"string"},"allowedTools":{"type":"string"},"metadata":{"type":"object","additionalProperties":{"type":"string"}},"content":{"type":"string"},"archiveUrl":{"type":"string"},"validationWarnings":{"type":"array","items":{"type":"string"}},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","ref","source","skillName","skillPath","commitHash","commitTimestamp","name","description","content","archiveUrl","resolvedAt","createdAt","projectId"]},"CreateSkillInput":{"type":"object","properties":{"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]}},"required":["source","skillName","name","origin"]},"CodebaseResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"defaultBranch":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","sourceType","revisionCounter","createdAt","projectId"]},"UpdateCodebaseInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"defaultBranch":{"type":"string"}}},"CodebaseRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"codebaseId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"requestedRef":{"type":"string"},"resolvedCommitSha":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"originalFilename":{"type":"string"},"contentSha256":{"type":"string"},"archiveUrl":{"type":"string"},"sizeBytes":{"type":"number"},"fileCount":{"type":"number"},"creator":{"type":"string"},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","codebaseId","slug","revisionNumber","ref","sourceType","archiveUrl","resolvedAt","createdAt","projectId"]},"ResolveCodebaseRevisionInput":{"type":"object","properties":{"requestedRef":{"type":"string"},"creator":{"type":"string"}}},"ResourceResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"latestRevisionNumber":{"type":"number"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","revisionCounter","createdAt","projectId"]},"CreateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"slug":{"type":"string","minLength":1},"description":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["name","setup"]},"ResourceScript":{"type":"object","properties":{"sh":{"type":"string","minLength":1}}},"ResourceParameter":{"type":"object","properties":{"name":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"},"description":{"type":"string"},"required":{"type":"boolean","default":false},"default":{"type":"string"},"example":{"type":"string"}},"required":["name"]},"UpdateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"ResourceRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"resourceId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"contentSha256":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","resourceId","slug","revisionNumber","ref","setup","exports","contentSha256","createdAt","projectId"]},"CreateResourceRevisionInput":{"type":"object","properties":{"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["setup"]},"ExtensionResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","publisher","name","origin","createdAt","projectId"]},"ExtensionSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"publisher":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"version":{"type":"string"}},"required":["id","name","publisher","internal"]},"CreateExtensionInput":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]}},"required":["_id","publisher","name","origin"]},"UpdateExtensionInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"ExtensionVersionInfo":{"type":"object","properties":{"version":{"type":"string"},"preRelease":{"type":"boolean"},"lastUpdated":{"type":"string"}},"required":["version","preRelease","lastUpdated"]},"CreateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"}},"required":["title","description"]},"UpdateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}}}},"FeatureFlagResponse":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"enabled":{"type":"boolean"},"updatedAt":{"type":["string","null"],"format":"date-time"}},"required":["key","label","enabled","updatedAt"]},"UpdateFeatureFlagInput":{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]}},"parameters":{}},"paths":{"/api/v1/keys/preview":{"post":{"tags":["Keys"],"summary":"Preview key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}}},"/api/v1/keys":{"post":{"tags":["Keys"],"summary":"Create key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"201":{"description":"Key created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"get":{"tags":["Keys"],"summary":"List keys","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/KeyResponse"}}}}}}}},"/api/v1/keys/{id}":{"get":{"tags":["Keys"],"summary":"Get key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"put":{"tags":["Keys"],"summary":"Update key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"delete":{"tags":["Keys"],"summary":"Delete key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Key deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/api/v1/keys/{id}/validate":{"post":{"tags":["Keys"],"summary":"Validate key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateKeyInput"}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Key validation result"}}}}}}},"/api/v1/accounts":{"post":{"tags":["Accounts"],"summary":"Create account","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"201":{"description":"Account created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"get":{"tags":["Accounts"],"summary":"List accounts","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AccountResponse"}}}}}}}},"/api/v1/accounts/{id}":{"get":{"tags":["Accounts"],"summary":"Get account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"put":{"tags":["Accounts"],"summary":"Update account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"200":{"description":"Account updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"delete":{"tags":["Accounts"],"summary":"Delete account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Account deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/api/v1/users/me":{"get":{"tags":["Users"],"summary":"Get the authenticated user's identity","description":"Read the existing authenticated Scope identity without enrollment or profile writes. Responses must not be HTTP-cached.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMeResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"User is not enrolled or is disabled"},"503":{"description":"Authentication service unavailable"}}},"post":{"tags":["Users"],"summary":"Enroll the authenticated user","description":"JIT-enroll the authenticated user, refresh their profile and lastLoginAt, apply bootstrap-admin rules, and warm the access cache. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMeResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"User is disabled"},"503":{"description":"Authentication service unavailable"}}}},"/health":{"get":{"tags":["Health"],"summary":"Liveness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"version":{"type":"string"}},"required":["status","version"]}}}}}}},"/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"migrations":{}},"required":["status"]}}}},"503":{"description":"Service is not ready"}}}},"/about":{"get":{"tags":["System"],"summary":"API metadata","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"description":{"type":"string"},"workers":{"type":"array","items":{"type":"string"}}},"required":["name","version","buildTime","environment","description","workers"]}}}}}}},"/api/v1/version":{"get":{"tags":["System"],"summary":"Version info","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"commit":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"strictAgentCapabilities":{"type":"boolean"}},"required":["commit","buildTime","environment","strictAgentCapabilities"]}}}}}}},"/api/v1/projects":{"get":{"tags":["Projects"],"summary":"List all projects","parameters":[{"schema":{"type":"string","enum":["true","false"],"description":"When true, include soft-deleted projects in the result"},"required":false,"description":"When true, include soft-deleted projects in the result","name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}}}}}},"post":{"tags":["Projects"],"summary":"Create a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/projects/{id}":{"get":{"tags":["Projects"],"summary":"Get a project","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"patch":{"tags":["Projects"],"summary":"Update a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"delete":{"tags":["Projects"],"summary":"Soft-delete a project","responses":{"204":{"description":"Success"},"404":{"description":"Project not found"}}}},"/api/v1/projects/{id}/restore":{"post":{"tags":["Projects"],"summary":"Restore a soft-deleted project","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}}},"/api/v1/submissions":{"get":{"tags":["Submissions"],"summary":"List submissions","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionListResponse"}}}}}}},"/api/v1/submissions/{id}":{"get":{"tags":["Submissions"],"summary":"Get a submission","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionResponse"}}}},"404":{"description":"Submission not found"}}}},"/api/v1/submissions/{id}/runs":{"get":{"tags":["Submissions"],"summary":"List current and historical submission attempts","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"runs":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionRunResponse"}}},"required":["runs"]}}}},"404":{"description":"Submission not found"}}}},"/api/v1/submissions/{id}/runs/{submissionRunId}":{"get":{"tags":["Submissions"],"summary":"Get one submission attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"submissionRunId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionRunResponse"}}}},"404":{"description":"Submission attempt not found"}}}},"/api/v1/submissions/{id}/retry":{"post":{"tags":["Submissions"],"summary":"Retry current submission processing","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"submissionRunId":{"type":"string"}},"required":["submissionRunId"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionRetryResponse"}}}},"404":{"description":"Submission not found"},"409":{"description":"Submission attempt is stale or dependencies are blocked"}}}},"/api/v1/requests":{"post":{"tags":["Requests"],"summary":"Submit request(s)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/CreateRequestInput"},{"type":"object","properties":{"count":{"type":"number","minimum":1,"maximum":10,"default":1},"skills":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"codebase":{"type":"string"}}}]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/RequestResponse"},{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}]}}}}}},"get":{"tags":["Requests"],"summary":"List requests","parameters":[{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"profileId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"status","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"outcome","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"model","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"os","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"priority","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"agentVersion","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"type":"string","enum":["task","submissionId","profile"]},"required":false,"name":"groupBy","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"after","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"last","in":"query"},{"schema":{"type":"string","enum":["created","updated","priority","worker","status","id","duration","createdAt"]},"required":false,"name":"sortBy","in":"query"},{"schema":{"type":"string","enum":["asc","desc"]},"required":false,"name":"sortDir","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"turns","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"turnsOp","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"maxIterations","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"maxIterationsOp","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaginatedRunsResponse"},{"$ref":"#/components/schemas/PaginatedRunGroupsResponse"}]}}}}}}},"/api/v1/requests/facets":{"get":{"tags":["Requests"],"summary":"List run filter facets","description":"Returns every distinct value and its full-dataset count per categorical filter dimension for the Runs list rail. Counts are absolute over all non-deleted runs **in the given project**: they intentionally ignore the active search, date range, iteration, and categorical selections so every selectable value stays visible with a stable count. Requires ?projectId=. Computed with parallel $group aggregations (Cosmos has no $facet) and cached per-project for a short TTL.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunFacetsResponse"}}}}}}},"/api/v1/requests/{id}":{"get":{"tags":["Requests"],"summary":"Get request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestResponse"}}}},"404":{"description":"Not found"}}},"delete":{"tags":["Requests"],"summary":"Soft-delete request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Not found"}}}},"/api/v1/analysis":{"get":{"tags":["Requests"],"summary":"Compute pass@k / success@T metrics","description":"Aggregates pass@k / success@T metrics over a single project's done runs. Requires ?projectId=.","parameters":[{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"k","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Analysis metrics"}}}}}}},"/api/v1/requests/bulk-resubmit":{"post":{"tags":["Requests"],"summary":"Bulk resubmit requests","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResubmitInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}}}}}}},"/api/v1/requests/bulk":{"delete":{"tags":["Requests"],"summary":"Bulk soft-delete requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"number"}},"required":["deleted"]}}}}}}},"/api/v1/requests/archive":{"post":{"tags":["Requests"],"summary":"Download batch archive of multiple runs","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Gzipped batch archive containing individual run archives"},"400":{"description":"Invalid input"},"404":{"description":"One or more runs not found"}}}},"/api/v1/runs/upload":{"post":{"tags":["Requests"],"summary":"Import run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"}}}},"/api/v1/runs/upload-batch":{"post":{"tags":["Requests"],"summary":"Import batch run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"207":{"description":"Multi-status: 201 if all runs imported, 400 if none imported, 207 if partial"},"400":{"description":"No archive uploaded, empty archive, or all runs failed"}}}},"/api/v1/requests/{id}/reports":{"get":{"tags":["Reports"],"summary":"Get reports for request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Run not found"}}}},"/api/v1/requests/{id}/runs":{"get":{"tags":["Requests"],"summary":"List attempts for a request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RunState"}}}}},"404":{"description":"Request not found"}}}},"/api/v1/requests/{id}/runs/{runId}":{"get":{"tags":["Requests"],"summary":"Get a single attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunState"}}}},"404":{"description":"Request or run not found"}}}},"/api/v1/requests/bulk-retry":{"post":{"tags":["Requests"],"summary":"Bulk retry requests (start new attempts)","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"force":{"type":"boolean"}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"retried":{"type":"integer"},"skipped":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"},"error":{"type":"string"}},"required":["requestId"]}}},"required":["retried","skipped","results"]}}}}}}},"/api/v1/requests/{id}/retry":{"post":{"tags":["Requests"],"summary":"Retry a request (start a new attempt)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"force":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"}},"required":["requestId","runId","attemptNumber"]}}}},"404":{"description":"Request not found"},"409":{"description":"Conflict — request is not in a retryable state, or a concurrent retry won the race"},"422":{"description":"Cannot retry — current run not yet terminal"}}}},"/api/v1/requests/{id}/priority":{"post":{"tags":["Requests"],"summary":"Set priority on a single request","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"priority":{"type":"integer"}},"required":["priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"priority":{"type":"number"}},"required":["id","priority"]}}}}}}},"/api/v1/requests/bulk-priority":{"post":{"tags":["Requests"],"summary":"Set priority on multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100},"priority":{"type":"integer"}},"required":["ids","priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/pause":{"post":{"tags":["Requests"],"summary":"Pause a single request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/{id}/resume":{"post":{"tags":["Requests"],"summary":"Resume a paused request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/bulk-pause":{"post":{"tags":["Requests"],"summary":"Pause multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/bulk-resume":{"post":{"tags":["Requests"],"summary":"Resume multiple paused requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/cancel":{"post":{"tags":["Requests"],"summary":"Cancel a request (marks as done/failed and signals worker to exit)","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"previousStatus":{"type":"string"},"status":{"type":"string"},"outcome":{"type":"string"}},"required":["id","previousStatus","status","outcome"]}}}}}}},"/api/v1/requests/bulk-cancel":{"post":{"tags":["Requests"],"summary":"Cancel multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"cancelled":{"type":"number"},"skipped":{"type":"number"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"cancelled":{"type":"boolean"},"previousStatus":{"type":"string"},"error":{"type":"string"}},"required":["id","cancelled"]}}},"required":["cancelled","skipped","results"]}}}}}}},"/api/v1/requests/{id}/logs":{"get":{"tags":["Requests"],"summary":"Stream request logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/logs":{"get":{"tags":["Requests"],"summary":"Stream logs for a specific attempt (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific attempt and iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/video":{"get":{"tags":["Requests"],"summary":"Download session recording","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/video":{"get":{"tags":["Requests"],"summary":"Download session recording for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/criteria/generate-prompt":{"post":{"tags":["Criteria"],"summary":"Generate criterion prompt from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"},"gates":{"type":"array","items":{"type":"string"}}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"},"suggestedId":{"type":"string"},"suggestedParents":{"type":"array","items":{"type":"string"}},"suggestedChildren":{"type":"array","items":{"type":"string"}}},"required":["prompt","suggestedId","suggestedParents","suggestedChildren"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/criteria/seed":{"post":{"tags":["Criteria"],"summary":"Seed criteria in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"criteria":{"type":"array","items":{"$ref":"#/components/schemas/CreateCriteriaInput"}}},"required":["criteria"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}},"400":{"description":"Seed batch would introduce a dependency cycle"}}}},"/api/v1/criteria":{"get":{"tags":["Criteria"],"summary":"List criteria","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","description":"Comma-separated criterion IDs to include"},"required":false,"description":"Comma-separated criterion IDs to include","name":"ids","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"When true and ids is set, also include dependency ancestors"},"required":false,"description":"When true and ids is set, also include dependency ancestors","name":"ancestors","in":"query"},{"schema":{"type":"string","enum":["gate","observation"],"description":"Filter by criterion kind"},"required":false,"description":"Filter by criterion kind","name":"kind","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaResponse"}}}}}}},"post":{"tags":["Criteria"],"summary":"Create criterion","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCriteriaInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"409":{"description":"Criterion already exists"}}}},"/api/v1/criteria/mdp":{"get":{"tags":["Criteria"],"summary":"Compute MDP transitions","parameters":[{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"since","in":"query"},{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{}}}}}}}},"/api/v1/criteria/graph":{"get":{"tags":["Criteria"],"summary":"Get criteria DAG","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaGraph"}}}}}}},"/api/v1/criteria/{id}":{"get":{"tags":["Criteria"],"summary":"Get criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"404":{"description":"Criterion not found"}}},"put":{"tags":["Criteria"],"summary":"Update criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCriteriaInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"400":{"description":"Invalid dependency reference or self-reference"},"404":{"description":"Criterion not found"}}},"delete":{"tags":["Criteria"],"summary":"Soft-delete criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Criterion not found"},"409":{"description":"Criterion has dependents"}}}},"/api/v1/prompt-features/generate-prompt":{"post":{"tags":["Prompt Features"],"summary":"Generate prompt feature from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features/seed":{"post":{"tags":["Prompt Features"],"summary":"Seed prompt features in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}},"required":["features"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}}}}},"/api/v1/prompt-features/extract-from-text":{"post":{"tags":["Prompt Features"],"summary":"Extract features from text","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string"},"model":{"type":"string"},"type":{"type":"string","enum":["select","agents.md"]}},"required":["text"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["features","cached"]}}}},"400":{"description":"Empty text string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features":{"get":{"tags":["Prompt Features"],"summary":"List features","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","enum":["select","agents.md"]},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}}}}},"post":{"tags":["Prompt Features"],"summary":"Create feature","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"409":{"description":"Feature already exists"}}}},"/api/v1/prompt-features/{id}":{"get":{"tags":["Prompt Features"],"summary":"Get feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"404":{"description":"Feature not found"}}},"put":{"tags":["Prompt Features"],"summary":"Update feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Feature not found"}}},"delete":{"tags":["Prompt Features"],"summary":"Soft-delete feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Feature not found"}}}},"/api/v1/task-prompts/generate":{"post":{"tags":["Task Prompts"],"summary":"Generate task prompts","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string"},"existingPrompt":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"string"}}},"required":["tasks"]}}}},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts":{"get":{"tags":["Task Prompts"],"summary":"List task prompts","parameters":[{"schema":{"type":["number","null"]},"required":false,"name":"limit","in":"query"},{"schema":{"type":["number","null"]},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"$ref":"#/components/schemas/PromptType"},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TaskPromptResponse"}},"total":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["items","total","limit","offset"]}}}}}},"post":{"tags":["Task Prompts"],"summary":"Create or find task prompt","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTaskPromptInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Empty text string"}}}},"/api/v1/task-prompts/{id}":{"get":{"tags":["Task Prompts"],"summary":"Get task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"404":{"description":"Task prompt not found"}}},"delete":{"tags":["Task Prompts"],"summary":"Soft-delete task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}},"required":["deleted"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/content":{"get":{"tags":["Task Prompts"],"summary":"Get resolved task prompt content","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"}},"required":["id","text"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/extract-features":{"post":{"tags":["Task Prompts"],"summary":"Extract features from task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"force","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"model":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"taskPromptId":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["taskPromptId","features","cached"]}}}},"404":{"description":"Task prompt not found"},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts/{id}/features/{featureId}":{"patch":{"tags":["Task Prompts"],"summary":"Toggle feature flag on task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"featureId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchTaskPromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Invalid detected value"},"404":{"description":"Task prompt or feature not found"}}}},"/api/v1/reports":{"post":{"tags":["Reports"],"summary":"Create report","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Run or template not found"}}},"get":{"tags":["Reports"],"summary":"List reports","parameters":[{"schema":{"type":"string"},"required":false,"name":"requestId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}}}}},"/api/v1/reports/bulk-create":{"post":{"tags":["Reports"],"summary":"Bulk create reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkCreateReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/bulk-status":{"post":{"tags":["Reports"],"summary":"Bulk get report statuses","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportStatusInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/bulk-summary":{"post":{"tags":["Reports"],"summary":"Bulk get report summary per run","description":"Returns aggregated report status counts per run. Only the latest report per template is counted (re-triggers are deduplicated).","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}":{"get":{"tags":["Reports"],"summary":"Get report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"404":{"description":"Report not found"}}}},"/api/v1/reports/{id}/logs":{"get":{"tags":["Reports"],"summary":"Stream report logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Report not found"}}}},"/api/v1/reports/trigger":{"post":{"tags":["Reports"],"summary":"Trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"triggered":{"type":"number"},"reports":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}},"required":["triggered","reports"]}}}},"400":{"description":"Invalid input"},"404":{"description":"Run not found"}}}},"/api/v1/reports/bulk-trigger":{"post":{"tags":["Reports"],"summary":"Bulk trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkTriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}/insights":{"get":{"tags":["Reports"],"summary":"Get insights for report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/InsightResponse"},{"type":"object","properties":{"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}}}]}}}}},"404":{"description":"Report not found"}}},"post":{"tags":["Reports"],"summary":"Link insight to report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"isNew":{"type":"boolean"}},"required":["insightId"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]}}}},"404":{"description":"Report or insight not found"},"409":{"description":"Insight already referenced by this report"}}}},"/api/v1/profiles":{"post":{"tags":["Profiles"],"summary":"Create a new profile","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"get":{"tags":["Profiles"],"summary":"List profiles","parameters":[{"schema":{"type":"string"},"required":false,"name":"workerType","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}":{"get":{"tags":["Profiles"],"summary":"Get profile with latest version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"post":{"tags":["Profiles"],"summary":"Create new profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["workerType","model"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}},"put":{"tags":["Profiles"],"summary":"Update profile identity","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileIdentity"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileResponse"}}}}}},"delete":{"tags":["Profiles"],"summary":"Delete profile","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/profiles/{profileId}/versions":{"get":{"tags":["Profiles"],"summary":"List profile versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}/versions/{version}":{"get":{"tags":["Profiles"],"summary":"Get profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"},{"schema":{"type":["number","null"]},"required":false,"name":"version","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}},"/api/v1/report-templates/default-system-prompt":{"get":{"tags":["Report Templates"],"summary":"Get default system prompt","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string"}},"required":["content"]}}}}}}},"/api/v1/report-templates/available-models":{"get":{"tags":["Report Templates"],"summary":"List models available for report generation","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"modelId":{"type":"string"}},"required":["modelId"]}}}}}}}},"/api/v1/report-templates":{"get":{"tags":["Report Templates"],"summary":"List report templates","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}}}}},"post":{"tags":["Report Templates"],"summary":"Create report template","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportTemplateInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"409":{"description":"Report template already exists"}}}},"/api/v1/report-templates/{id}":{"get":{"tags":["Report Templates"],"summary":"Get report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"put":{"tags":["Report Templates"],"summary":"Update report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateReportTemplateInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"delete":{"tags":["Report Templates"],"summary":"Delete report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Report template not found"}}}},"/api/v1/agents":{"get":{"tags":["Agents"],"summary":"List agents","parameters":[{"schema":{"type":"string"},"required":false,"name":"modelProvider","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentResponse"}}}}}}},"post":{"tags":["Agents"],"summary":"Create or update agent (upsert)","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAgentInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"409":{"description":"Restored agent versions conflict with an active queue owner"}}}},"/api/v1/agents/{id}":{"get":{"tags":["Agents"],"summary":"Get agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"404":{"description":"Agent not found"}}},"put":{"tags":["Agents"],"summary":"Update agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"}}},"delete":{"tags":["Agents"],"summary":"Delete agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Agent not found"}}}},"/api/v1/agents/{id}/versions":{"get":{"tags":["Agents"],"summary":"List agent versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}}}}},"404":{"description":"Agent not found"}}},"post":{"tags":["Agents"],"summary":"Register agent version (upsert)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterAgentVersionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent registration did not stabilize"}}}},"/api/v1/agents/{id}/versions/{agentVersion}":{"patch":{"tags":["Agents"],"summary":"Patch agent version","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"agentVersion","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchAgentVersionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Invalid status value"},"404":{"description":"Agent or version not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent activation did not stabilize"}}}},"/api/v1/models":{"get":{"tags":["Models"],"summary":"List models","parameters":[{"schema":{"type":"string"},"required":false,"name":"agentId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"provider","in":"query"},{"schema":{"type":"string","enum":["active","disappeared"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ModelResponse"}}}}}}}},"/api/v1/models/{id}":{"get":{"tags":["Models"],"summary":"Get model","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelResponse"}}}}}}},"/api/v1/models/sync":{"post":{"tags":["Models"],"summary":"Sync models from provider","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agentId":{"type":"string"},"provider":{"type":"string"},"models":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"providerAvailableFrom":{"type":"string"},"providerEndOfLife":{"type":"string"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}}},"required":["id"]}},"scannedAt":{"type":"string"}},"required":["agentId","provider","models","scannedAt"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"array","items":{"type":"string"}},"removed":{"type":"array","items":{"type":"string"}},"unchanged":{"type":"array","items":{"type":"string"}}},"required":["added","removed","unchanged"]}}}}}}},"/api/v1/mcp/servers":{"get":{"tags":["MCP Servers"],"summary":"List MCP servers","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"post":{"tags":["MCP Servers"],"summary":"Create MCP server","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}},"required":["_id","name","type"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"/api/v1/mcp/servers/{id}":{"get":{"tags":["MCP Servers"],"summary":"Get MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"put":{"tags":["MCP Servers"],"summary":"Update MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMcpServerInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"delete":{"tags":["MCP Servers"],"summary":"Delete MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/skills":{"get":{"tags":["Skills"],"summary":"List all skills","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"post":{"tags":["Skills"],"summary":"Create or import a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSkillInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"/api/v1/skills/search":{"get":{"tags":["Skills"],"summary":"Search skills (internal + external)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/search/external":{"get":{"tags":["Skills"],"summary":"Search external skills registry","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project."},"required":false,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/discover":{"get":{"tags":["Skills"],"summary":"Discover skills in a GitHub repository","parameters":[{"schema":{"type":"string"},"required":true,"name":"source","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillDiscoveryResult"}}}}},"400":{"description":"Missing or malformed source parameter"},"404":{"description":"Repository not found"},"502":{"description":"GitHub API error"}}}},"/api/v1/skills/{id}(*)/revisions":{"get":{"tags":["Skills"],"summary":"List skill revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}}},"404":{"description":"Skill not found"}}}},"/api/v1/skills/{id}(*)":{"get":{"tags":["Skills"],"summary":"Get skill by slug","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}},"404":{"description":"Skill not found"}}},"delete":{"tags":["Skills"],"summary":"Soft-delete a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success"},"404":{"description":"Skill not found"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)/archive":{"get":{"tags":["Skill Revisions"],"summary":"Download skill revision archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Skill revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ref","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skill-revisions/{id}":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ID","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skills/{id}(*)/resolve":{"post":{"tags":["Skills"],"summary":"Trigger skill resolution","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebases":{"get":{"tags":["Codebases"],"summary":"List all codebases","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseResponse"}}}}}}},"post":{"tags":["Codebases"],"summary":"Create a codebase (archive codebases require the archive file)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input or missing archive"}}}},"/api/v1/codebases/{id}":{"get":{"tags":["Codebases"],"summary":"Get a codebase","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"patch":{"tags":["Codebases"],"summary":"Update a codebase","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCodebaseInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"delete":{"tags":["Codebases"],"summary":"Delete a codebase","responses":{"204":{"description":"Success"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebases/{id}/revisions":{"get":{"tags":["Codebases"],"summary":"List codebase revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}}},"404":{"description":"Codebase not found"}}},"post":{"tags":["Codebases"],"summary":"Resolve a new git codebase revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolveCodebaseRevisionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"400":{"description":"Not a git codebase"},"404":{"description":"Codebase not found"},"502":{"description":"GitHub resolution failed"}}}},"/api/v1/codebases/{id}/upload":{"post":{"tags":["Codebases"],"summary":"Upload a codebase archive as a new revision","responses":{"201":{"description":"Success"},"400":{"description":"Missing archive or not an archive codebase"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebase-revisions/{id}/archive":{"get":{"tags":["Codebase Revisions"],"summary":"Download codebase revision archive","responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebase-revisions/{id}":{"get":{"tags":["Codebase Revisions"],"summary":"Get codebase revision by id","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources":{"get":{"tags":["Resources"],"summary":"List all resources","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ResourceResponse"}}}}}}},"post":{"tags":["Resources"],"summary":"Create a resource and its first revision","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateResourceInput"}}}},"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input"},"409":{"description":"Resource slug or revision ref already exists in this project"}}}},"/api/v1/resources/{id}":{"get":{"tags":["Resources"],"summary":"Get a resource","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"patch":{"tags":["Resources"],"summary":"Update a resource","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResourceInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"delete":{"tags":["Resources"],"summary":"Delete a resource","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success"},"404":{"description":"Resource not found"}}}},"/api/v1/resources/{id}/revisions":{"get":{"tags":["Resource Revisions"],"summary":"List resource revisions","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}}},"404":{"description":"Resource not found"}}},"post":{"tags":["Resource Revisions"],"summary":"Create a resource revision","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateResourceRevisionInput"}}}},"responses":{"201":{"description":"Success"},"404":{"description":"Resource not found"},"409":{"description":"Resource revision ref already exists in this project"}}}},"/api/v1/resources/{id}/revisions/latest":{"get":{"tags":["Resource Revisions"],"summary":"Get the latest resource revision","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/{id}/revisions/{revisionNumber}":{"get":{"tags":["Resource Revisions"],"summary":"Get a resource revision by resource and revision number","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"revisionNumber","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/revisions/{id}":{"get":{"tags":["Resource Revisions"],"summary":"Get resource revision by id","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/extensions":{"get":{"tags":["Extensions"],"summary":"List all extensions","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"post":{"tags":["Extensions"],"summary":"Create or import an extension","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExtensionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"/api/v1/extensions/search":{"get":{"tags":["Extensions"],"summary":"Search extensions (internal + marketplace)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/extensions/{id}":{"get":{"tags":["Extensions"],"summary":"Get extension by ID","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"put":{"tags":["Extensions"],"summary":"Update extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateExtensionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"delete":{"tags":["Extensions"],"summary":"Delete extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/extensions/{id}/versions":{"get":{"tags":["Extensions"],"summary":"List available versions for an extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"preRelease","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionVersionInfo"}}}}}}}},"/api/v1/insights":{"get":{"tags":["Insights"],"summary":"List insights","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"post":{"tags":["Insights"],"summary":"Create insight","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInsightInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"/api/v1/insights/search":{"get":{"tags":["Insights"],"summary":"Search insights","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}}},"/api/v1/insights/{id}":{"get":{"tags":["Insights"],"summary":"Get insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"put":{"tags":["Insights"],"summary":"Update insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateInsightInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"delete":{"tags":["Insights"],"summary":"Delete insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/upvote":{"post":{"tags":["Insights"],"summary":"Upvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/downvote":{"post":{"tags":["Insights"],"summary":"Downvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/block":{"post":{"tags":["Insights"],"summary":"Block insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/unblock":{"post":{"tags":["Insights"],"summary":"Unblock insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/reports":{"get":{"tags":["Insights"],"summary":"Get reports referencing insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Insight not found"}}}},"/api/v1/feature-flags":{"get":{"tags":["Feature Flags"],"summary":"List feature flags","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"/api/v1/feature-flags/{key}":{"put":{"tags":["Feature Flags"],"summary":"Update feature flag","parameters":[{"schema":{"type":"string"},"required":true,"name":"key","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFeatureFlagInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"webhooks":{}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Scope API","version":"1.0.0","description":"REST API for the Scope platform — benchmarking AI coding agents"},"servers":[{"url":"/","description":"Current server"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Use the unchanged IdP access token, without the Bearer prefix."}},"schemas":{"KeyResponse":{"type":"object","properties":{},"additionalProperties":{}},"KeyInput":{"type":"object","properties":{},"additionalProperties":{}},"ValidateKeyInput":{"type":"object","properties":{"token":{"type":"string"}},"required":["token"]},"AccountResponse":{"type":"object","properties":{},"additionalProperties":{}},"AccountInput":{"type":"object","properties":{},"additionalProperties":{}},"UserMeResponse":{"type":"object","properties":{"id":{"type":"string"},"role":{"type":"string"},"email":{"type":"string"},"displayName":{"type":"string"},"idp":{"type":"string"},"idpTenant":{"type":"string"}},"required":["id"]},"ProjectResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","createdAt"]},"CreateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"creator":{"type":"string"}},"required":["name"]},"UpdateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"SubmissionListResponse":{"type":"object","properties":{"submissions":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionResponse"}}},"required":["submissions"]},"SubmissionResponse":{"type":"object","properties":{"submissionId":{"type":"string"},"projectId":{"type":"string"},"requestIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"submissionRunId":{"type":"string"},"attemptNumber":{"type":"integer"},"readiness":{"type":"string","enum":["waiting","ready","blocked","processing","done","failed"]},"requests":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionRequestSummary"}},"dependencyProjection":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionDependencyProjection"}},"blocking":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionBlockingDetail"}},"handlerProgress":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/SubmissionHandlerStatus"}},"report":{"$ref":"#/components/schemas/SubmissionReportPointer"},"supersededAt":{"type":["string","null"],"format":"date-time"},"supersededBySubmissionRunId":{"type":"string"},"supersededReason":{"type":"string","enum":["request-vector-changed","manual-retry"]}},"required":["submissionId","projectId","requestIds","createdAt","submissionRunId","attemptNumber","readiness","requests","dependencyProjection","blocking"]},"SubmissionRequestSummary":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"task":{"type":"string"},"status":{"type":"string"},"outcome":{"type":"string"},"workerType":{"type":"string"},"model":{"type":"string"},"profileId":{"type":"string"}},"required":["requestId","runId","task","status","workerType"]},"SubmissionDependencyProjection":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"handlerId":{"type":"string"},"state":{"type":"string","enum":["waiting","ready","failed","blocked"]},"version":{"type":"number"},"updatedAt":{"type":["string","null"],"format":"date-time"},"error":{"type":"string"}},"required":["requestId","runId","handlerId","state"]},"SubmissionBlockingDetail":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"handlerId":{"type":"string"},"reason":{"type":"string","enum":["request-missing","run-changed","dependency-not-ready","dependency-failed","dependency-blocked"]},"error":{"type":"string"}},"required":["requestId","runId","handlerId","reason"]},"SubmissionHandlerStatus":{"type":"object","properties":{"status":{"type":"string","enum":["queued","processing","done","failed"]},"version":{"type":"number"},"updatedAt":{"type":["string","null"],"format":"date-time"},"error":{"type":"string"}},"required":["status"]},"SubmissionReportPointer":{"type":"object","properties":{"reportId":{"type":"string"},"url":{"type":"string"},"updatedAt":{"type":["string","null"],"format":"date-time"}}},"ReportResponse":{"type":"object","properties":{"_id":{"type":"string"},"requestId":{"type":"string"},"submissionId":{"type":"string"},"submissionRunId":{"type":"string"},"templateId":{"type":"string"},"reporter":{"$ref":"#/components/schemas/Reporter"},"content":{"type":"string"},"structuredArtifact":{"$ref":"#/components/schemas/StructuredReportArtifact"},"status":{"type":"string","enum":["pending","generating","completed","failed"]},"error":{"type":"string"},"insightReferences":{"type":"array","items":{"$ref":"#/components/schemas/InsightReference"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","status","createdAt","projectId"]},"Reporter":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"gitHash":{"type":"string"},"model":{"type":"string"},"agentId":{"type":"string"},"agentVersion":{"type":"string"}},"required":["id","name","gitHash","model","agentId","agentVersion"]},"StructuredReportArtifact":{"type":"object","properties":{"schemaId":{"type":"string"},"schemaVersion":{"type":"string"},"contentType":{"type":"string","enum":["application/json"]},"url":{"type":"string"}},"required":["schemaId","schemaVersion","contentType","url"]},"InsightReference":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]},"SubmissionRunResponse":{"type":"object","properties":{"_id":{"type":"string"},"attemptNumber":{"type":"integer","minimum":1},"vectorFingerprint":{"type":"string"},"requests":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionRequestRef"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"dependencyProjection":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionDependencyProjection"},"default":[]},"handlerStatus":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/SubmissionHandlerStatus"}},"blocking":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionBlockingDetail"},"default":[]},"report":{"$ref":"#/components/schemas/SubmissionReportPointer"},"reportsTriggeredAt":{"type":["string","null"],"format":"date-time"},"supersededAt":{"type":["string","null"],"format":"date-time"},"supersededBySubmissionRunId":{"type":"string"},"supersededReason":{"type":"string","enum":["request-vector-changed","manual-retry"]},"submissionId":{"type":"string"},"projectId":{"type":"string"}},"required":["_id","attemptNumber","vectorFingerprint","requests","createdAt"]},"SubmissionRequestRef":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"}},"required":["requestId","runId"]},"SubmissionRetryResponse":{"type":"object","properties":{"submissionId":{"type":"string"},"submissionRunId":{"type":"string"},"attemptNumber":{"type":"integer"},"readiness":{"type":"string","enum":["waiting","ready","blocked","processing","done","failed"]},"blocking":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionBlockingDetail"}}},"required":["submissionId","submissionRunId","attemptNumber","readiness","blocking"]},"RequestResponse":{"type":"object","properties":{"_id":{"type":"string"},"scenario":{"$ref":"#/components/schemas/Scenario"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"maxIterations":{"type":"number"},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"deletedAt":{"type":["string","null"],"format":"date-time"},"taskPromptId":{"type":"string"},"agentsMdPromptId":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBinding"}},"extensions":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"profileId":{"type":"string"},"profileVersionId":{"type":"string"},"submissionId":{"type":"string"},"submissionExpectedRequestCount":{"type":"integer","minimum":1},"submissionRequestIndex":{"type":"integer","minimum":0},"submissionInitialRunId":{"type":"string"},"submissionFinalizedAt":{"type":["string","null"],"format":"date-time"},"priority":{"type":"integer","default":0},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}},"observations":{"type":"array","items":{"type":"string"}},"gateSummaries":{"type":"array","items":{"$ref":"#/components/schemas/GateRunSummary"}},"run":{"$ref":"#/components/schemas/RunState"},"projectId":{"type":"string"}},"required":["_id","scenario","workerType","createdAt","projectId"]},"Scenario":{"type":"object","properties":{"version":{"type":"string","enum":["v1","v2"]},"task":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}}},"required":["task","criteria"]},"Persona":{"type":"object","properties":{"personality":{"type":"string","enum":["demanding","friendly"]},"experience":{"type":"string","enum":["junior","senior"]},"verbosity":{"type":"string","enum":["brief","moderate"]},"type":{"type":"string","enum":["traditional","ai_assisted","vibe"]}},"required":["personality","experience","verbosity","type"]},"ResourceBinding":{"type":"object","properties":{"ref":{"type":"string"},"revisionId":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref","revisionId","params"]},"GateConfig":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"promptId":{"type":"string"},"promptText":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}},"maxIterations":{"type":"integer","minimum":1,"maximum":50}},"required":["gate","criteria"]},"GateRunSummary":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"status":{"type":"string","enum":["passed","failed","skipped"]},"iterations":{"type":"integer","minimum":0}},"required":["gate","status","iterations"]},"RunState":{"type":"object","properties":{"_id":{"type":"string"},"attemptNumber":{"type":"integer","minimum":1},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"queuedQueueName":{"type":"string"},"outcome":{"type":"string","enum":["succeeded","failed","finished"]},"result":{"type":"string"},"error":{"type":"string"},"logsUrl":{"type":"string"},"updatedAt":{"type":["string","null"],"format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"turns":{"type":"array","items":{"$ref":"#/components/schemas/ConversationTurn"}},"workerVersion":{"type":"string"},"os":{"type":"object","properties":{"platform":{"type":"string"},"release":{"type":"string"},"arch":{"type":"string"}},"required":["platform","release","arch"]},"lastHeartbeatAt":{"type":["string","null"],"format":"date-time"},"worker":{"type":"object","properties":{"instanceId":{"type":"string"},"podName":{"type":"string"}},"required":["instanceId"]},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"setupVideoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"aiCallCount":{"type":"number"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRunOutcome"}},"mcpRegistered":{"type":"boolean"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"pausedAt":{"type":["string","null"],"format":"date-time"},"resumedAt":{"type":["string","null"],"format":"date-time"},"observationResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}}},"required":["_id","attemptNumber","status"]},"ConversationTurn":{"type":"object","properties":{"iteration":{"type":"number"},"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"codingAgentResponse":{"type":"string"},"judgeFeedback":{"type":"string"},"snapshotUrl":{"type":"string"},"passed":{"type":"boolean"},"timestamp":{"type":["string","null"],"format":"date-time"},"criteriaResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}},"observationResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"startedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"toolCalls":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"arguments":{"type":"object","additionalProperties":{}},"response":{"type":"string"},"timestamp":{"type":"string"}},"required":["id","name","arguments"]}},"toolCallsUrl":{"type":"string"},"toolCallCount":{"type":"number"},"aiCallCount":{"type":"number"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"chatResultUrl":{"type":"string"},"chatResultFormat":{"type":"string"}},"required":["iteration","judgeFeedback","snapshotUrl","passed","timestamp"]},"CriterionResult":{"type":"object","properties":{"criterionId":{"type":"string"},"passed":{"type":"boolean"},"feedback":{"type":"string"},"evaluated":{"type":"boolean"}},"required":["criterionId","passed","feedback","evaluated"]},"TokenUsage":{"type":"object","properties":{"promptTokens":{"type":"number"},"completionTokens":{"type":"number"},"totalTokens":{"type":"number"}},"required":["promptTokens","completionTokens","totalTokens"]},"ResourceRunOutcome":{"type":"object","properties":{"ref":{"type":"string"},"slug":{"type":"string"},"revisionId":{"type":"string"},"setupSucceeded":{"type":"boolean"},"published":{"type":"array","items":{"type":"string"}},"params":{"type":"object","additionalProperties":{"type":"string"}},"setupDurationMs":{"type":"number"},"error":{"type":"string"},"teardownRan":{"type":"boolean"}},"required":["ref","slug","revisionId","setupSucceeded","published"]},"ResourceBindingSpec":{"anyOf":[{"type":"string"},{"type":"object","properties":{"ref":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref"]}]},"CreateRequestInput":{"type":"object","properties":{"scenario":{"$ref":"#/components/schemas/Scenario"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"maxIterations":{"type":"integer","minimum":1,"maximum":50},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}},"profileId":{"type":"string"},"profileVariations":{"type":"array","items":{"type":"string"}},"priority":{"type":"integer"},"agentsMd":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}},"observations":{"type":"array","items":{"type":"string"}}},"required":["scenario"]},"RunFacetsResponse":{"type":"object","properties":{"total":{"type":"number"},"facets":{"type":"object","properties":{"workerType":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"status":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"outcome":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"model":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"os":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"priority":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"agentVersion":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"profileId":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}}},"required":["workerType","status","outcome","model","os","priority","agentVersion","profileId"]}},"required":["total","facets"]},"RunFacetBucket":{"type":"object","properties":{"value":{"type":"string"},"count":{"type":"number"}},"required":["value","count"]},"PaginatedRunsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"Cursors":{"type":"object","properties":{"next":{"type":["string","null"]},"prev":{"type":["string","null"]}},"required":["next","prev"]},"PaginatedRunGroupsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RunGroup"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"RunGroup":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"runIds":{"type":"array","items":{"type":"string"}},"aggregates":{"$ref":"#/components/schemas/GroupAggregates"},"uniform":{"$ref":"#/components/schemas/GroupUniformValues"}},"required":["key","label","runIds","aggregates","uniform"]},"GroupAggregates":{"type":"object","properties":{"count":{"type":"number"},"turns":{"$ref":"#/components/schemas/AggregateStats"},"duration":{"$ref":"#/components/schemas/AggregateStats"},"promptTokens":{"$ref":"#/components/schemas/AggregateStats"},"completionTokens":{"$ref":"#/components/schemas/AggregateStats"},"statusCounts":{"type":"object","additionalProperties":{"type":"number"}},"outcomeCounts":{"type":"object","additionalProperties":{"type":"number"}}},"required":["count","turns","duration","promptTokens","completionTokens","statusCounts","outcomeCounts"]},"AggregateStats":{"type":["object","null"],"properties":{"min":{"type":"number"},"max":{"type":"number"},"mean":{"type":"number"},"stdDev":{"type":"number"}},"required":["min","max","mean","stdDev"]},"GroupUniformValues":{"type":"object","properties":{"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":"string"},"platform":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"submissionId":{"type":"string"},"task":{"type":"string"}}},"BulkResubmitInput":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"count":{"type":"integer","minimum":1,"maximum":10,"default":1},"overrides":{"type":"object","properties":{"profileId":{"type":["string","null"]},"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":["string","null"]},"reasoningEffort":{"type":["string","null"]},"maxIterations":{"type":["number","null"]},"mcpServers":{"type":["array","null"],"items":{"type":"string"}},"skillRevisions":{"type":["array","null"],"items":{"type":"string"}},"extensions":{"type":["array","null"],"items":{"type":"string"}}}}},"required":["ids"]},"CreateCriteriaInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"],"default":"gate"},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]}},"required":["id","prompt"]},"CriteriaResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"]},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"CriteriaGraph":{"type":"object","properties":{"nodes":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphNode"}},"edges":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphEdge"}}},"required":["nodes","edges"]},"CriteriaGraphNode":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"]},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]}},"required":["id","prompt"]},"CriteriaGraphEdge":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"]},"UpdateCriteriaInput":{"type":"object","properties":{"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"kind":{"type":"string","enum":["gate","observation"]},"taxonomyElementId":{"type":"string","enum":["dimension:idiomatic-use","dimension:dependency-currency","dimension:configuration-correctness"]},"subject":{"type":"string","enum":["run","iteration"]}}},"CreatePromptFeatureInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["id","prompt"]},"PromptType":{"type":"string","enum":["select","build","test","run","deploy","agents.md"]},"PromptFeatureResult":{"type":"object","properties":{"featureId":{"type":"string"},"detected":{"type":"boolean"},"evaluated":{"type":"boolean"}},"required":["featureId","detected","evaluated"]},"SuggestedPromptFeature":{"type":"object","properties":{"suggestedId":{"type":"string"},"behavior":{"type":"string"},"prompt":{"type":"string"}},"required":["suggestedId","behavior","prompt"]},"PromptFeatureResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"UpdatePromptFeatureInput":{"type":"object","properties":{"prompt":{"type":"string"}}},"TaskPromptResponse":{"type":"object","properties":{"_id":{"type":"string"},"keyId":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"text":{"type":"string"},"contentBlobUrl":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","keyId","createdAt","projectId"]},"CreateTaskPromptInput":{"type":"object","properties":{"text":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["text"]},"PatchTaskPromptFeatureInput":{"type":"object","properties":{"detected":{"type":"boolean"}},"required":["detected"]},"CreateReportInput":{"type":"object","properties":{"requestId":{"type":"string"},"templateId":{"type":"string"}},"required":["requestId"]},"BulkCreateReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}},"templateId":{"type":"string"}},"required":["requestIds"]},"BulkReportStatusInput":{"type":"object","properties":{"reportIds":{"type":"array","items":{"type":"string"}}},"required":["reportIds"]},"BulkReportSummaryResponse":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/ReportSummary"}},"ReportSummary":{"type":"object","properties":{"total":{"type":"number"},"pending":{"type":"number"},"generating":{"type":"number"},"completed":{"type":"number"},"failed":{"type":"number"}},"required":["total","pending","generating","completed","failed"]},"BulkReportSummaryInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"TriggerReportsInput":{"type":"object","properties":{"requestId":{"type":"string"}},"required":["requestId"]},"TriggerSubmissionReportsInput":{"type":"object","properties":{"submissionId":{"type":"string"},"submissionRunId":{"type":"string"}},"required":["submissionId","submissionRunId"]},"BulkTriggerReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"InsightResponse":{"type":"object","properties":{"_id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"upvotes":{"type":"number"},"downvotes":{"type":"number"},"blocked":{"type":"boolean"},"referenceCount":{"type":"number"},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","title","description","upvotes","downvotes","blocked","referenceCount","createdBy","createdAt","projectId"]},"ProfileWithVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"version":{"$ref":"#/components/schemas/ProfileVersionResponse"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","version","projectId"]},"ProfileVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"profileId":{"type":"string"},"version":{"type":"number"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","profileId","version","workerType","model","createdAt","projectId"]},"CreateProfileInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["name","workerType","model"]},"ProfileResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","projectId"]},"UpdateProfileIdentity":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512}}},"ReportTemplateResponse":{"type":"object","properties":{"_id":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"subject":{"type":"string","enum":["run","submission"],"default":"run"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","id","name","userPrompt","createdAt","projectId"]},"ReportTemplateSystemPrompt":{"type":"object","properties":{"mode":{"type":"string","enum":["append","override"]},"content":{"type":"string"}},"required":["mode","content"]},"ReportTrigger":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["always"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["criteria"]},"criteriaIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","criteriaIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["taskPrompt"]},"taskPromptIds":{"type":"array","items":{"type":"string"}}},"required":["type","taskPromptIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["promptFeature"]},"featureIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","featureIds"]}]},"CreateReportTemplateInput":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"subject":{"type":"string","enum":["run","submission"],"default":"run"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0}},"required":["id","name","userPrompt"]},"UpdateReportTemplateInput":{"type":"object","properties":{"name":{"type":"string"},"subject":{"type":"string","enum":["run","submission"]},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"allOf":[{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},{"type":["object","null"]}]},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":["string","null"]},"timeoutMs":{"type":["integer","null"],"exclusiveMinimum":0}}},"AgentResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"},"versions":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","supportedModels","createdAt"]},"AgentCapabilities":{"type":"object","properties":{"supportsReasoningEffort":{"type":"boolean"},"supportsMcpServers":{"type":"boolean"},"supportsSkills":{"type":"boolean"},"supportsExtensions":{"type":"boolean"},"supportsResources":{"type":"boolean"}}},"AgentVersion":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string"},"status":{"type":"string","enum":["active","retired"]},"createdAt":{"type":["string","null"],"format":"date-time"}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","status","createdAt"]},"CreateAgentInput":{"type":"object","properties":{"_id":{"type":"string","minLength":1},"name":{"type":"string","minLength":1},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}},"required":["_id","name"]},"UpdateAgentInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}}},"RegisterAgentVersionInput":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string","minLength":1}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","queueName"]},"PatchAgentVersionInput":{"type":"object","properties":{"status":{"type":"string","enum":["active","retired"]}},"required":["status"]},"ModelResponse":{"type":"object","properties":{"_id":{"type":"string"},"modelId":{"type":"string"},"provider":{"type":"string"},"agentId":{"type":"string"},"firstSeenAt":{"type":["string","null"],"format":"date-time"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"disappearedAt":{"type":["string","null"],"format":"date-time"},"providerAvailableFrom":{"type":["string","null"],"format":"date-time"},"providerEndOfLife":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"$ref":"#/components/schemas/ModelCapabilities"}},"required":["_id","modelId","provider","agentId","firstSeenAt","lastSeenAt"]},"ModelCapabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}},"McpServerResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","type","createdAt","projectId"]},"McpServerHeader":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]},"UpdateMcpServerInput":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}}},"SkillResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","source","skillName","name","origin","createdAt","projectId"]},"SkillSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"source":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"installs":{"type":"number"}},"required":["id","name","source","internal"]},"SkillDiscoveryResult":{"type":"object","properties":{"skillName":{"type":"string"},"skillPath":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"existsInLibrary":{"type":"boolean"},"currentRevisionCommitSha":{"type":"string"},"latestUpstreamCommitSha":{"type":"string"},"updateAvailable":{"type":"boolean"},"lastImportedAt":{"type":"string"}},"required":["skillName","skillPath"]},"SkillRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"ref":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"skillPath":{"type":"string"},"commitHash":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"name":{"type":"string"},"description":{"type":"string"},"license":{"type":"string"},"compatibility":{"type":"string"},"allowedTools":{"type":"string"},"metadata":{"type":"object","additionalProperties":{"type":"string"}},"content":{"type":"string"},"archiveUrl":{"type":"string"},"validationWarnings":{"type":"array","items":{"type":"string"}},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","ref","source","skillName","skillPath","commitHash","commitTimestamp","name","description","content","archiveUrl","resolvedAt","createdAt","projectId"]},"CreateSkillInput":{"type":"object","properties":{"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]}},"required":["source","skillName","name","origin"]},"CodebaseResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"defaultBranch":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","sourceType","revisionCounter","createdAt","projectId"]},"UpdateCodebaseInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"defaultBranch":{"type":"string"}}},"CodebaseRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"codebaseId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"requestedRef":{"type":"string"},"resolvedCommitSha":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"originalFilename":{"type":"string"},"contentSha256":{"type":"string"},"archiveUrl":{"type":"string"},"sizeBytes":{"type":"number"},"fileCount":{"type":"number"},"creator":{"type":"string"},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","codebaseId","slug","revisionNumber","ref","sourceType","archiveUrl","resolvedAt","createdAt","projectId"]},"ResolveCodebaseRevisionInput":{"type":"object","properties":{"requestedRef":{"type":"string"},"creator":{"type":"string"}}},"ResourceResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"latestRevisionNumber":{"type":"number"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","revisionCounter","createdAt","projectId"]},"CreateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"slug":{"type":"string","minLength":1},"description":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["name","setup"]},"ResourceScript":{"type":"object","properties":{"sh":{"type":"string","minLength":1}}},"ResourceParameter":{"type":"object","properties":{"name":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"},"description":{"type":"string"},"required":{"type":"boolean","default":false},"default":{"type":"string"},"example":{"type":"string"}},"required":["name"]},"UpdateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"ResourceRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"resourceId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"contentSha256":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","resourceId","slug","revisionNumber","ref","setup","exports","contentSha256","createdAt","projectId"]},"CreateResourceRevisionInput":{"type":"object","properties":{"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["setup"]},"ExtensionResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","publisher","name","origin","createdAt","projectId"]},"ExtensionSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"publisher":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"version":{"type":"string"}},"required":["id","name","publisher","internal"]},"CreateExtensionInput":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]}},"required":["_id","publisher","name","origin"]},"UpdateExtensionInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"ExtensionVersionInfo":{"type":"object","properties":{"version":{"type":"string"},"preRelease":{"type":"boolean"},"lastUpdated":{"type":"string"}},"required":["version","preRelease","lastUpdated"]},"CreateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"}},"required":["title","description"]},"UpdateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}}}},"FeatureFlagResponse":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"enabled":{"type":"boolean"},"updatedAt":{"type":["string","null"],"format":"date-time"}},"required":["key","label","enabled","updatedAt"]},"UpdateFeatureFlagInput":{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]}},"parameters":{}},"paths":{"/api/v1/keys/preview":{"post":{"tags":["Keys"],"summary":"Preview key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}}},"/api/v1/keys":{"post":{"tags":["Keys"],"summary":"Create key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"201":{"description":"Key created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"get":{"tags":["Keys"],"summary":"List keys","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/KeyResponse"}}}}}}}},"/api/v1/keys/{id}":{"get":{"tags":["Keys"],"summary":"Get key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"put":{"tags":["Keys"],"summary":"Update key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"delete":{"tags":["Keys"],"summary":"Delete key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Key deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/api/v1/keys/{id}/validate":{"post":{"tags":["Keys"],"summary":"Validate key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateKeyInput"}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Key validation result"}}}}}}},"/api/v1/accounts":{"post":{"tags":["Accounts"],"summary":"Create account","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"201":{"description":"Account created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"get":{"tags":["Accounts"],"summary":"List accounts","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AccountResponse"}}}}}}}},"/api/v1/accounts/{id}":{"get":{"tags":["Accounts"],"summary":"Get account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"put":{"tags":["Accounts"],"summary":"Update account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"200":{"description":"Account updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"delete":{"tags":["Accounts"],"summary":"Delete account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Account deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/api/v1/users/me":{"get":{"tags":["Users"],"summary":"Get the authenticated user's identity","description":"Read the existing authenticated Scope identity without enrollment or profile writes. Responses must not be HTTP-cached.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMeResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"User is not enrolled or is disabled"},"503":{"description":"Authentication service unavailable"}}},"post":{"tags":["Users"],"summary":"Enroll the authenticated user","description":"JIT-enroll the authenticated user, refresh their profile and lastLoginAt, apply bootstrap-admin rules, and warm the access cache. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMeResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"User is disabled"},"503":{"description":"Authentication service unavailable"}}}},"/health":{"get":{"tags":["Health"],"summary":"Liveness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"version":{"type":"string"}},"required":["status","version"]}}}}}}},"/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"migrations":{}},"required":["status"]}}}},"503":{"description":"Service is not ready"}}}},"/about":{"get":{"tags":["System"],"summary":"API metadata","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"description":{"type":"string"},"workers":{"type":"array","items":{"type":"string"}}},"required":["name","version","buildTime","environment","description","workers"]}}}}}}},"/api/v1/version":{"get":{"tags":["System"],"summary":"Version info","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"commit":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"strictAgentCapabilities":{"type":"boolean"}},"required":["commit","buildTime","environment","strictAgentCapabilities"]}}}}}}},"/api/v1/projects":{"get":{"tags":["Projects"],"summary":"List all projects","parameters":[{"schema":{"type":"string","enum":["true","false"],"description":"When true, include soft-deleted projects in the result"},"required":false,"description":"When true, include soft-deleted projects in the result","name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}}}}}},"post":{"tags":["Projects"],"summary":"Create a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/projects/{id}":{"get":{"tags":["Projects"],"summary":"Get a project","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"patch":{"tags":["Projects"],"summary":"Update a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"delete":{"tags":["Projects"],"summary":"Soft-delete a project","responses":{"204":{"description":"Success"},"404":{"description":"Project not found"}}}},"/api/v1/projects/{id}/restore":{"post":{"tags":["Projects"],"summary":"Restore a soft-deleted project","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}}},"/api/v1/submissions":{"get":{"tags":["Submissions"],"summary":"List submissions","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionListResponse"}}}}}}},"/api/v1/submissions/{id}/runs/{submissionRunId}/logs":{"get":{"tags":["Submissions"],"summary":"Stream logs for a submission attempt (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"submissionRunId","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Submission or submission attempt not found"}}}},"/api/v1/submissions/{id}/reports":{"get":{"tags":["Reports"],"summary":"Get reports for submission","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Submission not found"}}}},"/api/v1/submissions/{id}":{"get":{"tags":["Submissions"],"summary":"Get a submission","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionResponse"}}}},"404":{"description":"Submission not found"}}}},"/api/v1/submissions/{id}/runs":{"get":{"tags":["Submissions"],"summary":"List current and historical submission attempts","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"runs":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionRunResponse"}}},"required":["runs"]}}}},"404":{"description":"Submission not found"}}}},"/api/v1/submissions/{id}/runs/{submissionRunId}":{"get":{"tags":["Submissions"],"summary":"Get one submission attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"submissionRunId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionRunResponse"}}}},"404":{"description":"Submission attempt not found"}}}},"/api/v1/submissions/{id}/retry":{"post":{"tags":["Submissions"],"summary":"Retry current submission processing","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"submissionRunId":{"type":"string"}},"required":["submissionRunId"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionRetryResponse"}}}},"404":{"description":"Submission not found"},"409":{"description":"Submission attempt is stale or dependencies are blocked"}}}},"/api/v1/requests":{"post":{"tags":["Requests"],"summary":"Submit request(s)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/CreateRequestInput"},{"type":"object","properties":{"count":{"type":"number","minimum":1,"maximum":10,"default":1},"skills":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"codebase":{"type":"string"}}}]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/RequestResponse"},{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}]}}}}}},"get":{"tags":["Requests"],"summary":"List requests","parameters":[{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"profileId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"status","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"outcome","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"model","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"os","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"priority","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"agentVersion","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"type":"string","enum":["task","submissionId","profile"]},"required":false,"name":"groupBy","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"after","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"last","in":"query"},{"schema":{"type":"string","enum":["created","updated","priority","worker","status","id","duration","createdAt"]},"required":false,"name":"sortBy","in":"query"},{"schema":{"type":"string","enum":["asc","desc"]},"required":false,"name":"sortDir","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"turns","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"turnsOp","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"maxIterations","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"maxIterationsOp","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaginatedRunsResponse"},{"$ref":"#/components/schemas/PaginatedRunGroupsResponse"}]}}}}}}},"/api/v1/requests/facets":{"get":{"tags":["Requests"],"summary":"List run filter facets","description":"Returns every distinct value and its full-dataset count per categorical filter dimension for the Runs list rail. Counts are absolute over all non-deleted runs **in the given project**: they intentionally ignore the active search, date range, iteration, and categorical selections so every selectable value stays visible with a stable count. Requires ?projectId=. Computed with parallel $group aggregations (Cosmos has no $facet) and cached per-project for a short TTL.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunFacetsResponse"}}}}}}},"/api/v1/requests/{id}":{"get":{"tags":["Requests"],"summary":"Get request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestResponse"}}}},"404":{"description":"Not found"}}},"delete":{"tags":["Requests"],"summary":"Soft-delete request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Not found"}}}},"/api/v1/analysis":{"get":{"tags":["Requests"],"summary":"Compute pass@k / success@T metrics","description":"Aggregates pass@k / success@T metrics over a single project's done runs. Requires ?projectId=.","parameters":[{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"k","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Analysis metrics"}}}}}}},"/api/v1/requests/bulk-resubmit":{"post":{"tags":["Requests"],"summary":"Bulk resubmit requests","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResubmitInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}}}}}}},"/api/v1/requests/bulk":{"delete":{"tags":["Requests"],"summary":"Bulk soft-delete requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"number"}},"required":["deleted"]}}}}}}},"/api/v1/requests/archive":{"post":{"tags":["Requests"],"summary":"Download batch archive of multiple runs","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Gzipped batch archive containing individual run archives"},"400":{"description":"Invalid input"},"404":{"description":"One or more runs not found"}}}},"/api/v1/runs/upload":{"post":{"tags":["Requests"],"summary":"Import run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"}}}},"/api/v1/runs/upload-batch":{"post":{"tags":["Requests"],"summary":"Import batch run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"207":{"description":"Multi-status: 201 if all runs imported, 400 if none imported, 207 if partial"},"400":{"description":"No archive uploaded, empty archive, or all runs failed"}}}},"/api/v1/requests/{id}/reports":{"get":{"tags":["Reports"],"summary":"Get reports for request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Run not found"}}}},"/api/v1/requests/{id}/runs":{"get":{"tags":["Requests"],"summary":"List attempts for a request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RunState"}}}}},"404":{"description":"Request not found"}}}},"/api/v1/requests/{id}/runs/{runId}":{"get":{"tags":["Requests"],"summary":"Get a single attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunState"}}}},"404":{"description":"Request or run not found"}}}},"/api/v1/requests/bulk-retry":{"post":{"tags":["Requests"],"summary":"Bulk retry requests (start new attempts)","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"force":{"type":"boolean"}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"retried":{"type":"integer"},"skipped":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"},"error":{"type":"string"}},"required":["requestId"]}}},"required":["retried","skipped","results"]}}}}}}},"/api/v1/requests/{id}/retry":{"post":{"tags":["Requests"],"summary":"Retry a request (start a new attempt)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"force":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"}},"required":["requestId","runId","attemptNumber"]}}}},"404":{"description":"Request not found"},"409":{"description":"Conflict — request is not in a retryable state, or a concurrent retry won the race"},"422":{"description":"Cannot retry — current run not yet terminal"}}}},"/api/v1/requests/{id}/priority":{"post":{"tags":["Requests"],"summary":"Set priority on a single request","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"priority":{"type":"integer"}},"required":["priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"priority":{"type":"number"}},"required":["id","priority"]}}}}}}},"/api/v1/requests/bulk-priority":{"post":{"tags":["Requests"],"summary":"Set priority on multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100},"priority":{"type":"integer"}},"required":["ids","priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/pause":{"post":{"tags":["Requests"],"summary":"Pause a single request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/{id}/resume":{"post":{"tags":["Requests"],"summary":"Resume a paused request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/bulk-pause":{"post":{"tags":["Requests"],"summary":"Pause multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/bulk-resume":{"post":{"tags":["Requests"],"summary":"Resume multiple paused requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/cancel":{"post":{"tags":["Requests"],"summary":"Cancel a request (marks as done/failed and signals worker to exit)","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"previousStatus":{"type":"string"},"status":{"type":"string"},"outcome":{"type":"string"}},"required":["id","previousStatus","status","outcome"]}}}}}}},"/api/v1/requests/bulk-cancel":{"post":{"tags":["Requests"],"summary":"Cancel multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"cancelled":{"type":"number"},"skipped":{"type":"number"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"cancelled":{"type":"boolean"},"previousStatus":{"type":"string"},"error":{"type":"string"}},"required":["id","cancelled"]}}},"required":["cancelled","skipped","results"]}}}}}}},"/api/v1/requests/{id}/logs":{"get":{"tags":["Requests"],"summary":"Stream request logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/logs":{"get":{"tags":["Requests"],"summary":"Stream logs for a specific attempt (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific attempt and iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/video":{"get":{"tags":["Requests"],"summary":"Download session recording","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/video":{"get":{"tags":["Requests"],"summary":"Download session recording for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/criteria/generate-prompt":{"post":{"tags":["Criteria"],"summary":"Generate criterion prompt from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"},"gates":{"type":"array","items":{"type":"string"}}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"},"suggestedId":{"type":"string"},"suggestedParents":{"type":"array","items":{"type":"string"}},"suggestedChildren":{"type":"array","items":{"type":"string"}}},"required":["prompt","suggestedId","suggestedParents","suggestedChildren"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/criteria/seed":{"post":{"tags":["Criteria"],"summary":"Seed criteria in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"criteria":{"type":"array","items":{"$ref":"#/components/schemas/CreateCriteriaInput"}}},"required":["criteria"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}},"400":{"description":"Seed batch would introduce a dependency cycle"}}}},"/api/v1/criteria":{"get":{"tags":["Criteria"],"summary":"List criteria","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","description":"Comma-separated criterion IDs to include"},"required":false,"description":"Comma-separated criterion IDs to include","name":"ids","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"When true and ids is set, also include dependency ancestors"},"required":false,"description":"When true and ids is set, also include dependency ancestors","name":"ancestors","in":"query"},{"schema":{"type":"string","enum":["gate","observation"],"description":"Filter by criterion kind"},"required":false,"description":"Filter by criterion kind","name":"kind","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaResponse"}}}}}}},"post":{"tags":["Criteria"],"summary":"Create criterion","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCriteriaInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"409":{"description":"Criterion already exists"}}}},"/api/v1/criteria/mdp":{"get":{"tags":["Criteria"],"summary":"Compute MDP transitions","parameters":[{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"since","in":"query"},{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{}}}}}}}},"/api/v1/criteria/graph":{"get":{"tags":["Criteria"],"summary":"Get criteria DAG","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaGraph"}}}}}}},"/api/v1/criteria/{id}":{"get":{"tags":["Criteria"],"summary":"Get criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"404":{"description":"Criterion not found"}}},"put":{"tags":["Criteria"],"summary":"Update criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCriteriaInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"400":{"description":"Invalid dependency reference or self-reference"},"404":{"description":"Criterion not found"}}},"delete":{"tags":["Criteria"],"summary":"Soft-delete criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Criterion not found"},"409":{"description":"Criterion has dependents"}}}},"/api/v1/prompt-features/generate-prompt":{"post":{"tags":["Prompt Features"],"summary":"Generate prompt feature from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features/seed":{"post":{"tags":["Prompt Features"],"summary":"Seed prompt features in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}},"required":["features"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}}}}},"/api/v1/prompt-features/extract-from-text":{"post":{"tags":["Prompt Features"],"summary":"Extract features from text","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string"},"model":{"type":"string"},"type":{"type":"string","enum":["select","agents.md"]}},"required":["text"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["features","cached"]}}}},"400":{"description":"Empty text string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features":{"get":{"tags":["Prompt Features"],"summary":"List features","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","enum":["select","agents.md"]},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}}}}},"post":{"tags":["Prompt Features"],"summary":"Create feature","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"409":{"description":"Feature already exists"}}}},"/api/v1/prompt-features/{id}":{"get":{"tags":["Prompt Features"],"summary":"Get feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"404":{"description":"Feature not found"}}},"put":{"tags":["Prompt Features"],"summary":"Update feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Feature not found"}}},"delete":{"tags":["Prompt Features"],"summary":"Soft-delete feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Feature not found"}}}},"/api/v1/task-prompts/generate":{"post":{"tags":["Task Prompts"],"summary":"Generate task prompts","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string"},"existingPrompt":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"string"}}},"required":["tasks"]}}}},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts":{"get":{"tags":["Task Prompts"],"summary":"List task prompts","parameters":[{"schema":{"type":["number","null"]},"required":false,"name":"limit","in":"query"},{"schema":{"type":["number","null"]},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"$ref":"#/components/schemas/PromptType"},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TaskPromptResponse"}},"total":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["items","total","limit","offset"]}}}}}},"post":{"tags":["Task Prompts"],"summary":"Create or find task prompt","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTaskPromptInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Empty text string"}}}},"/api/v1/task-prompts/{id}":{"get":{"tags":["Task Prompts"],"summary":"Get task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"404":{"description":"Task prompt not found"}}},"delete":{"tags":["Task Prompts"],"summary":"Soft-delete task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}},"required":["deleted"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/content":{"get":{"tags":["Task Prompts"],"summary":"Get resolved task prompt content","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"}},"required":["id","text"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/extract-features":{"post":{"tags":["Task Prompts"],"summary":"Extract features from task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"force","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"model":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"taskPromptId":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["taskPromptId","features","cached"]}}}},"404":{"description":"Task prompt not found"},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts/{id}/features/{featureId}":{"patch":{"tags":["Task Prompts"],"summary":"Toggle feature flag on task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"featureId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchTaskPromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Invalid detected value"},"404":{"description":"Task prompt or feature not found"}}}},"/api/v1/reports":{"post":{"tags":["Reports"],"summary":"Create report","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Run or template not found"}}},"get":{"tags":["Reports"],"summary":"List reports","parameters":[{"schema":{"type":"string"},"required":false,"name":"requestId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionRunId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}}}}},"/api/v1/reports/bulk-create":{"post":{"tags":["Reports"],"summary":"Bulk create reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkCreateReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}/structured":{"get":{"tags":["Reports"],"summary":"Download a report's structured JSON artifact","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success"},"404":{"description":"Report or structured artifact not found"}}}},"/api/v1/reports/bulk-status":{"post":{"tags":["Reports"],"summary":"Bulk get report statuses","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportStatusInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/bulk-summary":{"post":{"tags":["Reports"],"summary":"Bulk get report summary per run","description":"Returns aggregated report status counts per run. Only the latest report per template is counted (re-triggers are deduplicated).","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}":{"get":{"tags":["Reports"],"summary":"Get report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"404":{"description":"Report not found"}}}},"/api/v1/reports/{id}/logs":{"get":{"tags":["Reports"],"summary":"Stream report logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Report not found"}}}},"/api/v1/reports/trigger":{"post":{"tags":["Reports"],"summary":"Trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"triggered":{"type":"number"},"reports":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}},"required":["triggered","reports"]}}}},"400":{"description":"Invalid input"},"404":{"description":"Run not found"}}}},"/api/v1/reports/trigger-submission":{"post":{"tags":["Reports"],"summary":"Trigger submission reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubmissionReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"triggered":{"type":"number"},"reports":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}},"required":["triggered","reports"]}}}},"404":{"description":"Submission attempt not found"},"409":{"description":"Submission attempt is not ready"}}}},"/api/v1/reports/bulk-trigger":{"post":{"tags":["Reports"],"summary":"Bulk trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkTriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}/insights":{"get":{"tags":["Reports"],"summary":"Get insights for report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/InsightResponse"},{"type":"object","properties":{"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}}}]}}}}},"404":{"description":"Report not found"}}},"post":{"tags":["Reports"],"summary":"Link insight to report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"isNew":{"type":"boolean"}},"required":["insightId"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]}}}},"404":{"description":"Report or insight not found"},"409":{"description":"Insight already referenced by this report"}}}},"/api/v1/profiles":{"post":{"tags":["Profiles"],"summary":"Create a new profile","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"get":{"tags":["Profiles"],"summary":"List profiles","parameters":[{"schema":{"type":"string"},"required":false,"name":"workerType","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}":{"get":{"tags":["Profiles"],"summary":"Get profile with latest version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"post":{"tags":["Profiles"],"summary":"Create new profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["workerType","model"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}},"put":{"tags":["Profiles"],"summary":"Update profile identity","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileIdentity"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileResponse"}}}}}},"delete":{"tags":["Profiles"],"summary":"Delete profile","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/profiles/{profileId}/versions":{"get":{"tags":["Profiles"],"summary":"List profile versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}/versions/{version}":{"get":{"tags":["Profiles"],"summary":"Get profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"},{"schema":{"type":["number","null"]},"required":false,"name":"version","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}},"/api/v1/report-templates/default-system-prompt":{"get":{"tags":["Report Templates"],"summary":"Get default system prompt","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string"}},"required":["content"]}}}}}}},"/api/v1/report-templates/available-models":{"get":{"tags":["Report Templates"],"summary":"List models available for report generation","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"modelId":{"type":"string"}},"required":["modelId"]}}}}}}}},"/api/v1/report-templates":{"get":{"tags":["Report Templates"],"summary":"List report templates","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}}}}},"post":{"tags":["Report Templates"],"summary":"Create report template","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportTemplateInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"409":{"description":"Report template already exists"}}}},"/api/v1/report-templates/{id}":{"get":{"tags":["Report Templates"],"summary":"Get report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"put":{"tags":["Report Templates"],"summary":"Update report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateReportTemplateInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"delete":{"tags":["Report Templates"],"summary":"Delete report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Report template not found"}}}},"/api/v1/agents":{"get":{"tags":["Agents"],"summary":"List agents","parameters":[{"schema":{"type":"string"},"required":false,"name":"modelProvider","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentResponse"}}}}}}},"post":{"tags":["Agents"],"summary":"Create or update agent (upsert)","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAgentInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"409":{"description":"Restored agent versions conflict with an active queue owner"}}}},"/api/v1/agents/{id}":{"get":{"tags":["Agents"],"summary":"Get agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"404":{"description":"Agent not found"}}},"put":{"tags":["Agents"],"summary":"Update agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"}}},"delete":{"tags":["Agents"],"summary":"Delete agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Agent not found"}}}},"/api/v1/agents/{id}/versions":{"get":{"tags":["Agents"],"summary":"List agent versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}}}}},"404":{"description":"Agent not found"}}},"post":{"tags":["Agents"],"summary":"Register agent version (upsert)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterAgentVersionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent registration did not stabilize"}}}},"/api/v1/agents/{id}/versions/{agentVersion}":{"patch":{"tags":["Agents"],"summary":"Patch agent version","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"agentVersion","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchAgentVersionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Invalid status value"},"404":{"description":"Agent or version not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent activation did not stabilize"}}}},"/api/v1/models":{"get":{"tags":["Models"],"summary":"List models","parameters":[{"schema":{"type":"string"},"required":false,"name":"agentId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"provider","in":"query"},{"schema":{"type":"string","enum":["active","disappeared"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ModelResponse"}}}}}}}},"/api/v1/models/{id}":{"get":{"tags":["Models"],"summary":"Get model","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelResponse"}}}}}}},"/api/v1/models/sync":{"post":{"tags":["Models"],"summary":"Sync models from provider","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agentId":{"type":"string"},"provider":{"type":"string"},"models":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"providerAvailableFrom":{"type":"string"},"providerEndOfLife":{"type":"string"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}}},"required":["id"]}},"scannedAt":{"type":"string"}},"required":["agentId","provider","models","scannedAt"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"array","items":{"type":"string"}},"removed":{"type":"array","items":{"type":"string"}},"unchanged":{"type":"array","items":{"type":"string"}}},"required":["added","removed","unchanged"]}}}}}}},"/api/v1/mcp/servers":{"get":{"tags":["MCP Servers"],"summary":"List MCP servers","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"post":{"tags":["MCP Servers"],"summary":"Create MCP server","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}},"required":["_id","name","type"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"/api/v1/mcp/servers/{id}":{"get":{"tags":["MCP Servers"],"summary":"Get MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"put":{"tags":["MCP Servers"],"summary":"Update MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMcpServerInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"delete":{"tags":["MCP Servers"],"summary":"Delete MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/skills":{"get":{"tags":["Skills"],"summary":"List all skills","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"post":{"tags":["Skills"],"summary":"Create or import a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSkillInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"/api/v1/skills/search":{"get":{"tags":["Skills"],"summary":"Search skills (internal + external)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/search/external":{"get":{"tags":["Skills"],"summary":"Search external skills registry","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project."},"required":false,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/discover":{"get":{"tags":["Skills"],"summary":"Discover skills in a GitHub repository","parameters":[{"schema":{"type":"string"},"required":true,"name":"source","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillDiscoveryResult"}}}}},"400":{"description":"Missing or malformed source parameter"},"404":{"description":"Repository not found"},"502":{"description":"GitHub API error"}}}},"/api/v1/skills/{id}(*)/revisions":{"get":{"tags":["Skills"],"summary":"List skill revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}}},"404":{"description":"Skill not found"}}}},"/api/v1/skills/{id}(*)":{"get":{"tags":["Skills"],"summary":"Get skill by slug","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}},"404":{"description":"Skill not found"}}},"delete":{"tags":["Skills"],"summary":"Soft-delete a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success"},"404":{"description":"Skill not found"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)/archive":{"get":{"tags":["Skill Revisions"],"summary":"Download skill revision archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Skill revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ref","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skill-revisions/{id}":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ID","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skills/{id}(*)/resolve":{"post":{"tags":["Skills"],"summary":"Trigger skill resolution","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebases":{"get":{"tags":["Codebases"],"summary":"List all codebases","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseResponse"}}}}}}},"post":{"tags":["Codebases"],"summary":"Create a codebase (archive codebases require the archive file)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input or missing archive"}}}},"/api/v1/codebases/{id}":{"get":{"tags":["Codebases"],"summary":"Get a codebase","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"patch":{"tags":["Codebases"],"summary":"Update a codebase","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCodebaseInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"delete":{"tags":["Codebases"],"summary":"Delete a codebase","responses":{"204":{"description":"Success"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebases/{id}/revisions":{"get":{"tags":["Codebases"],"summary":"List codebase revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}}},"404":{"description":"Codebase not found"}}},"post":{"tags":["Codebases"],"summary":"Resolve a new git codebase revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolveCodebaseRevisionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"400":{"description":"Not a git codebase"},"404":{"description":"Codebase not found"},"502":{"description":"GitHub resolution failed"}}}},"/api/v1/codebases/{id}/upload":{"post":{"tags":["Codebases"],"summary":"Upload a codebase archive as a new revision","responses":{"201":{"description":"Success"},"400":{"description":"Missing archive or not an archive codebase"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebase-revisions/{id}/archive":{"get":{"tags":["Codebase Revisions"],"summary":"Download codebase revision archive","responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebase-revisions/{id}":{"get":{"tags":["Codebase Revisions"],"summary":"Get codebase revision by id","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources":{"get":{"tags":["Resources"],"summary":"List all resources","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ResourceResponse"}}}}}}},"post":{"tags":["Resources"],"summary":"Create a resource and its first revision","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateResourceInput"}}}},"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input"},"409":{"description":"Resource slug or revision ref already exists in this project"}}}},"/api/v1/resources/{id}":{"get":{"tags":["Resources"],"summary":"Get a resource","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"patch":{"tags":["Resources"],"summary":"Update a resource","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResourceInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"delete":{"tags":["Resources"],"summary":"Delete a resource","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success"},"404":{"description":"Resource not found"}}}},"/api/v1/resources/{id}/revisions":{"get":{"tags":["Resource Revisions"],"summary":"List resource revisions","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}}},"404":{"description":"Resource not found"}}},"post":{"tags":["Resource Revisions"],"summary":"Create a resource revision","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateResourceRevisionInput"}}}},"responses":{"201":{"description":"Success"},"404":{"description":"Resource not found"},"409":{"description":"Resource revision ref already exists in this project"}}}},"/api/v1/resources/{id}/revisions/latest":{"get":{"tags":["Resource Revisions"],"summary":"Get the latest resource revision","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/{id}/revisions/{revisionNumber}":{"get":{"tags":["Resource Revisions"],"summary":"Get a resource revision by resource and revision number","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"revisionNumber","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/revisions/{id}":{"get":{"tags":["Resource Revisions"],"summary":"Get resource revision by id","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/extensions":{"get":{"tags":["Extensions"],"summary":"List all extensions","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"post":{"tags":["Extensions"],"summary":"Create or import an extension","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExtensionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"/api/v1/extensions/search":{"get":{"tags":["Extensions"],"summary":"Search extensions (internal + marketplace)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/extensions/{id}":{"get":{"tags":["Extensions"],"summary":"Get extension by ID","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"put":{"tags":["Extensions"],"summary":"Update extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateExtensionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"delete":{"tags":["Extensions"],"summary":"Delete extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/extensions/{id}/versions":{"get":{"tags":["Extensions"],"summary":"List available versions for an extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"preRelease","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionVersionInfo"}}}}}}}},"/api/v1/insights":{"get":{"tags":["Insights"],"summary":"List insights","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"post":{"tags":["Insights"],"summary":"Create insight","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInsightInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"/api/v1/insights/search":{"get":{"tags":["Insights"],"summary":"Search insights","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}}},"/api/v1/insights/{id}":{"get":{"tags":["Insights"],"summary":"Get insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"put":{"tags":["Insights"],"summary":"Update insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateInsightInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"delete":{"tags":["Insights"],"summary":"Delete insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/upvote":{"post":{"tags":["Insights"],"summary":"Upvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/downvote":{"post":{"tags":["Insights"],"summary":"Downvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/block":{"post":{"tags":["Insights"],"summary":"Block insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/unblock":{"post":{"tags":["Insights"],"summary":"Unblock insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/reports":{"get":{"tags":["Insights"],"summary":"Get reports referencing insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Insight not found"}}}},"/api/v1/feature-flags":{"get":{"tags":["Feature Flags"],"summary":"List feature flags","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"/api/v1/feature-flags/{key}":{"put":{"tags":["Feature Flags"],"summary":"Update feature flag","parameters":[{"schema":{"type":"string"},"required":true,"name":"key","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFeatureFlagInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"webhooks":{}} \ No newline at end of file