@shardflux/cli 0.2.0 → 0.3.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ Every command and option the README shows is available from the version named here. Below 1.0, a minor release may
4
+ break compatibility; breaking changes are marked **Breaking**.
5
+
6
+ ## 0.3.1 (not yet published; npm `latest` is 0.3.0)
7
+
8
+ Needs `@shardflux/sdk` 0.6.2 or later.
9
+
10
+ The API no longer lets a start (open, resume, fork) wait in `capacity_pending` forever: one that no host could admit
11
+ 15 minutes after it began fails with `capacity_unavailable` (retryable; nothing was started, a suspended workspace
12
+ stays suspended).
13
+
14
+ - A failed operation's error has `retryable` (the operation error's own flag): `retryable=true|false` in the error line,
15
+ `"retryable"` in the `--json` error document, next to `details.error_code`. `details.reason` carries the operation
16
+ error's reason (for `capacity_unavailable`, why no host fit, e.g. `no_ready_hosts`), printed after the message.
17
+ - A retryable failure gets a hint to retry later. Exit codes are unchanged (6 for any failed operation); `shard` does
18
+ not retry.
19
+ - A `--timeout` that ends while the start waits for capacity names the time it gives up (the SDK's message).
20
+ - Help and README: `--timeout` and exit codes 5 and 6 describe the 15-minute limit.
21
+ - `shard workspaces get` names the origin of a template version test instance (`template <id> version <n>`, contracts
22
+ §24.6) instead of reading it as a draft state.
23
+
24
+ ## 0.3.0 (2026-09-28)
25
+
26
+ Needs `@shardflux/sdk` 0.6.0 or later.
27
+
28
+ ### Where the time went: `--timing`
29
+
30
+ - `--timing` on `workspaces open`, `workspaces suspend|resume|fork|delete|close|reset` (with `--wait`, the whole wait),
31
+ `operations wait` and `templates draft open|test|state|discard` prints the SDK's lifecycle timing on stderr after
32
+ the call: the client phases (the request, each operation state seen while waiting with the server's reason, such as
33
+ `capacity_pending (no_ready_host)`), the operation's own server timing (queued, ran, total; start or resume path),
34
+ the time spent outside the server, and retries. It is printed when the call fails, times out or is interrupted
35
+ too, before the error, and the exit code is unchanged.
36
+ - `workspaces exec`, `files read|write|ls` and `workspaces changes` take `--timing` too: it times the wake, when the
37
+ command had to wake a suspended workspace.
38
+ - With `--json`, the timing is a `timing` field of the JSON on stdout instead (snake_case; `null` when the command
39
+ made no lifecycle call), and of the error document on stderr (`{"error": {...}, "timing": {...}}`). Existing fields
40
+ are unchanged.
41
+
42
+ ### Waits are the SDK's
43
+
44
+ - `workspaces open` waits with the SDK's waited open: the API holds the request until the workspace is ready
45
+ (`Prefer: wait`, up to 20 s at a time) instead of the CLI polling and then reading the workspace. The ready
46
+ workspace comes with its first tool token, so the open is listed as an agent session under the CLI's label (as a
47
+ reopen of a running workspace already was). Output, `--timeout` and exit codes are unchanged.
48
+ - Lifecycle commands with `--wait` use the SDK's `wait`. Output and exit codes are unchanged, except that
49
+ `workspaces fork --wait` prints the new workspace as it is after the fork, not as it was requested.
50
+ - Ctrl-C ends a held open at once (exit 130); the open continues server side, and opening the key again keeps
51
+ waiting.
52
+
53
+ ## 0.2.0 (2026-09-28)
54
+
55
+ Secrets (`secrets ...`, `workspaces open --secret`, `workspaces secrets`), session workspaces (`--lifetime session`,
56
+ `workspaces close`), layered workspaces (`workspaces reset|save-as-template|changes`), templates
57
+ (`templates list|files|diff|draft ...`), and waking suspended workspaces on use (`--no-wake`, `--wake-timeout`).
58
+
59
+ ## 0.1.0 (2026-09-26)
60
+
61
+ First release: `login`, `whoami`, `usage`, `workspaces open|list|get|exec|suspend|resume|fork|delete|sessions`,
62
+ `files read|write|ls`, `operations list|get|wait`, `--json`, and documented exit codes.
package/README.md CHANGED
@@ -56,14 +56,14 @@ every run.
56
56
  | `login`, `whoami` | Show the principal behind the key. |
57
57
  | `version` | Print the CLI and SDK versions. |
58
58
  | `usage [--org <id>]` | Usage summary for the current period: plan, meters and allowances. |
59
- | `workspaces open <key> --template <slug> [--secret NAME]... [--lifetime persistent\|session] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. |
59
+ | `workspaces open <key> --template <slug> [--secret NAME]... [--lifetime persistent\|session] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m] [--timing]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. |
60
60
  | `workspaces list [--state S] [--prefix P] [--lifetime persistent\|session\|any] [--purpose standard\|template_draft\|template_test\|any] [--include-deleted] [--all] [--limit N] [--cursor C]` | One page, or every page with `--all`. The next cursor is printed on stderr. By default only persistent standard workspaces are listed. |
61
61
  | `workspaces get <id\|key>` | One workspace (deleted ones included). |
62
62
  | `workspaces exec <id\|key> [--cwd D] [--env K=V]... [--timeout T] [--stdin F\|-] -- <cmd> [args...]` | Runs argv with no shell. Output is streamed and resumes from byte offsets after dropped connections. `shard` exits with the command's exit code. Ctrl-C cancels the command. |
63
63
  | `files read <id\|key> <path> [--out F]` | Raw bytes to stdout, or to a local file. |
64
64
  | `files write <id\|key> <path> [--from F\|-] [--append] [--no-parents]` | Atomic write. The default source is stdin. |
65
65
  | `files ls <id\|key> <path> [--limit N]` | Directory listing. |
66
- | `workspaces suspend\|resume <id\|key> [--wait] [--timeout T]` | Lifecycle operation. Returns immediately unless you pass `--wait`. |
66
+ | `workspaces suspend\|resume <id\|key> [--wait] [--timeout T] [--timing]` | Lifecycle operation. Returns immediately unless you pass `--wait`. |
67
67
  | `workspaces fork <id\|key> <new-key> [caps] [--wait]` | Fork into a new key. |
68
68
  | `workspaces delete <id\|key> --yes [--wait]` | Delete the workspace. Tool access ends at once. The key of a persistent workspace is never reused. |
69
69
  | `workspaces close <id\|key> [--wait]` | End a session workspace now: it is deleted, and the key then opens a new workspace. A persistent workspace is refused (`not_session`). |
