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
15 changes: 15 additions & 0 deletions .changeset/cli-attributes-and-cancel-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@workflow/cli': minor
---

Add `wf inspect attributes`, which lists the attribute keys recorded on your runs with a run count and when each was first and last seen, and `wf inspect runs --attribute key=value` (repeatable) to filter runs by them.

`wf cancel` now resolves the plan's listing window once instead of once per status, removing a redundant request.

`wf inspect sleeps` now falls back to the event log when the analytics read is unavailable, rather than ending the command. Plan, access, and invalid-argument failures are still reported, since their messages tell you what to do.

`--limit`, `--runId` and `--attribute` are checked before any backend work, so a mistyped value or a flag given to a listing that cannot use it names the flag instead of being ignored, and an argument the read path rejects is now reported rather than raised as an unhandled error.

`wf inspect events --hookId` now filters events to that hook. The flag parsed and was then dropped before the listing read it, so it returned the run's whole event list.

`wf inspect --limit` now accepts 1-100, matching what the listings actually serve. Run-scoped listings previously took values up to 1000 and failed against the backend for anything above 100. `wf cancel --limit` is bounded the same way, down from an advertised 500 that no backend accepted.
65 changes: 62 additions & 3 deletions docs/content/docs/v5/configuration/cli-and-web-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,14 @@ Vercel project and auth settings can often be inferred from `.vercel/project.jso
### `--sort`

- Environment variable: none
- Default: `desc`
- Default: `desc` for time-ordered listings; `workflow inspect attributes` orders keys alphabetically unless you pass this flag
- Sort order for list commands. Accepts `asc` or `desc`.

### `--limit`

- Environment variable: none
- Default: `20`
- Number of items returned per page for list commands.
- Number of items returned per page for list commands. `workflow cancel` bounds it separately; see [`--limit` (cancel)](#--limit-cancel).

### `--cursor`

Expand All @@ -115,6 +115,63 @@ Vercel project and auth settings can often be inferred from `.vercel/project.jso
- Default: disabled
- Enables keyboard-controlled pagination for supported list commands.

## Inspect filtering

Flags for `workflow inspect`. Each list view accepts a different subset, noted
per flag.

### `--runId` / `-r`

- Command: `workflow inspect`
- Default: unset
- Scopes the listing to one run. Required for `steps`, `events`, and `sleeps`; optional for `hooks`. Must be a run ID: `wrun_` followed by a 26-character ULID.

### `--limit` (inspect)

- Command: `workflow inspect`
- Default: `20`
- Must be an integer between 1 and 100, the smallest page any inspect listing accepts. Larger pages are reachable by paging: pass `--cursor`, or `--interactive` to walk them.

### `--stepId` / `-s`

- Command: `workflow inspect events`
- Default: unset
- Filters events to one step.

### `--hookId`

- Command: `workflow inspect events`
- Default: unset
- Filters events to one hook.

### `--attribute`

- Command: `workflow inspect runs`
- Default: unset
- Filters runs to those whose [attributes](/docs/observability/attributes) match every `key=value` pair given. Repeatable up to 8 times, and splits on the first `=` so a value may contain one.
- Requires a backend with the analytics read path; ignored with a warning otherwise.
- Cannot be combined with `--url` or `--web`, which hand off to the dashboard, or with `--withData`, which reads payloads from storage. Storage carries no attribute index.
- Use `workflow inspect attributes` to discover which keys exist.

### `--since` / `--until`

- Command: `workflow inspect runs`, `workflow inspect attributes`
- Default: the backend's own window
- Bounds the listing to a window. `--since` opens the window and accepts a relative duration (`30m`, `12h`, `7d`, `2w`) or a timestamp. `--until` is optional and defaults to now, so `--until` on its own is rejected.
- Requires a backend with the analytics read path; ignored with a warning otherwise.

### `--withData` / `-d`

- Command: `workflow inspect`
- Default: disabled
- Includes full input and output payloads in list views. Deprecated for list views — use `workflow inspect <resource> <id>` to read one item's payloads. Setting it also moves the read off the analytics path, which carries metadata only.

### `--decrypt`

- Command: `workflow inspect`
- Default: disabled
- Decrypts encrypted values. Triggers an audit-logged key retrieval.

## Bulk cancel

