@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 +62 -0
- package/README.md +71 -7
- package/dist/args.d.ts +1 -1
- package/dist/args.js +26 -7
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +42 -22
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +7 -2
- package/dist/format.d.ts +4 -1
- package/dist/format.js +18 -2
- package/dist/http.d.ts +6 -1
- package/dist/http.js +14 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/main.js +26 -2
- package/dist/timing.d.ts +17 -0
- package/dist/timing.js +65 -0
- package/package.json +3 -2
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
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.
|
|
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
|
];
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
19
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
}
|
package/dist/timing.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
42
|
+
"@shardflux/sdk": "^0.6.2"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@eslint/js": "10.0.1",
|