@@ -77,7 +77,7 @@ every run.
77
77
  | `secrets update <id\|NAME> [--description T] [--tool T]... [--allow-workspace ID]... [--any-workspace] [--allow-project ID]... [--all-projects] [--clear-projects]` | Change the description or usage permissions. |
78
78
  | `secrets rotate <id\|NAME> [--from-file F]` | Store a new value (standard input or file); older values are erased. |
79
79
  | `secrets versions <id\|NAME>`, `secrets delete <id\|NAME> --yes`, `secrets access-log <id\|NAME>` | Versions, deletion (also removes the name from every workspace binding), and the access log. |
80
- | `operations list <id\|key> [--state S] [--kind K]`, `operations get <op>`, `operations wait <op> [--timeout T]` | Operations. |
80
+ | `operations list <id\|key> [--state S] [--kind K]`, `operations get <op>`, `operations wait <op> [--timeout T] [--timing]` | Operations. |
81
81
  | `templates list [--owner platform\|organization] [--include-archived] [--all]` | Templates the project can open, with their open version, layouts, file list state and draft. |
82
82
  | `templates files <slug> <version> [path] [--stat] [--owner O] [--all] [--limit N]` | One directory of a version's file tree, or one entry with `--stat`. |
83
83
  | `templates diff <slug> --from <version\|base> --to <version> [--prefix P] [--change K] [--all]` | Diff two versions: added, removed, changed, type_changed, metadata. The totals come first. |
@@ -106,9 +106,69 @@ never runs twice, because the workspace refused it before doing anything.
106
106
  is still queued after 120000 ms`). The operation continues server side:
107
107
  `shard operations wait <id>` keeps waiting.
108
108
  - A resume or open that fails exits 6.
109
+
110
+ ## Starts that wait for capacity
111
+
112
+ An open, resume or fork that no host can admit yet waits in `capacity_pending` for at most 15
113
+ minutes. If `--timeout` ends first, the timeout error names the time it gives up. A start still
114
+ pending then fails with `capacity_unavailable`: nothing was started, and a suspended
115
+ workspace stays suspended. `shard` exits 6 as for any failed operation, and the error says
116
+ `retryable=true` (with `--json`, `"retryable": true`) with a hint to retry later:
117
+
118
+ ```text
119
+ error: Operation 01a0df37-b3a9-7a14-a3e7-11395c349c39 (open) failed: capacity_unavailable (no_ready_hosts) [code=operation_failed operation_id=01a0df37-b3a9-7a14-a3e7-11395c349c39 retryable=true]
120
+ hint: no host could admit it before its deadline; nothing was started. Retry later.
121
+ ```
122
+
123
+ `shard` does not retry it for you. A definitive failure has `retryable=false`.
109
124
  - `--no-wake` (or `SHARDFLUX_NO_WAKE=1`) turns this off. A workspace that is not running is then
110
125
  refused at once (exit 1, `workspace_not_running`).
111
126
 
127
+ ## Timing
128
+
129
+ `--timing` (0.3.0+) says where the time of an open or a wait went. After the call, `shard` prints the lifecycle
130
+ timing that [`@shardflux/sdk`](https://www.npmjs.com/package/@shardflux/sdk) measured (its `formatTiming()`) on
131
+ stderr. It is printed when the call fails, times out or is interrupted too, before the error, and the exit code does
132
+ not change. A slow open reads, for example:
133
+
134
+ ```text
135
+ $ shard ws open acme/demo --template python-node-browser --timing
136
+ Workspace acme/demo (01a0e5a8-3edd-74ba-b489-d62b8925e342) is ready.
137
+ ...
138
+ open 34.18 s, succeeded (workspace 01a0e5a8-3edd-74ba-b489-d62b8925e342, operation 01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33)
139
+ client: request 20.01 s (held) → capacity_pending 13.52 s (no_ready_host) → running 0.59 s → view 42 ms ∥ token 61 ms
140
+ server: queued 33.40 s, ran 0.62 s, total 34.02 s; start warm, boot to ready 79 ms
141
+ outside the server: 0.16 s
142
+ ```
143
+
144
+ Here the time went to waiting for a host with capacity; the network and the VM start were fast.
145
+
146
+ - **client**: phases on your clock. `request` is the API call; `(held)` means the API held it until the workspace
147
+ was ready, up to 20 s at a time. Then come the operation states seen while waiting, with the server's reason, and
148
+ reading the workspace.
149
+ - **server**: the operation's own timing. `queued` runs until the operation began running, including any wait for
150
+ capacity; `ran` is the cell's work. Then the start or resume path the cell reported.
151
+ - **outside the server**: your total minus the operation's, so network, polling and the final reads. A large value
152
+ with a small server total points at the connection, not at the workspace.
153
+ - **retries**: transient failures that were retried.
154
+
155
+ Which call is timed:
156
+
157
+ - `workspaces open` and `templates draft open|test`: the open, including the wait unless `--no-wait`.
158
+ - `workspaces suspend|resume|fork|delete|close|reset` and `templates draft state|discard`: with `--wait`, the request
159
+ and the whole wait; without it, the request.
160
+ - `operations wait`: the wait.
161
+ - `workspaces exec`, `files read|write|ls` and `workspaces changes`: the wake, when the command had to wake a
162
+ suspended workspace. Without a wake nothing is printed.
163
+
164
+ With `--json`, stdout carries the timing as a `timing` field of the command's JSON instead: `action`, `outcome`,
165
+ `workspace_id`, `operation_id`, `total_ms`, `phases` (`phase`, `reason`, `operation_id`, `start_ms`, `duration_ms`),
166
+ `retries`, `server` (`queued_ms`, `run_ms`, `total_ms`, `start_path`, `resume_path`, `boot_to_ready_ms`, ...) and
167
+ `outside_server_ms`. It is `null` when the command made no lifecycle call. On a failure the error document on stderr
168
+ carries it: `{"error": {...}, "timing": {...}}`.
169
+
170
+ `shard` does not stream progress while it waits. For live events, use the SDK's `onProgress`.
171
+
112
172
  ## Secrets
113
173
 
114
174
  ```sh
@@ -134,7 +194,7 @@ printf %s "$NEW_KEY" | shard secrets rotate OPENAI_API_KEY
134
194
 
135
195
  - **Human (default):** aligned tables and key/value blocks on stdout. Errors go to stderr as
136
196
  `error: <message> (<reason>) [code=... status=... request_id=... operation_id=...]`, with a
137
- hint when there is one.
197
+ hint when there is one. A failed operation adds `retryable=true|false` (0.3.1+).
138
198
  - **`--json`:** stdout carries the API's own objects:
139
199
  - `workspaces open`/`get` print the workspace view; `list` prints `{data, next_cursor}`.
140
200
  - Lifecycle commands print the operation; `fork` prints `{operation, workspace}`.
@@ -145,7 +205,11 @@ printf %s "$NEW_KEY" | shard secrets rotate OPENAI_API_KEY
145
205
  print `{data, next_cursor}`; `rotate` prints `{secret, version}`; `delete` prints
146
206
  `{deleted, id, name, scope}`; `workspaces secrets` prints `{workspace_id, names, secrets}`.
147
207
 
148
- Errors go to stderr as `{"error": {code, message, status, request_id, ...}}`.
208
+ Errors go to stderr as `{"error": {code, message, status, request_id, ...}}`. A failed operation is
209
+ `{"error": {"code": "operation_failed", "message", "operation_id", "retryable", "details": {"error_code",
210
+ "reason"}, "operation"}}`; `retryable` (0.3.1+) is the operation error's own flag.
211
+ - **`--timing`:** the timing goes to stderr after the output. With `--json` it is a `timing` field instead
212
+ (see "Timing").
149
213
 
150
214
  ## Exit codes
151
215
 
@@ -156,8 +220,8 @@ printf %s "$NEW_KEY" | shard secrets rotate OPENAI_API_KEY
156
220
  | 2 | usage error: bad arguments, or input the API rejected as `validation_failed` |
157
221
  | 3 | not found: workspace, operation or route |
158
222
  | 4 | authentication or authorization: missing, malformed, revoked or insufficient key |
159
- | 5 | timeout: waiting gave up (`--timeout`, or `--wake-timeout` while waking a workspace); the operation continues server side (`shard operations wait <id>`) |
160
- | 6 | the awaited operation ended `failed` or `canceled` (also a resume or open that failed while waking a workspace) |
223
+ | 5 | timeout: waiting gave up (`--timeout`, or `--wake-timeout` while waking a workspace); the operation continues server side (`shard operations wait <id>`), a start waiting for capacity for at most 15 minutes |
224
+ | 6 | the awaited operation ended `failed` or `canceled` (also a resume or open that failed while waking a workspace). `retryable=true` in the error means it may succeed later (`capacity_unavailable`) |
161
225
  | 130 | interrupted (Ctrl-C). Waits stop at once, and exec sends a cancel to the session. |
162
226
  | exec | the command's own code: 124 if it timed out, 128+N if killed by signal N. Failures before the command ran use the codes above; use `--json` to tell them apart. |
163
227
 
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.2.0";
1
+ export declare const CLI_VERSION = "0.3.1";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { parseArgs } from 'node:util';
12
12
  import { UsageError } from "./errors.js";
13
- export const CLI_VERSION = '0.2.0';
13
+ export const CLI_VERSION = '0.3.1';
14
14
  export const GLOBAL_OPTIONS = {
15
15
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
16
16
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -25,9 +25,21 @@ const CAPS = {
25
25
  'memory-mib': { type: 'string', value: '<n>', description: 'Memory cap in MiB.' },
26
26
  'disk-gib': { type: 'string', value: '<n>', description: 'Disk cap in GiB.' },
27
27
  };
28
+ /** `--timing` (0.3.0+): the SDK's lifecycle timing of the command's open, wait or wake (README "Timing"). */
29
+ const TIMING = {
30
+ timing: {
31
+ type: 'boolean',
32
+ description: 'Print where the time went on stderr after the call, also when it fails: client phases, the operation’s server timing, retries (with --json: a "timing" field instead).',
33
+ },
34
+ };
35
+ /** `--timing` of the commands that can wake a suspended workspace: the wake's timing, when there was one. */
36
+ const WAKE_TIMING = {
37
+ timing: { type: 'boolean', description: 'Print where the time went when the command had to wake the workspace (stderr; with --json a "timing" field, null without a wake).' },
38
+ };
28
39
  const WAIT = {
29
40
  wait: { type: 'boolean', description: 'Wait until the operation finishes.' },
30
- timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (e.g. 90s, 5m; default 5m). The operation continues server side.' },
41
+ timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (e.g. 90s, 5m; default 5m). The operation continues server side (a start waiting for capacity until its deadline, 15 minutes after it began; then it fails with capacity_unavailable).' },
42
+ ...TIMING,
31
43
  };
32
44
  const REF = { name: 'id|key', required: true };
33
45
  const SLUG = { name: 'slug', required: true };
@@ -99,13 +111,15 @@ export const COMMANDS = [
99
111
  },
100
112
  ...CAPS,
101
113
  wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
102
- timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m). The start continues server side.' },
114
+ timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m). The start continues server side; one waiting for capacity gives up 15 minutes after it began (capacity_unavailable, retryable).' },
115
+ ...TIMING,
103
116
  },
104
117
  examples: [
105
118
  'shard workspaces open acme/demo --template python-node-browser',
106
119
  'shard ws open job-42 --template python-node-browser --lifetime session',
107
120
  'shard ws open acme/demo --template python-node-browser --secret OPENAI_API_KEY --secret DATABASE_URL',
108
121
  'shard ws open acme/demo --template python-node-browser --no-wait --json',
122
+ 'shard ws open acme/demo --template python-node-browser --timing',
109
123
  ],
110
124
  },
111
125
  {
@@ -133,6 +147,7 @@ export const COMMANDS = [
133
147
  env: { type: 'string', multiple: true, value: '<K=V>', description: 'Environment variable (repeatable).' },
134
148
  timeout: { type: 'string', value: '<duration>', description: 'Kill the command after this long (exit code 124).' },
135
149
  stdin: { type: 'string', value: '<file|->', description: 'Send this file (or - for standard input) to the command’s stdin (max 1 MiB).' },
150
+ ...WAKE_TIMING,
136
151
  },
137
152
  examples: ['shard ws exec acme/demo -- python3 -V', 'shard ws exec acme/demo --cwd /home/user/app --env CI=1 -- bash -lc "npm test"'],
138
153
  },
@@ -188,6 +203,7 @@ export const COMMANDS = [
188
203
  summary: { type: 'boolean', description: 'Also print totals over everything under --path.' },
189
204
  ...PAGE,
190
205
  limit: { type: 'string', value: '<n>', description: 'Page size, 1-1000 (default 1000).' },
206
+ ...WAKE_TIMING,
191
207
  },
192
208
  },
193
209
  { path: ['workspaces', 'sessions'], summary: 'List the attributed agent sessions (tool-token principals and labels) of a workspace', positionals: [REF], options: {} },
