From 0fbd54fd56d048379ab5b5c1c3c036a16188bdbb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:38:56 +0200 Subject: [PATCH 1/4] chore(deps): pin @polymorfa/sdk 0.1.0-dev.20260922102711 from npm Replace the github:polymorfa/sdks#94bfd09 pin with the exact npm dev prerelease. The newer SDK reports a claim activity when a placed call is answered; the calls media test expects it. README install notes say the CLI itself is not on npm yet. --- README.md | 17 ++++++-- docs/command-research/calls.md | 5 ++- docs/development/bansafe-sdk-integration.md | 43 +++++++++++++++++---- package-lock.json | 17 ++------ package.json | 2 +- test/commands/calls-sockets.test.ts | 1 + 6 files changed, 57 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8e268db9..078d5d42 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ The official command-line interface for Polymorfa. -The package is under active development and has not been published to npm. -The source repository is private, so GitHub installation requires repository -access until `@polymorfa/cli` is released publicly. +The package is under active development. `@polymorfa/cli` is not published to +npm yet, so `npm install --global @polymorfa/cli` does not work. Once the CLI is +published, that command will be the supported installation. Until then, install +from the private source repository, which requires repository access. ## Requirements @@ -30,12 +31,20 @@ npm link polymorfa version ``` -The verified version output includes both runtime components: +Both methods install the CLI's dependencies, including the TypeScript SDK +`@polymorfa/sdk`, from the public npm registry. The CLI pins one exact SDK +prerelease in `package.json`. + +The version output includes both runtime components: ```text polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.0 ``` +The SDK component reports the version constant built into the SDK. SDK dev +prereleases report `0.1.0-dev.0`, not their full prerelease version; run +`npm ls @polymorfa/sdk` to see the installed build. + ## Authentication profiles Until the backend exposes a revocable CLI device-authorization flow, login diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 45f0844d..0fea7de8 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -3,8 +3,9 @@ - **Status:** Implemented against Calls contract revision 1, unpublished - **Reviewed:** 2026-09-17 - **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` - subpath) at `polymorfa/sdks` commit - `94bfd09825817f842434e206e6a77adb5cc0c414` + subpath) `0.1.0-dev.20260922102711` from npm. The research was done against + `polymorfa/sdks` commit `94bfd09825817f842434e206e6a77adb5cc0c414`; the npm + build also reports a `claim` activity when the placed call is answered. ## Sources reviewed diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index b440d814..2dc9b2fc 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -1,12 +1,39 @@ # BanSafe SDK integration check -The BanSafe CLI commands depend on the matching typed SDK revision pinned in -`package.json`. During coordinated SDK and CLI development, verify both -worktrees without replacing that immutable dependency in either manifest: +The BanSafe CLI commands depend on the matching typed SDK version pinned in +`package.json`. -The pinned SDK revision is `polymorfa/sdks@94bfd09825817f842434e206e6a77adb5cc0c414` -(the Calls client ships in the same package as `@polymorfa/sdk/calls`). -Its coverage ledger is reconciled against the API contracts at +## SDK dependency + +The CLI installs `@polymorfa/sdk` from npm. Each merge to the `dev` branch of +`polymorfa/sdks` publishes a prerelease under the `dev` dist-tag, versioned +`0.1.0-dev.` (for example +`0.1.0-dev.20260919094454`). Later builds always sort higher. See +`docs/releasing.md` in `polymorfa/sdks`. + +Pin one exact version, never a range or the `dev` tag, so every install and +published CLI resolves the same SDK build: + +```shell +npm view @polymorfa/sdk dist-tags # find the newest dev build +npm install --save-exact @polymorfa/sdk@0.1.0-dev. +npm ci && npm run verify +``` + +Git-commit pins (`github:polymorfa/sdks#`) no longer work. The SDK lives +in `packages/typescript` of a private workspace root, and npm installs the +repository root. + +The CLI pins `0.1.0-dev.20260922102711`, the first build published from npm +with provenance. The last git pin was +`polymorfa/sdks@94bfd09825817f842434e206e6a77adb5cc0c414`. + +## Coordinated SDK and CLI development + +During coordinated SDK and CLI development, verify both worktrees without +replacing the pinned dependency in either manifest. The Calls client ships in +the same package as `@polymorfa/sdk/calls`. The SDK coverage ledger at the last +git pin is reconciled against the API contracts at `polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform snapshot SHA-256 `2a30e8e0af8902703609f8ed0f5bb2c108b39c5c11863d5ebd8907e56a9f25d2`). @@ -29,8 +56,8 @@ npx vitest run test/commands/bansafe.test.ts npm run build ``` -Before publishing the CLI, confirm that the pinned SDK revision remains -available, run `npm ci`, and run `npm run verify` from a fresh checkout. A +Before publishing the CLI, confirm that the pinned SDK version is published +(`npm view @polymorfa/sdk@ version`), run `npm ci`, and run `npm run verify` from a fresh checkout. A local-path install is integration evidence only; it is not a publishable dependency. diff --git a/package-lock.json b/package-lock.json index 489c34a4..f71d41a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", + "@polymorfa/sdk": "0.1.0-dev.20260922102711", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,18 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#94bfd09825817f842434e206e6a77adb5cc0c414", - "integrity": "sha512-C12O4fRO1i5poh2PpEB9FwaznqiiloOKV94jroS+NeTXFD9we2km53q/xp4Ftyq8GyWQUO7AY9pwPFsRiW3m6A==", - "workspaces": [ - "packages/calls", - "packages/browser", - "packages/ui", - "packages/elements", - "packages/react", - "packages/nextjs", - "packages/devtools" - ], + "version": "0.1.0-dev.20260922102711", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260922102711.tgz", + "integrity": "sha512-m2cnvInBZr4HZKslZewikM48mQIcREHuHJ3oF+/My/DN7pscwquTTkN6RM0mIQfxV+848hzsJZYoIqWxTgvqQA==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index ee632383..b16ea713 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", + "@polymorfa/sdk": "0.1.0-dev.20260922102711", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index 0475ad8d..42235d17 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -350,6 +350,7 @@ describe('calls watch and media', () => { expect(statuses).toEqual([ 'ready', 'placed', + 'claim', 'state', 'state', 'connected', From 43dd1abfb6f875a2a3eb34971bb8b9d2df6f36e0 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:38:56 +0200 Subject: [PATCH 2/4] refactor(operation): use the SDK operations resource The npm SDK ships client.operations, so the operation commands drop the raw-transport adapter. Tests now run the real SDK against a loopback server. operation wait/watch --timeout (seconds) shadowed the global per-request --timeout (milliseconds) and reached the client as timeoutMs, so --timeout 0 failed and --timeout N aborted plain reads after N ms. The wait budget no longer reaches the client. --- src/commands/operation/wait.ts | 4 +- src/commands/operation/watch.ts | 4 +- src/developer/operations.ts | 203 ++++++------------------------- test/commands/operations.test.ts | 184 ++++++++++++++-------------- 4 files changed, 141 insertions(+), 254 deletions(-) diff --git a/src/commands/operation/wait.ts b/src/commands/operation/wait.ts index ee359bba..ef651110 100644 --- a/src/commands/operation/wait.ts +++ b/src/commands/operation/wait.ts @@ -26,7 +26,9 @@ export default class OperationWait extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(OperationWait) - const session = await this.developerSession(flags) + // --timeout here is the wait budget in seconds. It replaces the global + // per-request timeout (milliseconds), so it must not reach the client. + const session = await this.developerSession({...flags, timeout: undefined}) const response = await operationsFor(session).wait(args.operation, { maxWaitMs: flags.timeout * 1000, requestOptions: session.requestOptions, diff --git a/src/commands/operation/watch.ts b/src/commands/operation/watch.ts index 63532a03..61a4e83a 100644 --- a/src/commands/operation/watch.ts +++ b/src/commands/operation/watch.ts @@ -22,7 +22,9 @@ export default class OperationWatch extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(OperationWatch) - const session = await this.developerSession(flags) + // --timeout here is the wait budget in seconds. It replaces the global + // per-request timeout (milliseconds), so it must not reach the client. + const session = await this.developerSession({...flags, timeout: undefined}) const deadline = Date.now() + flags.timeout * 1000 let afterSequence: number | undefined for (;;) { diff --git a/src/developer/operations.ts b/src/developer/operations.ts index 7b6905e0..dc549dd7 100644 --- a/src/developer/operations.ts +++ b/src/developer/operations.ts @@ -1,178 +1,55 @@ import type {ApiResponse, CursorPage, RequestOptions} from '@polymorfa/sdk' -import type {OperationLike} from './operation-view.js' import type {DeveloperSession} from './client.js' - -/** - * Operations lifecycle client with the same shape as the SDK's - * `client.operations` (list/get/wait/cancel). The CLI's pinned SDK predates - * that resource, so this adapter issues the same Platform requests through - * the SDK's authenticated raw transport. Replace it with - * `session.client.operations` when the SDK pin includes the resource. - */ -export const OPERATION_WAIT_MAX_SECONDS = 30 -const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']) +import type {OperationLike} from './operation-view.js' export type OperationRecord = OperationLike +export interface OperationListQuery { + readonly cursor?: string + readonly kind?: string + readonly limit?: number + readonly resourceId?: string | undefined + readonly resourceType?: string + readonly since?: string + readonly status?: string + readonly until?: string +} + export interface OperationWaitOptions { readonly afterSequence?: number readonly maxWaitMs?: number - readonly requestOptions?: RequestOptions + readonly requestOptions?: Omit } -type Query = Readonly> - -export function operationsFor(session: DeveloperSession) { - const {client} = session - const projectId = - client.projectId ?? - (session.context.owner === 'project' - ? (session.context.projectId ?? null) - : null) - const prefix = - projectId === null - ? '/platform/operations' - : `/platform/projects/${encodeURIComponent(projectId)}/operations` - const raw = client.raw as unknown as { - request(request: Record): Promise> - paginate( - request: Record, - decode: ( - data: unknown, - metadata: unknown, - ) => {items: readonly T[]; nextCursor?: string | null}, - ): Promise> - } - const unwrap = (response: ApiResponse<{data: T}>): ApiResponse => { - const body = response.data as unknown - if ( - typeof body !== 'object' || - body === null || - !('data' in body) || - body.data === undefined - ) { - throw new Error( - 'The Polymorfa API returned an invalid response envelope.', - ) - } - return {data: (body as {data: T}).data, metadata: response.metadata} - } - const item = (operationId: string, suffix = '') => - `${prefix}/${encodeURIComponent(operationId)}${suffix}` - - async function get( +/** + * The SDK's `client.operations` resource for the session's scope. The + * session client is already bound to the selected project when the context + * is project-owned, so the SDK resolves the team or project route itself. + * + * Operation kinds and statuses are passed through as the API spells them, so + * a newer server value is not rejected by the CLI before the API sees it. + */ +export interface OperationsClient { + cancel( operationId: string, - query: {readonly wait?: number; readonly afterSequence?: number} = {}, - options: RequestOptions = {}, - ): Promise> { - const wait = query.wait ?? 0 - return unwrap( - await raw.request<{data: OperationRecord}>({ - method: 'GET', - path: item(operationId), - query: clean(query), - ...options, - ...(wait > 0 && options.timeoutMs === undefined - ? {timeoutMs: (wait + 15) * 1000} - : {}), - }), - ) - } - - return { - cancel: async (operationId: string, options: RequestOptions = {}) => - unwrap( - await raw.request<{data: Record}>({ - method: 'POST', - path: item(operationId, '/cancel'), - ...options, - }), - ), - get, - list: (query: Query, options: RequestOptions = {}) => - raw.paginate( - {method: 'GET', path: prefix, query: clean(query), ...options}, - (data) => { - const envelope = data as { - data?: unknown - page?: {nextCursor?: string | null} - } - if (!Array.isArray(envelope?.data)) { - throw new Error( - 'The Polymorfa API returned an invalid collection envelope.', - ) - } - return { - items: envelope.data as OperationRecord[], - nextCursor: envelope.page?.nextCursor ?? null, - } - }, - ), - async wait( - operationId: string, - options: OperationWaitOptions = {}, - ): Promise> { - const maxWaitMs = options.maxWaitMs ?? 5 * 60_000 - if (!Number.isFinite(maxWaitMs) || maxWaitMs < 0) { - throw new Error( - 'maxWaitMs must be a finite, non-negative number of milliseconds.', - ) - } - const deadline = Date.now() + maxWaitMs - let latest: ApiResponse | undefined - for (;;) { - const remaining = Math.max(0, deadline - Date.now()) - const wait = Math.min( - OPERATION_WAIT_MAX_SECONDS, - Math.floor(remaining / 1000), - ) - // Bound the request by what is left of the budget, so a server that - // holds the connection open cannot outlive --timeout. The final read - // still gets a second to answer. - const budget = new AbortController() - const timer = setTimeout( - () => budget.abort(), - Math.max(remaining, 1000), - ) - let response: ApiResponse - try { - response = await get( - operationId, - { - wait, - ...(options.afterSequence === undefined - ? {} - : {afterSequence: options.afterSequence}), - }, - {...options.requestOptions, signal: budget.signal}, - ) - } catch (error) { - // A spent budget reports the last state this wait saw; the command - // then decides between success and the timeout condition. - if (!budget.signal.aborted || latest === undefined) throw error - return latest - } finally { - clearTimeout(timer) - } - latest = response - const operation = response.data - if ( - TERMINAL.has(operation.status) || - (options.afterSequence !== undefined && - operation.sequence > options.afterSequence) || - wait === 0 || - Date.now() >= deadline - ) { - return response - } - } - }, - } + options?: RequestOptions, + ): Promise>> + get( + operationId: string, + params?: {readonly afterSequence?: number; readonly wait?: number}, + options?: RequestOptions, + ): Promise> + list( + query: OperationListQuery, + options?: RequestOptions, + ): Promise> + wait( + operationId: string, + options?: OperationWaitOptions, + ): Promise> } -function clean(query: Query): Record { - return Object.fromEntries( - Object.entries(query).filter(([, value]) => value !== undefined), - ) as Record +export function operationsFor(session: DeveloperSession): OperationsClient { + return session.client.operations as unknown as OperationsClient } diff --git a/test/commands/operations.test.ts b/test/commands/operations.test.ts index a3646cc4..bc29e613 100644 --- a/test/commands/operations.test.ts +++ b/test/commands/operations.test.ts @@ -1,4 +1,15 @@ -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {createServer, type IncomingHttpHeaders, type Server} from 'node:http' + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest' import OperationCancel from '../../src/commands/operation/cancel.js' import OperationList from '../../src/commands/operation/list.js' @@ -10,62 +21,76 @@ import {withEnvironment} from '../helpers/http-server.js' const PROJECT = '11111111-1111-4111-8111-111111111111' const OPERATION = '00000000-0000-4000-8000-000000000099' -const sdk = vi.hoisted(() => ({ - calls: [] as Array<{kind: string; request: Record}>, - responses: [] as unknown[], - slowRequest: undefined as - ((signal?: AbortSignal) => Promise) | undefined, -})) +interface Captured { + readonly headers: IncomingHttpHeaders + readonly method: string + readonly path: string + readonly query: Record +} -vi.mock('@polymorfa/sdk', async (importOriginal) => { - const original = await importOriginal() +const HANG = Symbol('hang') +type Reply = unknown - class Client { - projectId: string | null = null - readonly raw = { - paginate: ( - request: Record, - decode: (d: unknown) => {items: unknown[]; nextCursor?: string | null}, - ) => { - sdk.calls.push({kind: 'paginate', request}) - const body = next() - const decoded = decode(body) - return Promise.resolve({ - hasMore: Boolean(decoded.nextCursor), - items: decoded.items, - nextCursor: decoded.nextCursor ?? undefined, - response: {data: body, metadata: metadata()}, - }) - }, - request: (request: Record) => { - sdk.calls.push({kind: 'request', request}) - if (sdk.slowRequest !== undefined && sdk.calls.length > 1) { - return sdk.slowRequest(request.signal as AbortSignal | undefined) - } - return Promise.resolve({data: next(), metadata: metadata()}) - }, - } - - project(projectId: string): Client { - const scoped = new Client() - scoped.projectId = projectId - return scoped - } - } - - return {...original, Client} -}) +const api = { + calls: [] as Captured[], + closedEarly: 0, + responses: [] as Reply[], + server: undefined as Server | undefined, + url: '', +} -function next(): unknown { - const value = sdk.responses.shift() +function next(): Reply { + const value = api.responses.shift() if (value === undefined) throw new Error('Missing response.') return value } describe('operation commands', () => { + beforeAll(async () => { + api.server = createServer((request, reply) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + api.calls.push({ + headers: request.headers, + method: request.method ?? '', + path: url.pathname, + query: Object.fromEntries(url.searchParams), + }) + let body: Reply + try { + body = next() + } catch { + reply.writeHead(500).end() + return + } + if (body === HANG) { + request.socket.once('close', () => (api.closedEarly += 1)) + return + } + reply.writeHead(200, { + 'content-type': 'application/json', + 'x-request-id': 'req_1', + }) + reply.end(JSON.stringify(body)) + }) + await new Promise((resolve) => + api.server?.listen(0, '127.0.0.1', resolve), + ) + const address = api.server.address() + if (address === null || typeof address === 'string') { + throw new Error('Test server did not bind.') + } + api.url = `http://127.0.0.1:${address.port}` + }) + + afterAll(async () => { + api.server?.closeAllConnections() + await new Promise((resolve) => api.server?.close(resolve)) + }) + beforeEach(() => { - sdk.calls.length = 0 - sdk.responses.length = 0 + api.calls.length = 0 + api.closedEarly = 0 + api.responses.length = 0 process.exitCode = undefined }) @@ -75,7 +100,7 @@ describe('operation commands', () => { }) test('lists organization operations with filters', async () => { - sdk.responses.push({ + api.responses.push({ data: [operation('running', 2)], page: {nextCursor: null}, }) @@ -93,11 +118,11 @@ describe('operation commands', () => { '--json', ]), ) - expect(sdk.calls[0]?.request).toMatchObject({ + expect(api.calls[0]).toMatchObject({ method: 'GET', path: '/platform/operations', query: { - limit: 25, + limit: '25', resourceId: 'c1', resourceType: 'campaign', status: 'running', @@ -110,7 +135,7 @@ describe('operation commands', () => { }) test('views a project operation on the project route', async () => { - sdk.responses.push({data: operation('running', 2)}) + api.responses.push({data: operation('running', 2)}) const output = silence() await environment(() => OperationView.run([ @@ -121,7 +146,7 @@ describe('operation commands', () => { PROJECT, ]), ) - expect(sdk.calls[0]?.request).toMatchObject({ + expect(api.calls[0]).toMatchObject({ method: 'GET', path: `/platform/projects/${PROJECT}/operations/${OPERATION}`, }) @@ -131,7 +156,7 @@ describe('operation commands', () => { }) test('waits with server long-polls and exits 0 on success', async () => { - sdk.responses.push( + api.responses.push( {data: operation('running', 2)}, {data: operation('succeeded', 3)}, ) @@ -146,16 +171,15 @@ describe('operation commands', () => { '--json', ]), ) - expect(sdk.calls.map((call) => call.request.query)).toEqual([ - {wait: 30}, - {wait: 30}, + expect(api.calls.map((call) => call.query)).toEqual([ + {wait: '30'}, + {wait: '30'}, ]) - expect(sdk.calls[0]?.request.timeoutMs).toBe(45_000) expect(process.exitCode).toBeUndefined() }) test('exits 1 when the operation fails', async () => { - sdk.responses.push({data: operation('failed', 3)}) + api.responses.push({data: operation('failed', 3)}) silence() await environment(() => OperationWait.run([OPERATION, '--owner', 'organization', '--json']), @@ -164,7 +188,7 @@ describe('operation commands', () => { }) test('exits 8 when the timeout ends first', async () => { - sdk.responses.push({data: operation('running', 2)}) + api.responses.push({data: operation('running', 2)}) silence() vi.spyOn(process.stderr, 'write').mockImplementation(() => true) const error = await environment(() => @@ -178,11 +202,11 @@ describe('operation commands', () => { ]), ).catch((caught: unknown) => caught) expect(exitCode(error)).toBe(8) - expect(sdk.calls[0]?.request.query).toEqual({wait: 0}) + expect(api.calls[0]?.query).toEqual({wait: '0'}) }) test('watch prints each new sequence until terminal', async () => { - sdk.responses.push( + api.responses.push( {data: operation('running', 2)}, {data: operation('cancelling', 3)}, {data: operation('cancelled', 4)}, @@ -195,20 +219,12 @@ describe('operation commands', () => { expect(text).toContain('\trunning\t') expect(text).toContain('\tcancelling\t') expect(text).toContain('\tcancelled\t') - expect(sdk.calls[1]?.request.query).toMatchObject({afterSequence: 2}) + expect(api.calls[1]?.query).toMatchObject({afterSequence: '2'}) expect(process.exitCode).toBe(1) }) test('stops a wait at --timeout even when the server holds the request', async () => { - let aborted = false - sdk.slowRequest = (signal?: AbortSignal) => - new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { - aborted = true - reject(new Error('aborted')) - }) - }) - sdk.responses.push({data: operation('running', 2)}) + api.responses.push({data: operation('running', 2)}, HANG) silence() vi.spyOn(process.stderr, 'write').mockImplementation(() => true) const error = await environment(() => @@ -221,13 +237,13 @@ describe('operation commands', () => { '--json', ]), ).catch((caught: unknown) => caught) - expect(aborted).toBe(true) expect(exitCode(error)).toBe(8) - sdk.slowRequest = undefined + expect(api.calls).toHaveLength(2) + await vi.waitFor(() => expect(api.closedEarly).toBe(1)) }, 20_000) test('cancels with confirmation and a caller idempotency key', async () => { - sdk.responses.push({ + api.responses.push({ data: { idempotency: {id: 'receipt_1', key: 'cancel_1', replayed: false}, operation: operation('cancelling', 3), @@ -246,11 +262,11 @@ describe('operation commands', () => { '--json', ]), ) - expect(sdk.calls[0]?.request).toMatchObject({ - idempotencyKey: 'cancel_1', + expect(api.calls[0]).toMatchObject({ method: 'POST', path: `/platform/operations/${OPERATION}/cancel`, }) + expect(api.calls[0]?.headers['idempotency-key']).toBe('cancel_1') expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ idempotency: {key: 'cancel_1', replayed: false}, resource: {id: OPERATION, type: 'operation'}, @@ -263,7 +279,7 @@ describe('operation commands', () => { OperationCancel.run([OPERATION, '--owner', 'organization', '--json']), ).catch((caught: unknown) => caught) expect(exitCode(error)).toBe(2) - expect(sdk.calls).toHaveLength(0) + expect(api.calls).toHaveLength(0) }) }) @@ -291,22 +307,12 @@ function operation(status: string, sequence: number) { } } -function metadata() { - return { - apiVersion: '2026-08-20', - attempts: 1, - headers: {}, - requestId: 'req_1', - status: 200, - } -} - function environment(run: () => Promise): Promise { return withEnvironment( { POLYMORFA_API_KEY: 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - POLYMORFA_BASE_URL: 'https://api.example.test', + POLYMORFA_BASE_URL: api.url, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, }, From 7dedc9a2ccd887d326647363d038fa225e232a95 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:55:25 +0200 Subject: [PATCH 3/4] docs(calls): describe the npm SDK prerelease pin in the gaps list --- docs/command-research/calls.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 0fea7de8..26fc13c7 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -130,6 +130,7 @@ internal SDK module. - `calls media` does not attach to a call that was already ringing or answered before it started, and carries no video. - `calls watch` does not report outbound calls placed by other participants. -- `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is not - published; development pins an exact Git commit. The CLI cannot release - until `@polymorfa/sdk` is published with a compatible semver range. +- `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is published to + npm only as dev prereleases; the CLI pins exactly + `0.1.0-dev.20260922102711`. The CLI cannot release until a stable + `@polymorfa/sdk` is published with a compatible semver range. From ee517a4ca35af8ca9d40f8cce9b7d7e95b4fead4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:57:29 +0200 Subject: [PATCH 4/4] fix(operation): bound the first watch read by --timeout Clearing the client request timeout left the initial operation read without a deadline, so a server that never answered could hold operation watch past --timeout. Abort it at the wait budget. --- src/commands/operation/watch.ts | 50 ++++++++++++++++++++++++++++++-- test/commands/operations.test.ts | 19 ++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/commands/operation/watch.ts b/src/commands/operation/watch.ts index 61a4e83a..432925e0 100644 --- a/src/commands/operation/watch.ts +++ b/src/commands/operation/watch.ts @@ -1,7 +1,13 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {operationsFor} from '../../developer/operations.js' +import type {ApiResponse, RequestOptions} from '@polymorfa/sdk' + +import { + operationsFor, + type OperationRecord, + type OperationsClient, +} from '../../developer/operations.js' import {developerResponse} from '../../developer/output.js' import {CliConditionError} from '../../errors/conditions.js' import { @@ -33,7 +39,13 @@ export default class OperationWatch extends BaseCommand { // for the next transition. const response = afterSequence === undefined - ? await operations.get(args.operation, {}, session.requestOptions) + ? await this.firstRead( + operations, + args.operation, + session.requestOptions, + deadline, + flags.timeout, + ) : await operations.wait(args.operation, { afterSequence, maxWaitMs: Math.max(0, deadline - Date.now()), @@ -60,4 +72,38 @@ export default class OperationWatch extends BaseCommand { } } } + + /** + * The first read reports the current state. It gets the same budget as + * --timeout (at least one second to answer), so a server that never + * replies cannot hold the watch past its deadline. + */ + private async firstRead( + operations: OperationsClient, + operationId: string, + requestOptions: RequestOptions, + deadline: number, + timeout: number, + ): Promise> { + const budget = new AbortController() + const timer = setTimeout( + () => budget.abort(), + Math.max(deadline - Date.now(), 1000), + ) + try { + return await operations.get( + operationId, + {}, + {...requestOptions, signal: budget.signal}, + ) + } catch (error) { + if (!budget.signal.aborted) throw error + throw new CliConditionError( + 'action_required', + `Operation ${operationId} did not report its state within ${timeout} seconds.`, + ) + } finally { + clearTimeout(timer) + } + } } diff --git a/test/commands/operations.test.ts b/test/commands/operations.test.ts index bc29e613..bccea36e 100644 --- a/test/commands/operations.test.ts +++ b/test/commands/operations.test.ts @@ -242,6 +242,25 @@ describe('operation commands', () => { await vi.waitFor(() => expect(api.closedEarly).toBe(1)) }, 20_000) + test('stops watch at --timeout when the first read hangs', async () => { + api.responses.push(HANG) + silence() + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const error = await environment(() => + OperationWatch.run([ + OPERATION, + '--owner', + 'organization', + '--timeout', + '1', + '--json', + ]), + ).catch((caught: unknown) => caught) + expect(exitCode(error)).toBe(8) + expect(api.calls).toHaveLength(1) + await vi.waitFor(() => expect(api.closedEarly).toBe(1)) + }, 20_000) + test('cancels with confirmation and a caller idempotency key', async () => { api.responses.push({ data: {