`workflow cancel <run-id>` cancels one run. Given a filter instead, it bulk-cancels a batch; bulk mode requires `--status` or `--workflowName`.
Expand All @@ -124,18 +181,20 @@ Vercel project and auth settings can often be inferred from `.vercel/project.jso
- Command: `workflow cancel`
- Default: unset
- Restricts the batch to this status. Only `pending` and `running` are accepted; terminal runs cannot be canceled.
- Also filters `workflow inspect runs`, which accepts any run status. It does not narrow `workflow inspect attributes`, which indexes keys per tenant rather than per run; passing it there warns and lists every key.

### `--workflowName` / `-n`

- Command: `workflow cancel`
- Default: unset
- Restricts the batch to one workflow. Expects the generated workflow ID from `workflow inspect runs`, not the short function name.
- Also filters `workflow inspect runs` and `workflow inspect attributes`.

### `--limit` (cancel)

- Command: `workflow cancel`
- Default: `50`
- Maximum runs to cancel in one batch (1–500). Only one batch is canceled per invocation; run the command again to cancel the next batch.
- Maximum runs to cancel in one batch (1–100), the largest page the run listing serves. Only one batch is canceled per invocation; run the command again to cancel the next batch.

### `--confirm` / `-y`

Expand Down
24 changes: 23 additions & 1 deletion docs/content/docs/v5/observability/attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ related:
- /docs/api-reference/workflow-errors/workflow-world-error
---

[`setAttributes`](/docs/api-reference/workflow/set-attributes) attaches plaintext string metadata to the current workflow run. These attributes appear in the Workflow CLI and web UI, and you can use them to search and filter runs through the [Analytics API](/docs/api-reference/workflow-runtime/world/analytics).
[`setAttributes`](/docs/api-reference/workflow/set-attributes) attaches plaintext string metadata to the current workflow run. These attributes appear in the Workflow CLI and web UI, and you can search and filter runs by them from either the [CLI](#from-the-cli) or the [Analytics API](/docs/api-reference/workflow-runtime/world/analytics).

You can also seed any attributes directly when starting a run:

Expand Down Expand Up @@ -82,6 +82,28 @@ Expanding an `attr_set` event (in the run sidebar or the Events tab) shows the c

## Searching and filtering by attributes

### From the CLI

`workflow inspect attributes` lists the keys recorded on this project's runs,
with how many runs carry each and when it was first and last seen:

```bash
workflow inspect attributes
```

Pass one or more `--attribute key=value` pairs to `inspect runs` to list the
runs carrying them. Repeatable up to 8 times:

```bash
workflow inspect runs --attribute phase=received --status running
```

Both require a backend with the analytics read path. `--attribute` is ignored
with a warning on backends without one, and `inspect attributes` reports that
it is unavailable.

### From the Analytics API

The [Analytics API](/docs/api-reference/workflow-runtime/world/analytics) can discover which attribute keys exist and filter run listings by them. The `analytics` namespace is optional on `World`, so feature-detect it before use; it is absent on local, Postgres, and other custom Worlds:

```typescript lineNumbers
Expand Down
30 changes: 22 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@ import { WorkflowRunStatusSchema } from '@workflow/world';
import chalk from 'chalk';
import { BaseCommand } from '../base.js';
import {
BULK_CANCEL_MAX_LIMIT,
BULK_CANCEL_MIN_LIMIT,
CANCELLABLE_STATUSES,
CLI_CANCEL_REASON,
performBulkCancel,
validateBulkCancelLimit,
} from '../lib/bulk-cancel.js';
import { LOGGING_CONFIG, logger } from '../lib/config/log.js';
import {
getObservabilityUpgradeRequiredMessage,
isObservabilityUpgradeRequiredError,
} from '../lib/inspect/errors.js';
import { reportActionableApiError } from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';

Expand All @@ -31,11 +30,25 @@ export default class Cancel extends BaseCommand {
'$ workflow cancel --status=running -y',
];

/**
* Recorded in `run()` so `catch()` can tell a Vercel 403 from any other
* one. oclif hands `catch` the error alone, with no parsed flags.
*
* Assigned after `this.parse()`, so a `catch` reached by a parse failure
* sees `undefined`. That costs nothing: a parse failure is not an API
* error, so there is no 403 to classify.
*/
private backend?: string;