@@ -206,7 +222,7 @@ export const COMMANDS = [
206
222
  path: ['files', 'read'],
207
223
  summary: 'Print (or save) a file from a workspace',
208
224
  positionals: [REF, { name: 'path', required: true }],
209
- options: { out: { type: 'string', value: '<file>', description: 'Write to this local file instead of stdout.' } },
225
+ options: { out: { type: 'string', value: '<file>', description: 'Write to this local file instead of stdout.' }, ...WAKE_TIMING },
210
226
  },
211
227
  {
212
228
  path: ['files', 'write'],
@@ -216,13 +232,14 @@ export const COMMANDS = [
216
232
  from: { type: 'string', value: '<file|->', description: 'Local file to upload, or - for standard input (default -).' },
217
233
  append: { type: 'boolean', description: 'Append instead of replacing.' },
218
234
  parents: { type: 'boolean', description: 'Create missing parent directories (default; --no-parents to refuse).' },
235
+ ...WAKE_TIMING,
219
236
  },
220
237
  },
221
238
  {
222
239
  path: ['files', 'ls'],
223
240
  summary: 'List a directory in a workspace',
224
241
  positionals: [REF, { name: 'path', required: true }],
225
- options: { limit: { type: 'string', value: '<n>', description: 'Maximum entries (default 1000).' } },
242
+ options: { limit: { type: 'string', value: '<n>', description: 'Maximum entries (default 1000).' }, ...WAKE_TIMING },
226
243
  },
227
244
  {
228
245
  path: ['operations', 'list'],
@@ -347,6 +364,7 @@ export const COMMANDS = [
347
364
  ...CAPS,
348
365
  wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
349
366
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m).' },
367
+ ...TIMING,
350
368
  },
351
369
  examples: ['shard templates draft open acme-dev --base python-node-browser@5'],
352
370
  },
@@ -374,6 +392,7 @@ export const COMMANDS = [
374
392
  ...CAPS,
375
393
  wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
376
394
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m).' },
395
+ ...TIMING,
377
396
  },
378
397
  },
379
398
  {
@@ -393,7 +412,7 @@ export const COMMANDS = [
393
412
  path: ['operations', 'wait'],
394
413
  summary: 'Wait for an operation to finish (exit 0 succeeded, 6 failed/canceled, 5 timeout)',
395
414
  positionals: [{ name: 'operation-id', required: true }],
396
- options: { timeout: { type: 'string', value: '<duration>', description: 'Give up after this long (default 5m).' } },
415
+ options: { timeout: { type: 'string', value: '<duration>', description: 'Give up after this long (default 5m).' }, ...TIMING },
397
416
  },
398
417
  ];
