@yolo-labs/yolo-cli 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * `yolo plan validate` core (Phase 8c.1).
3
+ *
4
+ * Pure offline validation of a `.yolo/plans/<slug>.md` plan file. No
5
+ * network I/O. Three checks, in order:
6
+ *
7
+ * 1. **Parse** — file must split cleanly into YAML frontmatter +
8
+ * markdown body via the standard `---` delimiters.
9
+ * 2. **Schema** — frontmatter must satisfy the Draft-07 JSON Schema
10
+ * shipped at `src/plan-frontmatter.schema.json` (a copy of the
11
+ * repo's `.yolo/plans/.schema.json`; a drift-check test enforces
12
+ * they stay byte-equal).
13
+ * 3. **Canonical** — running the file through `canonicalizePlanFile`
14
+ * must return the same bytes. This is the parity that
15
+ * `yolo plan import` will rely on, and that 8c.5's CI check
16
+ * enforces across all `.yolo/plans/*.md` files in the repo.
17
+ *
18
+ * The first failing check short-circuits subsequent ones (a parse
19
+ * failure makes "schema" / "canonical" meaningless). Successful checks
20
+ * still run all three and report all errors when the `collectAll`
21
+ * flag is true (used by tests; the CLI prints the first one).
22
+ *
23
+ * Exit codes (used by cli.ts):
24
+ * - 0 = valid
25
+ * - 1 = validation failure (parse / schema / canonical)
26
+ * - 64 = usage error (missing file argument, file not found at path)
27
+ *
28
+ * Out of scope (lands in 8c.3 import):
29
+ * - planId-vs-filename equality (semantic check; the import command
30
+ * enforces it because the file path is the source of truth there).
31
+ * - Stricter `name` regex (semantic, Unicode-aware — was deliberately
32
+ * dropped from the schema in Phase 8b Round 1 because Draft-07
33
+ * pattern doesn't support `\\p{L}`/`\\p{N}` consistently across
34
+ * validators).
35
+ * - Cross-step / cross-gate semantic checks (gate.requires resolves
36
+ * to a sibling step, declaredOutput names unique within a step,
37
+ * etc.) — these belong in plan-validators.ts on the server side
38
+ * and run at import-time anyway.
39
+ *
40
+ * No-coercion rule (Phase 8 contract; Codex 8c.1 Round 1 Medium):
41
+ * The Phase 8 design and the canonicalizer's docstring both pin a
42
+ * "no boolean/numeric coercion" rule on validate/import. js-yaml's
43
+ * default `load` happily coerces unquoted scalars
44
+ * (`minPassRate: 0.95` → JS number 0.95). For schema-typed
45
+ * positions (PlanInputDecl.required: boolean, autoRetryCap: integer,
46
+ * StepDeclaredOutput.required: boolean, etc.) coercion is fine
47
+ * because Ajv enforces the declared type. For the free-form maps
48
+ * (`gate.config`, `integrationPolicy`) the JSON Schema is
49
+ * intentionally `additionalProperties: true` and can't catch silent
50
+ * type drift. The `assertStringValuedFreeFormMap` walk below is
51
+ * the enforcement point: every leaf in those maps must be a string
52
+ * or array of strings, and the user is told to quote the value if
53
+ * not.
54
+ * (`template.metadata` is already string-locked at the schema level
55
+ * via `additionalProperties: { type: string }`, so a coerced int
56
+ * there fails Ajv directly — no extra walk needed.)
57
+ */
58
+ import { readFileSync } from 'node:fs';
59
+ import { dirname } from 'node:path';
60
+ import { fileURLToPath } from 'node:url';
61
+ import path from 'node:path';
62
+ import yaml from 'js-yaml';
63
+ import Ajv from 'ajv';
64
+ import { canonicalizePlanFile } from './canonicalizer.js';
65
+ const __dirname = dirname(fileURLToPath(import.meta.url));
66
+ const SCHEMA_PATH = path.join(__dirname, 'plan-frontmatter.schema.json');
67
+ // Lazy-load + cache: keeps test parallelism cheap. The schema is
68
+ // small (<10 KB), so synchronous read + JSON.parse on first use is
69
+ // fine.
70
+ let cachedSchema;
71
+ function getSchema() {
72
+ if (!cachedSchema) {
73
+ cachedSchema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
74
+ }
75
+ return cachedSchema;
76
+ }
77
+ let cachedValidator = null;
78
+ function getValidator() {
79
+ if (!cachedValidator) {
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ const ajv = new Ajv({ allErrors: true });
82
+ cachedValidator = ajv.compile(getSchema());
83
+ }
84
+ return cachedValidator;
85
+ }
86
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
87
+ /**
88
+ * Validate the contents of a plan file (already read into memory).
89
+ * Pure — no fs access; the caller owns reading the file. Used by both
90
+ * the CLI (`validatePlanFile` wraps a `readFileSync`) and tests
91
+ * (which feed in synthesized strings).
92
+ */
93
+ export function validatePlanText(text) {
94
+ const errors = [];
95
+ // 1) Parse
96
+ const match = text.match(FRONTMATTER_RE);
97
+ if (!match) {
98
+ return {
99
+ ok: false,
100
+ errors: [
101
+ {
102
+ kind: 'parse',
103
+ message: 'plan file must start with `---` frontmatter delimiter, contain a closing `---`, and have a body — no match',
104
+ },
105
+ ],
106
+ };
107
+ }
108
+ const [, fmText, body] = match;
109
+ let frontmatter;
110
+ try {
111
+ const loaded = yaml.load(fmText);
112
+ if (loaded === null || typeof loaded !== 'object' || Array.isArray(loaded)) {
113
+ return {
114
+ ok: false,
115
+ errors: [
116
+ {
117
+ kind: 'parse',
118
+ message: 'frontmatter must be a YAML mapping (object), not a list or scalar',
119
+ },
120
+ ],
121
+ };
122
+ }
123
+ frontmatter = loaded;
124
+ }
125
+ catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ return {
128
+ ok: false,
129
+ errors: [{ kind: 'parse', message: `YAML parse failed: ${msg}` }],
130
+ };
131
+ }
132
+ // 2) Schema
133
+ const validate = getValidator();
134
+ const ok = validate(frontmatter);
135
+ if (!ok) {
136
+ const ajvErrors = validate.errors ?? [];
137
+ for (const err of ajvErrors) {
138
+ errors.push({
139
+ kind: 'schema',
140
+ message: err.message ?? 'schema validation failed',
141
+ path: err.dataPath || '',
142
+ });
143
+ }
144
+ }
145
+ // 2b) No-coercion rule on free-form maps. See header comment.
146
+ if ('integrationPolicy' in frontmatter) {
147
+ assertStringValuedFreeFormMap(frontmatter.integrationPolicy, '.integrationPolicy', errors);
148
+ }
149
+ if (Array.isArray(frontmatter.steps)) {
150
+ for (let i = 0; i < frontmatter.steps.length; i++) {
151
+ const step = frontmatter.steps[i];
152
+ if (!step || !Array.isArray(step.gates))
153
+ continue;
154
+ for (let j = 0; j < step.gates.length; j++) {
155
+ const gate = step.gates[j];
156
+ if (gate && 'config' in gate) {
157
+ assertStringValuedFreeFormMap(gate.config, `.steps[${i}].gates[${j}].config`, errors);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ // 3) Canonical
163
+ // Only meaningful when the parse step succeeded — schema fails
164
+ // don't preclude canonicalization, so we always run this check too
165
+ // and the caller gets a complete report.
166
+ const canonical = canonicalizePlanFile(frontmatter, body);
167
+ if (canonical !== text) {
168
+ errors.push({
169
+ kind: 'canonical',
170
+ message: 'file is not in canonical form (frontmatter field order / lex-sort / body normalization). Run `yolo plan import` to rewrite, or canonicalize manually.',
171
+ });
172
+ }
173
+ if (errors.length > 0) {
174
+ return { ok: false, errors };
175
+ }
176
+ const planId = typeof frontmatter.planId === 'string' ? frontmatter.planId : '';
177
+ return { ok: true, planId, bytes: text.length };
178
+ }
179
+ /**
180
+ * Read a plan file from disk and validate it.
181
+ *
182
+ * Throws `ENOENT`-style errors through the caller (cli.ts maps them
183
+ * to exit code 64 + a friendly message).
184
+ */
185
+ export function validatePlanFile(filePath) {
186
+ const text = readFileSync(filePath, 'utf8');
187
+ return validatePlanText(text);
188
+ }
189
+ /**
190
+ * Walk a free-form map (gate.config / integrationPolicy) recursively
191
+ * and reject any non-string LEAF. Phase 8 + the 8b canonicalizer
192
+ * explicitly support nested mappings/arrays in these positions
193
+ * (`sortNestedKeys` recurses through them); the no-coercion contract
194
+ * applies only at the leaves — every terminal value must be a string,
195
+ * because js-yaml's default load coerces unquoted scalars to JS
196
+ * booleans/numbers and the JSON Schema's `additionalProperties: true`
197
+ * can't catch it (Codex 8c.1 Round 1 Medium).
198
+ *
199
+ * Recursion rules:
200
+ * - object → recurse into each property (path: `.key`).
201
+ * - array → recurse into each item (path: `[i]`).
202
+ * - string → OK (leaf).
203
+ * - anything else (number / boolean / null / undefined) → REJECT.
204
+ *
205
+ * The top-level value (gate.config or integrationPolicy itself) MUST
206
+ * be a mapping; a list or scalar there is also a structural error.
207
+ *
208
+ * Error paths use dot/bracket notation matching Ajv's `dataPath` style
209
+ * so the rendered output stays consistent.
210
+ */
211
+ function assertStringValuedFreeFormMap(value, pathPrefix, errors) {
212
+ if (value === undefined)
213
+ return;
214
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
215
+ errors.push({
216
+ kind: 'schema',
217
+ path: pathPrefix,
218
+ message: 'free-form map must be a YAML mapping (object), not a list, scalar, or null',
219
+ });
220
+ return;
221
+ }
222
+ walkLeaves(value, pathPrefix, errors);
223
+ }
224
+ /**
225
+ * Recursive helper: walk a value of any shape and report non-string
226
+ * leaves. Used by `assertStringValuedFreeFormMap` once we've confirmed
227
+ * the outer value is a mapping.
228
+ */
229
+ function walkLeaves(value, pathPrefix, errors) {
230
+ if (typeof value === 'string')
231
+ return;
232
+ if (Array.isArray(value)) {
233
+ for (let i = 0; i < value.length; i++) {
234
+ walkLeaves(value[i], `${pathPrefix}[${i}]`, errors);
235
+ }
236
+ return;
237
+ }
238
+ if (value !== null && typeof value === 'object') {
239
+ for (const [key, leaf] of Object.entries(value)) {
240
+ walkLeaves(leaf, `${pathPrefix}.${key}`, errors);
241
+ }
242
+ return;
243
+ }
244
+ errors.push({
245
+ kind: 'schema',
246
+ path: pathPrefix,
247
+ message: `value must be a string (got ${describeJsType(value)}). YAML scalar coercion is disallowed in free-form config maps; quote the value explicitly to keep it as a string`,
248
+ });
249
+ }
250
+ function describeJsType(value) {
251
+ if (value === null)
252
+ return 'null';
253
+ if (Array.isArray(value))
254
+ return 'array';
255
+ return typeof value;
256
+ }
257
+ /**
258
+ * Render a `ValidationFail` result as human-readable text for the
259
+ * CLI. One line per error, with kind tag + optional path. Caller
260
+ * appends a trailing newline if needed.
261
+ */
262
+ export function formatErrors(errors) {
263
+ return errors
264
+ .map((e) => {
265
+ const tag = `[${e.kind}]`;
266
+ const loc = e.path ? ` (at ${e.path})` : '';
267
+ return `${tag}${loc} ${e.message}`;
268
+ })
269
+ .join('\n');
270
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Plan-file revision hash (Phase 8c.2).
3
+ *
4
+ * The lockfile records a `lastImportedRevision` for each
5
+ * `(workspaceId, planId)` so re-import can detect divergence: if the
6
+ * file's current canonical bytes hash to the same revision as the
7
+ * lockfile entry's `lastImportedRevision`, the file hasn't changed
8
+ * since the last import (no-op or version bump only). If the hash
9
+ * differs, the user actually edited the file — and `import` then
10
+ * compares the lockfile's `lastImportedVersion` against the DB's
11
+ * current `version` to decide whether to fast-forward
12
+ * (`work.update_plan`) or refuse (without `--force`).
13
+ *
14
+ * Hash domain: the **canonical** bytes of the plan file
15
+ * (frontmatter + body, post-`canonicalizePlanFile`). Hashing the raw
16
+ * input bytes would mean cosmetic re-formatting (whitespace, key
17
+ * order) shows up as drift; using the canonical form means only
18
+ * semantic edits register.
19
+ *
20
+ * Format: `sha256:<hex>` per the design doc strawman
21
+ * (`docs/_ORCHESTRATION_PHASE8_PLAN_FILES_DESIGN.md` §"Plan ID
22
+ * identity"). The `sha256:` prefix is intentional: it leaves room for
23
+ * a future algorithm bump (e.g., `blake3:…`) without re-keying old
24
+ * entries.
25
+ */
26
+ import { createHash } from 'node:crypto';
27
+ export const REVISION_PREFIX = 'sha256:';
28
+ /**
29
+ * Compute the lockfile revision hash for a canonical plan-file string.
30
+ * Pure: same input → same output.
31
+ */
32
+ export function computeRevisionHash(canonicalText) {
33
+ const digest = createHash('sha256').update(canonicalText, 'utf8').digest('hex');
34
+ return `${REVISION_PREFIX}${digest}`;
35
+ }
36
+ /**
37
+ * True if `revision` is well-formed (matches the `sha256:<64-hex>`
38
+ * shape this version emits). Used by lockfile-read code to flag
39
+ * malformed entries up front rather than letting them propagate.
40
+ */
41
+ export function isWellFormedRevision(revision) {
42
+ return /^sha256:[0-9a-f]{64}$/.test(revision);
43
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `yolo run get <runId>` — read a Plan Run from the substrate.
3
+ *
4
+ * Mints a session-bound delegated token (work.get_run scope) and
5
+ * GETs `/internal/work/workspaces/<wsId>/runs/<planRunId>`. Prints
6
+ * a human-readable summary by default; `--json` for the raw response
7
+ * (useful for jq pipelines).
8
+ *
9
+ * No file I/O, no lockfile mutation. Pure read.
10
+ *
11
+ * Errors map to exit codes via the CLI wrapper:
12
+ * - 0 = success
13
+ * - 1 = http (run not found, etc.)
14
+ * - 64 = usage (bad runId, missing env trio, --workspace mismatch)
15
+ */
16
+ import { SUBSTRATE_CLI_RUN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
17
+ import { resolveSubstrateContext } from './auth-context.js';
18
+ // ─── Public entry ─────────────────────────────────────────────────────────
19
+ const PLAN_RUN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,191}$/;
20
+ export async function runRunGet(options) {
21
+ const env = options.env ?? process.env;
22
+ const format = options.outputFormat ?? 'summary';
23
+ if (!PLAN_RUN_ID_REGEX.test(options.planRunId)) {
24
+ return fail('usage', `invalid planRunId '${options.planRunId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,191}$/`);
25
+ }
26
+ const auth = resolveSubstrateContext(env);
27
+ if (!auth.ok)
28
+ return fail('auth', auth.message);
29
+ const { sessionId, commonApiUrl, userToken } = auth.context;
30
+ let mint;
31
+ try {
32
+ mint = await mintSubstrateToken({
33
+ commonApiUrl,
34
+ userToken,
35
+ sessionId,
36
+ scopes: SUBSTRATE_CLI_RUN_SCOPES,
37
+ fetchImpl: options.fetchImpl,
38
+ });
39
+ }
40
+ catch (err) {
41
+ if (err instanceof WorkClientError) {
42
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
43
+ }
44
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
45
+ }
46
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
47
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
48
+ }
49
+ const ctx = {
50
+ commonApiUrl,
51
+ delegatedToken: mint.token,
52
+ fetchImpl: options.fetchImpl,
53
+ };
54
+ const response = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/runs/${options.planRunId}`, { method: 'GET' });
55
+ if (!response.ok) {
56
+ const text = await safeReadText(response);
57
+ return fail('http', `work.get_run failed: HTTP ${response.status} — ${text}`, { status: response.status });
58
+ }
59
+ const json = (await response.json());
60
+ if (!json.run) {
61
+ return fail('http', 'work.get_run response missing `run` field');
62
+ }
63
+ return {
64
+ ok: true,
65
+ output: format === 'json' ? formatJson(json.run) : formatSummary(json.run, mint.workspaceId),
66
+ run: json.run,
67
+ workspaceId: mint.workspaceId,
68
+ };
69
+ }
70
+ // ─── Output formatting ────────────────────────────────────────────────────
71
+ /**
72
+ * Header + per-step table. Table columns auto-size to the longest
73
+ * stepId / state / stepRunId / tileId so output stays aligned even
74
+ * for plans with mixed-width identifiers.
75
+ */
76
+ export function formatSummary(run, workspaceId) {
77
+ const lines = [];
78
+ lines.push(`Plan Run ${run.planRunId} (workspace ${workspaceId}, run #${run.runNumber}, plan ${run.planId} v${run.planVersion}, executionState=${run.executionState})`);
79
+ const operatorTag = run.operatorAgentId
80
+ ? `${run.operatorAgentId}${run.operatorResponsive === false ? ' (unresponsive)' : run.operatorResponsive === true ? ' (responsive)' : ''}`
81
+ : '(unbound)';
82
+ lines.push(` operator: ${operatorTag}`);
83
+ if (run.startedAt)
84
+ lines.push(` startedAt: ${run.startedAt}`);
85
+ if (run.endedAt)
86
+ lines.push(` endedAt: ${run.endedAt}`);
87
+ const inputCount = run.inputs ? Object.keys(run.inputs).length : 0;
88
+ if (inputCount > 0)
89
+ lines.push(` inputs: ${inputCount}`);
90
+ lines.push(` steps: ${run.steps.length}`);
91
+ if (run.steps.length === 0)
92
+ return lines.join('\n');
93
+ const stepIdW = Math.max(6, ...run.steps.map((s) => s.stepId.length));
94
+ const stateW = Math.max(5, ...run.steps.map((s) => s.state.length));
95
+ const stepRunW = Math.max(8, ...run.steps.map((s) => (s.latestStepRunId ?? '—').length));
96
+ for (const step of run.steps) {
97
+ const stepRun = step.latestStepRunId ?? '—';
98
+ const tile = step.tileId ?? '—';
99
+ lines.push(` - ${step.stepId.padEnd(stepIdW)} ${step.state.padEnd(stateW)} ${stepRun.padEnd(stepRunW)} ${tile}`);
100
+ }
101
+ return lines.join('\n');
102
+ }
103
+ function formatJson(run) {
104
+ return JSON.stringify(run, null, 2);
105
+ }
106
+ // ─── Helpers ──────────────────────────────────────────────────────────────
107
+ function fail(kind, message, detail) {
108
+ return { ok: false, kind, message, detail };
109
+ }
110
+ function describeError(err) {
111
+ return err instanceof Error ? err.message : String(err);
112
+ }
113
+ async function safeReadText(response) {
114
+ try {
115
+ return await response.text();
116
+ }
117
+ catch {
118
+ return '<no body>';
119
+ }
120
+ }
121
+ export function exitCodeForFailure(kind) {
122
+ switch (kind) {
123
+ case 'usage':
124
+ case 'auth':
125
+ case 'workspace_mismatch':
126
+ return 64;
127
+ case 'http':
128
+ return 1;
129
+ }
130
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `yolo run pause / resume / cancel <runId>` — lifecycle transitions.
3
+ *
4
+ * The three verbs share a single runner because the routes share a
5
+ * single transition handler on the server (common-api/src/routes/
6
+ * workspaces.ts:transitionPlanRunForUser). They differ only in
7
+ * target state, valid source states, and whether they accept a
8
+ * `--reason`.
9
+ *
10
+ * Auth model: hits the user-facing route at
11
+ * `/v1/workspaces/:id/runs/:planRunId/{verb}` via X-Internal-Auth +
12
+ * X-User-Id (the substrate CLI is by definition invoked by the
13
+ * workspace owner — operating as them is the right default). The
14
+ * user route checks workspace ownership only and skips R4 operator
15
+ * binding, so this works regardless of which agent (if any) is
16
+ * bound to the run.
17
+ *
18
+ * Why not the MCP path: that path's R4 binding makes sense for
19
+ * agent-to-agent calls (claude pausing its own run) but produces
20
+ * 403 NOT_AUTHORIZED whenever the user wants to cancel a run owned
21
+ * by a different agent or by the webapp launcher. Forcing the user
22
+ * to know which side of that fence they're on is bad UX. The CLI
23
+ * decides automatically.
24
+ *
25
+ * Errors map to exit codes via the CLI wrapper:
26
+ * - 0 = success
27
+ * - 1 = http (404, 409 INVALID_STATE, 5xx)
28
+ * - 64 = usage (bad runId, missing env trio, --workspace mismatch,
29
+ * reason too long pre-network, bad target verb)
30
+ */
31
+ import { SUBSTRATE_CLI_RUN_SCOPES, WorkClientError, mintSubstrateToken, userRouteRequest, } from './work-client.js';
32
+ import { resolveSubstrateContext } from './auth-context.js';
33
+ // ─── Public entry ─────────────────────────────────────────────────────────
34
+ const PLAN_RUN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,191}$/;
35
+ const REASON_MAX_LEN = 1024;
36
+ export async function runRunLifecycle(options) {
37
+ const env = options.env ?? process.env;
38
+ const format = options.outputFormat ?? 'summary';
39
+ if (options.verb !== 'pause' && options.verb !== 'resume' && options.verb !== 'cancel') {
40
+ return fail('usage', `unknown verb '${options.verb}': must be pause, resume, or cancel`);
41
+ }
42
+ if (!PLAN_RUN_ID_REGEX.test(options.planRunId)) {
43
+ return fail('usage', `invalid planRunId '${options.planRunId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,191}$/`);
44
+ }
45
+ if (options.reason !== undefined) {
46
+ if (typeof options.reason !== 'string' || options.reason.length > REASON_MAX_LEN) {
47
+ return fail('usage', `--reason must be a string of at most ${REASON_MAX_LEN} chars`);
48
+ }
49
+ if (options.verb === 'resume') {
50
+ return fail('usage', '--reason is not accepted by `run resume`');
51
+ }
52
+ }
53
+ const auth = resolveSubstrateContext(env);
54
+ if (!auth.ok)
55
+ return fail('auth', auth.message);
56
+ const { sessionId, commonApiUrl, userToken } = auth.context;
57
+ let mint;
58
+ try {
59
+ mint = await mintSubstrateToken({
60
+ commonApiUrl,
61
+ userToken,
62
+ sessionId,
63
+ scopes: SUBSTRATE_CLI_RUN_SCOPES,
64
+ fetchImpl: options.fetchImpl,
65
+ });
66
+ }
67
+ catch (err) {
68
+ if (err instanceof WorkClientError) {
69
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
70
+ }
71
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
72
+ }
73
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
74
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
75
+ }
76
+ const body = {};
77
+ if (options.reason !== undefined && options.verb !== 'resume') {
78
+ body.reason = options.reason;
79
+ }
80
+ const path = `/workspaces/${mint.workspaceId}/runs/${options.planRunId}/${options.verb}`;
81
+ const response = await userRouteRequest({
82
+ commonApiUrl,
83
+ userToken,
84
+ fetchImpl: options.fetchImpl,
85
+ }, path, { method: 'POST', jsonBody: body });
86
+ if (!response.ok) {
87
+ const text = await safeReadText(response);
88
+ return fail('http', `work.${options.verb}_run failed: HTTP ${response.status} — ${text}`, { status: response.status });
89
+ }
90
+ const json = (await response.json());
91
+ if (typeof json.planRunId !== 'string' || typeof json.executionState !== 'string') {
92
+ return fail('http', `work.${options.verb}_run response missing planRunId or executionState`);
93
+ }
94
+ return {
95
+ ok: true,
96
+ output: format === 'json' ? formatJson(json) : formatSummary(options.verb, json),
97
+ response: json,
98
+ workspaceId: mint.workspaceId,
99
+ };
100
+ }
101
+ // ─── Output formatting ────────────────────────────────────────────────────
102
+ export function formatSummary(verb, response) {
103
+ const past = verb === 'pause' ? 'paused' : verb === 'resume' ? 'resumed' : 'cancelled';
104
+ return `OK: ${past} run ${response.planRunId} (executionState=${response.executionState})`;
105
+ }
106
+ function formatJson(response) {
107
+ return JSON.stringify(response, null, 2);
108
+ }
109
+ // ─── Helpers ──────────────────────────────────────────────────────────────
110
+ function fail(kind, message, detail) {
111
+ return { ok: false, kind, message, detail };
112
+ }
113
+ function describeError(err) {
114
+ return err instanceof Error ? err.message : String(err);
115
+ }
116
+ async function safeReadText(response) {
117
+ try {
118
+ return await response.text();
119
+ }
120
+ catch {
121
+ return '<no body>';
122
+ }
123
+ }
124
+ export function exitCodeForFailure(kind) {
125
+ switch (kind) {
126
+ case 'usage':
127
+ case 'auth':
128
+ case 'workspace_mismatch':
129
+ return 64;
130
+ case 'http':
131
+ return 1;
132
+ }
133
+ }