async catch(error: any) {
if (isObservabilityUpgradeRequiredError(error)) {
logger.error(getObservabilityUpgradeRequiredMessage());
// Print the errors that name something the caller can fix. Only the plan
// gate was handled before, so an argument the World rejected locally —
// `--limit` above the listing cap, say — reached oclif's default handler
// and exited 1 with nothing on screen unless -v was set.
if (reportActionableApiError(error, this.backend)) {
process.exit(1);
} else if (LOGGING_CONFIG.VERBOSE_MODE) {
}
if (LOGGING_CONFIG.VERBOSE_MODE) {
console.error(error);
}
throw error;
Expand Down Expand Up @@ -68,7 +81,7 @@ export default class Cancel extends BaseCommand {
helpLabel: '-n, --workflowName',
}),
limit: Flags.integer({
description: 'Max runs to cancel in bulk mode (1-500)',
description: `Max runs to cancel in bulk mode (${BULK_CANCEL_MIN_LIMIT}-${BULK_CANCEL_MAX_LIMIT})`,
required: false,
default: 50,
helpGroup: 'Bulk Cancel',
Expand All @@ -87,6 +100,7 @@ export default class Cancel extends BaseCommand {

public async run(): Promise<void> {
const { flags, args } = await this.parse(Cancel);
this.backend = flags.backend;

const world = await setupCliWorld(flags, this.config.version);
if (!world) {
Expand Down
139 changes: 139 additions & 0 deletions packages/cli/src/commands/inspect-flag-forwarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { fileURLToPath } from 'node:url';
import { Config } from '@oclif/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toInspectOptions } from './inspect.js';

const state = vi.hoisted(() => ({
setupCliWorld: vi.fn(),
listEvents: vi.fn(),
listAttributes: vi.fn(),
}));

vi.mock('../lib/inspect/setup.js', () => ({
setupCliWorld: state.setupCliWorld,
}));

// Only the two listings these cases dispatch to are replaced; the rest of the
// module is kept so the command's imports still resolve.
vi.mock('../lib/inspect/output.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/inspect/output.js')>()),
listEvents: state.listEvents,
listAttributes: state.listAttributes,
}));

const { default: Inspect } = await import('./inspect.js');

const VALID_RUN = 'wrun_01K4BZQ5T2J8HXFM6WD3PNAVCE';
const VALID_HOOK = 'hook_01K4BZQ5T2J8HXFM6WD3PNAVCE';

const runInspect = async (argv: string[]): Promise<void> => {
const config = await Config.load(
fileURLToPath(new URL('../..', import.meta.url))
);
const exit = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);
try {
await Inspect.run(argv, config).catch(() => undefined);
} finally {
exit.mockRestore();
}
};

beforeEach(() => {
state.setupCliWorld.mockReset().mockResolvedValue({});
state.listEvents.mockReset().mockResolvedValue(undefined);
state.listAttributes.mockReset().mockResolvedValue(undefined);
process.exitCode = 0;
});

/**
* A flag that parses, clears validation, and is then dropped before any
* listing reads it is indistinguishable from a successful unfiltered answer.
* Unit tests on the listings cannot see it: they pass options directly and so
* skip the projection where the drop happens. These go through `Inspect.run`.
*/
describe('flag forwarding through the command', () => {
it('forwards --hookId to the events listing', async () => {
await runInspect(['events', '--runId', VALID_RUN, '--hookId', VALID_HOOK]);

expect(state.listEvents).toHaveBeenCalled();
expect(state.listEvents.mock.calls[0][1]).toMatchObject({
hookId: VALID_HOOK,
runId: VALID_RUN,
});
});

it('forwards --stepId to the events listing', async () => {
await runInspect([
'events',
'--runId',
VALID_RUN,
'--stepId',
'step_01K4BZQ5T2J8HXFM6WD3PNAVCE',
]);

expect(state.listEvents.mock.calls[0][1]).toMatchObject({
stepId: 'step_01K4BZQ5T2J8HXFM6WD3PNAVCE',
});
});

// The warning `listAttributes` prints for this flag was unreachable from
// the CLI while the projection dropped it.
it('forwards --hookId far enough for the attributes listing to warn', async () => {
await runInspect(['attributes', '--hookId', VALID_HOOK]);

expect(state.listAttributes.mock.calls[0][1]).toMatchObject({
hookId: VALID_HOOK,
});
});
});

/**
* The projection itself, pinned key by key. Every filtering flag the listings
* read has to appear here; `--hookId` was declared as a flag and read by
* `listEvents` but never copied across.
*/
describe('toInspectOptions', () => {
it('copies every filtering flag through', () => {
const options = toInspectOptions(
{
json: true,
runId: VALID_RUN,
stepId: 'step_x',
hookId: 'hook_x',
cursor: 'c',
sort: 'asc',
limit: 25,
workflowName: 'orderWorkflow',
status: 'failed',
since: '7d',
until: '1h',
withData: true,
decrypt: true,
backend: 'vercel',
interactive: true,
},
{ tenant: 'acme' }
);

expect(options).toEqual({
json: true,
runId: VALID_RUN,
stepId: 'step_x',
hookId: 'hook_x',
cursor: 'c',
sort: 'asc',
limit: 25,
workflowName: 'orderWorkflow',
attributes: { tenant: 'acme' },
status: 'failed',
since: '7d',
until: '1h',
withData: true,
decrypt: true,
backend: 'vercel',
interactive: true,
});
});
});
Loading
Loading