diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 452dcd2e8..aa2f220c9 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -45,9 +45,14 @@ jobs: - name: Restore Astro build cache uses: actions/cache@v4 with: + # Only the persistent data store is cached. `website/.astro` holds + # generated content-collection modules that import source files by + # name, so restoring it through the loose restore-keys below — which + # match a different source tree by design — resurrects imports for + # files that have since been renamed or deleted, and the build fails + # resolving them. path: | website/node_modules/.astro - website/.astro key: astro-${{ runner.os }}-${{ hashFiles('website/pnpm-lock.yaml') }}-${{ hashFiles('website/src/**') }} restore-keys: | astro-${{ runner.os }}-${{ hashFiles('website/pnpm-lock.yaml') }}- diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap index 3d347ffca..49b9f80b3 100644 --- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap +++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap @@ -26,6 +26,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "supportsReasoningEffort": { "type": "boolean", }, + "supportsResources": { + "type": "boolean", + }, "supportsSkills": { "type": "boolean", }, @@ -786,6 +789,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "reasoningEffort": { "type": "string", }, + "resources": { + "items": { + "$ref": "#/components/schemas/ResourceBindingSpec", + }, + "type": "array", + }, "skillRevisions": { "items": { "type": "string", @@ -950,6 +959,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "reasoningEffort": { "type": "string", }, + "resources": { + "items": { + "$ref": "#/components/schemas/ResourceBindingSpec", + }, + "type": "array", + }, "scenario": { "$ref": "#/components/schemas/Scenario", }, @@ -965,6 +980,78 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "type": "object", }, + "CreateResourceInput": { + "properties": { + "creator": { + "type": "string", + }, + "description": { + "type": "string", + }, + "exports": { + "items": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string", + }, + "type": "array", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/ResourceParameter", + }, + "type": "array", + }, + "setup": { + "$ref": "#/components/schemas/ResourceScript", + }, + "slug": { + "minLength": 1, + "type": "string", + }, + "teardown": { + "$ref": "#/components/schemas/ResourceScript", + }, + }, + "required": [ + "name", + "setup", + ], + "type": "object", + }, + "CreateResourceRevisionInput": { + "properties": { + "creator": { + "type": "string", + }, + "exports": { + "items": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string", + }, + "type": "array", + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/ResourceParameter", + }, + "type": "array", + }, + "setup": { + "$ref": "#/components/schemas/ResourceScript", + }, + "teardown": { + "$ref": "#/components/schemas/ResourceScript", + }, + }, + "required": [ + "setup", + ], + "type": "object", + }, "CreateSkillInput": { "properties": { "description": { @@ -1999,6 +2086,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "reasoningEffort": { "type": "string", }, + "resources": { + "items": { + "$ref": "#/components/schemas/ResourceBindingSpec", + }, + "type": "array", + }, "skillRevisions": { "items": { "type": "string", @@ -2621,6 +2714,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "reasoningEffort": { "type": "string", }, + "resources": { + "items": { + "$ref": "#/components/schemas/ResourceBinding", + }, + "type": "array", + }, "run": { "$ref": "#/components/schemas/RunState", }, @@ -2670,228 +2769,492 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "type": "object", }, - "RunFacetBucket": { + "ResourceBinding": { "properties": { - "count": { - "type": "number", + "params": { + "additionalProperties": { + "type": "string", + }, + "type": "object", }, - "value": { + "ref": { + "type": "string", + }, + "revisionId": { "type": "string", }, }, "required": [ - "value", - "count", + "ref", + "revisionId", + "params", ], "type": "object", }, - "RunFacetsResponse": { - "properties": { - "facets": { + "ResourceBindingSpec": { + "anyOf": [ + { + "type": "string", + }, + { "properties": { - "agentVersion": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", - }, - "type": "array", - }, - "model": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", - }, - "type": "array", - }, - "os": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", - }, - "type": "array", - }, - "outcome": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", - }, - "type": "array", - }, - "priority": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", - }, - "type": "array", - }, - "profileId": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", - }, - "type": "array", - }, - "status": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", + "params": { + "additionalProperties": { + "type": "string", }, - "type": "array", + "type": "object", }, - "workerType": { - "items": { - "$ref": "#/components/schemas/RunFacetBucket", - }, - "type": "array", + "ref": { + "type": "string", }, }, "required": [ - "workerType", - "status", - "outcome", - "model", - "os", - "priority", - "agentVersion", - "profileId", + "ref", ], "type": "object", }, - "total": { - "type": "number", - }, - }, - "required": [ - "total", - "facets", ], - "type": "object", }, - "RunGroup": { + "ResourceParameter": { "properties": { - "aggregates": { - "$ref": "#/components/schemas/GroupAggregates", + "default": { + "type": "string", }, - "key": { + "description": { "type": "string", }, - "label": { + "example": { "type": "string", }, - "runIds": { - "items": { - "type": "string", - }, - "type": "array", + "name": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string", }, - "uniform": { - "$ref": "#/components/schemas/GroupUniformValues", + "required": { + "default": false, + "type": "boolean", }, }, "required": [ - "key", - "label", - "runIds", - "aggregates", - "uniform", + "name", ], "type": "object", }, - "RunState": { + "ResourceResponse": { "properties": { "_id": { "type": "string", }, - "aiCallCount": { - "type": "number", - }, - "attemptNumber": { - "minimum": 1, - "type": "integer", - }, - "durationMs": { - "type": "number", - }, - "error": { - "type": "string", - }, - "finishedAt": { + "createdAt": { "format": "date-time", "type": [ "string", "null", ], }, - "harUrl": { + "creator": { "type": "string", }, - "lastHeartbeatAt": { + "deletedAt": { "format": "date-time", "type": [ "string", "null", ], }, - "logsUrl": { + "description": { "type": "string", }, - "os": { - "properties": { - "arch": { - "type": "string", - }, - "platform": { - "type": "string", - }, - "release": { - "type": "string", - }, - }, - "required": [ - "platform", - "release", - "arch", - ], - "type": "object", - }, - "outcome": { - "enum": [ - "succeeded", - "failed", - "finished", - ], + "latestRevisionId": { "type": "string", }, - "pausedAt": { - "format": "date-time", - "type": [ - "string", - "null", - ], + "latestRevisionNumber": { + "type": "number", }, - "queuedQueueName": { + "name": { "type": "string", }, - "rawChatFormat": { + "projectId": { "type": "string", }, - "rawChatUrl": { - "type": "string", + "revisionCounter": { + "type": "number", }, - "result": { + "slug": { "type": "string", }, - "resumedAt": { + "updatedAt": { "format": "date-time", "type": [ "string", "null", ], }, - "setupVideoUrls": { - "items": { - "type": "string", - }, - "type": "array", - }, - "startedAt": { - "format": "date-time", - "type": [ - "string", - "null", + }, + "required": [ + "_id", + "slug", + "name", + "revisionCounter", + "createdAt", + "projectId", + ], + "type": "object", + }, + "ResourceRevisionResponse": { + "properties": { + "_id": { + "type": "string", + }, + "contentSha256": { + "type": "string", + }, + "createdAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "creator": { + "type": "string", + }, + "deduplicated": { + "type": "boolean", + }, + "exports": { + "items": { + "type": "string", + }, + "type": "array", + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/ResourceParameter", + }, + "type": "array", + }, + "projectId": { + "type": "string", + }, + "ref": { + "type": "string", + }, + "resourceId": { + "type": "string", + }, + "revisionNumber": { + "type": "number", + }, + "setup": { + "$ref": "#/components/schemas/ResourceScript", + }, + "slug": { + "type": "string", + }, + "teardown": { + "$ref": "#/components/schemas/ResourceScript", + }, + }, + "required": [ + "_id", + "resourceId", + "slug", + "revisionNumber", + "ref", + "setup", + "exports", + "contentSha256", + "createdAt", + "projectId", + ], + "type": "object", + }, + "ResourceRunOutcome": { + "properties": { + "error": { + "type": "string", + }, + "params": { + "additionalProperties": { + "type": "string", + }, + "type": "object", + }, + "published": { + "items": { + "type": "string", + }, + "type": "array", + }, + "ref": { + "type": "string", + }, + "revisionId": { + "type": "string", + }, + "setupDurationMs": { + "type": "number", + }, + "setupSucceeded": { + "type": "boolean", + }, + "slug": { + "type": "string", + }, + "teardownRan": { + "type": "boolean", + }, + }, + "required": [ + "ref", + "slug", + "revisionId", + "setupSucceeded", + "published", + ], + "type": "object", + }, + "ResourceScript": { + "properties": { + "sh": { + "minLength": 1, + "type": "string", + }, + }, + "type": "object", + }, + "RunFacetBucket": { + "properties": { + "count": { + "type": "number", + }, + "value": { + "type": "string", + }, + }, + "required": [ + "value", + "count", + ], + "type": "object", + }, + "RunFacetsResponse": { + "properties": { + "facets": { + "properties": { + "agentVersion": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + "model": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + "os": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + "outcome": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + "priority": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + "profileId": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + "status": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + "workerType": { + "items": { + "$ref": "#/components/schemas/RunFacetBucket", + }, + "type": "array", + }, + }, + "required": [ + "workerType", + "status", + "outcome", + "model", + "os", + "priority", + "agentVersion", + "profileId", + ], + "type": "object", + }, + "total": { + "type": "number", + }, + }, + "required": [ + "total", + "facets", + ], + "type": "object", + }, + "RunGroup": { + "properties": { + "aggregates": { + "$ref": "#/components/schemas/GroupAggregates", + }, + "key": { + "type": "string", + }, + "label": { + "type": "string", + }, + "runIds": { + "items": { + "type": "string", + }, + "type": "array", + }, + "uniform": { + "$ref": "#/components/schemas/GroupUniformValues", + }, + }, + "required": [ + "key", + "label", + "runIds", + "aggregates", + "uniform", + ], + "type": "object", + }, + "RunState": { + "properties": { + "_id": { + "type": "string", + }, + "aiCallCount": { + "type": "number", + }, + "attemptNumber": { + "minimum": 1, + "type": "integer", + }, + "durationMs": { + "type": "number", + }, + "error": { + "type": "string", + }, + "finishedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "harUrl": { + "type": "string", + }, + "lastHeartbeatAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "logsUrl": { + "type": "string", + }, + "mcpRegistered": { + "type": "boolean", + }, + "os": { + "properties": { + "arch": { + "type": "string", + }, + "platform": { + "type": "string", + }, + "release": { + "type": "string", + }, + }, + "required": [ + "platform", + "release", + "arch", + ], + "type": "object", + }, + "outcome": { + "enum": [ + "succeeded", + "failed", + "finished", + ], + "type": "string", + }, + "pausedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "queuedQueueName": { + "type": "string", + }, + "rawChatFormat": { + "type": "string", + }, + "rawChatUrl": { + "type": "string", + }, + "resources": { + "items": { + "$ref": "#/components/schemas/ResourceRunOutcome", + }, + "type": "array", + }, + "result": { + "type": "string", + }, + "resumedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "setupVideoUrls": { + "items": { + "type": "string", + }, + "type": "array", + }, + "startedAt": { + "format": "date-time", + "type": [ + "string", + "null", ], }, "status": { @@ -3549,6 +3912,18 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "type": "object", }, + "UpdateResourceInput": { + "properties": { + "description": { + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + }, + "type": "object", + }, "UserMeResponse": { "properties": { "displayName": { @@ -6741,6 +7116,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "reasoningEffort": { "type": "string", }, + "resources": { + "items": { + "$ref": "#/components/schemas/ResourceBindingSpec", + }, + "type": "array", + }, "skillRevisions": { "items": { "type": "string", @@ -8633,73 +9014,214 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/PaginatedRunsResponse", - }, - { - "$ref": "#/components/schemas/PaginatedRunGroupsResponse", + "anyOf": [ + { + "$ref": "#/components/schemas/PaginatedRunsResponse", + }, + { + "$ref": "#/components/schemas/PaginatedRunGroupsResponse", + }, + ], + }, + }, + }, + "description": "Success", + }, + }, + "summary": "List requests", + "tags": [ + "Requests", + ], + }, + "post": { + "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", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreateRequestInput", + }, + { + "default": undefined, + "properties": { + "agentVersion": { + "type": "string", + }, + "codebase": { + "type": "string", + }, + "count": { + "default": 1, + "maximum": 10, + "minimum": 1, + "type": "number", + }, + "skills": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "type": "object", + }, + ], + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/RequestResponse", + }, + { + "items": { + "$ref": "#/components/schemas/RequestResponse", + }, + "type": "array", + }, + ], + }, + }, + }, + "description": "Success", + }, + }, + "summary": "Submit request(s)", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/archive": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "ids": { + "items": { + "type": "string", + }, + "maxItems": 100, + "minItems": 1, + "type": "array", + }, + }, + "required": [ + "ids", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "201": { + "description": "Gzipped batch archive containing individual run archives", + }, + "400": { + "description": "Invalid input", + }, + "404": { + "description": "One or more runs not found", + }, + }, + "summary": "Download batch archive of multiple runs", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/bulk": { + "delete": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "ids": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "ids", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "deleted": { + "type": "number", }, + }, + "required": [ + "deleted", ], + "type": "object", }, }, }, "description": "Success", }, }, - "summary": "List requests", + "summary": "Bulk soft-delete requests", "tags": [ "Requests", ], }, + }, + "/api/v1/requests/bulk-cancel": { "post": { - "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", - }, - }, - ], "requestBody": { "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/CreateRequestInput", - }, - { - "default": undefined, - "properties": { - "agentVersion": { - "type": "string", - }, - "codebase": { - "type": "string", - }, - "count": { - "default": 1, - "maximum": 10, - "minimum": 1, - "type": "number", - }, - "skills": { - "items": { - "type": "string", - }, - "type": "array", - }, + "properties": { + "ids": { + "items": { + "type": "string", }, - "type": "object", + "maxItems": 100, + "minItems": 1, + "type": "array", }, + }, + "required": [ + "ids", ], + "type": "object", }, }, }, @@ -8709,30 +9231,57 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/RequestResponse", + "properties": { + "cancelled": { + "type": "number", }, - { + "results": { "items": { - "$ref": "#/components/schemas/RequestResponse", + "properties": { + "cancelled": { + "type": "boolean", + }, + "error": { + "type": "string", + }, + "id": { + "type": "string", + }, + "previousStatus": { + "type": "string", + }, + }, + "required": [ + "id", + "cancelled", + ], + "type": "object", }, "type": "array", }, + "skipped": { + "type": "number", + }, + }, + "required": [ + "cancelled", + "skipped", + "results", ], + "type": "object", }, }, }, "description": "Success", }, }, - "summary": "Submit request(s)", + "summary": "Cancel multiple requests", "tags": [ "Requests", ], }, }, - "/api/v1/requests/archive": { + "/api/v1/requests/bulk-pause": { "post": { "requestBody": { "content": { @@ -8758,23 +9307,126 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "responses": { "201": { - "description": "Gzipped batch archive containing individual run archives", + "content": { + "application/json": { + "schema": { + "properties": { + "skipped": { + "type": "number", + }, + "updated": { + "type": "number", + }, + }, + "required": [ + "updated", + "skipped", + ], + "type": "object", + }, + }, + }, + "description": "Success", }, - "400": { - "description": "Invalid input", + }, + "summary": "Pause multiple requests", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/bulk-priority": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "ids": { + "items": { + "type": "string", + }, + "maxItems": 100, + "minItems": 1, + "type": "array", + }, + "priority": { + "type": "integer", + }, + }, + "required": [ + "ids", + "priority", + ], + "type": "object", + }, + }, }, - "404": { - "description": "One or more runs not found", + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "skipped": { + "type": "number", + }, + "updated": { + "type": "number", + }, + }, + "required": [ + "updated", + "skipped", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + }, + "summary": "Set priority on multiple requests", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/bulk-resubmit": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkResubmitInput", + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RequestResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", }, }, - "summary": "Download batch archive of multiple runs", + "summary": "Bulk resubmit requests", "tags": [ "Requests", ], }, }, - "/api/v1/requests/bulk": { - "delete": { + "/api/v1/requests/bulk-resume": { + "post": { "requestBody": { "content": { "application/json": { @@ -8784,6 +9436,8 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "items": { "type": "string", }, + "maxItems": 100, + "minItems": 1, "type": "array", }, }, @@ -8796,17 +9450,21 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { "properties": { - "deleted": { + "skipped": { + "type": "number", + }, + "updated": { "type": "number", }, }, "required": [ - "deleted", + "updated", + "skipped", ], "type": "object", }, @@ -8815,24 +9473,26 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, - "summary": "Bulk soft-delete requests", + "summary": "Resume multiple paused requests", "tags": [ "Requests", ], }, }, - "/api/v1/requests/bulk-cancel": { + "/api/v1/requests/bulk-retry": { "post": { "requestBody": { "content": { "application/json": { "schema": { "properties": { + "force": { + "type": "boolean", + }, "ids": { "items": { "type": "string", }, - "maxItems": 100, "minItems": 1, "type": "array", }, @@ -8851,41 +9511,252 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "properties": { - "cancelled": { - "type": "number", - }, "results": { "items": { "properties": { - "cancelled": { - "type": "boolean", + "attemptNumber": { + "type": "integer", }, "error": { "type": "string", }, - "id": { + "requestId": { "type": "string", }, - "previousStatus": { + "runId": { "type": "string", }, }, "required": [ - "id", - "cancelled", + "requestId", ], "type": "object", }, "type": "array", }, - "skipped": { - "type": "number", + "retried": { + "type": "integer", + }, + "skipped": { + "type": "integer", + }, + }, + "required": [ + "retried", + "skipped", + "results", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + }, + "summary": "Bulk retry requests (start new attempts)", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/facets": { + "get": { + "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": [ + { + "description": "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", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunFacetsResponse", + }, + }, + }, + "description": "Success", + }, + }, + "summary": "List run filter facets", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + }, + }, + "required": [ + "message", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Not found", + }, + }, + "summary": "Soft-delete request", + "tags": [ + "Requests", + ], + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Not found", + }, + }, + "summary": "Get request", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{id}/archive": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "Gzipped run archive", + }, + "404": { + "description": "Not found", + }, + }, + "summary": "Download full run archive", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{id}/atif": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "The iteration number (1-based)", + "in": "query", + "name": "iteration", + "required": true, + "schema": { + "description": "The iteration number (1-based)", + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "ATIF v1.7 trajectory JSON file", + }, + "400": { + "description": "Missing or invalid iteration", + }, + "404": { + "description": "Not found", + }, + }, + "summary": "Download ATIF trajectory file for a specific iteration", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{id}/cancel": { + "post": { + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "type": "string", + }, + "outcome": { + "type": "string", + }, + "previousStatus": { + "type": "string", + }, + "status": { + "type": "string", }, }, "required": [ - "cancelled", - "skipped", - "results", + "id", + "previousStatus", + "status", + "outcome", ], "type": "object", }, @@ -8894,52 +9765,82 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, - "summary": "Cancel multiple requests", + "summary": "Cancel a request (marks as done/failed and signals worker to exit)", "tags": [ "Requests", ], }, }, - "/api/v1/requests/bulk-pause": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "ids": { - "items": { - "type": "string", - }, - "maxItems": 100, - "minItems": 1, - "type": "array", - }, - }, - "required": [ - "ids", - ], - "type": "object", - }, + "/api/v1/requests/{id}/har": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "HAR-format JSON file", + }, + "404": { + "description": "Not found", + }, + }, + "summary": "Download HAR file", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{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": "Not found", + }, }, + "summary": "Stream request logs (SSE)", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{id}/pause": { + "post": { "responses": { "201": { "content": { "application/json": { "schema": { "properties": { - "skipped": { - "type": "number", + "id": { + "type": "string", }, - "updated": { - "type": "number", + "status": { + "type": "string", }, }, "required": [ - "updated", - "skipped", + "id", + "status", ], "type": "object", }, @@ -8948,33 +9849,24 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, - "summary": "Pause multiple requests", + "summary": "Pause a single request", "tags": [ "Requests", ], }, }, - "/api/v1/requests/bulk-priority": { + "/api/v1/requests/{id}/priority": { "post": { "requestBody": { "content": { "application/json": { "schema": { "properties": { - "ids": { - "items": { - "type": "string", - }, - "maxItems": 100, - "minItems": 1, - "type": "array", - }, "priority": { "type": "integer", }, }, "required": [ - "ids", "priority", ], "type": "object", @@ -8988,16 +9880,16 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "properties": { - "skipped": { - "type": "number", + "id": { + "type": "string", }, - "updated": { + "priority": { "type": "number", }, }, "required": [ - "updated", - "skipped", + "id", + "priority", ], "type": "object", }, @@ -9006,30 +9898,31 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, - "summary": "Set priority on multiple requests", + "summary": "Set priority on a single request", "tags": [ "Requests", ], }, }, - "/api/v1/requests/bulk-resubmit": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkResubmitInput", - }, + "/api/v1/requests/{id}/reports": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", }, }, - }, + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/RequestResponse", + "$ref": "#/components/schemas/ReportResponse", }, "type": "array", }, @@ -9037,53 +9930,34 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "description": "Success", }, + "404": { + "description": "Run not found", + }, }, - "summary": "Bulk resubmit requests", + "summary": "Get reports for request", "tags": [ - "Requests", + "Reports", ], }, }, - "/api/v1/requests/bulk-resume": { + "/api/v1/requests/{id}/resume": { "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "ids": { - "items": { - "type": "string", - }, - "maxItems": 100, - "minItems": 1, - "type": "array", - }, - }, - "required": [ - "ids", - ], - "type": "object", - }, - }, - }, - }, "responses": { "201": { "content": { "application/json": { "schema": { "properties": { - "skipped": { - "type": "number", + "id": { + "type": "string", }, - "updated": { - "type": "number", + "status": { + "type": "string", }, }, "required": [ - "updated", - "skipped", + "id", + "status", ], "type": "object", }, @@ -9092,14 +9966,24 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, - "summary": "Resume multiple paused requests", + "summary": "Resume a paused request", "tags": [ "Requests", ], }, }, - "/api/v1/requests/bulk-retry": { + "/api/v1/requests/{id}/retry": { "post": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], "requestBody": { "content": { "application/json": { @@ -9108,17 +9992,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "force": { "type": "boolean", }, - "ids": { - "items": { - "type": "string", - }, - "minItems": 1, - "type": "array", - }, }, - "required": [ - "ids", - ], "type": "object", }, }, @@ -9130,40 +10004,20 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "properties": { - "results": { - "items": { - "properties": { - "attemptNumber": { - "type": "integer", - }, - "error": { - "type": "string", - }, - "requestId": { - "type": "string", - }, - "runId": { - "type": "string", - }, - }, - "required": [ - "requestId", - ], - "type": "object", - }, - "type": "array", - }, - "retried": { + "attemptNumber": { "type": "integer", }, - "skipped": { - "type": "integer", + "requestId": { + "type": "string", + }, + "runId": { + "type": "string", }, }, "required": [ - "retried", - "skipped", - "results", + "requestId", + "runId", + "attemptNumber", ], "type": "object", }, @@ -9171,26 +10025,30 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "description": "Success", }, + "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", + }, }, - "summary": "Bulk retry requests (start new attempts)", + "summary": "Retry a request (start a new attempt)", "tags": [ "Requests", ], }, }, - "/api/v1/requests/facets": { + "/api/v1/requests/{id}/runs": { "get": { - "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": [ { - "description": "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", + "in": "path", + "name": "id", "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", }, }, @@ -9200,21 +10058,27 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RunFacetsResponse", + "items": { + "$ref": "#/components/schemas/RunState", + }, + "type": "array", }, }, }, "description": "Success", }, + "404": { + "description": "Request not found", + }, }, - "summary": "List run filter facets", + "summary": "List attempts for a request", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}": { - "delete": { + "/api/v1/requests/{id}/runs/{runId}": { + "get": { "parameters": [ { "in": "path", @@ -9224,35 +10088,71 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "type": "string", }, }, + { + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, ], "responses": { "200": { "content": { "application/json": { "schema": { - "properties": { - "message": { - "type": "string", - }, - }, - "required": [ - "message", - ], - "type": "object", + "$ref": "#/components/schemas/RunState", }, }, }, "description": "Success", }, + "404": { + "description": "Request or run not found", + }, + }, + "summary": "Get a single attempt", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{id}/runs/{runId}/archive": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "Gzipped run archive", + }, "404": { "description": "Not found", }, }, - "summary": "Soft-delete request", + "summary": "Download full run archive for a specific attempt", "tags": [ "Requests", ], }, + }, + "/api/v1/requests/{id}/runs/{runId}/atif": { "get": { "parameters": [ { @@ -9263,29 +10163,77 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "type": "string", }, }, + { + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "The iteration number (1-based)", + "in": "query", + "name": "iteration", + "required": true, + "schema": { + "description": "The iteration number (1-based)", + "type": "string", + }, + }, ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestResponse", - }, - }, + "description": "ATIF v1.7 trajectory JSON file", + }, + "400": { + "description": "Missing or invalid iteration", + }, + "404": { + "description": "Not found", + }, + }, + "summary": "Download ATIF trajectory file for a specific attempt and iteration", + "tags": [ + "Requests", + ], + }, + }, + "/api/v1/requests/{id}/runs/{runId}/har": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", }, - "description": "Success", + }, + { + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "HAR-format JSON file", }, "404": { "description": "Not found", }, }, - "summary": "Get request", + "summary": "Download HAR file for a specific attempt", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/archive": { + "/api/v1/requests/{id}/runs/{runId}/logs": { "get": { "parameters": [ { @@ -9296,22 +10244,30 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "type": "string", }, }, + { + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, ], "responses": { "200": { - "description": "Gzipped run archive", + "description": "Server-sent event stream of log entries", }, "404": { "description": "Not found", }, }, - "summary": "Download full run archive", + "summary": "Stream logs for a specific attempt (SSE)", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/atif": { + "/api/v1/requests/{id}/runs/{runId}/snapshots/{iteration}": { "get": { "parameters": [ { @@ -9323,79 +10279,84 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "description": "The iteration number (1-based)", - "in": "query", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", "name": "iteration", "required": true, "schema": { - "description": "The iteration number (1-based)", "type": "string", }, }, ], "responses": { "200": { - "description": "ATIF v1.7 trajectory JSON file", - }, - "400": { - "description": "Missing or invalid iteration", + "description": "Gzipped snapshot archive", }, "404": { "description": "Not found", }, }, - "summary": "Download ATIF trajectory file for a specific iteration", + "summary": "Download iteration snapshot for a specific attempt", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/cancel": { - "post": { - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "id": { - "type": "string", - }, - "outcome": { - "type": "string", - }, - "previousStatus": { - "type": "string", - }, - "status": { - "type": "string", - }, - }, - "required": [ - "id", - "previousStatus", - "status", - "outcome", - ], - "type": "object", - }, - }, + "/api/v1/requests/{id}/runs/{runId}/tool-calls": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", }, - "description": "Success", + }, + { + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "JSONL stream — one ToolCall per line", + }, + "404": { + "description": "Not found", }, }, - "summary": "Cancel a request (marks as done/failed and signals worker to exit)", + "summary": "Download per-iteration tool-calls JSONL for a specific attempt", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/har": { + "/api/v1/requests/{id}/runs/{runId}/video": { "get": { "parameters": [ { "in": "path", - "name": "id", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", + "name": "runId", "required": true, "schema": { "type": "string", @@ -9404,19 +10365,19 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "responses": { "200": { - "description": "HAR-format JSON file", + "description": "WebM video recording (supports Range requests)", }, "404": { "description": "Not found", }, }, - "summary": "Download HAR file", + "summary": "Download session recording for a specific attempt", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/logs": { + "/api/v1/requests/{id}/snapshots/{iteration}": { "get": { "parameters": [ { @@ -9427,103 +10388,30 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "type": "string", }, }, + { + "in": "path", + "name": "iteration", + "required": true, + "schema": { + "type": "string", + }, + }, ], "responses": { "200": { - "description": "Server-sent event stream of log entries", + "description": "Gzipped snapshot archive", }, "404": { "description": "Not found", }, }, - "summary": "Stream request logs (SSE)", - "tags": [ - "Requests", - ], - }, - }, - "/api/v1/requests/{id}/pause": { - "post": { - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "id": { - "type": "string", - }, - "status": { - "type": "string", - }, - }, - "required": [ - "id", - "status", - ], - "type": "object", - }, - }, - }, - "description": "Success", - }, - }, - "summary": "Pause a single request", - "tags": [ - "Requests", - ], - }, - }, - "/api/v1/requests/{id}/priority": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "priority": { - "type": "integer", - }, - }, - "required": [ - "priority", - ], - "type": "object", - }, - }, - }, - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "id": { - "type": "string", - }, - "priority": { - "type": "number", - }, - }, - "required": [ - "id", - "priority", - ], - "type": "object", - }, - }, - }, - "description": "Success", - }, - }, - "summary": "Set priority on a single request", + "summary": "Download iteration snapshot", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/reports": { + "/api/v1/requests/{id}/tool-calls": { "get": { "parameters": [ { @@ -9537,62 +10425,20 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/ReportResponse", - }, - "type": "array", - }, - }, - }, - "description": "Success", + "description": "JSONL stream — one ToolCall per line", }, "404": { - "description": "Run not found", - }, - }, - "summary": "Get reports for request", - "tags": [ - "Reports", - ], - }, - }, - "/api/v1/requests/{id}/resume": { - "post": { - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "id": { - "type": "string", - }, - "status": { - "type": "string", - }, - }, - "required": [ - "id", - "status", - ], - "type": "object", - }, - }, - }, - "description": "Success", + "description": "Not found", }, }, - "summary": "Resume a paused request", + "summary": "Download per-iteration tool-calls JSONL", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/retry": { - "post": { + "/api/v1/requests/{id}/video": { + "get": { "parameters": [ { "in": "path", @@ -9603,71 +10449,32 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "force": { - "type": "boolean", - }, - }, - "type": "object", - }, - }, - }, - }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "attemptNumber": { - "type": "integer", - }, - "requestId": { - "type": "string", - }, - "runId": { - "type": "string", - }, - }, - "required": [ - "requestId", - "runId", - "attemptNumber", - ], - "type": "object", - }, - }, - }, - "description": "Success", + "200": { + "description": "WebM video recording (supports Range requests)", }, "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", + "description": "Not found", }, }, - "summary": "Retry a request (start a new attempt)", + "summary": "Download session recording", "tags": [ "Requests", ], }, }, - "/api/v1/requests/{id}/runs": { + "/api/v1/resources": { "get": { "parameters": [ { - "in": "path", - "name": "id", + "description": "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", }, }, @@ -9678,7 +10485,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/RunState", + "$ref": "#/components/schemas/ResourceResponse", }, "type": "array", }, @@ -9686,58 +10493,54 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "description": "Success", }, - "404": { - "description": "Request not found", - }, }, - "summary": "List attempts for a request", + "summary": "List all resources", "tags": [ - "Requests", + "Resources", ], }, - }, - "/api/v1/requests/{id}/runs/{runId}": { - "get": { + "post": { "parameters": [ { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - }, - }, - { - "in": "path", - "name": "runId", + "description": "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", }, }, ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RunState", - }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResourceInput", }, }, + }, + }, + "responses": { + "201": { "description": "Success", }, - "404": { - "description": "Request or run not found", + "400": { + "description": "Invalid input", + }, + "409": { + "description": "Resource slug or revision ref already exists in this project", }, }, - "summary": "Get a single attempt", + "summary": "Create a resource and its first revision", "tags": [ - "Requests", + "Resources", ], }, }, - "/api/v1/requests/{id}/runs/{runId}/archive": { + "/api/v1/resources/revisions/{id}": { "get": { "parameters": [ { @@ -9749,30 +10552,41 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "in": "path", - "name": "runId", + "description": "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", }, }, ], "responses": { "200": { - "description": "Gzipped run archive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + }, + }, + "description": "Success", }, "404": { - "description": "Not found", + "description": "Revision not found", }, }, - "summary": "Download full run archive for a specific attempt", + "summary": "Get resource revision by id", "tags": [ - "Requests", + "Resource Revisions", ], }, }, - "/api/v1/requests/{id}/runs/{runId}/atif": { - "get": { + "/api/v1/resources/{id}": { + "delete": { "parameters": [ { "in": "path", @@ -9783,42 +10597,31 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "in": "path", - "name": "runId", - "required": true, - "schema": { - "type": "string", - }, - }, - { - "description": "The iteration number (1-based)", + "description": "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": "iteration", + "name": "projectId", "required": true, "schema": { - "description": "The iteration number (1-based)", + "description": "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", }, }, ], "responses": { - "200": { - "description": "ATIF v1.7 trajectory JSON file", - }, - "400": { - "description": "Missing or invalid iteration", + "204": { + "description": "Success", }, "404": { - "description": "Not found", + "description": "Resource not found", }, }, - "summary": "Download ATIF trajectory file for a specific attempt and iteration", + "summary": "Delete a resource", "tags": [ - "Requests", + "Resources", ], }, - }, - "/api/v1/requests/{id}/runs/{runId}/har": { "get": { "parameters": [ { @@ -9830,30 +10633,39 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "in": "path", - "name": "runId", + "description": "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", }, }, ], "responses": { "200": { - "description": "HAR-format JSON file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse", + }, + }, + }, + "description": "Success", }, "404": { - "description": "Not found", + "description": "Resource not found", }, }, - "summary": "Download HAR file for a specific attempt", + "summary": "Get a resource", "tags": [ - "Requests", + "Resources", ], }, - }, - "/api/v1/requests/{id}/runs/{runId}/logs": { - "get": { + "patch": { "parameters": [ { "in": "path", @@ -9864,29 +10676,49 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "in": "path", - "name": "runId", + "description": "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", }, }, ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateResourceInput", + }, + }, + }, + }, "responses": { "200": { - "description": "Server-sent event stream of log entries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse", + }, + }, + }, + "description": "Success", }, "404": { - "description": "Not found", + "description": "Resource not found", }, }, - "summary": "Stream logs for a specific attempt (SSE)", + "summary": "Update a resource", "tags": [ - "Requests", + "Resources", ], }, }, - "/api/v1/requests/{id}/runs/{runId}/snapshots/{iteration}": { + "/api/v1/resources/{id}/revisions": { "get": { "parameters": [ { @@ -9898,17 +10730,21 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "in": "path", - "name": "runId", + "description": "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": "path", - "name": "iteration", - "required": true, + "in": "query", + "name": "limit", + "required": false, "schema": { "type": "string", }, @@ -9916,20 +10752,28 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "responses": { "200": { - "description": "Gzipped snapshot archive", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", }, "404": { - "description": "Not found", + "description": "Resource not found", }, }, - "summary": "Download iteration snapshot for a specific attempt", + "summary": "List resource revisions", "tags": [ - "Requests", + "Resource Revisions", ], }, - }, - "/api/v1/requests/{id}/runs/{runId}/tool-calls": { - "get": { + "post": { "parameters": [ { "in": "path", @@ -9940,29 +10784,45 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "in": "path", - "name": "runId", + "description": "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", }, }, ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResourceRevisionInput", + }, + }, + }, + }, "responses": { - "200": { - "description": "JSONL stream — one ToolCall per line", + "201": { + "description": "Success", }, "404": { - "description": "Not found", + "description": "Resource not found", + }, + "409": { + "description": "Resource revision ref already exists in this project", }, }, - "summary": "Download per-iteration tool-calls JSONL for a specific attempt", + "summary": "Create a resource revision", "tags": [ - "Requests", + "Resource Revisions", ], }, }, - "/api/v1/requests/{id}/runs/{runId}/video": { + "/api/v1/resources/{id}/revisions/latest": { "get": { "parameters": [ { @@ -9974,29 +10834,40 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, { - "in": "path", - "name": "runId", + "description": "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", }, }, ], "responses": { "200": { - "description": "WebM video recording (supports Range requests)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + }, + }, + "description": "Success", }, "404": { - "description": "Not found", + "description": "Revision not found", }, }, - "summary": "Download session recording for a specific attempt", + "summary": "Get the latest resource revision", "tags": [ - "Requests", + "Resource Revisions", ], }, }, - "/api/v1/requests/{id}/snapshots/{iteration}": { + "/api/v1/resources/{id}/revisions/{revisionNumber}": { "get": { "parameters": [ { @@ -10009,76 +10880,43 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, { "in": "path", - "name": "iteration", + "name": "revisionNumber", "required": true, "schema": { "type": "string", }, }, - ], - "responses": { - "200": { - "description": "Gzipped snapshot archive", - }, - "404": { - "description": "Not found", - }, - }, - "summary": "Download iteration snapshot", - "tags": [ - "Requests", - ], - }, - }, - "/api/v1/requests/{id}/tool-calls": { - "get": { - "parameters": [ { - "in": "path", - "name": "id", + "description": "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", }, }, ], "responses": { "200": { - "description": "JSONL stream — one ToolCall per line", - }, - "404": { - "description": "Not found", - }, - }, - "summary": "Download per-iteration tool-calls JSONL", - "tags": [ - "Requests", - ], - }, - }, - "/api/v1/requests/{id}/video": { - "get": { - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + }, }, - }, - ], - "responses": { - "200": { - "description": "WebM video recording (supports Range requests)", + "description": "Success", }, "404": { - "description": "Not found", + "description": "Revision not found", }, }, - "summary": "Download session recording", + "summary": "Get a resource revision by resource and revision number", "tags": [ - "Requests", + "Resource Revisions", ], }, }, diff --git a/apps/api/src/endpoints.test.ts b/apps/api/src/endpoints.test.ts index 545373638..9552f0696 100644 --- a/apps/api/src/endpoints.test.ts +++ b/apps/api/src/endpoints.test.ts @@ -1577,6 +1577,37 @@ describe("API Endpoints", () => { // =================================================================== describe("POST /api/v1/requests?worker=... (profile)", () => { + const resourceRevision = { + _id: "rev-github-simulator-1", + resourceId: "res-github-simulator", + projectId: TEST_PROJECT_ID, + slug: "github-simulator", + revisionNumber: 1, + ref: "github-simulator@r1", + setup: { sh: "echo SIMULATOR_URL=http://sim >> $SCOPE_SETUP_ENV" }, + exports: ["SIMULATOR_URL"], + parameters: [ + { name: "REPO", required: true }, + { name: "AS", required: false }, + ], + contentSha256: "sha", + createdAt: new Date(), + }; + + function mockGithubSimulatorResource() { + (mocks.resourceStore.getBySlug as any).mockResolvedValue({ + _id: "res-github-simulator", + projectId: TEST_PROJECT_ID, + slug: "github-simulator", + name: "GitHub Simulator", + revisionCounter: 1, + latestRevisionId: resourceRevision._id, + latestRevisionNumber: 1, + createdAt: new Date(), + }); + (mocks.resourceRevisionStore.getLatest as any).mockResolvedValue(resourceRevision); + } + it("applies profile fields server-side, ignoring client-omitted fields", async () => { (mocks.profileCollection.findOne as any).mockResolvedValue({ _id: "profile-1", @@ -1708,6 +1739,111 @@ describe("API Endpoints", () => { const filter = findSpy.mock.calls[0][0]; expect(filter).toHaveProperty("projectId", TEST_PROJECT_ID); }); + + it("returns 400 when a run resource param uses an unknown key", async () => { + mockGithubSimulatorResource(); + + const res = await request(testServer()) + .post(`/api/v1/requests?worker=coder-acp-copilot&projectId=${TEST_PROJECT_ID}`) + .send({ + scenario: { task: "Build something", criteria: ["works"] }, + model: "gpt-4o", + resources: [{ ref: "github-simulator", params: { REPO: "octo/api", REPOS: "typo/value" } }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('unknown parameter "REPOS"'); + expect(mocks.collection.insertOne).not.toHaveBeenCalled(); + }); + + it("returns 400 when a required resource param is missing", async () => { + mockGithubSimulatorResource(); + + const res = await request(testServer()) + .post(`/api/v1/requests?worker=coder-acp-copilot&projectId=${TEST_PROJECT_ID}`) + .send({ + scenario: { task: "Build something", criteria: ["works"] }, + model: "gpt-4o", + resources: ["github-simulator"], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('missing required parameter "REPO"'); + expect(mocks.collection.insertOne).not.toHaveBeenCalled(); + }); + + it("returns 400 with the parameter when a run overrides a profile-pinned resource param", async () => { + mockGithubSimulatorResource(); + (mocks.profileCollection.findOne as any).mockResolvedValue({ + _id: "profile-1", + name: "My Profile", + latestVersion: 1, + }); + (mocks.profileVersionCollection.findOne as any).mockResolvedValue({ + _id: "pv-1", + profileId: "profile-1", + version: 1, + workerType: "coder-acp-copilot", + model: "gpt-4o", + mcpServers: [], + skillRevisions: [], + resources: [{ ref: "github-simulator", params: { REPO: "pinned/repo" } }], + extensions: [], + }); + + const res = await request(testServer()) + .post(`/api/v1/requests?worker=coder-acp-copilot&projectId=${TEST_PROJECT_ID}`) + .send({ + scenario: { task: "Build something", criteria: ["works"] }, + profileId: "profile-1", + resources: [{ ref: "github-simulator", params: { REPO: "run/repo" } }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("controls these fields"); + expect(res.body.conflicts).toEqual( + expect.arrayContaining([expect.stringContaining("resources.github-simulator@r1.REPO")]) + ); + expect(mocks.collection.insertOne).not.toHaveBeenCalled(); + }); + + it("accepts a run filling a profile-unset resource param", async () => { + mockGithubSimulatorResource(); + (mocks.profileCollection.findOne as any).mockResolvedValue({ + _id: "profile-1", + name: "My Profile", + latestVersion: 1, + }); + (mocks.profileVersionCollection.findOne as any).mockResolvedValue({ + _id: "pv-1", + profileId: "profile-1", + version: 1, + workerType: "coder-acp-copilot", + model: "gpt-4o", + mcpServers: [], + skillRevisions: [], + resources: [{ ref: "github-simulator", params: { AS: "pinned/ns" } }], + extensions: [], + }); + + const res = await request(testServer()) + .post(`/api/v1/requests?worker=coder-acp-copilot&projectId=${TEST_PROJECT_ID}`) + .send({ + scenario: { task: "Build something", criteria: ["works"] }, + profileId: "profile-1", + resources: [{ ref: "github-simulator", params: { REPO: "run/repo" } }], + }); + + expect(res.status).toBe(201); + const doc = (mocks.collection.insertOne as any).mock.calls[0][0]; + expect(doc.resources).toEqual([ + { + ref: "github-simulator@r1", + revisionId: "rev-github-simulator-1", + params: { REPO: "run/repo", AS: "pinned/ns" }, + }, + ]); + }); }); describe("POST /api/v1/requests?worker=... (reasoning effort)", () => { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9017fba42..f35f6d0de 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,9 +9,9 @@ import { DefaultAzureCredential } from "@azure/identity"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; -import { TaskPromptStore, SkillRevisionStore, SkillResolver, CodebaseStore, CodebaseRevisionStore, CodebaseResolver, McpSecretClient, McpSecretUnavailableError, BlobStorage, RedisHeartbeatStore, ProjectStore, loadAuthConfigFromEnv } from "shared"; +import { TaskPromptStore, SkillRevisionStore, SkillResolver, CodebaseStore, CodebaseRevisionStore, CodebaseResolver, ResourceStore, ResourceRevisionStore, ResourceResolver, McpSecretClient, McpSecretUnavailableError, BlobStorage, RedisHeartbeatStore, ProjectStore, loadAuthConfigFromEnv } from "shared"; import { initTelemetry } from "telemetry"; -import type { TaskPromptDocument, SkillDocument, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, ProfileDocument, ProfileVersionDocument, ProjectDocument, HeartbeatStore, AuthProvider, ProfileEnricher, UserDocument } from "shared"; +import type { TaskPromptDocument, SkillDocument, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, ResourceDocument, ResourceRevisionDocument, ProfileDocument, ProfileVersionDocument, ProjectDocument, HeartbeatStore, AuthProvider, ProfileEnricher, UserDocument } from "shared"; import { UserStore } from "./auth/user-store.js"; import { createAuthMiddleware, createUserAccessMiddleware } from "./auth/middleware.js"; import { authErrorHandler } from "./auth/error-handler.js"; @@ -41,6 +41,7 @@ import { registerModelsRoutes } from "./routes/models.js"; import { registerMcpServersRoutes } from "./routes/mcp-servers.js"; import { registerSkillsRoutes } from "./routes/skills.js"; import { registerCodebasesRoutes } from "./routes/codebases.js"; +import { registerResourcesRoutes } from "./routes/resources.js"; import { registerExtensionsRoutes } from "./routes/extensions.js"; import { registerInsightsRoutes } from "./routes/insights.js"; import { registerSecretsRoutes } from "./routes/secrets.js"; @@ -126,6 +127,11 @@ let codebaseRevisionCollection: Collection; let codebaseStore: CodebaseStore; let codebaseRevisionStore: CodebaseRevisionStore; let codebaseResolver: CodebaseResolver; +let resourceCollection: Collection; +let resourceRevisionCollection: Collection; +let resourceStore: ResourceStore; +let resourceRevisionStore: ResourceRevisionStore; +let resourceResolver: ResourceResolver; let blobStorage: BlobStorage; let heartbeatStore: HeartbeatStore; let reportQueueClient: QueueClient; @@ -198,6 +204,12 @@ async function initializeClients(): Promise { tokenProvider: acquireGitHubPublicApiToken, }); + resourceCollection = db.collection("resources"); + resourceRevisionCollection = db.collection("resource-revisions"); + resourceStore = new ResourceStore(resourceCollection); + resourceRevisionStore = new ResourceRevisionStore(resourceRevisionCollection, resourceStore); + resourceResolver = new ResourceResolver(); + // Note: Collection indexes are managed by db-migrations (see 002-create-indexes.ts). // Run `pnpm migrate:up` to apply pending migrations. @@ -294,6 +306,11 @@ const routeCtx: RouteContext = { get codebaseStore() { return codebaseStore; }, get codebaseRevisionStore() { return codebaseRevisionStore; }, get codebaseResolver() { return codebaseResolver; }, + get resourceCollection() { return resourceCollection; }, + get resourceRevisionCollection() { return resourceRevisionCollection; }, + get resourceStore() { return resourceStore; }, + get resourceRevisionStore() { return resourceRevisionStore; }, + get resourceResolver() { return resourceResolver; }, get projectStore() { return projectStore; }, get reportQueueClient() { return reportQueueClient; }, get blobStorage() { return blobStorage; }, @@ -340,6 +357,7 @@ registerModelsRoutes(routeCtx); registerMcpServersRoutes(routeCtx); registerSkillsRoutes(routeCtx); registerCodebasesRoutes(routeCtx); +registerResourcesRoutes(routeCtx); registerExtensionsRoutes(routeCtx); registerInsightsRoutes(routeCtx); registerFeatureFlagRoutes(routeCtx); @@ -431,6 +449,11 @@ export interface TestDependencies { codebaseStore?: CodebaseStore; codebaseRevisionStore?: CodebaseRevisionStore; codebaseResolver?: CodebaseResolver; + resourceCollection?: Collection; + resourceRevisionCollection?: Collection; + resourceStore?: ResourceStore; + resourceRevisionStore?: ResourceRevisionStore; + resourceResolver?: ResourceResolver; reportQueueClient?: QueueClient; blobStorage?: BlobStorage; } @@ -467,6 +490,11 @@ export function _injectTestDependencies(deps: TestDependencies): void { if (deps.codebaseStore) codebaseStore = deps.codebaseStore; if (deps.codebaseRevisionStore) codebaseRevisionStore = deps.codebaseRevisionStore; if (deps.codebaseResolver) codebaseResolver = deps.codebaseResolver; + if (deps.resourceCollection) resourceCollection = deps.resourceCollection; + if (deps.resourceRevisionCollection) resourceRevisionCollection = deps.resourceRevisionCollection; + if (deps.resourceStore) resourceStore = deps.resourceStore; + if (deps.resourceRevisionStore) resourceRevisionStore = deps.resourceRevisionStore; + if (deps.resourceResolver) resourceResolver = deps.resourceResolver; if (deps.reportQueueClient) reportQueueClient = deps.reportQueueClient; if (deps.blobStorage) blobStorage = deps.blobStorage; } diff --git a/apps/api/src/route-context.ts b/apps/api/src/route-context.ts index 6a337a782..580eae160 100644 --- a/apps/api/src/route-context.ts +++ b/apps/api/src/route-context.ts @@ -20,8 +20,13 @@ import type { CodebaseStore, CodebaseRevisionStore, CodebaseResolver, + ResourceStore, + ResourceRevisionStore, + ResourceResolver, CodebaseDocument, CodebaseRevisionDocument, + ResourceDocument, + ResourceRevisionDocument, McpSecretClient, ProfileDocument, ProfileVersionDocument, @@ -106,6 +111,8 @@ export interface RouteContext { usersCollection: Collection; codebaseCollection: Collection; codebaseRevisionCollection: Collection; + resourceCollection: Collection; + resourceRevisionCollection: Collection; // Services taskPromptStore: TaskPromptStore; @@ -114,6 +121,9 @@ export interface RouteContext { codebaseStore: CodebaseStore; codebaseRevisionStore: CodebaseRevisionStore; codebaseResolver: CodebaseResolver; + resourceStore: ResourceStore; + resourceRevisionStore: ResourceRevisionStore; + resourceResolver: ResourceResolver; projectStore: ProjectStore; // Token Manager client (null when TOKEN_MANAGER_URL not set) diff --git a/apps/api/src/routes/profiles.ts b/apps/api/src/routes/profiles.ts index c7c6df5d2..b61c0b808 100644 --- a/apps/api/src/routes/profiles.ts +++ b/apps/api/src/routes/profiles.ts @@ -12,7 +12,7 @@ import { ExtensionClient, parseExtensionSpec, } from "shared"; -import type { ProfileDocument, ProfileVersionDocument } from "shared"; +import type { ProfileDocument, ProfileVersionDocument, ResourceBindingSpec } from "shared"; import { apiRoute } from "../openapi/api-route.js"; import type { RouteContext } from "../route-context.js"; import { resolveSkillSpecs } from "../utils/skill-helpers.js"; @@ -42,6 +42,29 @@ const versionResponse = (v: T): T => ({ _id: v.ref ?? v._id, }); +function normalizeResourceBindingSpecs(input: unknown): ResourceBindingSpec[] | undefined { + if (!Array.isArray(input) || input.length === 0) return undefined; + const specs = input + .map((item) => { + if (typeof item === "string") { + const ref = item.trim(); + return ref ? { ref } : undefined; + } + if (item && typeof item === "object" && typeof (item as { ref?: unknown }).ref === "string") { + const ref = (item as { ref: string }).ref.trim(); + if (!ref) return undefined; + const params = (item as { params?: unknown }).params; + return { + ref, + ...(params && typeof params === "object" ? { params: params as Record } : {}), + }; + } + return undefined; + }) + .filter((spec): spec is ResourceBindingSpec => spec !== undefined); + return specs.length > 0 ? specs : undefined; +} + // POST /api/v1/profiles — create a new profile (+ version 1) apiRoute(ctx.app, ctx.registry, { method: "post", @@ -53,8 +76,9 @@ apiRoute(ctx.app, ctx.registry, { response: ProfileWithVersionResponseSchema, handler: async (req, res, next) => { try { - const { name, description, workerType, model, reasoningEffort, agentVersion, mcpServers, skillRevisions, extensions } = req.body; + const { name, description, workerType, model, reasoningEffort, agentVersion, mcpServers, skillRevisions, resources, extensions } = req.body; const projectId = getQueryProjectId(req); + const resourceBindings = normalizeResourceBindingSpecs(resources); // A profile must be self-sufficient to submit a run, which requires a // model. Agents that don't declare any supportedModels can't satisfy @@ -136,6 +160,7 @@ apiRoute(ctx.app, ctx.registry, { agentVersion: agentCheck.agentVersion, ...(mcpServers && mcpServers.length > 0 ? { mcpServers } : {}), ...(resolvedSkillRevisions && resolvedSkillRevisions.length > 0 ? { skillRevisions: resolvedSkillRevisions } : {}), + ...(resourceBindings && resourceBindings.length > 0 ? { resources: resourceBindings } : {}), ...(resolvedExtensions && resolvedExtensions.length > 0 ? { extensions: resolvedExtensions } : {}), createdAt: now, }; @@ -287,7 +312,8 @@ apiRoute(ctx.app, ctx.registry, { return; } - const { workerType, model, reasoningEffort, agentVersion, mcpServers, skillRevisions, extensions } = req.body; + const { workerType, model, reasoningEffort, agentVersion, mcpServers, skillRevisions, resources, extensions } = req.body; + const resourceBindings = normalizeResourceBindingSpecs(resources); // Same self-sufficiency rule as POST /profiles: a profile (and any new // version) must carry a model, so reject agents that don't expose any. @@ -359,6 +385,7 @@ apiRoute(ctx.app, ctx.registry, { agentVersion: agentCheck.agentVersion, ...(mcpServers && mcpServers.length > 0 ? { mcpServers } : {}), ...(resolvedSkillRevisions && resolvedSkillRevisions.length > 0 ? { skillRevisions: resolvedSkillRevisions } : {}), + ...(resourceBindings && resourceBindings.length > 0 ? { resources: resourceBindings } : {}), ...(resolvedExtensions && resolvedExtensions.length > 0 ? { extensions: resolvedExtensions } : {}), createdAt: now, }; diff --git a/apps/api/src/routes/requests/index.ts b/apps/api/src/routes/requests/index.ts index 71400eada..0d719ffbb 100644 --- a/apps/api/src/routes/requests/index.ts +++ b/apps/api/src/routes/requests/index.ts @@ -32,12 +32,21 @@ import { runDurationMs, parseExtensionSpec, parseProfileSpec, + resolveResourceParams, validateGateConfigs, isCriterionCompatibleWithGate, orderGates, computeTaskPromptId, } from "shared"; -import type { ProfileDocument, ProfileVersionDocument, GateConfig, GateId } from "shared"; +import type { + ProfileDocument, + ProfileVersionDocument, + GateConfig, + GateId, + ResourceBinding, + ResourceBindingSpec, + ResourceRevisionDocument, +} from "shared"; import { apiRoute } from "../../openapi/api-route.js"; import type { ExtensionDocument, @@ -171,6 +180,40 @@ export function _resetRunFacetsCacheForTests(): void { runFacetsCache.clear(); } +/** + * Decide where a resubmit's resources come from. + * + * A resubmit must reproduce the original environment, so pinned bindings are + * preserved by default — re-resolving would let a run pinned to `simulator@r1` + * with `REPO=run/repo` come back as `simulator@r2` with the revision's default, + * fail 422 if that parameter is required, or lose its resources entirely if the + * profile declares none. + * + * Only an explicitly supplied replacement profile re-resolves. This is keyed off + * `overrideProfileId` rather than off the resolved profile version, because the + * latter is also populated when the caller simply keeps the original profile. + * + * @param overrideProfileId - `undefined` keeps the original profile, `null` + * detaches it, and a string selects a replacement. + */ +export function planResubmitResources( + overrideProfileId: string | null | undefined, + originalResources: ResourceBinding[] | undefined, + profileResourceSpecs: ResourceBindingSpec[] | undefined, +): + | { kind: "preserve"; bindings: ResourceBinding[] | null } + | { kind: "resolve"; specs: ResourceBindingSpec[] } { + if (typeof overrideProfileId === "string") { + return profileResourceSpecs && profileResourceSpecs.length > 0 + ? { kind: "resolve", specs: profileResourceSpecs } + : { kind: "preserve", bindings: null }; + } + return { + kind: "preserve", + bindings: originalResources && originalResources.length > 0 ? originalResources : null, + }; +} + export function registerRequestsRoutes(ctx: RouteContext): void { const upload = multer({ dest: tmpdir() }); @@ -185,6 +228,7 @@ const validatePersistedRequestTarget = (request: RequestDocument) => mcpServers: request.mcpServers, skillRevisions: request.skillRevisions, extensions: request.extensions, + resources: request.resources, }), strictCapabilities: ctx.strictAgentCapabilities, }); @@ -284,6 +328,120 @@ async function resolveGatePromptText( ); } +type ResourceBindingSpecInput = string | ResourceBindingSpec; + +function normalizeResourceBindingSpec(input: ResourceBindingSpecInput): ResourceBindingSpec { + return typeof input === "string" ? { ref: input } : input; +} + +function normalizeResourceBindingSpecs(input: unknown): ResourceBindingSpec[] | undefined { + if (!Array.isArray(input)) return undefined; + const specs = input + .map((item) => { + if (typeof item === "string") { + const ref = item.trim(); + return ref ? { ref } : undefined; + } + if (item && typeof item === "object" && typeof (item as { ref?: unknown }).ref === "string") { + const ref = (item as { ref: string }).ref.trim(); + if (!ref) return undefined; + const params = (item as { params?: unknown }).params; + return { + ref, + ...(params && typeof params === "object" ? { params: params as Record } : {}), + }; + } + return undefined; + }) + .filter((spec): spec is ResourceBindingSpec => spec !== undefined); + return specs.length > 0 ? specs : undefined; +} + +async function resolveResourceRevision( + ctx: RouteContext, + projectId: string, + spec: string, +): Promise { + const at = spec.lastIndexOf("@r"); + const slug = at > 0 ? spec.slice(0, at) : spec; + const revisionNumber = at > 0 ? Number(spec.slice(at + 2)) : undefined; + + let revision: ResourceRevisionDocument | null = null; + if (revisionNumber !== undefined && Number.isInteger(revisionNumber) && revisionNumber > 0) { + const resource = await ctx.resourceStore.getBySlug(projectId, slug); + revision = resource + ? await ctx.resourceRevisionStore.getByNumber(resource._id, revisionNumber) + : null; + } else { + // A bare spec is a slug or a revision id; try both before failing. + const resource = await ctx.resourceStore.getBySlug(projectId, spec); + revision = resource + ? await ctx.resourceRevisionStore.getLatest(resource._id) + : await ctx.resourceRevisionStore.get(spec); + } + + if (revision && revision.projectId !== projectId) return null; + return revision; +} + +async function resolveResourceBindings( + ctx: RouteContext, + projectId: string, + requestedSpecs: ResourceBindingSpec[] | undefined, + profileSpecs: ResourceBindingSpec[] | undefined, +): Promise<{ bindings?: ResourceBinding[]; conflicts: string[]; errors: string[] }> { + const conflicts: string[] = []; + const errors: string[] = []; + const effectiveSpecs = profileSpecs ?? requestedSpecs; + if (!effectiveSpecs || effectiveSpecs.length === 0) return { conflicts, errors }; + + if (profileSpecs && requestedSpecs && requestedSpecs.length > profileSpecs.length) { + conflicts.push(`resources: sent ${requestedSpecs.length} binding(s), profile requires ${profileSpecs.length}`); + } + + const bindings: ResourceBinding[] = []; + for (let index = 0; index < effectiveSpecs.length; index += 1) { + const profileSpec = profileSpecs?.[index]; + const runSpec = requestedSpecs?.[index]; + const effectiveSpec = normalizeResourceBindingSpec(profileSpec ?? runSpec!); + + const revision = await resolveResourceRevision(ctx, projectId, effectiveSpec.ref); + if (!revision) { + errors.push(`Resource '${effectiveSpec.ref}' not found in this project`); + continue; + } + + let runParams = runSpec?.params; + if (profileSpec && runSpec) { + const normalizedRunSpec = normalizeResourceBindingSpec(runSpec); + const runRevision = await resolveResourceRevision(ctx, projectId, normalizedRunSpec.ref); + if (!runRevision) { + errors.push(`Resource '${normalizedRunSpec.ref}' not found in this project`); + runParams = undefined; + } else if (runRevision._id !== revision._id) { + conflicts.push(`resources[${index}].ref: sent "${normalizedRunSpec.ref}", profile requires "${effectiveSpec.ref}"`); + runParams = undefined; + } + } + + const result = resolveResourceParams({ + parameters: revision.parameters, + profileParams: profileSpec?.params, + runParams, + ref: revision.ref, + }); + conflicts.push(...result.conflicts); + errors.push(...result.errors); + bindings.push({ + ref: revision.ref, + revisionId: revision._id, + params: result.params, + }); + } + + return { bindings, conflicts, errors }; +} + // Submit a request apiRoute(ctx.app, ctx.registry, { method: "post", @@ -301,7 +459,7 @@ apiRoute(ctx.app, ctx.registry, { successStatus: 201, handler: async (req, res) => { const projectId = getQueryProjectId(req); - const { scenario: scenarioObj, persona: personaObj, maxIterations, personaInstructions, count = 1, model: requestedModel, reasoningEffort: requestedReasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, extensions: extensionIds, agentVersion: requestedAgentVersion, profileId: requestedProfileSpec, profileVariations, priority: requestedPriority, agentsMd: requestedAgentsMd, agentsMdParentIds: requestedAgentsMdParentIds, gates: requestedGates, codebase: codebaseSpec, codebaseRevisionId: requestedCodebaseRevisionId } = req.body; + const { scenario: scenarioObj, persona: personaObj, maxIterations, personaInstructions, count = 1, model: requestedModel, reasoningEffort: requestedReasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, extensions: extensionIds, agentVersion: requestedAgentVersion, profileId: requestedProfileSpec, profileVariations, priority: requestedPriority, agentsMd: requestedAgentsMd, agentsMdParentIds: requestedAgentsMdParentIds, gates: requestedGates, codebase: codebaseSpec, codebaseRevisionId: requestedCodebaseRevisionId, resources: resourceSpecs } = req.body; let worker = req.query.worker as string | undefined; // AGENTS.md body + lineage (for any caller that wants to attach an @@ -354,6 +512,8 @@ apiRoute(ctx.app, ctx.registry, { } } + const requestedResourceSpecs = normalizeResourceBindingSpecs(resourceSpecs); + type VariationInput = { profileId: string; profileVersion?: number; @@ -481,6 +641,7 @@ apiRoute(ctx.app, ctx.registry, { agentVersion: string; mcpServers?: string[]; skillRevisions?: string[]; + resources?: ResourceBinding[]; extensions?: string[]; }; @@ -524,6 +685,13 @@ apiRoute(ctx.app, ctx.registry, { mcpServers: effectiveMcpServers, skillRevisions: effectiveSkills, extensions: effectiveExtensions, + // Mirrors resolveResourceBindings' precedence (profile wins). Uses the + // specs rather than resolved bindings because the capability check only + // needs to know whether any resource was requested, and resolution + // happens after this point. + resources: + normalizeResourceBindingSpecs(variationProfileVersion.resources) + ?? requestedResourceSpecs, }), strictCapabilities: ctx.strictAgentCapabilities, }); @@ -601,6 +769,29 @@ apiRoute(ctx.app, ctx.registry, { validatedExtensions = resolvedSpecs; } + const resourceResult = await resolveResourceBindings( + ctx, + projectId, + requestedResourceSpecs, + normalizeResourceBindingSpecs(variationProfileVersion.resources), + ); + if (resourceResult.errors.length > 0) { + res.status(400).json({ + error: resourceResult.errors.join("; "), + errors: resourceResult.errors, + variationProfileId: variationEntry.profileId, + }); + return; + } + if (resourceResult.conflicts.length > 0) { + res.status(400).json({ + error: `Profile "${variationEntry.profileId}" controls these fields. Either omit them or match the profile values.`, + conflicts: resourceResult.conflicts, + variationProfileId: variationEntry.profileId, + }); + return; + } + resolved.push({ entry: variationEntry, profile: variationProfile, @@ -610,6 +801,7 @@ apiRoute(ctx.app, ctx.registry, { agentVersion: targetCheck.agentVersion, mcpServers: validatedMcpServers, skillRevisions: resolvedSkillRevisions, + resources: resourceResult.bindings, extensions: validatedExtensions, }); } @@ -650,6 +842,7 @@ apiRoute(ctx.app, ctx.registry, { ...(r.mcpServers ? { mcpServers: r.mcpServers } : {}), ...(r.skillRevisions ? { skillRevisions: r.skillRevisions } : {}), ...(resolvedCodebaseRevisionId ? { codebaseRevisionId: resolvedCodebaseRevisionId } : {}), + ...(r.resources ? { resources: r.resources } : {}), ...(r.extensions ? { extensions: r.extensions } : {}), agentVersion: r.agentVersion, profileId: r.profile._id, @@ -690,6 +883,7 @@ apiRoute(ctx.app, ctx.registry, { let profileId: string | undefined; let profileVersionId: string | undefined; let profileVersion: ProfileVersionDocument | null = null; + let resolvedResources: ResourceBinding[] | undefined; if (requestedProfileSpec) { let requestedProfileId: string; let requestedProfileVersion: number | undefined; @@ -750,6 +944,18 @@ apiRoute(ctx.app, ctx.registry, { conflicts.push(`extensions: sent ${JSON.stringify(extensionIds)}, profile requires ${JSON.stringify(profileExts)}`); } } + const resourceResult = await resolveResourceBindings( + ctx, + projectId, + requestedResourceSpecs, + normalizeResourceBindingSpecs(profileVersion.resources), + ); + if (resourceResult.errors.length > 0) { + res.status(400).json({ error: resourceResult.errors.join("; "), errors: resourceResult.errors }); + return; + } + conflicts.push(...resourceResult.conflicts); + resolvedResources = resourceResult.bindings; if (conflicts.length > 0) { res.status(400).json({ error: `Profile "${profileId}" controls these fields. Either omit them or match the profile values.`, @@ -760,6 +966,13 @@ apiRoute(ctx.app, ctx.registry, { // Profile fields take precedence worker = profileVersion.workerType; + } else { + const resourceResult = await resolveResourceBindings(ctx, projectId, requestedResourceSpecs, undefined); + if (resourceResult.errors.length > 0) { + res.status(400).json({ error: resourceResult.errors.join("; "), errors: resourceResult.errors }); + return; + } + resolvedResources = resourceResult.bindings; } // Effective values: profile overrides client inputs for controlled fields @@ -838,6 +1051,7 @@ apiRoute(ctx.app, ctx.registry, { mcpServers: effectiveMcpServers, skillRevisions: effectiveSkills, extensions: effectiveExtensions, + resources: resolvedResources, }), strictCapabilities: ctx.strictAgentCapabilities, }); @@ -1048,6 +1262,7 @@ apiRoute(ctx.app, ctx.registry, { ...(validatedMcpServers ? { mcpServers: validatedMcpServers } : {}), ...(resolvedSkillRevisions ? { skillRevisions: resolvedSkillRevisions } : {}), ...(resolvedCodebaseRevisionId ? { codebaseRevisionId: resolvedCodebaseRevisionId } : {}), + ...(resolvedResources ? { resources: resolvedResources } : {}), ...(validatedExtensions ? { extensions: validatedExtensions } : {}), agentVersion: resolvedAgentVersion, ...(profileId ? { profileId } : {}), @@ -1109,6 +1324,7 @@ apiRoute(ctx.app, ctx.registry, { ...(validatedMcpServers ? { mcpServers: validatedMcpServers } : {}), ...(resolvedSkillRevisions ? { skillRevisions: resolvedSkillRevisions } : {}), ...(resolvedCodebaseRevisionId ? { codebaseRevisionId: resolvedCodebaseRevisionId } : {}), + ...(resolvedResources ? { resources: resolvedResources } : {}), ...(validatedExtensions ? { extensions: validatedExtensions } : {}), ...(resolvedAgentVersion ? { agentVersion: resolvedAgentVersion } : {}), ...(profileId ? { profileId } : {}), @@ -1868,6 +2084,30 @@ apiRoute(ctx.app, ctx.registry, { ? (activeProfileVersion.extensions ?? null) : (overrides?.extensions !== undefined ? overrides.extensions : original.extensions); + const resubmitPlan = planResubmitResources( + overrideProfileId, + original.resources, + normalizeResourceBindingSpecs(activeProfileVersion?.resources), + ); + let effectiveResources: ResourceBinding[] | null = null; + if (resubmitPlan.kind === "resolve") { + const resolvedResources = await resolveResourceBindings( + ctx, + original.projectId, + undefined, + resubmitPlan.specs, + ); + if (resolvedResources.errors.length > 0) { + res.status(422).json({ + error: `Resource resolution failed during resubmit: ${resolvedResources.errors.join("; ")}`, + }); + return; + } + effectiveResources = resolvedResources.bindings ?? null; + } else { + effectiveResources = resubmitPlan.bindings; + } + const targetCheck = await validateAgentTarget(ctx.agentCollection, { workerType: effectiveWorkerType, requestedVersion: @@ -1879,6 +2119,7 @@ apiRoute(ctx.app, ctx.registry, { mcpServers: effectiveMcpServers, skillRevisions: effectiveSkillRevisions, extensions: effectiveExtensions, + resources: effectiveResources, }), strictCapabilities: ctx.strictAgentCapabilities, }); @@ -1908,6 +2149,7 @@ apiRoute(ctx.app, ctx.registry, { ...(effectiveMcpServers && effectiveMcpServers.length > 0 ? { mcpServers: effectiveMcpServers } : {}), ...(resolvedSkillRevisions && resolvedSkillRevisions.length > 0 ? { skillRevisions: resolvedSkillRevisions } : {}), ...(effectiveExtensions && effectiveExtensions.length > 0 ? { extensions: effectiveExtensions } : {}), + ...(effectiveResources && effectiveResources.length > 0 ? { resources: effectiveResources } : {}), agentVersion: resolvedAgentVersion, ...(original.taskPromptId ? { taskPromptId: original.taskPromptId } : {}), ...(effectiveProfileId ? { profileId: effectiveProfileId } : {}), diff --git a/apps/api/src/routes/requests/resubmit-resources.test.ts b/apps/api/src/routes/requests/resubmit-resources.test.ts new file mode 100644 index 000000000..d13f6fd05 --- /dev/null +++ b/apps/api/src/routes/requests/resubmit-resources.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { planResubmitResources } from "./index.js"; +import type { ResourceBinding } from "shared"; + +const pinned: ResourceBinding[] = [ + { ref: "simulator@r1", revisionId: "rev-1", params: { REPO: "run/repo" } }, +]; + +describe("planResubmitResources", () => { + it("preserves the original bindings on an ordinary resubmit", () => { + // Regression: keyed off the resolved profile version, an ordinary resubmit of + // a profile-pinned run re-resolved the profile's specs — turning + // simulator@r1 with REPO=run/repo into simulator@r2 with the default. + expect(planResubmitResources(undefined, pinned, [{ ref: "simulator" }])).toEqual({ + kind: "preserve", + bindings: pinned, + }); + }); + + it("preserves the original bindings when the profile is detached", () => { + expect(planResubmitResources(null, pinned, undefined)).toEqual({ + kind: "preserve", + bindings: pinned, + }); + }); + + it("keeps resources the profile does not declare on an ordinary resubmit", () => { + // The original supplied resources the profile knows nothing about; they must + // not be silently dropped. + expect(planResubmitResources(undefined, pinned, undefined)).toEqual({ + kind: "preserve", + bindings: pinned, + }); + }); + + it("resolves the new profile's specs when a replacement profile is selected", () => { + expect(planResubmitResources("profile-2", pinned, [{ ref: "simulator@r2" }])).toEqual({ + kind: "resolve", + specs: [{ ref: "simulator@r2" }], + }); + }); + + it("drops resources when a replacement profile declares none", () => { + // A selected profile controls the field, so declaring no resources means the + // rerun has none — consistent with how it controls mcpServers and skills. + expect(planResubmitResources("profile-2", pinned, undefined)).toEqual({ + kind: "preserve", + bindings: null, + }); + }); + + it("returns null rather than an empty array when nothing was pinned", () => { + expect(planResubmitResources(undefined, undefined, undefined)).toEqual({ + kind: "preserve", + bindings: null, + }); + expect(planResubmitResources(undefined, [], undefined)).toEqual({ + kind: "preserve", + bindings: null, + }); + }); +}); diff --git a/apps/api/src/routes/resources.test.ts b/apps/api/src/routes/resources.test.ts new file mode 100644 index 000000000..f9aa73223 --- /dev/null +++ b/apps/api/src/routes/resources.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import express from "express"; +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; +import type { Collection } from "mongodb"; +import { + ResourceResolver, + ResourceRevisionStore, + ResourceStore, + type ResourceDocument, + type ResourceRevisionDocument, +} from "shared"; +import type { RouteContext } from "../route-context.js"; +import { registerResourcesRoutes } from "./resources.js"; + +type UnknownRecord = Record; + +type Update = { + $set?: Partial; + $inc?: Partial>; +}; + +function matches(doc: T, filter: UnknownRecord): boolean { + for (const [key, condition] of Object.entries(filter)) { + if (key === "$or" && Array.isArray(condition)) { + if (!condition.some((sub) => matches(doc, sub as UnknownRecord))) return false; + continue; + } + const value = doc[key]; + if (typeof condition === "object" && condition !== null && "$exists" in condition) { + const exists = value !== undefined; + if ((condition as { $exists: boolean }).$exists !== exists) return false; + continue; + } + if (typeof condition === "object" && condition !== null && "$lt" in condition) { + if (typeof value !== "number") return false; + if (!(value < (condition as { $lt: number }).$lt)) return false; + continue; + } + if (value !== condition) return false; + } + return true; +} + +function fakeCollection(seed: T[] = []) { + const docs = seed.map((doc) => ({ ...doc })); + return { + _docs: () => docs, + async findOne(filter: UnknownRecord) { + return docs.find((doc) => matches(doc as unknown as UnknownRecord, filter)) ?? null; + }, + find(filter: UnknownRecord) { + let result = docs.filter((doc) => matches(doc as unknown as UnknownRecord, filter)); + return { + sort(sortSpec: UnknownRecord) { + if (sortSpec.createdAt === -1) { + result = [...result].sort((a, b) => { + const aTime = a["createdAt" as keyof T] instanceof Date ? (a["createdAt" as keyof T] as Date).getTime() : 0; + const bTime = b["createdAt" as keyof T] instanceof Date ? (b["createdAt" as keyof T] as Date).getTime() : 0; + return bTime - aTime; + }); + } + if (sortSpec.revisionNumber === -1) { + result = [...result].sort((a, b) => Number(b["revisionNumber" as keyof T] ?? 0) - Number(a["revisionNumber" as keyof T] ?? 0)); + } + return this; + }, + limit(limitValue: number) { + result = result.slice(0, limitValue); + return this; + }, + async toArray() { + return result; + }, + }; + }, + async insertOne(doc: T) { + docs.push({ ...doc }); + return { insertedId: doc._id }; + }, + async updateOne(filter: UnknownRecord, update: Update) { + const doc = docs.find((candidate) => matches(candidate as unknown as UnknownRecord, filter)); + if (!doc) return { matchedCount: 0, modifiedCount: 0 }; + if (update.$inc) { + for (const [key, amount] of Object.entries(update.$inc)) { + const current = Number(doc[key as keyof T] ?? 0); + (doc as unknown as Record)[key] = current + Number(amount); + } + } + if (update.$set) Object.assign(doc, update.$set); + return { matchedCount: 1, modifiedCount: 1 }; + }, + async findOneAndUpdate(filter: UnknownRecord, update: Update) { + const doc = docs.find((candidate) => matches(candidate as unknown as UnknownRecord, filter)); + if (!doc) return null; + if (update.$inc) { + for (const [key, amount] of Object.entries(update.$inc)) { + const current = Number(doc[key as keyof T] ?? 0); + (doc as unknown as Record)[key] = current + Number(amount); + } + } + if (update.$set) Object.assign(doc, update.$set); + return doc; + }, + async updateMany(filter: UnknownRecord, update: Update) { + let modifiedCount = 0; + for (const doc of docs) { + if (matches(doc as unknown as UnknownRecord, filter)) { + if (update.$set) Object.assign(doc, update.$set); + modifiedCount += 1; + } + } + return { matchedCount: modifiedCount, modifiedCount }; + }, + async deleteOne(filter: UnknownRecord) { + const index = docs.findIndex((candidate) => matches(candidate as unknown as UnknownRecord, filter)); + if (index === -1) return { deletedCount: 0 }; + docs.splice(index, 1); + return { deletedCount: 1 }; + }, + async deleteMany(filter: UnknownRecord) { + const before = docs.length; + for (let index = docs.length - 1; index >= 0; index -= 1) { + if (matches(docs[index] as unknown as UnknownRecord, filter)) docs.splice(index, 1); + } + return { deletedCount: before - docs.length }; + }, + }; +} + +function buildApp() { + const app = express(); + app.use(express.json()); + const resourceCollection = fakeCollection(); + const revisionCollection = fakeCollection(); + const resourceStore = new ResourceStore(resourceCollection as unknown as Collection); + const resourceRevisionStore = new ResourceRevisionStore( + revisionCollection as unknown as Collection, + resourceStore + ); + const ctx = { + app, + registry: new OpenAPIRegistry(), + resourceStore, + resourceRevisionStore, + resourceResolver: new ResourceResolver(), + } as unknown as RouteContext; + registerResourcesRoutes(ctx); + app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).json({ error: err.message }); + }); + return { app, resourceCollection, revisionCollection }; +} + +const createBody = { + name: "GitHub Simulator", + slug: "github-simulator", + setup: { sh: "echo URL=http://localhost >> $SCOPE_SETUP_ENV" }, + teardown: { sh: "echo teardown" }, + exports: ["URL"], +}; + +describe("resource routes", () => { + it("rejects duplicate slugs within a project but allows the same slug in another project", async () => { + const { app, resourceCollection } = buildApp(); + + const first = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + const duplicate = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + const otherProject = await request(app).post("/api/v1/resources?projectId=proj-b").send(createBody); + + expect(first.status).toBe(201); + expect(duplicate.status).toBe(409); + expect(duplicate.body.error).toMatch(/already exists/); + expect(otherProject.status).toBe(201); + expect(resourceCollection._docs()).toHaveLength(2); + expect(new Set(resourceCollection._docs().map((doc) => doc.projectId))).toEqual(new Set(["proj-a", "proj-b"])); + }); + + it("creates changed bodies as new immutable revisions without editing older revisions", async () => { + const { app } = buildApp(); + + const created = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + expect(created.status).toBe(201); + const firstRevision = created.body.firstRevision as ResourceRevisionDocument; + + const second = await request(app) + .post("/api/v1/resources/github-simulator/revisions?projectId=proj-a") + .send({ setup: { sh: "echo URL=http://changed >> $SCOPE_SETUP_ENV" }, exports: ["URL"] }); + expect(second.status).toBe(201); + expect(second.body.revisionNumber).toBe(2); + + const fetchedFirst = await request(app).get("/api/v1/resources/github-simulator/revisions/1?projectId=proj-a"); + expect(fetchedFirst.status).toBe(200); + expect(fetchedFirst.body._id).toBe(firstRevision._id); + expect(fetchedFirst.body.setup.sh).toBe(createBody.setup.sh); + expect(fetchedFirst.body.revisionNumber).toBe(1); + }); + + it("persists revision parameter declarations on create and rejects invalid declarations", async () => { + const { app } = buildApp(); + + const created = await request(app) + .post("/api/v1/resources?projectId=proj-a") + .send({ + ...createBody, + parameters: [{ name: "REPO", required: true, example: "octo/api" }], + }); + expect(created.status).toBe(201); + expect(created.body.firstRevision.parameters).toEqual([ + { name: "REPO", required: true, example: "octo/api" }, + ]); + + const invalid = await request(app) + .post("/api/v1/resources/github-simulator/revisions?projectId=proj-a") + .send({ + setup: { sh: "echo URL=http://changed >> $SCOPE_SETUP_ENV" }, + exports: ["URL"], + parameters: [{ name: "URL", required: false }], + }); + expect(invalid.status).toBe(400); + expect(invalid.body.error).toContain("both a parameter and an export"); + }); + + it("does not expose mutation routes for immutable revisions", async () => { + const { app } = buildApp(); + const created = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + expect(created.status).toBe(201); + const revisionId = (created.body.firstRevision as ResourceRevisionDocument)._id; + + const patch = await request(app).patch(`/api/v1/resources/revisions/${revisionId}?projectId=proj-a`).send({ setup: { sh: "x" } }); + const put = await request(app).put(`/api/v1/resources/revisions/${revisionId}?projectId=proj-a`).send({ setup: { sh: "x" } }); + const del = await request(app).delete(`/api/v1/resources/revisions/${revisionId}?projectId=proj-a`); + + expect(patch.status).toBe(404); + expect(put.status).toBe(404); + expect(del.status).toBe(404); + }); +}); diff --git a/apps/api/src/routes/resources.ts b/apps/api/src/routes/resources.ts new file mode 100644 index 000000000..f8c666e0d --- /dev/null +++ b/apps/api/src/routes/resources.ts @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; +import { + buildResourceRevisionRef, + CreateResourceInputSchema, + CreateResourceRevisionInputSchema, + ResourceResponseSchema, + ResourceRevisionResponseSchema, + slugifyResourceName, + UpdateResourceInputSchema, + validateParameterDeclarations, + ResourceParameterError, + type ResourceDocument, +} from "shared"; +import { apiRoute } from "../openapi/api-route.js"; +import type { RouteContext } from "../route-context.js"; +import { ProjectIdQuerySchema, getQueryProjectId } from "../utils/project-scope.js"; + +async function resolveResource( + ctx: RouteContext, + projectId: string, + idOrSlug: string +): Promise { + const byId = await ctx.resourceStore.get(idOrSlug); + if (byId) return byId.projectId === projectId ? byId : null; + return ctx.resourceStore.getBySlug(projectId, idOrSlug); +} + +/** + * Existing resource holding this slug, including a soft-deleted one. + * + * Returned rather than a boolean so the caller can say *why* the slug is taken: + * a soft-deleted resource is invisible in every listing, so a bare "already + * exists" sends the caller hunting for something they cannot see. + */ +async function findResourceBySlugIncludingDeleted(ctx: RouteContext, projectId: string, slug: string) { + return ctx.resourceStore.getBySlug(projectId, slug, { includeDeleted: true }); +} + +async function hasRevisionRef(ctx: RouteContext, projectId: string, ref: string): Promise { + return (await ctx.resourceRevisionStore.getByRef(projectId, ref)) !== null; +} + +export function registerResourcesRoutes(ctx: RouteContext): void { + // =================================================================== + // Resources API + // =================================================================== + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources", + tags: ["Resources"], + summary: "List all resources", + query: ProjectIdQuerySchema, + response: z.array(ResourceResponseSchema), + handler: async (req, res, next) => { + try { + const resources = await ctx.resourceStore.list({ projectId: getQueryProjectId(req) }); + res.json(resources.map((resource) => ({ ...resource, id: resource._id }))); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "post", + path: "/api/v1/resources", + tags: ["Resources"], + summary: "Create a resource and its first revision", + query: ProjectIdQuerySchema, + body: CreateResourceInputSchema, + response: ResourceResponseSchema, + rawResponse: true, + successStatus: 201, + errorResponses: { + 400: { description: "Invalid input" }, + 409: { description: "Resource slug or revision ref already exists in this project" }, + }, + handler: async (req, res, next) => { + try { + const projectId = getQueryProjectId(req); + const { name, slug: requestedSlug, description, setup, teardown, exports: exportedNames, parameters, creator } = req.body; + const slug = slugifyResourceName(requestedSlug ?? name); + if (!slug) { + res.status(400).json({ error: "Could not derive a valid slug from the resource name" }); + return; + } + try { + validateParameterDeclarations(parameters, exportedNames); + } catch (error) { + if (error instanceof ResourceParameterError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } + + // Cosmos DB degrades the project-scoped unique index to non-unique, so + // the route must enforce same-project uniqueness before insert. A + // soft-deleted resource still reserves its slug so old refs stay stable. + const slugHolder = await findResourceBySlugIncludingDeleted(ctx, projectId, slug); + if (slugHolder) { + res.status(409).json({ + error: slugHolder.deletedAt + ? `A deleted resource still holds the slug '${slug}' in this project. Slugs are not released on delete, because existing runs resolve revisions by the '{slug}@rN' ref and reusing the slug would make those refs ambiguous. Choose a different slug.` + : `A resource with slug '${slug}' already exists in this project.`, + }); + return; + } + + const firstRef = buildResourceRevisionRef(slug, 1); + if (await hasRevisionRef(ctx, projectId, firstRef)) { + res.status(409).json({ error: `A resource revision with ref '${firstRef}' already exists in this project.` }); + return; + } + + const resource = await ctx.resourceStore.create({ + projectId, + name, + slug, + ...(description ? { description } : {}), + ...(creator ? { creator } : {}), + }); + + try { + const result = await ctx.resourceResolver.createRevision( + resource, + { + setup, + ...(teardown ? { teardown } : {}), + ...(exportedNames ? { exports: exportedNames } : {}), + ...(parameters ? { parameters } : {}), + ...(creator ? { creator } : {}), + }, + ctx.resourceRevisionStore + ); + const fresh = (await ctx.resourceStore.get(resource._id)) ?? resource; + res.status(201).json({ ...fresh, id: fresh._id, firstRevision: result.revision }); + } catch (revisionError) { + await ctx.resourceRevisionStore.deleteByResource(resource._id); + await ctx.resourceStore.hardDelete(resource._id); + const message = revisionError instanceof Error ? revisionError.message : String(revisionError); + res.status(400).json({ error: `Failed to create the first resource revision: ${message}` }); + } + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id", + tags: ["Resources"], + summary: "Get a resource", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceResponseSchema, + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + res.json({ ...resource, id: resource._id }); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "patch", + path: "/api/v1/resources/:id", + tags: ["Resources"], + summary: "Update a resource", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + body: UpdateResourceInputSchema, + response: ResourceResponseSchema, + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const updated = await ctx.resourceStore.update(resource._id, req.body); + if (!updated) { + res.status(404).json({ error: "Resource not found" }); + return; + } + res.json({ ...updated, id: updated._id }); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "delete", + path: "/api/v1/resources/:id", + tags: ["Resources"], + summary: "Delete a resource", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: z.any(), + rawResponse: true, + successStatus: 204, + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const ok = await ctx.resourceStore.softDelete(resource._id); + if (!ok) { + res.status(404).json({ error: "Resource not found" }); + return; + } + await ctx.resourceRevisionStore.softDeleteByResource(resource._id); + res.status(204).send(); + } catch (error) { + next(error); + } + }, + }); + + // =================================================================== + // Resource Revisions API + // =================================================================== + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id/revisions", + tags: ["Resource Revisions"], + summary: "List resource revisions", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema.merge(z.object({ limit: z.string().optional() })), + response: z.array(ResourceRevisionResponseSchema), + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const limitStr = req.query.limit as string | undefined; + const limit = Math.min(Math.max(parseInt(limitStr ?? "50", 10), 1), 200); + const revisions = await ctx.resourceRevisionStore.listByResource(resource._id, { limit }); + res.json(revisions); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "post", + path: "/api/v1/resources/:id/revisions", + tags: ["Resource Revisions"], + summary: "Create a resource revision", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + body: CreateResourceRevisionInputSchema, + response: ResourceRevisionResponseSchema, + rawResponse: true, + successStatus: 201, + errorResponses: { + 404: { description: "Resource not found" }, + 409: { description: "Resource revision ref already exists in this project" }, + }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + + // Application-level scoped ref guard for Cosmos DB, where the unique + // index degrades to non-unique. The atomic counter remains authoritative; + // this catches pre-existing/corrupt collisions before the insert path. + const nextRef = buildResourceRevisionRef(resource.slug, resource.revisionCounter + 1); + if (await hasRevisionRef(ctx, resource.projectId, nextRef)) { + res.status(409).json({ error: `A resource revision with ref '${nextRef}' already exists in this project.` }); + return; + } + + const { setup, teardown, exports: exportedNames, parameters, creator } = req.body; + try { + validateParameterDeclarations(parameters, exportedNames); + } catch (error) { + if (error instanceof ResourceParameterError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } + const result = await ctx.resourceResolver.createRevision( + resource, + { + setup, + ...(teardown ? { teardown } : {}), + ...(exportedNames ? { exports: exportedNames } : {}), + ...(parameters ? { parameters } : {}), + ...(creator ? { creator } : {}), + }, + ctx.resourceRevisionStore + ); + res + .status(result.deduplicated ? 200 : 201) + .json({ ...result.revision, deduplicated: result.deduplicated }); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id/revisions/latest", + tags: ["Resource Revisions"], + summary: "Get the latest resource revision", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceRevisionResponseSchema, + errorResponses: { 404: { description: "Revision not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const revision = await ctx.resourceRevisionStore.getLatest(resource._id); + if (!revision) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + res.json(revision); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id/revisions/:revisionNumber", + tags: ["Resource Revisions"], + summary: "Get a resource revision by resource and revision number", + params: z.object({ id: z.string(), revisionNumber: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceRevisionResponseSchema, + errorResponses: { 404: { description: "Revision not found" } }, + handler: async (req, res, next) => { + try { + const revisionNumber = Number(req.params.revisionNumber); + if (!Number.isInteger(revisionNumber) || revisionNumber < 1) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const revision = await ctx.resourceRevisionStore.getByNumber(resource._id, revisionNumber); + if (!revision) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + res.json(revision); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/revisions/:id", + tags: ["Resource Revisions"], + summary: "Get resource revision by id", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceRevisionResponseSchema, + errorResponses: { 404: { description: "Revision not found" } }, + handler: async (req, res, next) => { + try { + const revision = await ctx.resourceRevisionStore.get(req.params.id); + if (!revision || revision.projectId !== getQueryProjectId(req)) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + res.json(revision); + } catch (error) { + next(error); + } + }, + }); +} diff --git a/apps/api/src/test-helpers.ts b/apps/api/src/test-helpers.ts index bfe753775..023c46b73 100644 --- a/apps/api/src/test-helpers.ts +++ b/apps/api/src/test-helpers.ts @@ -158,6 +158,28 @@ export function createMockSkillResolver(): Record { }; } +export function createMockResourceStore(): Record { + return { + get: vi.fn().mockResolvedValue(null), + getBySlug: vi.fn().mockResolvedValue(null), + }; +} + +export function createMockResourceRevisionStore(): Record { + return { + get: vi.fn().mockResolvedValue(null), + getByNumber: vi.fn().mockResolvedValue(null), + getLatest: vi.fn().mockResolvedValue(null), + getByRef: vi.fn().mockResolvedValue(null), + }; +} + +export function createMockResourceResolver(): Record { + return { + createRevision: vi.fn(), + }; +} + // --------------------------------------------------------------------------- // Mock BlobStorage // --------------------------------------------------------------------------- @@ -200,6 +222,9 @@ export function createAllMockDependencies() { const taskPromptStore = createMockTaskPromptStore(); const skillRevisionStore = createMockSkillRevisionStore(); const skillResolver = createMockSkillResolver(); + const resourceStore = createMockResourceStore(); + const resourceRevisionStore = createMockResourceRevisionStore(); + const resourceResolver = createMockResourceResolver(); const reportQueueClient = createMockQueueClient(); const blobStorage = createMockBlobStorage(); @@ -227,6 +252,9 @@ export function createAllMockDependencies() { taskPromptStore, skillRevisionStore, skillResolver, + resourceStore, + resourceRevisionStore, + resourceResolver, reportQueueClient, blobStorage, } as unknown as TestDependencies & { @@ -252,6 +280,9 @@ export function createAllMockDependencies() { taskPromptStore: ReturnType; skillRevisionStore: ReturnType; skillResolver: ReturnType; + resourceStore: ReturnType; + resourceRevisionStore: ReturnType; + resourceResolver: ReturnType; reportQueueClient: ReturnType; blobStorage: ReturnType; }; diff --git a/apps/api/src/utils/agent-helpers.ts b/apps/api/src/utils/agent-helpers.ts index a53102c74..768a635cf 100644 --- a/apps/api/src/utils/agent-helpers.ts +++ b/apps/api/src/utils/agent-helpers.ts @@ -125,11 +125,13 @@ export function requestedAgentCapabilities(input: { mcpServers?: readonly string[] | null; skillRevisions?: readonly string[] | null; extensions?: readonly string[] | null; + resources?: readonly unknown[] | null; }): AgentTargetRequirements { return { reasoningEffort: Boolean(input.reasoningEffort), mcpServers: Boolean(input.mcpServers?.length), skills: Boolean(input.skillRevisions?.length), extensions: Boolean(input.extensions?.length), + resources: Boolean(input.resources?.length), }; } diff --git a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap index f0425ab73..656b9031c 100644 --- a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap +++ b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap @@ -381,6 +381,8 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "--model", "--name", "--project", + "--resource-param", + "--resources", "--skills", "--url", "--worker", @@ -415,6 +417,8 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "--id", "--mcp-servers", "--model", + "--resource-param", + "--resources", "--skills", "--url", "--worker", @@ -657,6 +661,70 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` }, }, }, + "resource": { + "subcommands": { + "create": { + "options": [ + "--creator", + "--description", + "--exports", + "--name", + "--output", + "--param", + "--project", + "--setup-file", + "--setup-sh", + "--slug", + "--teardown-file", + "--teardown-sh", + "--url", + ], + }, + "delete": { + "options": [ + "--project", + "--url", + ], + }, + "get": { + "options": [ + "--output", + "--project", + "--url", + ], + }, + "list": { + "options": [ + "--output", + "--project", + "--url", + ], + }, + "revisions": { + "options": [ + "--creator", + "--exports", + "--limit", + "--output", + "--param", + "--project", + "--setup-file", + "--setup-sh", + "--teardown-file", + "--teardown-sh", + "--url", + ], + }, + "update": { + "options": [ + "--description", + "--name", + "--project", + "--url", + ], + }, + }, + }, "run": { "subcommands": { "attempts": { @@ -758,6 +826,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "--agents-md", "--base-profile", "--codebase", + "--count", "--criteria", "--extensions", "--gates", @@ -771,6 +840,8 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "--profile-variations-file", "--project", "--reasoning-effort", + "--resource-param", + "--resources", "--scenario", "--skills", "--traits", diff --git a/apps/cli/src/commands/profile.ts b/apps/cli/src/commands/profile.ts index e8a13e0ea..88aea915f 100644 --- a/apps/cli/src/commands/profile.ts +++ b/apps/cli/src/commands/profile.ts @@ -9,6 +9,7 @@ import type { OutputFormat, DisplayField } from "../utils/types.js"; import { withOutputOption, withProjectOption, getDefaultApiUrl } from "../utils/shared.js"; import { requireProjectId } from "../utils/config.js"; import { apiFetch } from "../utils/api-client.js"; +import { buildResourceBindingSpecs, collectRepeatable, formatResourceBindings } from "../utils/resources.js"; export function registerProfileCommands(program: Command): void { // ─── Profile commands ────────────────────────────────────────────────────────── @@ -92,6 +93,7 @@ profile if (profile.version.agentVersion) console.log(` ${label('Agent:')} ${value(profile.version.agentVersion)}`); if (profile.version.mcpServers?.length) console.log(` ${label('MCP:')} ${profile.version.mcpServers.join(', ')}`); if (profile.version.skillRevisions?.length) console.log(` ${label('Skills:')} ${profile.version.skillRevisions.join(', ')}`); + if (profile.version.resources?.length) console.log(` ${label('Resources:')} ${formatResourceBindings(profile.version.resources)}`); if (profile.version.extensions?.length) console.log(` ${label('Exts:')} ${profile.version.extensions.join(', ')}`); } } else { @@ -120,6 +122,8 @@ profile .option("--agent-version ", "Agent version") .option("--mcp-servers ", "MCP server IDs") .option("--skills ", "Skill revision references") + .option("--resources ", "Resource specs to preset (slug, slug@rN, or revision id)") + .option("--resource-param :=", "Resource parameter preset (repeatable); matches a --resources entry by slug", collectRepeatable, []) .option("--extensions ", "Extension IDs (publisher.name or publisher.name@version)") .option("-u, --url ", "API base URL", getDefaultApiUrl()) .option("--project ", "Project ID for scoped operations (overrides SCOPE_PROJECT and the saved selection)") @@ -135,6 +139,8 @@ profile if (options.agentVersion) body.agentVersion = options.agentVersion; if (options.mcpServers) body.mcpServers = options.mcpServers; if (options.skills) body.skillRevisions = options.skills; + const resources = buildResourceBindingSpecs(options.resources, options.resourceParam); + if (resources) body.resources = resources; if (options.extensions) body.extensions = options.extensions; const response = await apiFetch(options.url, `/profiles`, { @@ -254,6 +260,7 @@ version if (ver.agentVersion) console.log(` ${label('Agent:')} ${value(ver.agentVersion)}`); if (ver.mcpServers?.length) console.log(` ${label('MCP:')} ${ver.mcpServers.join(', ')}`); if (ver.skillRevisions?.length) console.log(` ${label('Skills:')} ${ver.skillRevisions.join(', ')}`); + if (ver.resources?.length) console.log(` ${label('Resources:')} ${formatResourceBindings(ver.resources)}`); if (ver.extensions?.length) console.log(` ${label('Exts:')} ${ver.extensions.join(', ')}`); console.log(` ${label('Created:')} ${dimTimestamp(new Date(ver.createdAt).toLocaleString())}`); } else { @@ -264,6 +271,7 @@ version { key: 'agentVersion', label: 'Agent Version', formatter: (v: any) => v.agentVersion || '' }, { key: 'mcpServers', label: 'MCP Servers', formatter: (v: any) => (v.mcpServers || []).join(', ') }, { key: 'skillRevisions', label: 'Skills', formatter: (v: any) => (v.skillRevisions || []).join(', ') }, + { key: 'resources', label: 'Resources', formatter: (v: any) => formatResourceBindings(v.resources) }, { key: 'extensions', label: 'Extensions', formatter: (v: any) => (v.extensions || []).join(', ') }, { key: 'createdAt', label: 'Created' }, ]; @@ -284,6 +292,8 @@ version .option("--agent-version ", "Agent version") .option("--mcp-servers ", "MCP server IDs") .option("--skills ", "Skill revision references") + .option("--resources ", "Resource specs to preset (slug, slug@rN, or revision id)") + .option("--resource-param :=", "Resource parameter preset (repeatable); matches a --resources entry by slug", collectRepeatable, []) .option("--extensions ", "Extension IDs (publisher.name or publisher.name@version)") .option("-u, --url ", "API base URL", getDefaultApiUrl()) .action(async (options) => { @@ -295,6 +305,8 @@ version if (options.agentVersion) body.agentVersion = options.agentVersion; if (options.mcpServers) body.mcpServers = options.mcpServers; if (options.skills) body.skillRevisions = options.skills; + const resources = buildResourceBindingSpecs(options.resources, options.resourceParam); + if (resources) body.resources = resources; if (options.extensions) body.extensions = options.extensions; const response = await apiFetch(options.url, `/profiles/${options.id}`, { diff --git a/apps/cli/src/commands/resource.ts b/apps/cli/src/commands/resource.ts new file mode 100644 index 000000000..01b508899 --- /dev/null +++ b/apps/cli/src/commands/resource.ts @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { existsSync, readFileSync } from "fs"; +import { resolve } from "path"; +import { Command } from "commander"; +import type { ResourceDocument, ResourceParameter, ResourceRevisionDocument, ResourceScript } from "shared"; +import { configureHelp } from "../utils/helpFormatter.js"; +import { formatData, isMachineReadable } from "../utils/formatters.js"; +import type { DisplayField, OutputFormat } from "../utils/types.js"; +import { getDefaultApiUrl, withOutputOption, withProjectOption } from "../utils/shared.js"; +import { requireProjectId } from "../utils/config.js"; +import { apiFetch } from "../utils/api-client.js"; +import { errorText, label, successText, value, warnBanner } from "../utils/style.js"; +import { buildResourceParameters, collectRepeatable, formatParameterContract } from "../utils/resources.js"; + +type JsonDate = string | Date; +type ResourceApiDocument = Omit & { + id?: string; + createdAt: JsonDate; + updatedAt?: JsonDate; + deletedAt?: JsonDate; + firstRevision?: ResourceRevisionApiDocument; +}; +type ResourceRevisionApiDocument = Omit & { + id?: string; + createdAt: JsonDate; + deletedAt?: JsonDate; + deduplicated?: boolean; +}; + +interface ApiErrorBody { + error?: string; +} + +interface LifecycleOptions { + setupSh?: string; + setupFile?: string; + teardownSh?: string; + teardownFile?: string; + exports?: string[]; + param?: string[]; + creator?: string; +} + +function resourceId(resource: ResourceApiDocument): string { + return resource.id ?? resource._id; +} + +async function readError(response: Response): Promise { + const error = (await response.json().catch((): ApiErrorBody => ({ error: response.statusText }))) as ApiErrorBody; + return error.error ?? JSON.stringify(error); +} + +async function fetchJson(baseUrl: string, path: string, projectId: string, init?: RequestInit): Promise { + const response = await apiFetch(baseUrl, path, { ...init, projectId }); + if (!response.ok) throw new Error(await readError(response)); + return (await response.json()) as T; +} + +function readTextFile(path: string): string { + const fullPath = resolve(path); + if (!existsSync(fullPath)) { + throw new Error(`Path not found: ${fullPath}`); + } + return readFileSync(fullPath, "utf8"); +} + +function buildLifecycleBody(options: LifecycleOptions): { + setup: ResourceScript; + teardown?: ResourceScript; + exports?: string[]; + parameters?: ResourceParameter[]; + creator?: string; +} { + if (options.setupSh && options.setupFile) { + throw new Error("Use either --setup-sh or --setup-file, not both"); + } + if (options.teardownSh && options.teardownFile) { + throw new Error("Use either --teardown-sh or --teardown-file, not both"); + } + const setupBody = options.setupSh ?? (options.setupFile ? readTextFile(options.setupFile) : undefined); + if (!setupBody) { + throw new Error("Provide --setup-sh or --setup-file"); + } + const teardownBody = options.teardownSh ?? (options.teardownFile ? readTextFile(options.teardownFile) : undefined); + return { + setup: { sh: setupBody }, + ...(teardownBody ? { teardown: { sh: teardownBody } } : {}), + ...(options.exports && options.exports.length > 0 ? { exports: options.exports } : {}), + ...(options.param && options.param.length > 0 ? { parameters: buildResourceParameters(options.param) } : {}), + ...(options.creator ? { creator: options.creator } : {}), + }; +} + +function resourceFields(): DisplayField[] { + return [ + { key: "slug", label: "Slug", tableFormatter: (resource) => value(resource.slug) }, + { key: "name", label: "Name" }, + { key: "latestRevisionNumber", label: "Latest", formatter: (resource) => resource.latestRevisionNumber?.toString() ?? "—" }, + { key: "latestRevisionId", label: "Latest Revision", formatter: (resource) => resource.latestRevisionId ?? "—" }, + { key: "createdAt", label: "Created", formatter: (resource) => new Date(resource.createdAt).toLocaleString() }, + ]; +} + +function revisionFields(): DisplayField[] { + return [ + { key: "ref", label: "Ref", tableFormatter: (revision) => value(revision.ref) }, + { key: "revisionNumber", label: "Revision", formatter: (revision) => revision.revisionNumber.toString() }, + { key: "exports", label: "Exports", formatter: (revision) => revision.exports.join(", ") || "—" }, + { key: "parameters", label: "Parameters", formatter: (revision) => formatParameterContract(revision.parameters) }, + { key: "contentSha256", label: "Content SHA", formatter: (revision) => revision.contentSha256.substring(0, 12) }, + { key: "createdAt", label: "Created", formatter: (revision) => new Date(revision.createdAt).toLocaleString() }, + ]; +} + +function parseRevisionRef(spec: string): { slug: string; revisionNumber: number } | null { + const match = /^(.+)@r(\d+)$/.exec(spec); + if (!match) return null; + return { slug: match[1], revisionNumber: Number(match[2]) }; +} + +async function resolveResource(baseUrl: string, projectId: string, idOrSlug: string): Promise { + return fetchJson(baseUrl, `/resources/${encodeURIComponent(idOrSlug)}`, projectId); +} + +function addLifecycleOptions(command: Command): Command { + return command + .option("--setup-sh