From 89c4ca41f8c45053534b522ccd0665f001e98ae6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 20:33:25 +0200 Subject: [PATCH 1/3] feat(operation): add operation list, view, wait, watch, and cancel --- README.md | 49 +++++- src/commands/operation/cancel.ts | 49 ++++++ src/commands/operation/list.ts | 96 ++++++++++ src/commands/operation/shared.ts | 61 +++++++ src/commands/operation/view.ts | 30 ++++ src/commands/operation/wait.ts | 44 +++++ src/commands/operation/watch.ts | 57 ++++++ src/developer/operations.ts | 152 ++++++++++++++++ test/commands/operations.test.ts | 290 +++++++++++++++++++++++++++++++ 9 files changed, 819 insertions(+), 9 deletions(-) create mode 100644 src/commands/operation/cancel.ts create mode 100644 src/commands/operation/list.ts create mode 100644 src/commands/operation/shared.ts create mode 100644 src/commands/operation/view.ts create mode 100644 src/commands/operation/wait.ts create mode 100644 src/commands/operation/watch.ts create mode 100644 src/developer/operations.ts create mode 100644 test/commands/operations.test.ts diff --git a/README.md b/README.md index 3d206bde..a6d79be3 100644 --- a/README.md +++ b/README.md @@ -758,8 +758,8 @@ this route. Security-incident acknowledgement is repeatable and sends no body. Audit filters are exact matches, and `--limit` is bounded to 1 through 500. These list operations return complete arrays and expose no cursor. Project-token -listing requires one explicit project ID. Operation inspection is console-only; -the CLI does not expose operation polling or cancellation. +listing requires one explicit project ID. Follow asynchronous work with the +`operation` commands described in [Operations](#operations). Member invite, role update, and removal remain dashboard-session routes and are not exposed through server-key commands. The API also exposes no compatible @@ -823,7 +823,8 @@ polymorfa campaign requeue campaign_123 \ Launch, pause, resume, and stop return accepted durable work; they do not wait for the state transition. Check the campaign with `polymorfa campaign view`. -Operation inspection is console-only; the CLI has no operation-polling command. +Follow the returned operation with `polymorfa operation wait` or +`polymorfa operation watch`; see [Operations](#operations). Launch times accept epoch milliseconds or timezone-qualified ISO 8601 instants. List returns one complete newest-first array with no cursor. Requeue @@ -832,8 +833,9 @@ is synchronous and reports only the number moved. Every mutation accepts treated as server-side deduplication. The source exposes no campaign update, delete, validate, archive, duplicate, -recipient, event, history, replay, watch, or operation-cancel route. The CLI -does not substitute similarly named Platform campaign methods for these gaps. +recipient, event, history, or replay route. The CLI does not substitute +similarly named Platform campaign methods for these gaps. To follow or stop a +campaign's operation, use the `operation` commands in [Operations](#operations). ## Developer events and webhooks @@ -888,10 +890,39 @@ is capped at 10,000. The CLI shrinks the last request to the remaining item budget instead of fetching records it will discard. Replay, delivery retry, and webhook test return an idempotency receipt and an -operation ID in the JSON `operation.id` field. Operation inspection is -console-only, so the CLI has no command to poll or cancel that operation. Use -`webhook delivery view` and `webhook delivery attempt list` to follow the -resulting delivery. +operation ID in the JSON `operation.id` field. Follow it with +`polymorfa operation wait `, or use `webhook delivery view` and +`webhook delivery attempt list` to follow the resulting delivery. + +## Operations + +Campaign sends, production enrollments, and other durable work are tracked as +operations. The commands need `operations:read`; `operation cancel` needs +`operations:cancel`. With `--owner organization`, commands read team-owned +operations and every project's operations. With `--owner project`, they read +one project. + +```shell +polymorfa operation list --owner organization --status running +polymorfa operation list --owner organization \ + --resource-type campaign --resource-id campaign_123 +polymorfa operation view operation_123 --owner organization +polymorfa operation wait operation_123 --owner organization --timeout 600 +polymorfa operation watch operation_123 --owner organization +polymorfa operation cancel operation_123 --owner organization --yes +``` + +`operation wait` long-polls the API (up to 30 seconds per request) until the +operation succeeds, fails, or is cancelled. It exits `0` on success, `1` on +failure or cancellation, and `8` when `--timeout` (default 300 seconds) ends +first. `operation watch` prints each status change as it happens, one JSON +document per change with `--json`, and exits the same way. + +`operation cancel` works only while `capabilities.cancellable` is true (running +or action-required campaign and production-enrollment operations). It requires +`--yes` in noninteractive runs and accepts `--idempotency-key`; a retry with the +same key returns the original receipt. Other operations fail with a conflict +that includes the current operation. `event view` prints the retained payload in human output only with `--include-payload`. `webhook delivery attempt view` prints the redacted diff --git a/src/commands/operation/cancel.ts b/src/commands/operation/cancel.ts new file mode 100644 index 00000000..69d44794 --- /dev/null +++ b/src/commands/operation/cancel.ts @@ -0,0 +1,49 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {operationsFor} from '../../developer/operations.js' +import {idempotencyFlag, yesFlag} from '../../developer/flags.js' +import {developerMutation} from '../../developer/output.js' +import {record, stringField} from '../../developer/support.js' +import {confirmDangerous} from '../../input/confirmation.js' +import {idempotencyKey} from '../../input/idempotency.js' + +export default class OperationCancel extends BaseCommand { + static override args = { + operation: Args.string({description: 'Operation ID.', required: true}), + } + + static override description = + 'Request cancellation of a running campaign or production-enrollment operation. The operation moves to cancelling; follow it with operation wait.' + + static override flags = { + 'idempotency-key': idempotencyFlag, + yes: yesFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(OperationCancel) + await confirmDangerous({ + prompt: `Cancel operation ${args.operation}? This stops the work it performs.`, + yes: flags.yes, + }) + const session = await this.developerSession(flags) + const key = idempotencyKey(flags['idempotency-key']) + const response = await operationsFor(session).cancel(args.operation, { + ...session.requestOptions, + idempotencyKey: key, + }) + const receipt = record(response.data, 'Operation cancellation') + const operation = record(receipt.operation, 'Operation') + this.writeValue( + developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: stringField(receipt, 'operationId'), type: 'operation'}, + }), + `Operation ${stringField(receipt, 'operationId')} is ${stringField(operation, 'status')}.`, + flags, + ) + } +} diff --git a/src/commands/operation/list.ts b/src/commands/operation/list.ts new file mode 100644 index 00000000..c7c120d0 --- /dev/null +++ b/src/commands/operation/list.ts @@ -0,0 +1,96 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {operationsFor} from '../../developer/operations.js' +import {paginationFlags} from '../../developer/flags.js' +import {developerRead} from '../../developer/output.js' +import { + assertCursorNotCombined, + listResources, + paginationInput, +} from '../../developer/support.js' +import {optionalTimeRange} from '../../input/time.js' +import {OPERATION_STATUSES, renderOperationLine} from './shared.js' + +export default class OperationList extends BaseCommand { + static override description = + 'List asynchronous operations, newest first. Team owners see team and project operations; project owners see one project.' + + static override examples = [ + '<%= config.bin %> operation list --status running', + '<%= config.bin %> operation list --resource-type campaign --resource-id --json', + ] + + static override flags = { + ...paginationFlags, + kind: Flags.string({ + description: 'Filter by operation kind, for example campaign.', + }), + 'resource-id': Flags.string({ + dependsOn: ['resource-type'], + description: 'Filter by resource ID. Requires --resource-type.', + }), + 'resource-type': Flags.string({ + dependsOn: ['resource-id'], + description: 'Filter by resource type. Requires --resource-id.', + }), + since: Flags.string({ + description: 'Include operations created at or after this RFC 3339 time.', + }), + status: Flags.string({ + description: 'Filter by status.', + options: [...OPERATION_STATUSES], + }), + until: Flags.string({ + description: 'Include operations created before this RFC 3339 time.', + }), + } + + async run(): Promise { + const {flags} = await this.parse(OperationList) + assertCursorNotCombined(flags.cursor, [ + flags.kind, + flags['resource-id'], + flags['resource-type'], + flags.since, + flags.status, + flags.until, + ]) + const time = optionalTimeRange(flags.since, flags.until) + const session = await this.developerSession(flags) + const result = await listResources( + paginationInput(flags), + ({cursor, limit}) => + operationsFor(session).list( + { + ...(cursor === undefined ? {} : {cursor}), + limit, + ...time, + ...(flags.status === undefined ? {} : {status: flags.status}), + ...(flags.kind === undefined ? {} : {kind: flags.kind}), + ...(flags['resource-type'] === undefined + ? {} + : { + resourceId: flags['resource-id'], + resourceType: flags['resource-type'], + }), + }, + session.requestOptions, + ), + ) + this.writeValue( + developerRead( + result.items, + result.metadata, + session.context, + result.page, + ), + result.items.length === 0 + ? 'No operations found.' + : result.items + .map((operation) => renderOperationLine(operation)) + .join('\n'), + flags, + ) + } +} diff --git a/src/commands/operation/shared.ts b/src/commands/operation/shared.ts new file mode 100644 index 00000000..9a129bce --- /dev/null +++ b/src/commands/operation/shared.ts @@ -0,0 +1,61 @@ +import {Flags} from '@oclif/core' + +export const OPERATION_STATUSES = [ + 'pending', + 'running', + 'action_required', + 'cancelling', + 'succeeded', + 'failed', + 'cancelled', +] as const + +export const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled']) + +export interface OperationLike { + readonly id: string + readonly kind: string + readonly status: string + readonly sequence: number + readonly projectId: string | null + readonly resource: {readonly type: string; readonly id: string} + readonly capabilities: {readonly cancellable: boolean} + readonly progress: {readonly code: string} | null + readonly error: {readonly code: string} | null + readonly updatedAt: string +} + +export const waitFlags = { + timeout: Flags.integer({ + default: 300, + description: 'Stop waiting after this many seconds.', + max: 86_400, + min: 0, + }), +} + +export function renderOperation(operation: OperationLike): string { + return [ + ['ID', operation.id], + ['Kind', operation.kind], + ['Status', operation.status], + ['Sequence', String(operation.sequence)], + ['Project', operation.projectId ?? '(team)'], + ['Resource', `${operation.resource.type} ${operation.resource.id}`], + ['Cancellable', operation.capabilities.cancellable ? 'yes' : 'no'], + ['Progress', operation.progress?.code ?? '-'], + ['Error', operation.error?.code ?? '-'], + ['Updated', operation.updatedAt], + ] + .map(([label, value]) => `${label}\t${value}`) + .join('\n') +} + +export function renderOperationLine(operation: OperationLike): string { + return [ + operation.id, + operation.kind, + operation.status, + `${operation.resource.type}:${operation.resource.id}`, + ].join('\t') +} diff --git a/src/commands/operation/view.ts b/src/commands/operation/view.ts new file mode 100644 index 00000000..83698cca --- /dev/null +++ b/src/commands/operation/view.ts @@ -0,0 +1,30 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {operationsFor} from '../../developer/operations.js' +import {developerResponse} from '../../developer/output.js' +import {renderOperation} from './shared.js' + +export default class OperationView extends BaseCommand { + static override args = { + operation: Args.string({description: 'Operation ID.', required: true}), + } + + static override description = + 'View one asynchronous operation: status, sequence, progress, error, and whether it can be cancelled.' + + async run(): Promise { + const {args, flags} = await this.parse(OperationView) + const session = await this.developerSession(flags) + const response = await operationsFor(session).get( + args.operation, + {}, + session.requestOptions, + ) + this.writeValue( + developerResponse(response, session.context), + renderOperation(response.data), + flags, + ) + } +} diff --git a/src/commands/operation/wait.ts b/src/commands/operation/wait.ts new file mode 100644 index 00000000..2c7ca546 --- /dev/null +++ b/src/commands/operation/wait.ts @@ -0,0 +1,44 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {operationsFor} from '../../developer/operations.js' +import {developerResponse} from '../../developer/output.js' +import {CliConditionError} from '../../errors/conditions.js' +import {TERMINAL_STATUSES, renderOperation, waitFlags} from './shared.js' + +export default class OperationWait extends BaseCommand { + static override args = { + operation: Args.string({description: 'Operation ID.', required: true}), + } + + static override description = + 'Wait until an operation succeeds, fails, or is cancelled. Exits 0 on success, 1 on failure or cancellation, and 8 when --timeout ends first.' + + static override examples = [ + '<%= config.bin %> operation wait --timeout 600', + ] + + static override flags = waitFlags + + async run(): Promise { + const {args, flags} = await this.parse(OperationWait) + const session = await this.developerSession(flags) + const response = await operationsFor(session).wait(args.operation, { + maxWaitMs: flags.timeout * 1000, + requestOptions: session.requestOptions, + }) + const operation = response.data + this.writeValue( + developerResponse(response, session.context), + renderOperation(operation), + flags, + ) + if (!TERMINAL_STATUSES.has(operation.status)) { + throw new CliConditionError( + 'action_required', + `Operation ${operation.id} is still ${operation.status} after ${flags.timeout} seconds.`, + ) + } + if (operation.status !== 'succeeded') process.exitCode = 1 + } +} diff --git a/src/commands/operation/watch.ts b/src/commands/operation/watch.ts new file mode 100644 index 00000000..56ca97dc --- /dev/null +++ b/src/commands/operation/watch.ts @@ -0,0 +1,57 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {operationsFor} from '../../developer/operations.js' +import {developerResponse} from '../../developer/output.js' +import {CliConditionError} from '../../errors/conditions.js' +import {TERMINAL_STATUSES, renderOperationLine, waitFlags} from './shared.js' + +export default class OperationWatch extends BaseCommand { + static override args = { + operation: Args.string({description: 'Operation ID.', required: true}), + } + + static override description = + 'Print each status change of an operation until it succeeds, fails, or is cancelled. With --json, prints one JSON document per change. Exits like operation wait.' + + static override flags = waitFlags + + async run(): Promise { + const {args, flags} = await this.parse(OperationWatch) + const session = await this.developerSession(flags) + const deadline = Date.now() + flags.timeout * 1000 + let afterSequence: number | undefined + for (;;) { + const operations = operationsFor(session) + // The first read reports the current state; later reads long-poll + // for the next transition. + const response = + afterSequence === undefined + ? await operations.get(args.operation, {}, session.requestOptions) + : await operations.wait(args.operation, { + afterSequence, + maxWaitMs: Math.max(0, deadline - Date.now()), + requestOptions: session.requestOptions, + }) + const operation = response.data + if (afterSequence === undefined || operation.sequence > afterSequence) { + this.writeValue( + developerResponse(response, session.context), + renderOperationLine(operation), + flags, + ) + afterSequence = operation.sequence + } + if (TERMINAL_STATUSES.has(operation.status)) { + if (operation.status !== 'succeeded') process.exitCode = 1 + return + } + if (Date.now() >= deadline) { + throw new CliConditionError( + 'action_required', + `Operation ${operation.id} is still ${operation.status} after ${flags.timeout} seconds.`, + ) + } + } + } +} diff --git a/src/developer/operations.ts b/src/developer/operations.ts new file mode 100644 index 00000000..3ff82af5 --- /dev/null +++ b/src/developer/operations.ts @@ -0,0 +1,152 @@ +import type {ApiResponse, CursorPage, RequestOptions} from '@polymorfa/sdk' + +import type {OperationLike} from '../commands/operation/shared.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']) + +export type OperationRecord = OperationLike + +export interface OperationWaitOptions { + readonly afterSequence?: number + readonly maxWaitMs?: number + readonly requestOptions?: RequestOptions +} + +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( + 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 deadline = Date.now() + (options.maxWaitMs ?? 5 * 60_000) + for (;;) { + const remaining = Math.max(0, deadline - Date.now()) + const wait = Math.min( + OPERATION_WAIT_MAX_SECONDS, + Math.floor(remaining / 1000), + ) + const response = await get( + operationId, + { + wait, + ...(options.afterSequence === undefined + ? {} + : {afterSequence: options.afterSequence}), + }, + options.requestOptions, + ) + const operation = response.data + if ( + TERMINAL.has(operation.status) || + (options.afterSequence !== undefined && + operation.sequence > options.afterSequence) || + wait === 0 || + Date.now() >= deadline + ) { + return response + } + } + }, + } +} + +function clean(query: Query): Record { + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== undefined), + ) as Record +} diff --git a/test/commands/operations.test.ts b/test/commands/operations.test.ts new file mode 100644 index 00000000..9c21e23d --- /dev/null +++ b/test/commands/operations.test.ts @@ -0,0 +1,290 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +import OperationCancel from '../../src/commands/operation/cancel.js' +import OperationList from '../../src/commands/operation/list.js' +import OperationView from '../../src/commands/operation/view.js' +import OperationWait from '../../src/commands/operation/wait.js' +import OperationWatch from '../../src/commands/operation/watch.js' +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[], +})) + +vi.mock('@polymorfa/sdk', async (importOriginal) => { + const original = await importOriginal() + + 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}) + return Promise.resolve({data: next(), metadata: metadata()}) + }, + } + + project(projectId: string): Client { + const scoped = new Client() + scoped.projectId = projectId + return scoped + } + } + + return {...original, Client} +}) + +function next(): unknown { + const value = sdk.responses.shift() + if (value === undefined) throw new Error('Missing response.') + return value +} + +describe('operation commands', () => { + beforeEach(() => { + sdk.calls.length = 0 + sdk.responses.length = 0 + process.exitCode = undefined + }) + + afterEach(() => { + vi.restoreAllMocks() + process.exitCode = undefined + }) + + test('lists organization operations with filters', async () => { + sdk.responses.push({ + data: [operation('running', 2)], + page: {nextCursor: null}, + }) + const output = silence() + await environment(() => + OperationList.run([ + '--owner', + 'organization', + '--status', + 'running', + '--resource-type', + 'campaign', + '--resource-id', + 'c1', + '--json', + ]), + ) + expect(sdk.calls[0]?.request).toMatchObject({ + method: 'GET', + path: '/platform/operations', + query: { + limit: 25, + resourceId: 'c1', + resourceType: 'campaign', + status: 'running', + }, + }) + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ + data: [{id: OPERATION, status: 'running'}], + schemaVersion: 1, + }) + }) + + test('views a project operation on the project route', async () => { + sdk.responses.push({data: operation('running', 2)}) + const output = silence() + await environment(() => + OperationView.run([ + OPERATION, + '--owner', + 'project', + '--project', + PROJECT, + ]), + ) + expect(sdk.calls[0]?.request).toMatchObject({ + method: 'GET', + path: `/platform/projects/${PROJECT}/operations/${OPERATION}`, + }) + const text = output.mock.calls.map(([chunk]) => String(chunk)).join('') + expect(text).toContain('Status\trunning') + expect(text).toContain('Cancellable\tyes') + }) + + test('waits with server long-polls and exits 0 on success', async () => { + sdk.responses.push( + {data: operation('running', 2)}, + {data: operation('succeeded', 3)}, + ) + silence() + await environment(() => + OperationWait.run([ + OPERATION, + '--owner', + 'organization', + '--timeout', + '120', + '--json', + ]), + ) + expect(sdk.calls.map((call) => call.request.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)}) + silence() + await environment(() => + OperationWait.run([OPERATION, '--owner', 'organization', '--json']), + ) + expect(process.exitCode).toBe(1) + }) + + test('exits 8 when the timeout ends first', async () => { + sdk.responses.push({data: operation('running', 2)}) + silence() + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const error = await environment(() => + OperationWait.run([ + OPERATION, + '--owner', + 'organization', + '--timeout', + '0', + '--json', + ]), + ).catch((caught: unknown) => caught) + expect(exitCode(error)).toBe(8) + expect(sdk.calls[0]?.request.query).toEqual({wait: 0}) + }) + + test('watch prints each new sequence until terminal', async () => { + sdk.responses.push( + {data: operation('running', 2)}, + {data: operation('cancelling', 3)}, + {data: operation('cancelled', 4)}, + ) + const output = silence() + await environment(() => + OperationWatch.run([OPERATION, '--owner', 'organization']), + ) + const text = output.mock.calls.map(([chunk]) => String(chunk)).join('') + 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(process.exitCode).toBe(1) + }) + + test('cancels with confirmation and a caller idempotency key', async () => { + sdk.responses.push({ + data: { + idempotency: {id: 'receipt_1', key: 'cancel_1', replayed: false}, + operation: operation('cancelling', 3), + operationId: OPERATION, + }, + }) + const output = silence() + await environment(() => + OperationCancel.run([ + OPERATION, + '--owner', + 'organization', + '--yes', + '--idempotency-key', + 'cancel_1', + '--json', + ]), + ) + expect(sdk.calls[0]?.request).toMatchObject({ + idempotencyKey: 'cancel_1', + method: 'POST', + path: `/platform/operations/${OPERATION}/cancel`, + }) + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ + idempotency: {key: 'cancel_1', replayed: false}, + resource: {id: OPERATION, type: 'operation'}, + }) + }) + + test('does not cancel without --yes in a noninteractive process', async () => { + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const error = await environment(() => + OperationCancel.run([OPERATION, '--owner', 'organization', '--json']), + ).catch((caught: unknown) => caught) + expect(exitCode(error)).toBe(2) + expect(sdk.calls).toHaveLength(0) + }) +}) + +function silence() { + return vi.spyOn(process.stdout, 'write').mockImplementation(() => true) +} + +function operation(status: string, sequence: number) { + return { + actionRequired: null, + capabilities: {cancellable: status === 'running', watchable: true}, + completedAt: null, + createdAt: '2026-09-19T00:00:00Z', + error: null, + id: OPERATION, + kind: 'campaign', + organizationId: 'org_1', + progress: null, + projectId: PROJECT, + resource: {id: 'c1', type: 'campaign'}, + result: null, + sequence, + status, + updatedAt: '2026-09-19T00:00:00Z', + } +} + +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_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + run, + ) +} + +function exitCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('oclif' in error)) { + return undefined + } + return (error as {oclif?: {exit?: number}}).oclif?.exit +} From 7df5eb09eceda49df42665eca718b39290911fdc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:16:13 +0200 Subject: [PATCH 2/3] fix(operation): move the shared view helpers out of the command directory --- src/commands/operation/list.ts | 5 ++++- src/commands/operation/view.ts | 2 +- src/commands/operation/wait.ts | 6 +++++- src/commands/operation/watch.ts | 6 +++++- .../operation/shared.ts => developer/operation-view.ts} | 0 src/developer/operations.ts | 2 +- 6 files changed, 16 insertions(+), 5 deletions(-) rename src/{commands/operation/shared.ts => developer/operation-view.ts} (100%) diff --git a/src/commands/operation/list.ts b/src/commands/operation/list.ts index c7c120d0..7514ae41 100644 --- a/src/commands/operation/list.ts +++ b/src/commands/operation/list.ts @@ -10,7 +10,10 @@ import { paginationInput, } from '../../developer/support.js' import {optionalTimeRange} from '../../input/time.js' -import {OPERATION_STATUSES, renderOperationLine} from './shared.js' +import { + OPERATION_STATUSES, + renderOperationLine, +} from '../../developer/operation-view.js' export default class OperationList extends BaseCommand { static override description = diff --git a/src/commands/operation/view.ts b/src/commands/operation/view.ts index 83698cca..ac73eb96 100644 --- a/src/commands/operation/view.ts +++ b/src/commands/operation/view.ts @@ -3,7 +3,7 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base-command.js' import {operationsFor} from '../../developer/operations.js' import {developerResponse} from '../../developer/output.js' -import {renderOperation} from './shared.js' +import {renderOperation} from '../../developer/operation-view.js' export default class OperationView extends BaseCommand { static override args = { diff --git a/src/commands/operation/wait.ts b/src/commands/operation/wait.ts index 2c7ca546..ee359bba 100644 --- a/src/commands/operation/wait.ts +++ b/src/commands/operation/wait.ts @@ -4,7 +4,11 @@ import {BaseCommand} from '../../base-command.js' import {operationsFor} from '../../developer/operations.js' import {developerResponse} from '../../developer/output.js' import {CliConditionError} from '../../errors/conditions.js' -import {TERMINAL_STATUSES, renderOperation, waitFlags} from './shared.js' +import { + TERMINAL_STATUSES, + renderOperation, + waitFlags, +} from '../../developer/operation-view.js' export default class OperationWait extends BaseCommand { static override args = { diff --git a/src/commands/operation/watch.ts b/src/commands/operation/watch.ts index 56ca97dc..63532a03 100644 --- a/src/commands/operation/watch.ts +++ b/src/commands/operation/watch.ts @@ -4,7 +4,11 @@ import {BaseCommand} from '../../base-command.js' import {operationsFor} from '../../developer/operations.js' import {developerResponse} from '../../developer/output.js' import {CliConditionError} from '../../errors/conditions.js' -import {TERMINAL_STATUSES, renderOperationLine, waitFlags} from './shared.js' +import { + TERMINAL_STATUSES, + renderOperationLine, + waitFlags, +} from '../../developer/operation-view.js' export default class OperationWatch extends BaseCommand { static override args = { diff --git a/src/commands/operation/shared.ts b/src/developer/operation-view.ts similarity index 100% rename from src/commands/operation/shared.ts rename to src/developer/operation-view.ts diff --git a/src/developer/operations.ts b/src/developer/operations.ts index 3ff82af5..26910189 100644 --- a/src/developer/operations.ts +++ b/src/developer/operations.ts @@ -1,6 +1,6 @@ import type {ApiResponse, CursorPage, RequestOptions} from '@polymorfa/sdk' -import type {OperationLike} from '../commands/operation/shared.js' +import type {OperationLike} from './operation-view.js' import type {DeveloperSession} from './client.js' /** From ef55c1ada04392ba1990adaa03826cdb47679ff1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:31:26 +0200 Subject: [PATCH 3/3] fix(operation): bound each wait request by the remaining --timeout budget --- src/developer/operations.ts | 46 +++++++++++++++++++++++++------- test/commands/operations.test.ts | 32 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/developer/operations.ts b/src/developer/operations.ts index 26910189..7b6905e0 100644 --- a/src/developer/operations.ts +++ b/src/developer/operations.ts @@ -113,23 +113,49 @@ export function operationsFor(session: DeveloperSession) { operationId: string, options: OperationWaitOptions = {}, ): Promise> { - const deadline = Date.now() + (options.maxWaitMs ?? 5 * 60_000) + 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), ) - const response = await get( - operationId, - { - wait, - ...(options.afterSequence === undefined - ? {} - : {afterSequence: options.afterSequence}), - }, - options.requestOptions, + // 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) || diff --git a/test/commands/operations.test.ts b/test/commands/operations.test.ts index 9c21e23d..a3646cc4 100644 --- a/test/commands/operations.test.ts +++ b/test/commands/operations.test.ts @@ -13,6 +13,8 @@ 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, })) vi.mock('@polymorfa/sdk', async (importOriginal) => { @@ -37,6 +39,9 @@ vi.mock('@polymorfa/sdk', async (importOriginal) => { }, 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()}) }, } @@ -194,6 +199,33 @@ describe('operation commands', () => { 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)}) + silence() + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const error = await environment(() => + OperationWait.run([ + OPERATION, + '--owner', + 'organization', + '--timeout', + '1', + '--json', + ]), + ).catch((caught: unknown) => caught) + expect(aborted).toBe(true) + expect(exitCode(error)).toBe(8) + sdk.slowRequest = undefined + }, 20_000) + test('cancels with confirmation and a caller idempotency key', async () => { sdk.responses.push({ data: {