Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -837,8 +837,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
Expand Down Expand Up @@ -902,7 +902,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
Expand All @@ -911,8 +912,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

Expand Down Expand Up @@ -967,10 +969,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 <id>`, 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
Expand Down
49 changes: 49 additions & 0 deletions src/commands/operation/cancel.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
99 changes: 99 additions & 0 deletions src/commands/operation/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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 '../../developer/operation-view.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 <campaign-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<void> {
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,
)
}
}
30 changes: 30 additions & 0 deletions src/commands/operation/view.ts
Original file line number Diff line number Diff line change
@@ -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 '../../developer/operation-view.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<void> {
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,
)
}
}
48 changes: 48 additions & 0 deletions src/commands/operation/wait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 '../../developer/operation-view.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 <operation-id> --timeout 600',
]

static override flags = waitFlags

async run(): Promise<void> {
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
}
}
61 changes: 61 additions & 0 deletions src/commands/operation/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 '../../developer/operation-view.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<void> {
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.`,
)
}
}
}
}
Loading
Loading