diff --git a/CHANGELOG.md b/CHANGELOG.md index 926ab30..efaa998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Forward Vault Credential updates for secret rotation and metadata merge patches, with automatic retries disabled for write-only updates. +- Forward Usage aggregation by Identity and Template using hourly `start_at` / `end_at` windows in Asia/Shanghai, with fractional `active_seconds` and multi-ID filters. Legacy timestamp parameters are not exposed. +- Managed Session cancellation with the lightweight acknowledgement for both active and idle sessions, plus deployment-scoped Run listing and retrieval. + +### Changed + +- Forward Credential responses now expose `archived_at` and environment-variable `secret_name`. `auth.mcp_server_url` is optional because environment-variable credentials do not have an MCP URL; check for its presence before using it. + ## [0.2.0] - 2026-09-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d2daa1..c9a314a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,20 @@ npm run docs:check Never commit `.env.live`, tokens, credentials, generated logs, or test output. Live scenarios must register cleanup immediately after creating a resource. +`npm run test:live` includes Forward hourly Usage, Credential merge patches +and secret redaction, and Managed Session cancellation before and after sending +a turn. Managed deployment scenarios cover scoped Run listing/retrieval. Active +cancellation and scoped Run execution require the corresponding +`LIVE_ALLOW_WRITE=true` and `LIVE_ALLOW_EXECUTION=true` gates. Use separate +`LIVE_ENV_FILE` files with matching URL and PAT for CN and Global. + +Usage queries the last 24 completed whole hours in Asia/Shanghai in both regions; +empty pages verify only the collection. A Session may finish before cancellation +and return HTTP 200 instead of 202; tests record which response occurred. Those +responses do not prove active cancellation occurred. Cleanup failures remain +failures; offline replay exercises assertions and cleanup without account +credentials. + ## API and contract changes When adding or changing an endpoint: diff --git a/README.md b/README.md index c3b9983..ede19b0 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ for await (const event of client.sessions.events.list(session.id, { order: 'asc' } ``` -`ForwardClient` covers the Forward API — `templates`, `identities`, `sessions`, `schedules`, `scheduleRuns`, `batches`, `channels`, `channelPairings`, `environments`, `files`, `skills`, `vaults`, `memoryStores` and `models`. +`ForwardClient` covers the Forward API — `templates`, `identities`, `sessions`, `schedules`, `scheduleRuns`, `batches`, `channels`, `channelPairings`, `environments`, `files`, `skills`, `vaults`, `memoryStores`, `models` and `usage`. ```ts import { ManagedClient } from 'qca-sdk'; @@ -75,6 +75,39 @@ Each client also has its own entry point — `qca-sdk/forward` and `qca-sdk/mana import ForwardClient from 'qca-sdk/forward'; ``` +### Usage and cancellation + +Forward Usage requires PAT or Admin SAT. + +```ts +for await (const row of forward.usage.listIdentities({ + start_at: '2026-09-14T09:00:00', end_at: '2026-09-14T12:00:00', + identity_ids: ['idn_one', 'idn_two'], +})) { + console.log(row.identity_id, row.active_seconds, row.credits); +} +const acknowledgement = await managed.sessions.cancel('sess_one'); +for await (const run of managed.deployments.runs.list({ deployment_id: 'dep_one', limit: 20 })) { + console.log(run.id); +} +const run = await managed.deployments.runs.retrieve('drun_one', { deployment_id: 'dep_one' }); +``` + +`usage.listTemplates` accepts the same filters. Bounds are whole hours in +Asia/Shanghai for CN and Global, with an inclusive start, exclusive end, and a +maximum 744-hour span. Multi-ID filters accept arrays or comma-separated strings. +`active_seconds` preserves fractions. Legacy timestamp parameters and +`duration_seconds` are not exposed. + +`forward.vaults.credentials.update('vault_one', 'cred_one', { auth: {...} })` +rotates write-only secrets; only auth and metadata are patched, omitted fields +are preserved, and this operation never retries automatically. +`metadata: null` clears metadata; `metadata: { key: null }` deletes a key. + +Session cancellation returns the lightweight `canceling` acknowledgement for +active (HTTP 202) and idle (HTTP 200) sessions. The global `deploymentRuns` resource +remains available. + ### Configuration `pat` accepts a string or a function returning a string or a promise, which is re-resolved before every request attempt — useful for short-lived tokens. A `Credential` object can be supplied instead; `PATCredential.fromEnv()` reads `QODER_PAT`. diff --git a/docs/api/forward/README.md b/docs/api/forward/README.md index 69d0d86..e7569db 100644 --- a/docs/api/forward/README.md +++ b/docs/api/forward/README.md @@ -32,6 +32,7 @@ - [Skills](classes/Skills.md) - [SkillVersions](classes/SkillVersions.md) - [Templates](classes/Templates.md) +- [Usage](classes/Usage.md) - [VaultCredentials](classes/VaultCredentials.md) - [Vaults](classes/Vaults.md) @@ -105,6 +106,7 @@ - [IdentityStats](interfaces/IdentityStats.md) - [IdentityTemplate](interfaces/IdentityTemplate.md) - [IdentityUpdateParams](interfaces/IdentityUpdateParams.md) +- [IdentityUsage](interfaces/IdentityUsage.md) - [ImageSource](interfaces/ImageSource.md) - [ImageSourceParam](interfaces/ImageSourceParam.md) - [MCPServer](interfaces/MCPServer.md) @@ -202,17 +204,20 @@ - [TemplateListParams](interfaces/TemplateListParams.md) - [TemplateNewParams](interfaces/TemplateNewParams.md) - [TemplateUpdateParams](interfaces/TemplateUpdateParams.md) +- [TemplateUsage](interfaces/TemplateUsage.md) - [Tool](interfaces/Tool.md) - [ToolConfig](interfaces/ToolConfig.md) - [ToolConfigParam](interfaces/ToolConfigParam.md) - [ToolOverride](interfaces/ToolOverride.md) - [ToolOverrideParam](interfaces/ToolOverrideParam.md) - [ToolParam](interfaces/ToolParam.md) +- [UsageListParams](interfaces/UsageListParams.md) - [Vault](interfaces/Vault.md) - [VaultCredential](interfaces/VaultCredential.md) - [VaultCredentialAuth](interfaces/VaultCredentialAuth.md) - [VaultCredentialListParams](interfaces/VaultCredentialListParams.md) - [VaultCredentialNewParams](interfaces/VaultCredentialNewParams.md) +- [VaultCredentialUpdateParams](interfaces/VaultCredentialUpdateParams.md) - [VaultListParams](interfaces/VaultListParams.md) - [VaultNewParams](interfaces/VaultNewParams.md) @@ -240,6 +245,7 @@ - [TemplateCreateParams](type-aliases/TemplateCreateParams.md) - [VaultCreateParams](type-aliases/VaultCreateParams.md) - [VaultCredentialCreateParams](type-aliases/VaultCredentialCreateParams.md) +- [VaultCredentialUpdateAuth](type-aliases/VaultCredentialUpdateAuth.md) ## References diff --git a/docs/api/forward/classes/Usage.md b/docs/api/forward/classes/Usage.md new file mode 100644 index 0000000..ebc40f3 --- /dev/null +++ b/docs/api/forward/classes/Usage.md @@ -0,0 +1,83 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / Usage + +# Class: Usage + +Identity/Template aggregates over an hourly Asia/Shanghai window. PAT or Admin SAT required. + +## Extends + +- `APIResource` + +## Constructors + +### Constructor + +> **new Usage**(`_client`): `Usage` + +#### Parameters + +##### \_client + +[`APIClient`](../../index/classes/APIClient.md) + +#### Returns + +`Usage` + +#### Inherited from + +`APIResource.constructor` + +## Properties + +### \_client + +> `protected` `readonly` **\_client**: [`APIClient`](../../index/classes/APIClient.md) + +#### Inherited from + +`APIResource._client` + +## Methods + +### listIdentities() + +> **listIdentities**(`params`, `options?`): [`PagePromise`](../../index/classes/PagePromise.md)\<[`IdentityUsage`](../interfaces/IdentityUsage.md)\> + +#### Parameters + +##### params + +[`UsageListParams`](../interfaces/UsageListParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`PagePromise`](../../index/classes/PagePromise.md)\<[`IdentityUsage`](../interfaces/IdentityUsage.md)\> + +*** + +### listTemplates() + +> **listTemplates**(`params`, `options?`): [`PagePromise`](../../index/classes/PagePromise.md)\<[`TemplateUsage`](../interfaces/TemplateUsage.md)\> + +#### Parameters + +##### params + +[`UsageListParams`](../interfaces/UsageListParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`PagePromise`](../../index/classes/PagePromise.md)\<[`TemplateUsage`](../interfaces/TemplateUsage.md)\> diff --git a/docs/api/forward/classes/VaultCredentials.md b/docs/api/forward/classes/VaultCredentials.md index f809afb..cfcf7df 100644 --- a/docs/api/forward/classes/VaultCredentials.md +++ b/docs/api/forward/classes/VaultCredentials.md @@ -159,3 +159,33 @@ listVaultCredential #### Operation getVaultCredential + +*** + +### update() + +> **update**(`id`, `credID`, `params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`VaultCredential`](../interfaces/VaultCredential.md)\> + +Rotate write-only secrets or merge metadata. This operation is never automatically retried. + +#### Parameters + +##### id + +`string` + +##### credID + +`string` + +##### params + +[`VaultCredentialUpdateParams`](../interfaces/VaultCredentialUpdateParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`VaultCredential`](../interfaces/VaultCredential.md)\> diff --git a/docs/api/forward/interfaces/IdentityUsage.md b/docs/api/forward/interfaces/IdentityUsage.md new file mode 100644 index 0000000..b265153 --- /dev/null +++ b/docs/api/forward/interfaces/IdentityUsage.md @@ -0,0 +1,37 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / IdentityUsage + +# Interface: IdentityUsage + +## Properties + +### active\_seconds + +> **active\_seconds**: `number` + +*** + +### credits + +> **credits**: `number` + +*** + +### identity\_id + +> **identity\_id**: `string` + +*** + +### session\_count + +> **session\_count**: `number` + +*** + +### type + +> **type**: `"identity_usage"` diff --git a/docs/api/forward/interfaces/TemplateUsage.md b/docs/api/forward/interfaces/TemplateUsage.md new file mode 100644 index 0000000..8cae795 --- /dev/null +++ b/docs/api/forward/interfaces/TemplateUsage.md @@ -0,0 +1,43 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / TemplateUsage + +# Interface: TemplateUsage + +## Properties + +### active\_identities + +> **active\_identities**: `number` + +*** + +### active\_seconds + +> **active\_seconds**: `number` + +*** + +### credits + +> **credits**: `number` + +*** + +### session\_count + +> **session\_count**: `number` + +*** + +### template\_id + +> **template\_id**: `string` + +*** + +### type + +> **type**: `"template_usage"` diff --git a/docs/api/forward/interfaces/UsageListParams.md b/docs/api/forward/interfaces/UsageListParams.md new file mode 100644 index 0000000..4a7a638 --- /dev/null +++ b/docs/api/forward/interfaces/UsageListParams.md @@ -0,0 +1,67 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / UsageListParams + +# Interface: UsageListParams + +Hourly window in Asia/Shanghai, including CN and Global. No legacy timestamp parameters. + +## Properties + +### after\_id? + +> `optional` **after\_id?**: `string` + +*** + +### before\_id? + +> `optional` **before\_id?**: `string` + +*** + +### end\_at + +> **end\_at**: `string` + +Exclusive YYYY-MM-DDTHH:00:00; maximum span 744 hours. + +*** + +### identity\_id? + +> `optional` **identity\_id?**: `string` + +*** + +### identity\_ids? + +> `optional` **identity\_ids?**: `string` \| `string`[] + +*** + +### limit? + +> `optional` **limit?**: `number` + +*** + +### start\_at + +> **start\_at**: `string` + +Inclusive YYYY-MM-DDTHH:00:00. + +*** + +### template\_id? + +> `optional` **template\_id?**: `string` + +*** + +### template\_ids? + +> `optional` **template\_ids?**: `string` \| `string`[] diff --git a/docs/api/forward/interfaces/VaultCredential.md b/docs/api/forward/interfaces/VaultCredential.md index 68ee3d0..1deed3a 100644 --- a/docs/api/forward/interfaces/VaultCredential.md +++ b/docs/api/forward/interfaces/VaultCredential.md @@ -14,6 +14,14 @@ Additional fields are preserved verbatim by the SDK. ## Properties +### archived\_at? + +> `optional` **archived\_at?**: `string` \| `null` + +Archive time, null while active. + +*** + ### auth > **auth**: [`VaultCredentialAuth`](VaultCredentialAuth.md) diff --git a/docs/api/forward/interfaces/VaultCredentialAuth.md b/docs/api/forward/interfaces/VaultCredentialAuth.md index e4ef898..1f7a37d 100644 --- a/docs/api/forward/interfaces/VaultCredentialAuth.md +++ b/docs/api/forward/interfaces/VaultCredentialAuth.md @@ -14,9 +14,15 @@ Additional fields are preserved verbatim by the SDK. ## Properties -### mcp\_server\_url +### mcp\_server\_url? -> **mcp\_server\_url**: `string` +> `optional` **mcp\_server\_url?**: `string` + +*** + +### secret\_name? + +> `optional` **secret\_name?**: `string` *** diff --git a/docs/api/forward/interfaces/VaultCredentialUpdateParams.md b/docs/api/forward/interfaces/VaultCredentialUpdateParams.md new file mode 100644 index 0000000..2158401 --- /dev/null +++ b/docs/api/forward/interfaces/VaultCredentialUpdateParams.md @@ -0,0 +1,29 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / VaultCredentialUpdateParams + +# Interface: VaultCredentialUpdateParams + +Merge patch. Supply auth or metadata; null clears metadata or deletes a key. + +## Properties + +### auth? + +> `optional` **auth?**: [`VaultCredentialUpdateAuth`](../type-aliases/VaultCredentialUpdateAuth.md) + +*** + +### identity\_id? + +> `optional` **identity\_id?**: `string` + +PAT only. SAT credentials determine their own owner scope. + +*** + +### metadata? + +> `optional` **metadata?**: `Record`\<`string`, `unknown`\> \| `null` diff --git a/docs/api/forward/type-aliases/VaultCredentialUpdateAuth.md b/docs/api/forward/type-aliases/VaultCredentialUpdateAuth.md new file mode 100644 index 0000000..98bf1d3 --- /dev/null +++ b/docs/api/forward/type-aliases/VaultCredentialUpdateAuth.md @@ -0,0 +1,9 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / VaultCredentialUpdateAuth + +# Type Alias: VaultCredentialUpdateAuth + +> **VaultCredentialUpdateAuth** = \{ `token?`: `string`; `type`: `"static_bearer"`; \} \| \{ `secret_value?`: `string`; `type`: `"environment_variable"`; \} \| \{ `access_token?`: `string`; `expires_at?`: `string` \| `null`; `refresh?`: \{ `refresh_token?`: `string`; `scope?`: `string` \| `null`; `token_endpoint_auth?`: `Record`\<`string`, `unknown`\>; \}; `type`: `"mcp_oauth"`; \} diff --git a/docs/api/index/classes/ForwardClient.md b/docs/api/index/classes/ForwardClient.md index b53369d..b3fed00 100644 --- a/docs/api/index/classes/ForwardClient.md +++ b/docs/api/index/classes/ForwardClient.md @@ -170,6 +170,12 @@ *** +### usage + +> `readonly` **usage**: [`Usage`](../../forward/classes/Usage.md) + +*** + ### vaults > `readonly` **vaults**: [`Vaults`](../../forward/classes/Vaults.md) diff --git a/docs/api/managed/README.md b/docs/api/managed/README.md index 65050c7..6f151e5 100644 --- a/docs/api/managed/README.md +++ b/docs/api/managed/README.md @@ -12,6 +12,7 @@ - [AgentsVersions](classes/AgentsVersions.md) - [DeploymentRuns](classes/DeploymentRuns.md) - [Deployments](classes/Deployments.md) +- [DeploymentScopedRuns](classes/DeploymentScopedRuns.md) - [Dreams](classes/Dreams.md) - [Environments](classes/Environments.md) - [EnvironmentsWork](classes/EnvironmentsWork.md) @@ -83,6 +84,8 @@ - [DeploymentRunGetParams](interfaces/DeploymentRunGetParams.md) - [DeploymentRunListParams](interfaces/DeploymentRunListParams.md) - [DeploymentRunParams](interfaces/DeploymentRunParams.md) +- [DeploymentScopedRunListParams](interfaces/DeploymentScopedRunListParams.md) +- [DeploymentScopedRunRetrieveParams](interfaces/DeploymentScopedRunRetrieveParams.md) - [DeploymentUnpauseParams](interfaces/DeploymentUnpauseParams.md) - [DeploymentUpdateParams](interfaces/DeploymentUpdateParams.md) - [DirectCallerParam](interfaces/DirectCallerParam.md) @@ -481,6 +484,8 @@ - [ServerToolCallerParam](interfaces/ServerToolCallerParam.md) - [ServerToolUseBlockParam](interfaces/ServerToolUseBlockParam.md) - [SessionArchiveParams](interfaces/SessionArchiveParams.md) +- [SessionCancelParams](interfaces/SessionCancelParams.md) +- [SessionCancelResponse](interfaces/SessionCancelResponse.md) - [SessionDeleteParams](interfaces/SessionDeleteParams.md) - [SessionEventListParams](interfaces/SessionEventListParams.md) - [SessionEventSendParams](interfaces/SessionEventSendParams.md) diff --git a/docs/api/managed/classes/DeploymentScopedRuns.md b/docs/api/managed/classes/DeploymentScopedRuns.md new file mode 100644 index 0000000..713f1b5 --- /dev/null +++ b/docs/api/managed/classes/DeploymentScopedRuns.md @@ -0,0 +1,87 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [managed](../README.md) / DeploymentScopedRuns + +# Class: DeploymentScopedRuns + +Runs belonging to a specific deployment. + +## Extends + +- `APIResource` + +## Constructors + +### Constructor + +> **new DeploymentScopedRuns**(`_client`): `DeploymentScopedRuns` + +#### Parameters + +##### \_client + +[`APIClient`](../../index/classes/APIClient.md) + +#### Returns + +`DeploymentScopedRuns` + +#### Inherited from + +`APIResource.constructor` + +## Properties + +### \_client + +> `protected` `readonly` **\_client**: [`APIClient`](../../index/classes/APIClient.md) + +#### Inherited from + +`APIResource._client` + +## Methods + +### list() + +> **list**(`params`, `options?`): [`PagePromise`](../../index/classes/PagePromise.md)\<[`ManagedAgentsDeploymentRun`](../interfaces/ManagedAgentsDeploymentRun.md)\> + +#### Parameters + +##### params + +[`DeploymentScopedRunListParams`](../interfaces/DeploymentScopedRunListParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`PagePromise`](../../index/classes/PagePromise.md)\<[`ManagedAgentsDeploymentRun`](../interfaces/ManagedAgentsDeploymentRun.md)\> + +*** + +### retrieve() + +> **retrieve**(`runID`, `params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`ManagedAgentsDeploymentRun`](../interfaces/ManagedAgentsDeploymentRun.md)\> + +#### Parameters + +##### runID + +`string` + +##### params + +[`DeploymentScopedRunRetrieveParams`](../interfaces/DeploymentScopedRunRetrieveParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`ManagedAgentsDeploymentRun`](../interfaces/ManagedAgentsDeploymentRun.md)\> diff --git a/docs/api/managed/classes/Deployments.md b/docs/api/managed/classes/Deployments.md index f78aa67..ec1632b 100644 --- a/docs/api/managed/classes/Deployments.md +++ b/docs/api/managed/classes/Deployments.md @@ -40,6 +40,12 @@ `APIResource._client` +*** + +### runs + +> `readonly` **runs**: [`DeploymentScopedRuns`](DeploymentScopedRuns.md) + ## Methods ### archive() diff --git a/docs/api/managed/classes/Sessions.md b/docs/api/managed/classes/Sessions.md index 10431b5..e6ece38 100644 --- a/docs/api/managed/classes/Sessions.md +++ b/docs/api/managed/classes/Sessions.md @@ -86,6 +86,32 @@ Archive Session *** +### cancel() + +> **cancel**(`sessionID`, `params?`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`SessionCancelResponse`](../interfaces/SessionCancelResponse.md)\> + +Cancel the current turn; an idle session is a safe no-op. + +#### Parameters + +##### sessionID + +`string` + +##### params? + +[`SessionCancelParams`](../interfaces/SessionCancelParams.md) = `{}` + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`SessionCancelResponse`](../interfaces/SessionCancelResponse.md)\> + +*** + ### create() > **create**(`params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`ManagedAgentsSession`](../interfaces/ManagedAgentsSession.md)\> diff --git a/docs/api/managed/interfaces/DeploymentScopedRunListParams.md b/docs/api/managed/interfaces/DeploymentScopedRunListParams.md new file mode 100644 index 0000000..1ada160 --- /dev/null +++ b/docs/api/managed/interfaces/DeploymentScopedRunListParams.md @@ -0,0 +1,61 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [managed](../README.md) / DeploymentScopedRunListParams + +# Interface: DeploymentScopedRunListParams + +## Properties + +### after\_id? + +> `optional` **after\_id?**: `string` + +*** + +### before\_id? + +> `optional` **before\_id?**: `string` + +*** + +### betas? + +> `optional` **betas?**: `string`[] + +*** + +### deployment\_id + +> **deployment\_id**: `string` + +*** + +### limit? + +> `optional` **limit?**: `number` + +*** + +### page? + +> `optional` **page?**: `string` + +*** + +### triggered\_after? + +> `optional` **triggered\_after?**: `string` + +*** + +### triggered\_before? + +> `optional` **triggered\_before?**: `string` + +*** + +### workspace\_id? + +> `optional` **workspace\_id?**: `string` diff --git a/docs/api/managed/interfaces/DeploymentScopedRunRetrieveParams.md b/docs/api/managed/interfaces/DeploymentScopedRunRetrieveParams.md new file mode 100644 index 0000000..9c02d82 --- /dev/null +++ b/docs/api/managed/interfaces/DeploymentScopedRunRetrieveParams.md @@ -0,0 +1,39 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [managed](../README.md) / DeploymentScopedRunRetrieveParams + +# Interface: DeploymentScopedRunRetrieveParams + +## Extends + +- [`DeploymentRunRetrieveParams`](../type-aliases/DeploymentRunRetrieveParams.md) + +## Properties + +### betas? + +> `optional` **betas?**: `string`[] + +Optional header to specify the beta version(s) you want to use. + +#### Inherited from + +`DeploymentRunRetrieveParams.betas` + +*** + +### deployment\_id + +> **deployment\_id**: `string` + +*** + +### workspace\_id? + +> `optional` **workspace\_id?**: `string` + +#### Inherited from + +`DeploymentRunRetrieveParams.workspace_id` diff --git a/docs/api/managed/interfaces/SessionCancelParams.md b/docs/api/managed/interfaces/SessionCancelParams.md new file mode 100644 index 0000000..46145b9 --- /dev/null +++ b/docs/api/managed/interfaces/SessionCancelParams.md @@ -0,0 +1,19 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [managed](../README.md) / SessionCancelParams + +# Interface: SessionCancelParams + +## Properties + +### betas? + +> `optional` **betas?**: `string`[] + +*** + +### workspace\_id? + +> `optional` **workspace\_id?**: `string` diff --git a/docs/api/managed/interfaces/SessionCancelResponse.md b/docs/api/managed/interfaces/SessionCancelResponse.md new file mode 100644 index 0000000..d63c5ea --- /dev/null +++ b/docs/api/managed/interfaces/SessionCancelResponse.md @@ -0,0 +1,27 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [managed](../README.md) / SessionCancelResponse + +# Interface: SessionCancelResponse + +Lightweight acknowledgement, returned for both applied (202) and idle (200) cancellation. + +## Properties + +### id + +> **id**: `string` + +*** + +### status + +> **status**: `"canceling"` + +*** + +### type + +> **type**: `"session"` diff --git a/src/forward/api-inventory.json b/src/forward/api-inventory.json index 8da828d..38806fc 100644 --- a/src/forward/api-inventory.json +++ b/src/forward/api-inventory.json @@ -4514,5 +4514,212 @@ "itemType": "void", "path": "/vaults/{id}/credentials/{cred_id}", "parameterFields": [] + }, + { + "operationId": "updateVaultCredential", + "entry": "vaults.credentials", + "method": "update", + "httpMethod": "POST", + "path": "/vaults/{id}/credentials/{cred_id}", + "parameters": [ + { + "name": "id", + "type": "string", + "location": "path", + "wireName": "id" + }, + { + "name": "cred_id", + "type": "string", + "location": "path", + "wireName": "cred_id" + } + ], + "paramsType": "VaultCredentialUpdateParams", + "paramsOptional": false, + "paramsLocation": "body", + "responseType": "json", + "parameterFields": [ + { + "name": "auth", + "type": "VaultCredentialUpdateAuth", + "location": "body", + "wireName": "auth", + "required": false + }, + { + "name": "metadata", + "type": "Record | null", + "location": "body", + "wireName": "metadata", + "required": false + }, + { + "name": "identity_id", + "type": "string", + "location": "query", + "wireName": "identity_id", + "required": false + } + ], + "itemType": "VaultCredential" + }, + { + "operationId": "listIdentityUsage", + "entry": "usage", + "method": "listIdentities", + "httpMethod": "GET", + "path": "/usage/identities", + "parameters": [], + "paramsType": "UsageListParams", + "paramsOptional": false, + "paramsLocation": "query", + "responseType": "page", + "pagination": "cursor", + "itemType": "IdentityUsage", + "parameterFields": [ + { + "name": "start_at", + "type": "string", + "location": "query", + "wireName": "start_at", + "required": true + }, + { + "name": "end_at", + "type": "string", + "location": "query", + "wireName": "end_at", + "required": true + }, + { + "name": "limit", + "type": "number", + "location": "query", + "wireName": "limit", + "required": false + }, + { + "name": "after_id", + "type": "string", + "location": "query", + "wireName": "after_id", + "required": false + }, + { + "name": "before_id", + "type": "string", + "location": "query", + "wireName": "before_id", + "required": false + }, + { + "name": "identity_id", + "type": "string", + "location": "query", + "wireName": "identity_id", + "required": false + }, + { + "name": "identity_ids", + "type": "string | string[]", + "location": "query", + "wireName": "identity_ids", + "required": false + }, + { + "name": "template_id", + "type": "string", + "location": "query", + "wireName": "template_id", + "required": false + }, + { + "name": "template_ids", + "type": "string | string[]", + "location": "query", + "wireName": "template_ids", + "required": false + } + ] + }, + { + "operationId": "listTemplateUsage", + "entry": "usage", + "method": "listTemplates", + "httpMethod": "GET", + "path": "/usage/templates", + "parameters": [], + "paramsType": "UsageListParams", + "paramsOptional": false, + "paramsLocation": "query", + "responseType": "page", + "pagination": "cursor", + "itemType": "TemplateUsage", + "parameterFields": [ + { + "name": "start_at", + "type": "string", + "location": "query", + "wireName": "start_at", + "required": true + }, + { + "name": "end_at", + "type": "string", + "location": "query", + "wireName": "end_at", + "required": true + }, + { + "name": "limit", + "type": "number", + "location": "query", + "wireName": "limit", + "required": false + }, + { + "name": "after_id", + "type": "string", + "location": "query", + "wireName": "after_id", + "required": false + }, + { + "name": "before_id", + "type": "string", + "location": "query", + "wireName": "before_id", + "required": false + }, + { + "name": "identity_id", + "type": "string", + "location": "query", + "wireName": "identity_id", + "required": false + }, + { + "name": "identity_ids", + "type": "string | string[]", + "location": "query", + "wireName": "identity_ids", + "required": false + }, + { + "name": "template_id", + "type": "string", + "location": "query", + "wireName": "template_id", + "required": false + }, + { + "name": "template_ids", + "type": "string | string[]", + "location": "query", + "wireName": "template_ids", + "required": false + } + ] } ] diff --git a/src/forward/forwardClient.ts b/src/forward/forwardClient.ts index 4582e65..380bdaa 100644 --- a/src/forward/forwardClient.ts +++ b/src/forward/forwardClient.ts @@ -14,6 +14,7 @@ import { Skills } from './skill.js'; import { Vaults } from './vault.js'; import { MemoryStores } from './memory-store.js'; import { Models } from './model.js'; +import { Usage } from './usage.js'; export type ForwardClientOptions = ClientOptions; @@ -32,6 +33,7 @@ export class ForwardClient extends APIClient { readonly vaults: Vaults = new Vaults(this); readonly memoryStores: MemoryStores = new MemoryStores(this); readonly models: Models = new Models(this); + readonly usage: Usage = new Usage(this); constructor(options: ForwardClientOptions = {}) { super(options, 'forward'); diff --git a/src/forward/index.ts b/src/forward/index.ts index 623dc98..a27ed05 100644 --- a/src/forward/index.ts +++ b/src/forward/index.ts @@ -37,3 +37,4 @@ export type { Uploadable } from '../core/uploads.js'; export { PATCredential } from '../core/credentials.js'; export type { Credential } from '../core/credentials.js'; export * from '../core/error.js'; +export { Usage } from './usage.js'; diff --git a/src/forward/types.ts b/src/forward/types.ts index c9b0ae0..894df81 100644 --- a/src/forward/types.ts +++ b/src/forward/types.ts @@ -3118,6 +3118,8 @@ export interface VaultCredential { * Credential 元数据。 */ metadata: Record; + /** Archive time, null while active. */ + archived_at?: string | null; /** * 创建时间,RFC 3339 格式。 */ @@ -3132,7 +3134,8 @@ export interface VaultCredential { export interface VaultCredentialAuth { type: string; - mcp_server_url: string; + mcp_server_url?: string; + secret_name?: string; /** Additional fields are preserved verbatim by the SDK. */ [key: string]: unknown; } @@ -3170,3 +3173,45 @@ export type TemplateCreateParams = TemplateNewParams; export type VaultCreateParams = VaultNewParams; export type VaultCredentialCreateParams = VaultCredentialNewParams; + +export type VaultCredentialUpdateAuth = + | { type: 'static_bearer'; token?: string } + | { type: 'environment_variable'; secret_value?: string } + | { type: 'mcp_oauth'; access_token?: string; expires_at?: string | null; + refresh?: { refresh_token?: string; scope?: string | null; token_endpoint_auth?: Record } }; +/** Merge patch. Supply auth or metadata; null clears metadata or deletes a key. */ +export interface VaultCredentialUpdateParams { + auth?: VaultCredentialUpdateAuth; + metadata?: Record | null; + /** PAT only. SAT credentials determine their own owner scope. */ + identity_id?: string; +} +/** Hourly window in Asia/Shanghai, including CN and Global. No legacy timestamp parameters. */ +export interface UsageListParams { + /** Inclusive YYYY-MM-DDTHH:00:00. */ + start_at: string; + /** Exclusive YYYY-MM-DDTHH:00:00; maximum span 744 hours. */ + end_at: string; + limit?: number; + after_id?: string; + before_id?: string; + identity_id?: string; + identity_ids?: string | string[]; + template_id?: string; + template_ids?: string | string[]; +} +export interface IdentityUsage { + type: 'identity_usage'; + identity_id: string; + session_count: number; + active_seconds: number; + credits: number; +} +export interface TemplateUsage { + type: 'template_usage'; + template_id: string; + active_identities: number; + session_count: number; + active_seconds: number; + credits: number; +} diff --git a/src/forward/usage.ts b/src/forward/usage.ts new file mode 100644 index 0000000..e691086 --- /dev/null +++ b/src/forward/usage.ts @@ -0,0 +1,15 @@ +import { APIResource } from '../core/resource.js'; +import type { RequestOptions } from '../core/client.js'; +import type { PagePromise } from '../core/pagination.js'; +import type { IdentityUsage, TemplateUsage, UsageListParams } from './types.js'; + +/** Identity/Template aggregates over an hourly Asia/Shanghai window. PAT or Admin SAT required. */ +export class Usage extends APIResource { + listIdentities(params: UsageListParams, options?: RequestOptions): PagePromise { + return this._client.getAPIList('usage/identities', params, options, 'cursor'); + } + listTemplates(params: UsageListParams, options?: RequestOptions): PagePromise { + return this._client.getAPIList('usage/templates', params, options, 'cursor'); + } +} +export type { IdentityUsage, TemplateUsage, UsageListParams } from './types.js'; diff --git a/src/forward/vault-credential.ts b/src/forward/vault-credential.ts index 32eb26e..b8ca42d 100644 --- a/src/forward/vault-credential.ts +++ b/src/forward/vault-credential.ts @@ -4,9 +4,18 @@ import type { RequestOptions } from '../core/client.js'; import type { APIPromise } from '../core/api-promise.js'; import type { PagePromise } from '../core/pagination.js'; import { pathParam, requestHeaders } from './_utils.js'; -import type { VaultCredential, VaultCredentialCreateParams, VaultCredentialListParams } from './types.js'; +import type { VaultCredential, VaultCredentialCreateParams, VaultCredentialListParams, VaultCredentialUpdateParams } from './types.js'; export class VaultCredentials extends APIResource { + /** Rotate write-only secrets or merge metadata. This operation is never automatically retried. */ + update(id: string, credID: string, params: VaultCredentialUpdateParams, options?: RequestOptions): APIPromise { + const { identity_id, ...body } = params; + return this._client.request({ + ...options, method: 'POST', path: `vaults/${pathParam(id, 'id')}/credentials/${pathParam(credID, 'cred_id')}`, + body: options?.body ?? body, query: { identity_id, ...options?.query }, maxRetries: 0, + }); + } + /** * 列出 Credential. * @@ -73,4 +82,4 @@ export class VaultCredentials extends APIResource { } -export type { VaultCredential, VaultCredentialCreateParams, VaultCredentialListParams } from './types.js'; +export type { VaultCredential, VaultCredentialCreateParams, VaultCredentialListParams, VaultCredentialUpdateParams } from './types.js'; diff --git a/src/managed/api-inventory.json b/src/managed/api-inventory.json index 1be0527..4c838a8 100644 --- a/src/managed/api-inventory.json +++ b/src/managed/api-inventory.json @@ -5473,5 +5473,173 @@ ], "responseType": "ManagedAgentsCredentialValidation", "responseMode": "json" + }, + { + "entry": "sessions.cancel", + "resource": "sessions", + "method": "cancel", + "httpMethod": "POST", + "path": "/sessions/{session_id}/cancel", + "params": [ + { + "name": "session_id", + "type": "string", + "location": "path", + "required": true + }, + { + "name": "params", + "type": "SessionCancelParams", + "location": "params", + "required": false, + "fields": [ + { + "name": "workspace_id", + "type": "string", + "location": "header", + "wireName": "qoder-workspace-id", + "required": false + }, + { + "name": "betas", + "type": "QoderBeta[]", + "location": "header", + "wireName": "x-qoder-beta", + "required": false + } + ] + } + ], + "responseType": "SessionCancelResponse", + "responseMode": "json" + }, + { + "entry": "deployments.runs.list", + "resource": "deployments.runs", + "method": "list", + "httpMethod": "GET", + "path": "/deployments/{deployment_id}/runs", + "params": [ + { + "name": "params", + "type": "DeploymentScopedRunListParams", + "location": "params", + "required": true, + "fields": [ + { + "name": "deployment_id", + "wireName": "deployment_id", + "location": "path", + "type": "string", + "required": true + }, + { + "name": "limit", + "type": "number", + "location": "query", + "wireName": "limit", + "required": false + }, + { + "name": "page", + "type": "string", + "location": "query", + "wireName": "page", + "required": false + }, + { + "name": "after_id", + "type": "string", + "location": "query", + "wireName": "after_id", + "required": false + }, + { + "name": "before_id", + "type": "string", + "location": "query", + "wireName": "before_id", + "required": false + }, + { + "name": "triggered_after", + "type": "string", + "location": "query", + "wireName": "triggered_after", + "required": false + }, + { + "name": "triggered_before", + "type": "string", + "location": "query", + "wireName": "triggered_before", + "required": false + }, + { + "name": "workspace_id", + "type": "string", + "location": "header", + "wireName": "qoder-workspace-id", + "required": false + }, + { + "name": "betas", + "type": "QoderBeta[]", + "location": "header", + "wireName": "x-qoder-beta", + "required": false + } + ] + } + ], + "responseType": "ManagedAgentsDeploymentRun", + "responseMode": "page", + "pagination": "page" + }, + { + "entry": "deployments.runs.retrieve", + "resource": "deployments.runs", + "method": "retrieve", + "httpMethod": "GET", + "path": "/deployments/{deployment_id}/runs/{run_id}", + "params": [ + { + "name": "runID", + "type": "string", + "location": "path", + "required": true + }, + { + "name": "params", + "type": "DeploymentScopedRunRetrieveParams", + "location": "params", + "required": true, + "fields": [ + { + "name": "deployment_id", + "wireName": "deployment_id", + "location": "path", + "type": "string", + "required": true + }, + { + "name": "workspace_id", + "type": "string", + "location": "header", + "wireName": "qoder-workspace-id", + "required": false + }, + { + "name": "betas", + "type": "QoderBeta[]", + "location": "header", + "wireName": "x-qoder-beta", + "required": false + } + ] + } + ], + "responseType": "ManagedAgentsDeploymentRun", + "responseMode": "json" } ] diff --git a/src/managed/index.ts b/src/managed/index.ts index f745248..a5c727d 100644 --- a/src/managed/index.ts +++ b/src/managed/index.ts @@ -10,3 +10,4 @@ export { Stream } from '../core/streaming.js'; export { ResumableSessionEventStream } from '../core/resumable-session-event-stream.js'; export { toFile, type Uploadable } from '../core/uploads.js'; export type { RequestOptions } from '../core/client.js'; +export { DeploymentScopedRuns } from './resources/deployment-scoped-run.js'; diff --git a/src/managed/resources/deployment-scoped-run.ts b/src/managed/resources/deployment-scoped-run.ts new file mode 100644 index 0000000..b112a4e --- /dev/null +++ b/src/managed/resources/deployment-scoped-run.ts @@ -0,0 +1,19 @@ +import { APIResource } from '../../core/resource.js'; +import type { RequestOptions } from '../../core/client.js'; +import type { APIPromise } from '../../core/api-promise.js'; +import type { PagePromise } from '../../core/pagination.js'; +import type { DeploymentScopedRunListParams, DeploymentScopedRunRetrieveParams, ManagedAgentsDeploymentRun } from '../types.js'; +import { splitParams, pathParam } from '../internal.js'; + +/** Runs belonging to a specific deployment. */ +export class DeploymentScopedRuns extends APIResource { + list(params: DeploymentScopedRunListParams, options?: RequestOptions): PagePromise { + const request = splitParams(params, options, { workspace_id: 'qoder-workspace-id', betas: 'x-qoder-beta' }, ['deployment_id']); + return this._client.getAPIList(`/deployments/${pathParam(params.deployment_id, 'deployment_id')}/runs`, request.values, request.options, 'page'); + } + retrieve(runID: string, params: DeploymentScopedRunRetrieveParams, options?: RequestOptions): APIPromise { + const request = splitParams(params, options, { workspace_id: 'qoder-workspace-id', betas: 'x-qoder-beta' }, ['deployment_id']); + return this._client.request({ ...request.options, method: 'GET', path: `/deployments/${pathParam(params.deployment_id, 'deployment_id')}/runs/${pathParam(runID, 'run_id')}` }); + } +} +export type { DeploymentScopedRunListParams, DeploymentScopedRunRetrieveParams } from '../types.js'; diff --git a/src/managed/resources/deployment.ts b/src/managed/resources/deployment.ts index 66dac4d..43fbedd 100644 --- a/src/managed/resources/deployment.ts +++ b/src/managed/resources/deployment.ts @@ -7,7 +7,10 @@ import type { Stream } from '../../core/streaming.js'; import type * as Types from '../types.js'; import { splitParams, pathParam, managedMultipart } from '../internal.js'; +import { DeploymentScopedRuns } from './deployment-scoped-run.js'; + export class Deployments extends APIResource { + readonly runs = new DeploymentScopedRuns(this._client); /** * Create Deployment diff --git a/src/managed/resources/index.ts b/src/managed/resources/index.ts index 5213756..92a6d67 100644 --- a/src/managed/resources/index.ts +++ b/src/managed/resources/index.ts @@ -19,3 +19,4 @@ export { Skills } from "./skill.js"; export { SkillsVersions } from "./skill-version.js"; export { Vaults } from "./vault.js"; export { VaultsCredentials } from "./vault-credential.js"; +export { DeploymentScopedRuns } from './deployment-scoped-run.js'; diff --git a/src/managed/resources/session.ts b/src/managed/resources/session.ts index 63495a5..faa0013 100644 --- a/src/managed/resources/session.ts +++ b/src/managed/resources/session.ts @@ -11,6 +11,12 @@ import { SessionsResources } from "./session-resource.js"; import { SessionsThreads } from "./session-thread.js"; export class Sessions extends APIResource { + /** Cancel the current turn; an idle session is a safe no-op. */ + cancel(sessionID: string, params: Types.SessionCancelParams = {}, options?: RequestOptions): APIPromise { + const request = splitParams(params, options, { workspace_id: 'qoder-workspace-id', betas: 'x-qoder-beta' }, []); + return this._client.request({ ...request.options, method: 'POST', path: `/sessions/${pathParam(sessionID, 'session_id')}/cancel` }); + } + readonly events = new SessionsEvents(this._client); readonly resources = new SessionsResources(this._client); readonly threads = new SessionsThreads(this._client); diff --git a/src/managed/types.ts b/src/managed/types.ts index 955e6df..51e3d2d 100644 --- a/src/managed/types.ts +++ b/src/managed/types.ts @@ -11588,3 +11588,28 @@ export type VaultRetrieveParams = VaultGetParams; export type VaultCredentialCreateParams = VaultCredentialNewParams; export type VaultCredentialRetrieveParams = VaultCredentialGetParams; + +/** Lightweight acknowledgement, returned for both applied (202) and idle (200) cancellation. */ +export interface SessionCancelResponse { + id: string; + type: 'session'; + status: 'canceling'; +} +export interface SessionCancelParams { + workspace_id?: string; + betas?: QoderBeta[]; +} +export interface DeploymentScopedRunListParams { + deployment_id: string; + limit?: number; + page?: string; + after_id?: string; + before_id?: string; + triggered_after?: string; + triggered_before?: string; + workspace_id?: string; + betas?: QoderBeta[]; +} +export interface DeploymentScopedRunRetrieveParams extends DeploymentRunRetrieveParams { + deployment_id: string; +} diff --git a/tests/api-contracts.test.mjs b/tests/api-contracts.test.mjs index bf66d27..3c190c1 100644 --- a/tests/api-contracts.test.mjs +++ b/tests/api-contracts.test.mjs @@ -9,7 +9,7 @@ const handwrittenMethods = { }; for (const mode of ['forward', 'managed']) { - test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 110 : 95} APIs)`, () => { + test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 113 : 98} APIs)`, () => { const client = testClient(mode, () => { throw Error('inventory must not call network'); }); const expectedRoots = [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]; const actualRoots = Object.entries(client).filter(([, value]) => value && typeof value === 'object' && Object.hasOwn(value, '_client')).map(([key]) => key); @@ -26,7 +26,7 @@ for (const mode of ['forward', 'managed']) { }; for (const entry of [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]) walk(client[entry], entry); const expected = methods[mode].map(m => `${m.entry}.${m.method}`); - assert.equal(expected.length, mode === 'forward' ? 110 : 95); + assert.equal(expected.length, mode === 'forward' ? 113 : 98); assert.equal(new Set(expected).size, expected.length); assert.deepEqual(actual.sort(), [...expected, ...handwrittenMethods[mode]].sort()); }); diff --git a/tests/api-expansion.test.mjs b/tests/api-expansion.test.mjs new file mode 100644 index 0000000..1829b45 --- /dev/null +++ b/tests/api-expansion.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { testClient, response, resource } from './helpers.mjs'; + +// Expectations frozen from api-doc master, rather than generated from SDK code. +const { operations } = JSON.parse(readFileSync(new URL('./fixtures/api-expansion.json', import.meta.url))); +const segment = 'id /?%#'; +for (const op of operations) { + test(`${op.mode}.${op.entry}.${op.method}: documented API expansion`, async () => { + const client = testClient(op.mode, async req => { + const url = new URL(req.url); + assert.equal(req.method, op.http); + assert.equal(url.pathname, `/api/v1/${op.mode === 'forward' ? 'forward' : 'cloud'}${op.path.replace(/\{\w+\}/g, encodeURIComponent(segment))}`); + assert.equal(req.headers.get('x-qoder-beta'), null); + for (const [key, value] of Object.entries(op.query)) { + assert.deepEqual(url.searchParams.getAll(key), (Array.isArray(value) ? value : [value]).map(String)); + } + assert.equal(url.searchParams.has('identity_ids[]'), false); + assert.equal(url.searchParams.has('template_ids[]'), false); + if (op.body) assert.deepEqual(await req.json(), op.body); + return response(op.response, op.method === 'create' ? 201 : op.entry === 'sessions' ? 202 : 200); + }); + let args = [...op.path.matchAll(/\{\w+\}/g)].map(() => segment); + if (op.paramsType) args.push({ ...op.body, ...op.query }); + if (op.entry === 'deployments.runs') { + args.shift(); + args[args.length - 1].deployment_id = segment; + } + const result = await resource(client, op.entry)[op.method](...args); + assert.deepEqual(JSON.parse(JSON.stringify(result)), op.response); + }); +} + +for (const method of ['listIdentities', 'listTemplates']) { + for (const backward of [false, true]) test(`Forward Usage ${method}: ${backward ? 'backward' : 'forward'} pagination retains the hourly window and filters`, async () => { + let calls = 0; + const op = operations.find(o => o.method === method); + const params = { ...op.query, ...(backward ? { before_id: 'initial' } : {}) }; + const client = testClient('forward', req => { + const url = new URL(req.url); + assert.equal(url.searchParams.get('start_at'), params.start_at); + assert.equal(url.searchParams.get('end_at'), params.end_at); + assert.deepEqual(url.searchParams.getAll('identity_ids'), params.identity_ids); + assert.deepEqual(url.searchParams.getAll('template_ids'), params.template_ids); + assert.equal(url.searchParams.has('start_time'), false); + assert.equal(url.searchParams.has('end_time'), false); + if (calls++) assert.equal(url.searchParams.get(backward ? 'before_id' : 'after_id'), backward ? 'first' : 'last'); + return response({ ...op.response, data: [op.response.data[0]], first_id: 'first', last_id: 'last', has_more: calls === 1 }); + }); + const page = await client.usage[method](params); + assert.equal(typeof page.data[0].active_seconds, 'number'); + assert.equal(page.data[0].active_seconds, op.response.data[0].active_seconds); + assert.equal(page.start_at, params.start_at); + await page.getNextPage(); + assert.equal(calls, 2); + assert.deepEqual(params, { ...op.query, ...(backward ? { before_id: 'initial' } : {}) }); + }); +} + +test('Scoped deployment runs replay the opaque cursor and stay scoped', async () => { + let calls = 0; + const op = operations.find(o => o.entry === 'deployments.runs' && o.method === 'list'); + const client = testClient('managed', req => { + const url = new URL(req.url); + assert.equal(url.pathname, '/api/v1/cloud/deployments/dep_one/runs'); + assert.equal(url.searchParams.get('triggered_after'), '2026-06-01T00:00:00Z'); + assert.equal(req.headers.get('qoder-workspace-id'), 'workspace_one'); + if (calls++) assert.equal(url.searchParams.get('page'), 'opaque +/=?'); + return response({ ...op.response, has_more: calls === 1, next_page: calls === 1 ? 'opaque +/=?' : null }); + }); + const runs = []; + for await (const run of client.deployments.runs.list({ deployment_id: 'dep_one', triggered_after: '2026-06-01T00:00:00Z', workspace_id: 'workspace_one' })) runs.push(run); + assert.equal(runs.length, 2); + assert.equal(calls, 2); +}); + +for (const status of [200, 202]) test(`Managed cancel accepts HTTP ${status} lightweight acknowledgement`, async () => { + const client = testClient('managed', () => response({ id: 'sess_one', type: 'session', status: 'canceling' }, status)); + assert.equal((await client.sessions.cancel('sess_one')).status, 'canceling'); +}); + +for (const failure of [429, 503, 'network']) test(`Credential rotation never retries ${failure}`, async () => { + let calls = 0; + const client = testClient('forward', () => { + calls++; + if (failure === 'network') throw new Error('connection failed'); + return response({ error: { type: 'api_error', message: 'rotation uncertain' } }, failure, { 'retry-after-ms': '1', 'x-should-retry': 'true' }); + }, { maxRetries: 3 }); + await assert.rejects(() => client.vaults.credentials.update('vault_one', 'cred_one', { auth: { type: 'static_bearer', token: 'new-test-secret' }, metadata: null }, { maxRetries: 3, idempotencyKey: 'caller-key' })); + assert.equal(calls, 1); +}); + +test('Credential rotation keeps query ownership separate and preserves null merge patches', async () => { + const client = testClient('forward', async req => { + assert.equal(new URL(req.url).searchParams.get('identity_id'), 'idn_one'); + assert.deepEqual(await req.json(), { auth: { type: 'mcp_oauth', expires_at: null, refresh: { scope: null, refresh_token: 'test-refresh' } }, metadata: { remove: null } }); + return response(operations.find(o => o.entry === 'vaults.credentials').response); + }); + await client.vaults.credentials.update('vault_one', 'cred_one', { identity_id: 'idn_one', auth: { type: 'mcp_oauth', expires_at: null, refresh: { scope: null, refresh_token: 'test-refresh' } }, metadata: { remove: null } }); +}); + +test('Usage accepts comma-separated filters without converting to bracketed query keys', async () => { + const client = testClient('forward', req => { + const query = new URL(req.url).searchParams; + assert.deepEqual(query.getAll('identity_ids'), ['idn_one,idn_two']); + assert.deepEqual(query.getAll('template_ids'), ['tmpl_one,tmpl_two']); + return response({ data: [], has_more: false }); + }); + await client.usage.listIdentities({ start_at: '2026-09-14T09:00:00', end_at: '2026-09-14T12:00:00', identity_ids: 'idn_one,idn_two', template_ids: 'tmpl_one,tmpl_two' }); +}); diff --git a/tests/fixtures/api-expansion.json b/tests/fixtures/api-expansion.json new file mode 100644 index 0000000..3eedac4 --- /dev/null +++ b/tests/fixtures/api-expansion.json @@ -0,0 +1,262 @@ +{ + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)", + "operations": [ + { + "mode": "forward", + "entry": "vaults.credentials", + "method": "update", + "goentry": "Vaults.Credentials", + "goname": "Update", + "http": "POST", + "path": "/vaults/{id}/credentials/{cred_id}", + "doc": "Credentials__update.md", + "body": { + "auth": { + "type": "static_bearer", + "token": "rotated-test-token" + }, + "metadata": { + "remove": null, + "rotated_by": "sdk" + } + }, + "query": { + "identity_id": "idn_contract" + }, + "response": { + "id": "vcred_xxx", + "type": "vault_credential", + "vault_id": "vault_xxx", + "auth": { + "type": "static_bearer", + "mcp_server_url": "https://mcp.example.com" + }, + "display_name": "", + "metadata": { + "rotated_by": "console" + }, + "archived_at": null, + "created_at": "2026-07-23T10:00:00Z", + "updated_at": "2026-08-27T12:00:00Z" + }, + "page": false, + "op": "updateVaultCredential", + "service": "VaultCredentialService", + "kind": "json", + "type": "VaultCredential", + "paramsType": "VaultCredentialUpdateParams" + }, + { + "mode": "forward", + "entry": "usage", + "method": "listIdentities", + "goentry": "Usage", + "goname": "ListIdentities", + "http": "GET", + "path": "/usage/identities", + "doc": "Usage__ListIdentityUsage.md", + "body": null, + "query": { + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "identity_ids": [ + "idn_one", + "idn_two" + ], + "template_ids": [ + "tmpl_one", + "tmpl_two" + ], + "limit": 20 + }, + "response": { + "type": "identity_usage.list", + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "has_more": false, + "first_id": "idn_abc", + "last_id": "idn_efg", + "data": [ + { + "type": "identity_usage", + "identity_id": "idn_abc", + "session_count": 3, + "active_seconds": 720.222, + "credits": 3.1 + }, + { + "type": "identity_usage", + "identity_id": "idn_efg", + "session_count": 2, + "active_seconds": 480.222, + "credits": 1.58 + } + ] + }, + "page": true, + "op": "listIdentityUsage", + "service": "UsageService", + "kind": "page", + "type": "IdentityUsage", + "paramsType": "UsageListParams" + }, + { + "mode": "forward", + "entry": "usage", + "method": "listTemplates", + "goentry": "Usage", + "goname": "ListTemplates", + "http": "GET", + "path": "/usage/templates", + "doc": "Usage__ListTemplateUsage.md", + "body": null, + "query": { + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "identity_ids": [ + "idn_one", + "idn_two" + ], + "template_ids": [ + "tmpl_one", + "tmpl_two" + ], + "limit": 20 + }, + "response": { + "type": "template_usage.list", + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "has_more": false, + "first_id": "tmpl_123", + "last_id": "tmpl_456", + "data": [ + { + "type": "template_usage", + "template_id": "tmpl_123", + "active_identities": 2, + "session_count": 3, + "active_seconds": 900.321, + "credits": 3.45 + }, + { + "type": "template_usage", + "template_id": "tmpl_456", + "active_identities": 2, + "session_count": 2, + "active_seconds": 300.123, + "credits": 1.23 + } + ] + }, + "page": true, + "op": "listTemplateUsage", + "service": "UsageService", + "kind": "page", + "type": "TemplateUsage", + "paramsType": "UsageListParams" + }, + { + "mode": "managed", + "entry": "sessions", + "method": "cancel", + "goentry": "Sessions", + "goname": "Cancel", + "http": "POST", + "path": "/sessions/{session_id}/cancel", + "doc": "sessions__cancel.md", + "body": null, + "query": {}, + "response": { + "id": "sess_019ef30a4f0275678965d496ab542ef0", + "type": "session", + "status": "canceling" + }, + "page": false, + "op": "cancelSession", + "service": "SessionService", + "kind": "json", + "type": "SessionCancelResponse", + "paramsType": "SessionCancelParams" + }, + { + "mode": "managed", + "entry": "deployments.runs", + "method": "list", + "goentry": "Deployments.Runs", + "goname": "List", + "http": "GET", + "path": "/deployments/{deployment_id}/runs", + "doc": "deployments__list-runs.md", + "body": null, + "query": { + "limit": 20, + "triggered_after": "2026-06-01T00:00:00Z" + }, + "response": { + "data": [ + { + "agent": { + "id": "agent_019ebb21ef8e7df6a559052c94875160", + "type": "agent", + "version": 1 + }, + "created_at": "2026-06-14T08:58:17Z", + "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a", + "error": null, + "id": "drun_019ec55a68af73028afa5b87931cb2f3", + "session_id": "sess_019ec55a68b37e1e8d660691af161ab4", + "trigger_context": { + "type": "manual" + }, + "type": "deployment_run" + } + ], + "first_id": "drun_019ec55a68af73028afa5b87931cb2f3", + "has_more": false, + "last_id": "drun_019ec55a68af73028afa5b87931cb2f3", + "next_page": null + }, + "page": true, + "op": "listDeploymentScopedRun", + "service": "DeploymentScopedRunService", + "kind": "page", + "type": "ManagedAgentsDeploymentRun", + "paramsType": "DeploymentScopedRunListParams" + }, + { + "mode": "managed", + "entry": "deployments.runs", + "method": "retrieve", + "goentry": "Deployments.Runs", + "goname": "Get", + "http": "GET", + "path": "/deployments/{deployment_id}/runs/{run_id}", + "doc": "deployments__get-run.md", + "body": null, + "query": {}, + "response": { + "agent": { + "id": "agent_019ebb21ef8e7df6a559052c94875160", + "type": "agent", + "version": 1 + }, + "created_at": "2026-06-14T08:58:17Z", + "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a", + "error": null, + "id": "drun_019ec55a68af73028afa5b87931cb2f3", + "session_id": "sess_019ec55a68b37e1e8d660691af161ab4", + "trigger_context": { + "type": "manual" + }, + "type": "deployment_run" + }, + "page": false, + "op": "getDeploymentScopedRun", + "service": "DeploymentScopedRunService", + "kind": "json", + "type": "ManagedAgentsDeploymentRun", + "paramsType": "DeploymentScopedRunRetrieveParams" + } + ] +} diff --git a/tests/fixtures/forward/api-contracts.json b/tests/fixtures/forward/api-contracts.json index 4e85bbb..51fcaba 100644 --- a/tests/fixtures/forward/api-contracts.json +++ b/tests/fixtures/forward/api-contracts.json @@ -2718,5 +2718,89 @@ "status": "archived" }, "empty": false + }, + { + "id": "vaults.credentials.update", + "response": { + "id": "vcred_xxx", + "type": "vault_credential", + "vault_id": "vault_xxx", + "auth": { + "type": "static_bearer", + "mcp_server_url": "https://mcp.example.com" + }, + "display_name": "", + "metadata": { + "rotated_by": "console" + }, + "archived_at": null, + "created_at": "2026-07-23T10:00:00Z", + "updated_at": "2026-08-27T12:00:00Z" + }, + "operation_id": "updateVaultCredential", + "entry": "Vaults.Credentials", + "empty": false + }, + { + "id": "usage.listIdentities", + "response": { + "type": "identity_usage.list", + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "has_more": false, + "first_id": "idn_abc", + "last_id": "idn_efg", + "data": [ + { + "type": "identity_usage", + "identity_id": "idn_abc", + "session_count": 3, + "active_seconds": 720.222, + "credits": 3.1 + }, + { + "type": "identity_usage", + "identity_id": "idn_efg", + "session_count": 2, + "active_seconds": 480.222, + "credits": 1.58 + } + ] + }, + "operation_id": "listIdentityUsage", + "entry": "Usage", + "empty": false + }, + { + "id": "usage.listTemplates", + "response": { + "type": "template_usage.list", + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "has_more": false, + "first_id": "tmpl_123", + "last_id": "tmpl_456", + "data": [ + { + "type": "template_usage", + "template_id": "tmpl_123", + "active_identities": 2, + "session_count": 3, + "active_seconds": 900.321, + "credits": 3.45 + }, + { + "type": "template_usage", + "template_id": "tmpl_456", + "active_identities": 2, + "session_count": 2, + "active_seconds": 300.123, + "credits": 1.23 + } + ] + }, + "operation_id": "listTemplateUsage", + "entry": "Usage", + "empty": false } ] diff --git a/tests/fixtures/forward/api-methods.json b/tests/fixtures/forward/api-methods.json index ed03758..1c26ce7 100644 --- a/tests/fixtures/forward/api-methods.json +++ b/tests/fixtures/forward/api-methods.json @@ -2800,5 +2800,107 @@ "kind": "path" } ] + }, + { + "id": "vaults.credentials.update", + "entry": "vaults.credentials", + "method": "update", + "args": [ + { + "name": "id", + "kind": "path" + }, + { + "name": "cred_id", + "kind": "path" + }, + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "auth", + "location": "body" + }, + { + "wire": "metadata", + "location": "body" + }, + { + "wire": "identity_id", + "location": "query" + } + ] + } + ], + "paginated": false + }, + { + "id": "usage.listIdentities", + "entry": "usage", + "method": "listIdentities", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "start_at", + "location": "query" + }, + { + "wire": "end_at", + "location": "query" + }, + { + "wire": "identity_ids", + "location": "query" + }, + { + "wire": "template_ids", + "location": "query" + }, + { + "wire": "limit", + "location": "query" + } + ] + } + ], + "paginated": true + }, + { + "id": "usage.listTemplates", + "entry": "usage", + "method": "listTemplates", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "start_at", + "location": "query" + }, + { + "wire": "end_at", + "location": "query" + }, + { + "wire": "identity_ids", + "location": "query" + }, + { + "wire": "template_ids", + "location": "query" + }, + { + "wire": "limit", + "location": "query" + } + ] + } + ], + "paginated": true } ] diff --git a/tests/fixtures/forward/api-operation-cases.json b/tests/fixtures/forward/api-operation-cases.json index 2d7497f..9128b75 100644 --- a/tests/fixtures/forward/api-operation-cases.json +++ b/tests/fixtures/forward/api-operation-cases.json @@ -1,7 +1,7 @@ { "schema_version": 1, "openapi_sha256": "cfc0d3556f61838f65174b4c8ae92eded0d7c9fc00dcbd58d07dd6365e711f96", - "operation_count": 110, + "operation_count": 113, "cases": [ { "operation_id": "addSessionResource", @@ -4769,6 +4769,162 @@ ], "request_body": null, "source": "Forward router POST /environments/{id}/archive; verified against CN production on 2026-09-09" + }, + { + "operation_id": "updateVaultCredential", + "resource": "Vaults.Credentials", + "clients": { + "go": "Vaults.Credentials", + "typescript": "vaults.credentials" + }, + "methods": { + "go": "Update", + "typescript": "update" + }, + "http_method": "POST", + "path": "/vaults/{id}/credentials/{cred_id}", + "parameters": [ + { + "wire_name": "id", + "location": "path", + "required": true, + "value": "path_contract" + }, + { + "wire_name": "cred_id", + "location": "path", + "required": true, + "value": "path_contract" + }, + { + "wire_name": "identity_id", + "location": "query", + "required": false, + "value": "idn_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "auth": { + "type": "static_bearer", + "token": "rotated-test-token" + }, + "metadata": { + "remove": null, + "rotated_by": "sdk" + } + } + }, + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "operation_id": "listIdentityUsage", + "resource": "Usage", + "clients": { + "go": "Usage", + "typescript": "usage" + }, + "methods": { + "go": "ListIdentities", + "typescript": "listIdentities" + }, + "http_method": "GET", + "path": "/usage/identities", + "parameters": [ + { + "wire_name": "start_at", + "location": "query", + "required": true, + "value": "2026-09-14T09:00:00" + }, + { + "wire_name": "end_at", + "location": "query", + "required": true, + "value": "2026-09-14T12:00:00" + }, + { + "wire_name": "identity_ids", + "location": "query", + "required": false, + "value": [ + "idn_one", + "idn_two" + ] + }, + { + "wire_name": "template_ids", + "location": "query", + "required": false, + "value": [ + "tmpl_one", + "tmpl_two" + ] + }, + { + "wire_name": "limit", + "location": "query", + "required": false, + "value": 20 + } + ], + "request_body": null, + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "operation_id": "listTemplateUsage", + "resource": "Usage", + "clients": { + "go": "Usage", + "typescript": "usage" + }, + "methods": { + "go": "ListTemplates", + "typescript": "listTemplates" + }, + "http_method": "GET", + "path": "/usage/templates", + "parameters": [ + { + "wire_name": "start_at", + "location": "query", + "required": true, + "value": "2026-09-14T09:00:00" + }, + { + "wire_name": "end_at", + "location": "query", + "required": true, + "value": "2026-09-14T12:00:00" + }, + { + "wire_name": "identity_ids", + "location": "query", + "required": false, + "value": [ + "idn_one", + "idn_two" + ] + }, + { + "wire_name": "template_ids", + "location": "query", + "required": false, + "value": [ + "tmpl_one", + "tmpl_two" + ] + }, + { + "wire_name": "limit", + "location": "query", + "required": false, + "value": 20 + } + ], + "request_body": null, + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" } ] } diff --git a/tests/fixtures/managed/api-contracts.json b/tests/fixtures/managed/api-contracts.json index 841c5fd..2ea209c 100644 --- a/tests/fixtures/managed/api-contracts.json +++ b/tests/fixtures/managed/api-contracts.json @@ -2443,5 +2443,71 @@ "environment_variables": {}, "vault_ids": [] } + }, + { + "id": "sessions.cancel", + "response": { + "id": "sess_019ef30a4f0275678965d496ab542ef0", + "type": "session", + "status": "canceling" + }, + "httpMethod": "POST", + "path": "/sessions/{}/cancel", + "route": "/sessions/{session_id}/cancel", + "doc": "sessions__cancel.md" + }, + { + "id": "deployments.runs.list", + "response": { + "data": [ + { + "agent": { + "id": "agent_019ebb21ef8e7df6a559052c94875160", + "type": "agent", + "version": 1 + }, + "created_at": "2026-06-14T08:58:17Z", + "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a", + "error": null, + "id": "drun_019ec55a68af73028afa5b87931cb2f3", + "session_id": "sess_019ec55a68b37e1e8d660691af161ab4", + "trigger_context": { + "type": "manual" + }, + "type": "deployment_run" + } + ], + "first_id": "drun_019ec55a68af73028afa5b87931cb2f3", + "has_more": false, + "last_id": "drun_019ec55a68af73028afa5b87931cb2f3", + "next_page": null + }, + "httpMethod": "GET", + "path": "/deployments/{}/runs", + "route": "/deployments/{deployment_id}/runs", + "doc": "deployments__list-runs.md" + }, + { + "id": "deployments.runs.retrieve", + "response": { + "agent": { + "id": "agent_019ebb21ef8e7df6a559052c94875160", + "type": "agent", + "version": 1 + }, + "created_at": "2026-06-14T08:58:17Z", + "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a", + "error": null, + "id": "drun_019ec55a68af73028afa5b87931cb2f3", + "session_id": "sess_019ec55a68b37e1e8d660691af161ab4", + "trigger_context": { + "type": "manual" + }, + "type": "deployment_run" + }, + "httpMethod": "GET", + "path": "/deployments/{}/runs/{}", + "route": "/deployments/{deployment_id}/runs/{run_id}", + "doc": "deployments__get-run.md" } ] diff --git a/tests/fixtures/managed/api-methods.json b/tests/fixtures/managed/api-methods.json index 4d0b8d7..3b772f1 100644 --- a/tests/fixtures/managed/api-methods.json +++ b/tests/fixtures/managed/api-methods.json @@ -3379,5 +3379,70 @@ ] } ] + }, + { + "id": "sessions.cancel", + "entry": "sessions", + "method": "cancel", + "args": [ + { + "name": "session_id", + "kind": "path" + }, + { + "name": "params", + "kind": "params", + "fields": [] + } + ], + "paginated": false + }, + { + "id": "deployments.runs.list", + "entry": "deployments.runs", + "method": "list", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "deployment_id", + "location": "path" + }, + { + "wire": "limit", + "location": "query" + }, + { + "wire": "triggered_after", + "location": "query" + } + ] + } + ], + "paginated": true + }, + { + "id": "deployments.runs.retrieve", + "entry": "deployments.runs", + "method": "retrieve", + "args": [ + { + "name": "runID", + "kind": "path" + }, + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "deployment_id", + "location": "path" + } + ] + } + ], + "paginated": false } ] diff --git a/tests/live/forward-live.test.mjs b/tests/live/forward-live.test.mjs index 1e4140a..5931a85 100644 --- a/tests/live/forward-live.test.mjs +++ b/tests/live/forward-live.test.mjs @@ -5,7 +5,7 @@ import { createForwardLiveSuite, forwardLiveSkipReason } from './forward-support // Live runs are opt-in through explicit gates and PAT env vars. Never read token files. for (const scenario of forwardScenarios) { test(`Forward live ${scenario.name}`, { skip: forwardLiveSkipReason(scenario) }, async (t) => { - const suite = createForwardLiveSuite(); + const suite = createForwardLiveSuite(t); try { await scenario.run(suite, t); } finally { await suite.close(); } }); diff --git a/tests/live/forward-scenarios.mjs b/tests/live/forward-scenarios.mjs index 18fb1b3..1fc3a0b 100644 --- a/tests/live/forward-scenarios.mjs +++ b/tests/live/forward-scenarios.mjs @@ -4,7 +4,7 @@ import { toFile } from '../../dist/forward/index.js'; import { batchTerminal, liveName, marker } from './forward-support.mjs'; // Each run function is also executed by offline scenario tests with an injected SDK client. -// Operation fixtures separately cover all 110 API methods, including destructive variants +// Operation fixtures separately cover all 113 API methods, including destructive variants // that the live lifecycle suite deliberately does not invoke. export const forwardScenarios = []; const scenario = (name, run, extras = {}) => forwardScenarios.push({ name, gates: ['WRITE'], run, ...extras }); @@ -153,14 +153,60 @@ scenario('vault_credential_lifecycle', async (s) => { s.cleanup('vault', (options) => s.client.vaults.delete(vault.id, options)); assert.equal((await s.client.vaults.retrieve(vault.id, s.options)).id, vault.id); const secret = 'sdk-live-placeholder-secret'; - const credential = await s.client.vaults.credentials.create(vault.id, { auth: { type: 'static_bearer', mcp_server_url: `https://example.com/${liveName('mcp')}`, token: secret } }, s.options); + const mcpURL = `https://example.com/${liveName('mcp')}`; + const credential = await s.client.vaults.credentials.create(vault.id, { auth: { type: 'static_bearer', mcp_server_url: mcpURL, token: secret } }, s.options); s.cleanup('credential', (options) => s.client.vaults.credentials.delete(vault.id, credential.id, options)); const got = await s.client.vaults.credentials.retrieve(vault.id, credential.id, s.options); assert.equal(got.id, credential.id); assert.ok(!JSON.stringify([credential, got]).includes(secret), 'credential responses must redact secrets'); await s.client.vaults.credentials.list(vault.id, {}, s.options); + const seeded = await s.client.vaults.credentials.update(vault.id, credential.id, { metadata: { keep: 'original', remove: 'old' } }, s.options); + const rotated = marker(); + const updated = await s.client.vaults.credentials.update(vault.id, credential.id, { auth: { type: 'static_bearer', token: rotated }, metadata: { remove: null, added: 'new' } }, s.options); + const saved = await s.client.vaults.credentials.retrieve(vault.id, credential.id, s.options); + for (const value of [updated, saved]) { + assert.equal(value.id, credential.id); + assert.equal(value.auth.type, 'static_bearer'); + assert.equal(value.auth.mcp_server_url, mcpURL); + assert.equal(value.metadata.keep, 'original'); + assert.equal(value.metadata.added, 'new'); + assert.ok(!('remove' in value.metadata)); + } + const cleared = await s.client.vaults.credentials.update(vault.id, credential.id, { metadata: null }, s.options); + const clearedSaved = await s.client.vaults.credentials.retrieve(vault.id, credential.id, s.options); + assert.deepEqual(cleared.metadata ?? {}, {}); + assert.deepEqual(clearedSaved.metadata ?? {}, {}); + for (const value of [secret, rotated]) assert.ok(!JSON.stringify([seeded, updated, saved, cleared, clearedSaved]).includes(value), 'secrets must be redacted'); }); +scenario('usage_hourly', async (s) => { + const end = Math.floor(Date.now() / 3_600_000) * 3_600_000; + const hour = value => new Date(value + 8 * 3_600_000).toISOString().slice(0, 13) + ':00:00'; + const bounds = { start_at: hour(end - 24 * 3_600_000), end_at: hour(end) }; + for (const [method, kind, idField] of [['listIdentities', 'identity', 'identity_id'], ['listTemplates', 'template', 'template_id']]) { + const page = await s.client.usage[method]({ ...bounds, limit: 2 }, s.options); + assert.equal(page.type, `${kind}_usage.list`); + assert.equal(page.start_at, bounds.start_at); + assert.equal(page.end_at, bounds.end_at); + assert.ok(page.data.length <= 2); + for (const row of page.data) { + assert.equal(row.type, `${kind}_usage`); + assert.ok(row[idField]); + for (const field of ['active_seconds', 'credits', 'session_count', ...(kind === 'template' ? ['active_identities'] : [])]) assert.ok(Number.isFinite(row[field]) && row[field] >= 0, `${field} must be a nonnegative number`); + } + if (page.hasNextPage()) { + const next = await page.getNextPage(); + assert.equal(next.start_at, bounds.start_at); + assert.equal(next.end_at, bounds.end_at); + } + const ids = page.data.map(row => row[idField]); + if (ids.length) for (const value of [ids, ids.join(',')]) { + const filtered = await s.client.usage[method]({ ...bounds, limit: 2, [idField + 's']: value }, s.options); + assert.ok(filtered.data.every(row => ids.includes(row[idField])), 'Usage ignored multi-ID filter'); + } + } +}, { gates: [] }); + scenario('channel_qr_session_lifecycle', async (s) => { const identity = await s.identity(); const template = await s.template((await s.environment()).id); diff --git a/tests/live/forward-support.mjs b/tests/live/forward-support.mjs index 9798a5f..b2e28e5 100644 --- a/tests/live/forward-support.mjs +++ b/tests/live/forward-support.mjs @@ -217,10 +217,16 @@ export function forwardLiveSkipReason(scenario) { return false; } -export function createForwardLiveSuite() { +export function createForwardLiveSuite(t) { const timeout = seconds('QODER_FORWARD_LIVE_TIMEOUT_SECONDS', 15); const scenarioTimeout = seconds('QODER_E2E_TIMEOUT_SECONDS', 180, 1800); - return new ForwardLiveSuite(new ForwardClient({ pat: process.env.QODER_FORWARD_PAT, timeout, maxRetries: 0 }), { + return new ForwardLiveSuite(new ForwardClient({ pat: process.env.QODER_FORWARD_PAT, timeout, maxRetries: 0, fetch: async (input, init) => { + const started = Date.now(); + const url = new URL(input instanceof Request ? input.url : String(input)); + const response = await fetch(input, init); + t?.diagnostic(`http method=${init?.method ?? 'GET'} path=${url.pathname} status=${response.status} request_id=${response.headers.get('x-request-id') ?? ''} elapsed=${Date.now() - started}ms`); + return response; + } }), { timeout, scenarioTimeout, model: process.env.QODER_FORWARD_MODEL, }); } diff --git a/tests/live/managed-scenarios.mjs b/tests/live/managed-scenarios.mjs index 54073dd..65cc158 100644 --- a/tests/live/managed-scenarios.mjs +++ b/tests/live/managed-scenarios.mjs @@ -27,6 +27,9 @@ export const managedScenarios = [ await s.client.sessions.events.list(session.id, {}, s.options()); await s.client.sessions.resources.list(session.id, {}, s.options()); await s.client.sessions.threads.list(session.id, {}, s.options()); + const { data: cancellation, response } = await s.client.sessions.cancel(session.id, {}, s.options()).withResponse(); + assert.equal(response.status, 200); + assert.deepEqual({ id: cancellation.id, type: cancellation.type, status: cancellation.status }, { id: session.id, type: 'session', status: 'canceling' }); }), scenario('memory_store_lifecycle', 'write', async (s) => { const store = await s.createMemoryStore(); @@ -84,6 +87,8 @@ export const managedScenarios = [ await s.client.deployments.pause(deployment.id, {}, s.options()); await s.client.deployments.unpause(deployment.id, {}, s.options()); await s.client.deploymentRuns.list({}, s.options()); + const scoped = await s.client.deployments.runs.list({ deployment_id: deployment.id, limit: 2 }, s.options()); + assert.ok(scoped.data.every(run => run.deployment_id === deployment.id)); }), scenario('dream_lifecycle', 'execution', async (s) => { const store = await s.createMemoryStore(); @@ -123,8 +128,42 @@ export const managedScenarios = [ s.cleanupSession(run.session_id); const got = await s.client.deploymentRuns.retrieve(run.id, {}, s.options()); assert.equal(got.session_id, run.session_id, 'Run session changed'); + const scoped = await s.client.deployments.runs.retrieve(run.id, { deployment_id: deployment.id }, s.options()); + assert.equal(scoped.id, run.id); + assert.equal(scoped.deployment_id, deployment.id); + assert.equal(scoped.session_id, run.session_id); + const runs = await s.client.deployments.runs.list({ deployment_id: deployment.id, limit: 10 }, s.options()); + assert.ok(runs.data.some(item => item.id === run.id), 'Scoped list did not contain the created Run'); + assert.ok(runs.data.every(item => item.deployment_id === deployment.id), 'Scoped list returned a different deployment'); await s.waitTurn(run.session_id, '', [token]); }), + scenario('deployment_scoped_runs', 'execution', async (s) => { + const environment = await s.createEnvironment(), agent = await s.createAgent(); + const deployment = await s.client.deployments.create({ name: unique('scoped-runs'), agent: agent.id, environment_id: environment.id, initial_events: [userMessage('Reply with SDK-LIVE.')] }, s.options()); + s.cleanup(`Deployment ${deployment.id}`, () => s.client.deployments.archive(deployment.id, {}, s.options())); + const run = await s.client.deployments.run(deployment.id, {}, s.options()); + assert.ok(run.session_id, 'Run returned no session for cleanup'); + s.cleanupSession(run.session_id); + const saved = await s.client.deployments.runs.retrieve(run.id, { deployment_id: deployment.id }, s.options()); + assert.equal(saved.id, run.id); + assert.equal(saved.deployment_id, deployment.id); + assert.equal(saved.session_id, run.session_id); + const runs = await s.client.deployments.runs.list({ deployment_id: deployment.id, limit: 10 }, s.options()); + assert.ok(runs.data.some(item => item.id === run.id), 'Scoped list omitted the created Run'); + assert.ok(runs.data.every(item => item.deployment_id === deployment.id), 'Scoped list returned another deployment'); + }), + scenario('session_cancel_active', 'execution', async (s) => { + const environment = await s.createEnvironment(); + const agent = await s.createAgent({ tools: [{ type: 'agent_toolset_20260401' }] }); + const session = await s.client.sessions.create({ agent: agent.id, environment_id: environment.id }, s.options()); + s.cleanupSession(session.id); + await s.sendTurn(session.id, 'Use a shell command to sleep 30 seconds, then reply with SDK-LIVE.'); + const { data, response } = await s.client.sessions.cancel(session.id, {}, s.options()).withResponse(); + // The turn can finish between send and cancel; HTTP 200 is then the documented idle no-op. + assert.ok([200, 202].includes(response.status)); + assert.deepEqual({ id: data.id, type: data.type, status: data.status }, { id: session.id, type: 'session', status: 'canceling' }); + while (!['idle', 'terminated'].includes((await s.client.sessions.retrieve(session.id, {}, s.options())).status)) await s.pollPause(); + }), scenario('dream_e2e', 'e2e', async (s) => { const store = await s.createMemoryStore(); const token = marker(); diff --git a/tests/scenario-harness.mjs b/tests/scenario-harness.mjs index ae6da37..0a808f8 100644 --- a/tests/scenario-harness.mjs +++ b/tests/scenario-harness.mjs @@ -117,6 +117,7 @@ export class MockPlatform { this.events.get(sessionID).push(user); result.push(user); if (input.type === 'user.interrupt') { session.status = 'idle'; continue; } const prompt = typeof input.content === 'string' ? input.content : (input.content ?? []).filter(x => x.type === 'text').map(x => x.text).join('\n'); + if (prompt.includes('sleep 30 seconds')) { session.status = 'running'; continue; } const events = this.events.get(sessionID); if (!prompt.startsWith('Reply with exactly ')) events.push({ id: this.id('evt'), type: 'agent.tool_use', name: 'Read' }); events.push({ id: this.id('evt'), type: 'agent.message', content: [{ type: 'text', text: this.proof(session, prompt) }] }, { id: this.id('evt'), type: 'session.status_idle', stop_reason: 'end_turn' }); @@ -150,6 +151,18 @@ export class MockPlatform { this.logs.push({ method: req.method, path, body: clone(body), query: Object.fromEntries(url.searchParams) }); const parts = path.split('/').filter(Boolean), action = parts.at(-1), parent = '/' + parts.slice(0,-1).join('/'); if (req.method === 'GET') { + if (parts[0] === 'usage') { + assert.ok(url.searchParams.get('start_at') && url.searchParams.get('end_at')); + assert.ok(!url.searchParams.has('start_time') && !url.searchParams.has('end_time')); + const kind = action === 'identities' ? 'identity' : 'template'; + return response({ type: `${kind}_usage.list`, start_at: url.searchParams.get('start_at'), end_at: url.searchParams.get('end_at'), data: [{ type: `${kind}_usage`, [`${kind}_id`]: `${kind}_usage_test`, session_count: 1, active_seconds: 1.25, credits: 0.5, ...(kind === 'template' ? { active_identities: 1 } : {}) }], has_more: false }); + } + if (parts[0] === 'deployments' && parts[2] === 'runs') { + const runs = this.data('/deployment_runs').filter(run => run.deployment_id === parts[1]); + if (parts.length === 3) return this.pagination(runs); + const run = runs.find(run => run.id === parts[3]); + return run ? response(clone(run)) : response({ error: { message: 'run not found in deployment' } }, 404); + } if (action === 'models') return this.pagination([{ id: 'model-test', is_enabled: true, enabled: true, name: 'Mock model' }]); if (action === 'stats') return response({ total: this.data('/identities').length }); if (action === 'stream' || action === 'events') { @@ -194,8 +207,10 @@ export class MockPlatform { } if (['archive','pause','unpause','disable','enable','cancel'].includes(action)) { const item = this.get(parent); + const wasActive = !['idle', 'terminated'].includes(item.status); item.status = ({ archive:'archived', pause:'paused', unpause:'active', cancel: parts[0] === 'sessions' ? 'idle' : 'canceled' })[action] ?? item.status; if (action === 'disable' || action === 'enable') item.enabled = action === 'enable'; + if (this.mode === 'managed' && parts[0] === 'sessions' && action === 'cancel') return response({ id: item.id, type: 'session', status: 'canceling' }, wasActive ? 202 : 200); return response(clone(item)); } if (action === 'clone') return response(this.create('/templates', { ...this.get(parent), id: this.id('tmpl') })); @@ -252,6 +267,20 @@ export class MockPlatform { } return response(clone(item), 201); } + if (this.mode === 'forward' && /\/vaults\/[^/]+\/credentials\/[^/]+$/.test(path)) { + const credential = this.get(path); + if ('metadata' in body) { + if (body.metadata === null) credential.metadata = {}; + else { + credential.metadata ??= {}; + for (const [key, value] of Object.entries(body.metadata)) { + if (value === null) delete credential.metadata[key]; else credential.metadata[key] = value; + } + } + } + assert.ok(!body.auth?.mcp_server_url, 'MCP URL is immutable during updates'); + return response(clone(credential)); + } return response(clone(this.update(path, body))); } assertCleanup() { diff --git a/tests/scenarios/forward.test.mjs b/tests/scenarios/forward.test.mjs index e031120..f38b772 100644 --- a/tests/scenarios/forward.test.mjs +++ b/tests/scenarios/forward.test.mjs @@ -4,7 +4,7 @@ import { ForwardLiveSuite } from '../live/forward-support.mjs'; import { forwardScenarios } from '../live/forward-scenarios.mjs'; for (const scenario of forwardScenarios) test(`Forward offline ${scenario.name}`, async () => { const platform = new MockPlatform('forward'); - const suite = new ForwardLiveSuite(platform.client, { timeout: 1000, scenarioTimeout: 2000, pollInterval: 1, model: 'model-test', fetch: platform.fetch }); + const suite = new ForwardLiveSuite(platform.client, { timeout: 1000, scenarioTimeout: 2000, pollInterval: 1, model: 'auto', fetch: platform.fetch }); try { if (scenario.requiresSession) { const environment = await suite.environment(), identity = await suite.identity(), template = await suite.template(environment.id); diff --git a/tests/types-smoke.ts b/tests/types-smoke.ts index 4571849..272d696 100644 --- a/tests/types-smoke.ts +++ b/tests/types-smoke.ts @@ -124,3 +124,24 @@ forward.serviceAccountTokens; managed.webhooks; // @ts-expect-error Model shape is a string or object, never a number. void managed.agents.create({ name: 'bad model', model: 123 }); + +// New public API signatures. +void forward.usage.listIdentities({ start_at: '2026-09-14T09:00:00', end_at: '2026-09-14T12:00:00', identity_ids: ['idn'] }); +void forward.usage.listTemplates({ start_at: '2026-09-14T09:00:00', end_at: '2026-09-14T12:00:00', template_ids: ['tmpl'] }); +void forward.vaults.credentials.update('vault', 'credential', { auth: { type: 'environment_variable', secret_value: 'secret' }, metadata: null }); +void managed.sessions.cancel('session').then(ack => { const status: 'canceling' = ack.status; void status; }); +void managed.deployments.runs.list({ deployment_id: 'deployment', triggered_after: '2026-06-01T00:00:00Z' }); +void managed.deployments.runs.retrieve('run', { deployment_id: 'deployment' }); +// @ts-expect-error Hourly bounds are required. +forward.usage.listIdentities({}); +// @ts-expect-error Legacy Unix-millisecond parameters are not exposed. +forward.usage.listIdentities({ start_at: '2026-09-14T09:00:00', end_at: '2026-09-14T12:00:00', start_time: 123 }); +// @ts-expect-error Credential auth patches cannot change immutable fields. +forward.vaults.credentials.update('vault', 'cred', { auth: { type: 'static_bearer', mcp_server_url: 'https://example.test' } }); +// @ts-expect-error Search is outside this expansion. +forward.vaults.search({}); +// @ts-expect-error Search is outside this expansion. +managed.vaults.search({}); + +// @ts-expect-error Scoped run queries require a deployment ID. +managed.deployments.runs.list({});