399
418
  const GROUPS = {
@@ -672,7 +691,7 @@ export const EXIT_CODE_HELP = [
672
691
  ' 3 not found (workspace, operation or route)',
673
692
  ' 4 authentication/authorization (missing, malformed, revoked or insufficient key)',
674
693
  ' 5 timeout (waiting gave up; the operation continues server side)',
675
- ' 6 operation failed or canceled',
694
+ ' 6 operation failed or canceled (retryable=true in the error: it may succeed later, e.g. capacity_unavailable)',
676
695
  ' 130 interrupted (Ctrl-C)',
677
696
  ' exec: the command’s own exit code (124 if it timed out, 128+N if killed by signal N)',
678
697
  ];
@@ -1,5 +1,6 @@
1
1
  import type { RunResult, Shardflux, Workspace } from '@shardflux/sdk';
2
2
  import type { Values } from './args.js';
3
+ import type { TimingRecorder } from './timing.js';
3
4
  export interface Writer {
4
5
  write(chunk: string | Uint8Array): boolean;
5
6
  }
@@ -21,6 +22,10 @@ export interface Ctx {
21
22
  /** Bound on lifecycle waits per cell call, wakes included (--wake-timeout / SHARDFLUX_WAKE_TIMEOUT_MS). */
22
23
  wakeTimeoutMs: number;
23
24
  signal: AbortSignal;
25
+ /** --timing: print the SDK's timing of the command's open, wait or wake (stderr; a `timing` field with --json). */
26
+ timing: boolean;
27
+ /** Timings and the last observed operation of the traced SDK calls this command made (the client's onProgress). */
28
+ traces: TimingRecorder;
24
29
  cloud(): Shardflux;
25
30
  }
26
31
  export type Handler = (ctx: Ctx, values: Values, positionals: string[]) => Promise<number>;
package/dist/commands.js CHANGED
@@ -9,6 +9,7 @@ import { readFile, writeFile } from 'node:fs/promises';
9
9
  import { SDK_VERSION, ShardfluxApiError } from '@shardflux/sdk';
10
10
  import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
11
11
  import { InterruptedError, NotFoundError, UsageError } from "./errors.js";
12
+ import { timingJson } from "./timing.js";
12
13
  import { bindingsText, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, fileTable, json, kv, operationDetail, operationTable, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, templateEntryText, templateFileTable, templateTable, usageText, workspaceDetail, workspaceTable, } from "./format.js";
13
14
  const DEFAULT_WAIT_MS = 300_000;
14
15
  const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
@@ -30,8 +31,14 @@ function secretNames(names, option) {
30
31
  throw new UsageError(`${option} names a secret twice`);
31
32
  return [...names];
32
33
  }
34
+ /** With --json --timing the JSON gains a `timing` field (null when the command made no traced lifecycle call). */
35
+ function withTiming(ctx, machine) {
36
+ if (!ctx.timing || typeof machine !== 'object' || machine === null || Array.isArray(machine))
37
+ return machine;
38
+ return { ...machine, timing: timingJson(ctx.traces.last) };
39
+ }
33
40
  function out(ctx, human, machine) {
34
- ctx.io.stdout.write(ctx.json ? json(machine) : human);
41
+ ctx.io.stdout.write(ctx.json ? json(withTiming(ctx, machine)) : human);
35
42
  }
36
43
  function caps(values) {
37
44
  const c = {};
@@ -158,6 +165,21 @@ async function waitOperation(ctx, operationId, timeoutMs) {
158
165
  throw err;
159
166
  }
160
167
  }
168
+ /**
169
+ * Runs an SDK call that waits itself (a waited open or lifecycle call). Ctrl-C reports the operation it was waiting
170
+ * for, as the SDK observed it; a held open interrupted before the server answered has none yet (`unnamed`).
171
+ */
172
+ async function waited(ctx, unnamed, call) {
173
+ try {
174
+ return await call();
175
+ }
176
+ catch (err) {
177
+ if (!ctx.signal.aborted)
178
+ throw err;
179
+ const id = ctx.traces.operationId;
180
+ throw id ? new InterruptedError(`Interrupted; operation ${id} continues server side.`, id) : new InterruptedError(`Interrupted; ${unnamed}`);
181
+ }
182
+ }
161
183
  async function getOperation(ctx, operationId) {
162
184
  if (!UUID.test(operationId))
163
185
  throw new UsageError(`<operation-id> must be a UUID (got "${operationId}")`);
@@ -259,20 +281,18 @@ export const HANDLERS = {
259
281
  const lifetime = oneOf(values, 'lifetime', LIFETIMES);
260
282
  const timeoutMs = waitTimeout(values);
261
283
  const cloud = ctx.cloud();
262
- let ws = await cloud.workspaces.open({
284
+ // The SDK's waited open: the server holds the request until the workspace is ready (Prefer: wait), then the SDK
285
+ // polls for what is left of --timeout; one trace (ws.lastTiming) covers all of it.
286
+ const ws = await waited(ctx, 'the open continues server side (open the key again to keep waiting).', () => cloud.workspaces.open({
263
287
  key: key,
264
288
  template,
265
289
  ...(c ? { caps: c } : {}),
266
290
  ...(bind.length ? { secrets: bind } : {}),
267
291
  ...(lifetime ? { lifetime } : {}),
268
292
  agentLabel: ctx.agentLabel,
269
- wait: false,
270
- });
293
+ wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
294
+ }));
271
295
  const op = ws.activeOperation;
272
- if (values.wait !== false && !ws.ready && op) {
273
- await waitOperation(ctx, op.id, timeoutMs);
274
- ws = await cloud.workspaces.get(ws.id);
275
- }
276
296
  const view = ws.data;
277
297
  const human = ws.ready
278
298
  ? `Workspace ${ws.key} (${ws.id}) is ready.\n${workspaceDetail(view)}`
@@ -351,7 +371,7 @@ export const HANDLERS = {
351
371
  }
352
372
  const code = execExitCode(r);
353
373
  if (ctx.json) {
354
- ctx.io.stdout.write(json({ session_id: r.sessionId, exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, canceled: r.canceled, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, reconnects: r.reconnects }));
374
+ ctx.io.stdout.write(json(withTiming(ctx, { session_id: r.sessionId, exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, canceled: r.canceled, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, reconnects: r.reconnects })));
355
375
  }
356
376
  return code;
357
377
  },
@@ -364,7 +384,7 @@ export const HANDLERS = {
364
384
  out(ctx, `wrote ${bytes.length} bytes to ${dest}\n`, { path, bytes: bytes.length, out: dest });
365
385
  }
366
386
  else if (ctx.json) {
367
- ctx.io.stdout.write(json({ path, bytes: bytes.length, content_base64: Buffer.from(bytes).toString('base64') }));
387
+ ctx.io.stdout.write(json(withTiming(ctx, { path, bytes: bytes.length, content_base64: Buffer.from(bytes).toString('base64') })));
368
388
  }
369
389
  else {
370
390
  ctx.io.stdout.write(bytes);
@@ -386,23 +406,23 @@ export const HANDLERS = {
386
406
  return 0;
387
407
  },
388
408
  async 'workspaces suspend'(ctx, values, [ref]) {
389
- return lifecycle(ctx, values, ref, 'Suspending', (ws) => ctx.cloud().workspaces.suspend(ws.id));
409
+ return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o));
390
410
  },
391
411
  async 'workspaces resume'(ctx, values, [ref]) {
392
- return lifecycle(ctx, values, ref, 'Resuming', (ws) => ctx.cloud().workspaces.resume(ws.id));
412
+ return lifecycle(ctx, values, ref, 'Resuming', (ws, o) => ctx.cloud().workspaces.resume(ws.id, o));
393
413
  },
394
414
  async 'workspaces delete'(ctx, values, [ref]) {
395
415
  if (values.yes !== true)
396
416
  throw new UsageError('refusing to delete without --yes (deletion revokes tool access immediately and cannot be undone)');
397
- return lifecycle(ctx, values, ref, 'Deleting', (ws) => ctx.cloud().workspaces.delete(ws.id));
417
+ return lifecycle(ctx, values, ref, 'Deleting', (ws, o) => ctx.cloud().workspaces.delete(ws.id, o));
398
418
  },
399
419
  async 'workspaces close'(ctx, values, [ref]) {
400
- return lifecycle(ctx, values, ref, 'Closing', (ws) => ctx.cloud().workspaces.close(ws.id));
420
+ return lifecycle(ctx, values, ref, 'Closing', (ws, o) => ctx.cloud().workspaces.close(ws.id, o));
401
421
  },
402
422
  async 'workspaces reset'(ctx, values, [ref]) {
403
423
  if (values.yes !== true)
404
424
  throw new UsageError('refusing to reset without --yes (reset wipes every change in the workspace and restarts it on its template)');
405
- return lifecycle(ctx, values, ref, 'Resetting', (ws) => ctx.cloud().workspaces.reset(ws.id));
425
+ return lifecycle(ctx, values, ref, 'Resetting', (ws, o) => ctx.cloud().workspaces.reset(ws.id, o));
406
426
  },
407
427
  async 'workspaces save-as-template'(ctx, values, [ref]) {
408
428
  const template = str(values.template);
@@ -656,10 +676,8 @@ export const HANDLERS = {
656
676
  const c = caps(values);
657
677
  const timeoutMs = waitTimeout(values);
658
678
  const source = await resolveWorkspace(ctx, ref);
659
- const res = await ctx.cloud().workspaces.fork(source.id, { key: newKey, ...(c ? { caps: c } : {}) });
660
- let op = res.operation;
661
- if (values.wait === true)
662
- op = await waitOperation(ctx, op.id, timeoutMs);
679
+ const res = await waited(ctx, 'the fork continues server side.', () => ctx.cloud().workspaces.fork(source.id, { key: newKey, ...(c ? { caps: c } : {}) }, lifecycleOptions(ctx, values, timeoutMs)));
680
+ const op = res.operation;
663
681
  out(ctx, lifecycleText(`Forking ${source.key} into`, res.workspace, op), { operation: op, workspace: res.workspace.data });
664
682
  return 0;
665
683
  },
@@ -912,12 +930,14 @@ async function resolveSecret(ctx, values, ref) {
912
930
  } while (cursor);
913
931
  throw new NotFoundError(project !== null ? `No secret named "${ref}" in this project (organization-wide secrets: add --org <organization-id>).` : `No organization secret named "${ref}".`);
914
932
  }
933
+ /** `--wait`: the SDK's own wait (the call resolves once the operation finished; one trace covers request and wait). */
934
+ function lifecycleOptions(ctx, values, timeoutMs) {
935
+ return values.wait === true ? { wait: { timeoutMs, signal: ctx.signal } } : {};
936
+ }
915
937
  async function lifecycle(ctx, values, ref, verb, start) {
916
938
  const timeoutMs = waitTimeout(values);
917
939
  const ws = await resolveWorkspace(ctx, ref);
918
- let op = await start(ws);
919
- if (values.wait === true)
920
- op = await waitOperation(ctx, op.id, timeoutMs);
940
+ const op = await waited(ctx, 'the operation continues server side.', () => start(ws, lifecycleOptions(ctx, values, timeoutMs)));
921
941
  out(ctx, lifecycleText(verb, ws, op), op);
922
942
  return 0;
923
943
  }
package/dist/errors.d.ts CHANGED
@@ -10,7 +10,7 @@ export declare const EXIT: {
10
10
  readonly auth: 4;
11
11
  /** Waiting ran out of time (the operation continues server side), or a request timed out. */
12
12
  readonly timeout: 5;
13
- /** The awaited operation ended `failed` or `canceled`. */
13
+ /** The awaited operation ended `failed` or `canceled` (the error's `retryable` says whether a retry may succeed). */
14
14
  readonly operationFailed: 6;
15
15
  /** SIGINT (Ctrl-C). */
16
16
  readonly interrupted: 130;
package/dist/errors.js CHANGED
@@ -16,7 +16,7 @@ export const EXIT = {
16
16
  auth: 4,
17
17
  /** Waiting ran out of time (the operation continues server side), or a request timed out. */
18
18
  timeout: 5,
19
- /** The awaited operation ended `failed` or `canceled`. */
19
+ /** The awaited operation ended `failed` or `canceled` (the error's `retryable` says whether a retry may succeed). */
20
20
  operationFailed: 6,
21
21
  /** SIGINT (Ctrl-C). */
22
22
  interrupted: 130,
@@ -69,7 +69,12 @@ export function describeError(err) {
69
69
  if (err instanceof OperationTimeoutError)
70
70
  return { code: 'timeout', message: err.message, operation_id: err.operationId, last_state: err.lastState };
71
71
  if (err instanceof OperationFailedError) {
72
- return { code: 'operation_failed', message: err.message, operation_id: err.operationId, ...(err.errorCode ? { details: { error_code: err.errorCode } } : {}), operation: err.operation };
72
+ // retryable: the operation error's own flag (true for capacity_unavailable: nothing was started, retry later).
73
+ // details.reason: the operation error's reason (e.g. the scheduler's no_ready_hosts), printed after the message.
74
+ const opDetails = err.operation.error?.details;
75
+ const reason = typeof opDetails === 'object' && opDetails !== null ? opDetails.reason : undefined;
76
+ const details = { ...(err.errorCode ? { error_code: err.errorCode } : {}), ...(typeof reason === 'string' ? { reason } : {}) };
77
+ return { code: 'operation_failed', message: err.message, operation_id: err.operationId, retryable: err.retryable, ...(Object.keys(details).length ? { details } : {}), operation: err.operation };
73
78
  }
74
79
  if (err instanceof UsageError)
75
80
  return { code: 'usage', message: err.message };
package/dist/format.d.ts CHANGED
@@ -36,7 +36,10 @@ export declare function bindingsText(b: WorkspaceSecretBindings): string;
36
36
  * freshness, then meters and allowances as tables. Columns absent from the response are omitted.
37
37
  */
38
38
  export declare function usageText(summary: unknown): string;
39
- /** One stderr line: `error: <message> [code=... status=... request_id=...]` plus a hint when there is one. */
39
+ /**
40
+ * One stderr line: `error: <message> [code=... status=... request_id=...]` plus a hint when there is one. A failed
41
+ * operation also shows `retryable=true|false`.
42
+ */
40
43
  export declare function errorText(e: ErrorInfo, hint?: string): string;
41
44
  export declare function templateTable(rows: readonly TemplateSummary[]): string;
42
45
  /** Permission bits as the four-digit octal `ls -l` users read (e.g. 0755). */
package/dist/format.js CHANGED
@@ -67,6 +67,17 @@ export function workspaceTable(ws) {
67
67
  export function isReady(w) {
68
68
  return w.observed_state === 'running' && w.desired_state === 'running' && w.deleted_at === null;
69
69
  }
70
+ /** A workspace's origin: a fork, a draft state (draft test instance) or a template version (version test instance). */
71
+ function originText(o) {
72
+ switch (o.kind) {
73
+ case 'fork':
74
+ return `fork of ${o.workspace_id}`;
75
+ case 'draft_state':
76
+ return `draft state ${o.checkpoint_id ?? '(capturing)'} of ${o.draft_workspace_id}`;
77
+ default:
78
+ return `template ${o.template_id} version ${o.version}`;
79
+ }
80
+ }
70
81
  export function workspaceDetail(w) {
71
82
  const c = w.ceilings;
72
83
  return kv([
@@ -87,7 +98,7 @@ export function workspaceDetail(w) {
87
98
  ['lifetime', w.lifetime === 'session' ? `session (ends after ${w.idle_timeout_seconds ?? '-'} s idle, or on close)` : 'persistent'],
88
99
  ['purpose', w.purpose],
89
100
  ['disk layout', w.disk_layout],
90
- ...(w.origin ? [['origin', w.origin.kind === 'fork' ? `fork of ${w.origin.workspace_id}` : `draft state ${w.origin.checkpoint_id ?? '(capturing)'} of ${w.origin.draft_workspace_id}`]] : []),
101
+ ...(w.origin ? [['origin', originText(w.origin)]] : []),
91
102
  ...(w.ended_reason ? [['ended', w.ended_reason]] : []),
92
103
  ]
93
104
  : []),
@@ -212,7 +223,10 @@ export function usageText(summary) {
212
223
  out += `\n${allowances}`;
213
224
  return out || json(summary);
214
225
  }
215
- /** One stderr line: `error: <message> [code=... status=... request_id=...]` plus a hint when there is one. */
226
+ /**
227
+ * One stderr line: `error: <message> [code=... status=... request_id=...]` plus a hint when there is one. A failed
228
+ * operation also shows `retryable=true|false`.
229
+ */
216
230
  export function errorText(e, hint) {
217
231
  const meta = [`code=${e.code}`];
218
232
  if (e.status !== undefined)
@@ -223,6 +237,8 @@ export function errorText(e, hint) {
223
237
  meta.push(`operation_id=${e.operation_id}`);
224
238
  if (e.source === 'cell')
225
239
  meta.push('source=cell');
240
+ if (e.code === 'operation_failed' && e.retryable !== undefined)
241
+ meta.push(`retryable=${e.retryable}`);
226
242
  const reason = e.details && typeof e.details.reason === 'string' ? ` (${e.details.reason})` : '';
227
243
  const issues = Array.isArray(e.details?.issues)
228
244
  ? e.details.issues.filter(isObject).map((i) => ` ${scalar(i.path)}: ${scalar(i.message)}\n`).join('')
package/dist/http.d.ts CHANGED
@@ -9,5 +9,10 @@
9
9
  * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
10
  * costs one TCP/TLS handshake, which is negligible for these call rates.
11
11
  * Set SHARDFLUX_HTTP_KEEPALIVE=1 to use the runtime's keep-alive pooling.
12
+ *
13
+ * `signal` (the CLI's Ctrl-C) is attached to held requests (`Prefer: wait=N`,
14
+ * held by the server for up to 20 s). The SDK passes the caller's signal to its
15
+ * held polls itself, but not to the held open (`workspaces.open()` with `wait`
16
+ * sends its POST without one), so Ctrl-C would otherwise wait the hold out.
12
17
  */
13
- export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch): typeof fetch;
18
+ export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch, signal?: AbortSignal): typeof fetch;
package/dist/http.js CHANGED
@@ -9,13 +9,22 @@
9
9
  * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
10
  * costs one TCP/TLS handshake, which is negligible for these call rates.
11
11
  * Set SHARDFLUX_HTTP_KEEPALIVE=1 to use the runtime's keep-alive pooling.
12
+ *
13
+ * `signal` (the CLI's Ctrl-C) is attached to held requests (`Prefer: wait=N`,
14
+ * held by the server for up to 20 s). The SDK passes the caller's signal to its
15
+ * held polls itself, but not to the held open (`workspaces.open()` with `wait`
16
+ * sends its POST without one), so Ctrl-C would otherwise wait the hold out.
12
17
  */
13
- export function makeFetch(env, base = fetch) {
14
- if (env.SHARDFLUX_HTTP_KEEPALIVE === '1')
15
- return base;
18
+ export function makeFetch(env, base = fetch, signal) {
19
+ const keepAlive = env.SHARDFLUX_HTTP_KEEPALIVE === '1';
16
20
  return (input, init) => {
17
21
  const headers = new Headers(init?.headers);
18
- headers.set('connection', 'close');
19
- return base(input, { ...init, headers });
22
+ const held = signal !== undefined && /\bwait\s*=/i.test(headers.get('prefer') ?? '');
23
+ if (keepAlive && !held)
24
+ return base(input, init);
25
+ if (!keepAlive)
26
+ headers.set('connection', 'close');
27
+ const s = held && signal ? (init?.signal ? AbortSignal.any([init.signal, signal]) : signal) : init?.signal;
28
+ return base(input, { ...init, headers, ...(s ? { signal: s } : {}) });
20
29
  };
21
30
  }
package/dist/index.d.ts CHANGED
@@ -10,3 +10,4 @@ export type { Ctx, Handler, Io, Writer } from './commands.js';
10
10
  export { EXIT, AuthConfigError, InterruptedError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from './errors.js';
11
11
  export type { ErrorInfo } from './errors.js';
12
12
  export { errorText, json, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
13
+ export { TimingRecorder, timingJson, timingText } from './timing.js';
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export { COMMANDS, GLOBAL_OPTIONS, CLI_VERSION, commandHelp, findCommand, normal
7
7
  export { HANDLERS, execExitCode, resolveWorkspace } from "./commands.js";
8
8
  export { EXIT, AuthConfigError, InterruptedError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
9
9
  export { errorText, json, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
10
+ export { TimingRecorder, timingJson, timingText } from "./timing.js";
package/dist/main.js CHANGED
@@ -10,6 +10,7 @@ import { HANDLERS } from "./commands.js";
10
10
  import { AuthConfigError, EXIT, InterruptedError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
11
11
  import { errorText, json } from "./format.js";
12
12
  import { makeFetch } from "./http.js";
13
+ import { TimingRecorder, timingJson, timingText } from "./timing.js";
13
14
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
14
15
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
15
16
  const isLabel = (s) => s.length >= 1 && s.length <= 100 && ![...s].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f);
@@ -30,6 +31,9 @@ export function abortableSleep(signal) {
30
31
  function hintFor(info) {
31
32
  if (info.code === 'timeout' && info.operation_id)
32
33
  return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
34
+ if (info.code === 'operation_failed' && info.retryable) {
35
+ return info.details?.error_code === 'capacity_unavailable' ? 'no host could admit it before its deadline; nothing was started. Retry later.' : 'the operation may succeed if you retry it later.';
36
+ }
33
37
  if (info.code === 'interrupted' && info.operation_id)
34
38
  return `keep waiting with: shard operations wait ${info.operation_id}`;
35
39
  if (info.code === 'unauthenticated' && info.status === undefined)
@@ -44,6 +48,9 @@ export async function run(argv, io, opts = {}) {
44
48
  const signal = opts.signal ?? new AbortController().signal;
45
49
  const rawKey = io.env.SHARDFLUX_API_KEY;
46
50
  const wantsJson = argv.slice(0, argv.includes('--') ? argv.indexOf('--') : argv.length).includes('--json');
51
+ // Every traced SDK call of the command (open, waited lifecycle calls, waits, wakes) reports here; --timing prints it.
52
+ const traces = new TimingRecorder();
53
+ let showTiming = false;
47
54
  const report = (err) => {
48
55
  const code = exitCodeFor(err, signal.aborted);
49
56
  const info = describeError(err);
@@ -52,7 +59,11 @@ export async function run(argv, io, opts = {}) {
52
59
  info.message = 'Interrupted.';
53
60
  }
54
61
  info.message = redact(info.message, rawKey);
55
- io.stderr.write(redact(wantsJson ? json({ error: info }) : errorText(info, hintFor(info)), rawKey));
62
+ // --timing on a failure: the failed call's timing (err.timing, else the last recorded), before the error; with
63
+ // --json a `timing` field next to `error`.
64
+ const timing = showTiming ? traces.forError(err) : null;
65
+ const text = wantsJson ? json(showTiming ? { error: info, timing: timingJson(timing) } : { error: info }) : `${timing ? timingText(timing) : ''}${errorText(info, hintFor(info))}`;
66
+ io.stderr.write(redact(text, rawKey));
56
67
  return code;
57
68
  };
58
69
  let parsed;
@@ -78,6 +89,7 @@ export async function run(argv, io, opts = {}) {
78
89
  throw new UsageError('--agent-label must be 1-100 printable characters');
79
90
  const wake = resolveWake(parsed.global.wake, io.env.SHARDFLUX_NO_WAKE);
80
91
  const wakeTimeoutMs = resolveWakeTimeout(parsed.global.wakeTimeout, io.env.SHARDFLUX_WAKE_TIMEOUT_MS);
92
+ showTiming = parsed.values.timing === true;
81
93
  let cloud;
82
94
  ctx = {
83
95
  io,
@@ -87,6 +99,8 @@ export async function run(argv, io, opts = {}) {
87
99
  wake,
88
100
  wakeTimeoutMs,
89
101
  signal,
102
+ timing: showTiming,
103
+ traces,
90
104
  cloud() {
91
105
  if (cloud)
92
106
  return cloud;
@@ -95,7 +109,14 @@ export async function run(argv, io, opts = {}) {
95
109
  throw new AuthConfigError('SHARDFLUX_API_KEY is not set.');
96
110
  if (!KEY_SHAPE.test(key))
97
111
  throw new AuthConfigError('SHARDFLUX_API_KEY is not a Shardflux project key (expected sfk_<key id>_<secret>).');
98
- cloud = new Shardflux({ apiKey: key, baseUrl: apiUrl, userAgent: `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}`, fetch: makeFetch(io.env), sleep: abortableSleep(signal) });
112
+ cloud = new Shardflux({
113
+ apiKey: key,
114
+ baseUrl: apiUrl,
115
+ userAgent: `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}`,
116
+ fetch: makeFetch(io.env, fetch, signal),
117
+ sleep: abortableSleep(signal),
118
+ onProgress: traces.listener,
119
+ });
99
120
  return cloud;
100
121
  },
101
122
  };
@@ -120,5 +141,8 @@ export async function run(argv, io, opts = {}) {
120
141
  return report(new InterruptedError('Interrupted.'));
121
142
  if ('err' in outcome)
122
143
  return report(outcome.err);
144
+ // With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
145
+ if (showTiming && !ctx.json && traces.last)
146
+ io.stderr.write(timingText(traces.last));
123
147
  return outcome.code;
124
148
  }
@@ -0,0 +1,17 @@
1
+ import type { LifecycleTiming, ProgressListener } from '@shardflux/sdk';
2
+ export declare class TimingRecorder {
3
+ /** The timing of the last traced call that ended (token fetches are not lifecycle calls and are skipped). */
4
+ last: LifecycleTiming | null;
5
+ /** The operation the command's traced calls last observed: named when Ctrl-C interrupts a wait. */
6
+ operationId: string | null;
7
+ readonly listener: ProgressListener;
8
+ /**
9
+ * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
10
+ * carries the token fetch's timing, which is not a lifecycle call: then only a wake (if any) counts.
11
+ */
12
+ forError(err: unknown): LifecycleTiming | null;
13
+ }
14
+ /** The human form (stderr): formatTiming() of the SDK, one block. */
15
+ export declare function timingText(t: LifecycleTiming): string;
16
+ /** The `--json` form: the SDK's LifecycleTiming with the CLI's snake_case keys (the rest of its JSON is the API's). */
17
+ export declare function timingJson(t: LifecycleTiming | null): Record<string, unknown> | null;
package/dist/timing.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `--timing`: where the time of a command's lifecycle call went, as @shardflux/sdk measures it (0.6.0+). The CLI
3
+ * does no timing of its own: the SDK traces open(), waited lifecycle calls, waitForOperation() and wakes, and emits
4
+ * each trace's LifecycleTiming in a `done` progress event (also on `err.timing` when the call fails). The recorder
5
+ * listens on the client (every traced call of the command), so a timing exists for failures too, including ones that
6
+ * carry no `timing` (network errors, Ctrl-C).
7
+ */
8
+ import { formatTiming } from '@shardflux/sdk';
9
+ export class TimingRecorder {
10
+ /** The timing of the last traced call that ended (token fetches are not lifecycle calls and are skipped). */
11
+ last = null;
12
+ /** The operation the command's traced calls last observed: named when Ctrl-C interrupts a wait. */
13
+ operationId = null;
14
+ listener = (e) => {
15
+ if (e.action === 'token' || e.action === 'tool')
16
+ return;
17
+ if (e.operationId)
18
+ this.operationId = e.operationId;
19
+ if (e.type === 'done')
20
+ this.last = e.timing;
21
+ };
22
+ /**
23
+ * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
24
+ * carries the token fetch's timing, which is not a lifecycle call: then only a wake (if any) counts.
25
+ */
26
+ forError(err) {
27
+ const own = typeof err === 'object' && err !== null ? err.timing : undefined;
28
+ return own && own.action !== 'token' ? own : this.last;
29
+ }
30
+ }
31
+ /** The human form (stderr): formatTiming() of the SDK, one block. */
32
+ export function timingText(t) {
33
+ return `${formatTiming(t)}\n`;
34
+ }
35
+ /** The `--json` form: the SDK's LifecycleTiming with the CLI's snake_case keys (the rest of its JSON is the API's). */
36
+ export function timingJson(t) {
37
+ if (!t)
38
+ return null;
39
+ const s = t.server;
40
+ return {
41
+ action: t.action,
42
+ outcome: t.outcome,
43
+ workspace_id: t.workspaceId,
44
+ operation_id: t.operationId,
45
+ total_ms: t.totalMs,
46
+ phases: t.phases.map((p) => ({ phase: p.phase, reason: p.reason, operation_id: p.operationId, start_ms: p.startMs, duration_ms: p.durationMs })),
47
+ retries: t.retries.map((r) => ({ at_ms: r.atMs, request: r.request, attempt: r.attempt, cause: r.cause, delay_ms: r.delayMs })),
48
+ server: s
49
+ ? {
50
+ operation_id: s.operationId,
51
+ kind: s.kind,
52
+ state: s.state,
53
+ queued_ms: s.queuedMs,
54
+ run_ms: s.runMs,
55
+ total_ms: s.totalMs,
56
+ start_path: s.startPath,
57
+ warm_fallback: s.warmFallback,
58
+ resume_path: s.resumePath,
59
+ boot_to_ready_ms: s.bootToReadyMs,
60
+ host_timings_ms: s.hostTimingsMs,
61
+ }
62
+ : null,
63
+ outside_server_ms: t.outsideServerMs,
64
+ };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Open, run commands in, move files to, suspend, resume and fork persistent agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -32,13 +32,14 @@
32
32
  "files": [
33
33
  "dist",
34
34
  "README.md",
35
+ "CHANGELOG.md",
35
36
  "LICENSE"
36
37
  ],
37
38
  "publishConfig": {
38
39
  "access": "public"
39
40
  },
40
41
  "dependencies": {
41
- "@shardflux/sdk": "^0.6.0"
42
+ "@shardflux/sdk": "^0.6.2"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@eslint/js": "10.0.1",