@shardflux/cli 0.7.0 → 0.8.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 +109 -0
- package/README.md +91 -13
- package/dist/args.d.ts +1 -1
- package/dist/args.js +50 -5
- package/dist/commands.js +219 -9
- package/dist/errors.d.ts +12 -1
- package/dist/errors.js +16 -1
- package/dist/format.d.ts +11 -1
- package/dist/format.js +68 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/main.js +57 -0
- package/dist/template-yaml.js +3 -0
- package/dist/timing.d.ts +14 -2
- package/dist/timing.js +39 -5
- package/package.json +2 -2
package/dist/main.js
CHANGED
|
@@ -76,9 +76,17 @@ function hintFor(info, cred, file) {
|
|
|
76
76
|
return 'this API has no billing configured: plans cannot be bought here';
|
|
77
77
|
if (info.code === 'allowance_exhausted')
|
|
78
78
|
return allowanceHint(info);
|
|
79
|
+
if (info.code === 'quota_exceeded') {
|
|
80
|
+
const quota = quotaHint(info);
|
|
81
|
+
if (quota)
|
|
82
|
+
return quota;
|
|
83
|
+
}
|
|
79
84
|
const policyHint = spendPolicyHint(info);
|
|
80
85
|
if (policyHint)
|
|
81
86
|
return policyHint;
|
|
87
|
+
const immutable = immutableHint(info);
|
|
88
|
+
if (immutable)
|
|
89
|
+
return immutable;
|
|
82
90
|
// File patches: what to do about each refusal. details.index is the 0-based edit (--old) index.
|
|
83
91
|
const reason = info.details?.reason;
|
|
84
92
|
const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
|
|
@@ -132,6 +140,21 @@ function allowanceHint(info) {
|
|
|
132
140
|
return undefined;
|
|
133
141
|
}
|
|
134
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* quota_exceeded (0.9.0): the cell gateway's 429 when every workspace the plan allows to work at once is busy (the SDK
|
|
145
|
+
* already retried it after Retry-After), and the API's 403 when the plan's Retained state is used up (new keys and
|
|
146
|
+
* forks are refused). Other limits keep the API's message alone.
|
|
147
|
+
*/
|
|
148
|
+
function quotaHint(info) {
|
|
149
|
+
const limit = info.details?.limit;
|
|
150
|
+
if (limit === 'concurrent_workspaces' && info.status === 429) {
|
|
151
|
+
const s = info.retry_after_seconds;
|
|
152
|
+
return `every workspace your plan allows to work at once is busy and the call did not run (it was already retried); retry ${typeof s === 'number' ? `in ${s} s` : 'in a few seconds'}, or upgrade: shard billing plans`;
|
|
153
|
+
}
|
|
154
|
+
if (limit === 'retained_state')
|
|
155
|
+
return 'delete workspaces you no longer need (shard ws delete <key> --yes), or upgrade: shard billing plans';
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
135
158
|
/** Dollars for a hint: `9` for 900 minor units, `9.50` for 950. */
|
|
136
159
|
const dollars = (minor) => (typeof minor === 'number' ? (minor % 100 === 0 ? String(minor / 100) : (minor / 100).toFixed(2)) : undefined);
|
|
137
160
|
/**
|
|
@@ -166,6 +189,35 @@ function spendPolicyHint(info) {
|
|
|
166
189
|
return undefined;
|
|
167
190
|
}
|
|
168
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Immutable paths (0.8.0; contracts §34): the build refusals (422 validation_failed per details.reason, with the paths
|
|
194
|
+
* and base the API names) and a files write under an immutable path (409 conflict read_only_path from the cell).
|
|
195
|
+
*/
|
|
196
|
+
function immutableHint(info) {
|
|
197
|
+
const d = info.details ?? {};
|
|
198
|
+
const reason = reasonOf(info);
|
|
199
|
+
const list = (v) => (Array.isArray(v) ? v.filter((p) => typeof p === 'string').join(', ') : '');
|
|
200
|
+
if (info.code === 'conflict' && reason === 'read_only_path') {
|
|
201
|
+
return 'this path is an immutable path of the workspace’s template: read-only, it follows the template’s newest version. Write elsewhere, or change it in the template and build a new version (shard templates get <slug> lists the immutable paths)';
|
|
202
|
+
}
|
|
203
|
+
if (info.code !== 'validation_failed')
|
|
204
|
+
return undefined;
|
|
205
|
+
if (reason === 'immutable_path_removed') {
|
|
206
|
+
const removed = list(d.removed);
|
|
207
|
+
const open = typeof d.open_version === 'number' ? `v${d.open_version}` : 'the open version';
|
|
208
|
+
return `a new version keeps every immutable path of ${open}: add ${removed || 'them'} back to immutable in template.yaml (adding paths is fine)`;
|
|
209
|
+
}
|
|
210
|
+
if (reason === 'immutable_paths_unsupported_base') {
|
|
211
|
+
if (d.field === 'recipe')
|
|
212
|
+
return 'this template has immutable paths: build it from a template.yaml (shard templates export <slug>@<version> writes the current one, shard templates build builds it)';
|
|
213
|
+
const base = typeof d.base === 'object' && d.base !== null ? d.base.name : undefined;
|
|
214
|
+
return typeof base === 'string' ? `build on a newer version of ${base} (shard templates get ${base} lists its versions)` : 'build on a newer version of the base';
|
|
215
|
+
}
|
|
216
|
+
if (reason === 'invalid_path' && typeof d.field === 'string' && d.field.startsWith('recipe.immutable[')) {
|
|
217
|
+
return `fix ${d.field.slice('recipe.'.length)} in template.yaml (the list counts from 0)`;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
169
221
|
/**
|
|
170
222
|
* 409 conflict host_feature_unavailable (0.5.0): the call is not available for this workspace. Not retryable, so the
|
|
171
223
|
* hint names the fallback that works now.
|
|
@@ -258,6 +310,8 @@ const NO_NUDGE_REASONS = new Set([
|
|
|
258
310
|
'subscription_exists',
|
|
259
311
|
// A stale spend policy (If-Match): the hint names the version to retry with (0.5.1).
|
|
260
312
|
'version_mismatch',
|
|
313
|
+
// A write under an immutable path (0.8.0): the hint says where to change it.
|
|
314
|
+
'read_only_path',
|
|
261
315
|
]);
|
|
262
316
|
/**
|
|
263
317
|
* Whether a failure ends with the `feedback:` line (0.5.0+): every failure except the expected flow above (feedback
|
|
@@ -266,6 +320,9 @@ const NO_NUDGE_REASONS = new Set([
|
|
|
266
320
|
function nudges(info, command) {
|
|
267
321
|
if (command === undefined || command === 'feedback')
|
|
268
322
|
return false;
|
|
323
|
+
// A plan limit whose hint names the next step (0.9.0): busy working slots, or Retained state used up.
|
|
324
|
+
if (info.code === 'quota_exceeded' && quotaHint(info) !== undefined)
|
|
325
|
+
return false;
|
|
269
326
|
const reason = reasonOf(info);
|
|
270
327
|
return !NO_NUDGE_CODES.has(info.code) && !(reason !== undefined && NO_NUDGE_REASONS.has(reason));
|
|
271
328
|
}
|
package/dist/template-yaml.js
CHANGED
|
@@ -20,6 +20,9 @@ export function initTemplateYaml(base) {
|
|
|
20
20
|
# build: what goes into the template (languages, packages, files, steps). settings: what a workspace gets when it opens.
|
|
21
21
|
schema: shardflux.template-recipe.v2
|
|
22
22
|
base: ${base}
|
|
23
|
+
# Immutable paths: directories every workspace of this template mounts read-only from its newest published version
|
|
24
|
+
# (workspaces pick up a new version at their next cold boot or resume), e.g. tools or data you ship with each version.
|
|
25
|
+
# immutable: [/opt/acme]
|
|
23
26
|
build:
|
|
24
27
|
# Languages from the platform's table: python, node, go, rust, java (version omitted: the base's default).
|
|
25
28
|
languages: []
|
package/dist/timing.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export declare class TimingRecorder {
|
|
|
15
15
|
* --wait`, an `operations wait` of that resume, or a command's wake. `lostSuspendNotice()` says it.
|
|
16
16
|
*/
|
|
17
17
|
lostSuspend: LifecycleTiming | null;
|
|
18
|
+
/**
|
|
19
|
+
* (0.8.1+) The last traced resume that restored the workspace's newest checkpoint after the machine it ran on failed
|
|
20
|
+
* (`host_lost.restored_from` `checkpoint`): a `ws resume --wait`, an `operations wait` of that resume, or a command's
|
|
21
|
+
* wake. `hostLostNotice()` says it. A restore from the disk is a cold boot (`coldBootNotice()`).
|
|
22
|
+
*/
|
|
23
|
+
hostLost: LifecycleTiming | null;
|
|
18
24
|
readonly listener: ProgressListener;
|
|
19
25
|
/**
|
|
20
26
|
* The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
|
|
@@ -24,15 +30,21 @@ export declare class TimingRecorder {
|
|
|
24
30
|
}
|
|
25
31
|
/**
|
|
26
32
|
* The one-line stderr notice after a command whose resume booted the workspace's saved disk (0.6.0+), with or without
|
|
27
|
-
* `--timing`: the workspace runs
|
|
33
|
+
* `--timing`: the workspace runs with its files kept, but running processes were restarted. The reason follows in
|
|
34
|
+
* parentheses: `runtime_changed`, or in words for `host_lost` (0.8.1+).
|
|
28
35
|
*/
|
|
29
36
|
export declare function coldBootNotice(t: LifecycleTiming): string;
|
|
37
|
+
/**
|
|
38
|
+
* The one-line stderr notice after a command whose resume restored the workspace's newest checkpoint after the machine
|
|
39
|
+
* it ran on failed (0.8.1+; `host_lost.restored_from` `checkpoint`): the state is as of `state_as_of`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function hostLostNotice(t: LifecycleTiming): string;
|
|
30
42
|
/**
|
|
31
43
|
* The one-line stderr notice after a command whose resume restored an earlier checkpoint because the latest suspend
|
|
32
44
|
* could not be kept (0.7.0+; `lost_suspend`): the state the workspace runs with is as of `state_as_of`.
|
|
33
45
|
*/
|
|
34
46
|
export declare function lostSuspendNotice(t: LifecycleTiming): string;
|
|
35
|
-
/** The notices a command's traced calls leave on stderr (cold boot, lost suspend), in that order. */
|
|
47
|
+
/** The notices a command's traced calls leave on stderr (cold boot, lost suspend, host loss), in that order. */
|
|
36
48
|
export declare function notices(r: TimingRecorder): string;
|
|
37
49
|
/** The human form (stderr): formatTiming() of the SDK, one block. */
|
|
38
50
|
export declare function timingText(t: LifecycleTiming): string;
|
package/dist/timing.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* carry no `timing` (network errors, Ctrl-C).
|
|
7
7
|
*/
|
|
8
8
|
import { formatTiming } from '@shardflux/sdk';
|
|
9
|
+
import { byteSize } from "./format.js";
|
|
9
10
|
export class TimingRecorder {
|
|
10
11
|
/** The timing of the last traced call that ended (token fetches are not lifecycle calls and are skipped). */
|
|
11
12
|
last = null;
|
|
@@ -22,6 +23,12 @@ export class TimingRecorder {
|
|
|
22
23
|
* --wait`, an `operations wait` of that resume, or a command's wake. `lostSuspendNotice()` says it.
|
|
23
24
|
*/
|
|
24
25
|
lostSuspend = null;
|
|
26
|
+
/**
|
|
27
|
+
* (0.8.1+) The last traced resume that restored the workspace's newest checkpoint after the machine it ran on failed
|
|
28
|
+
* (`host_lost.restored_from` `checkpoint`): a `ws resume --wait`, an `operations wait` of that resume, or a command's
|
|
29
|
+
* wake. `hostLostNotice()` says it. A restore from the disk is a cold boot (`coldBootNotice()`).
|
|
30
|
+
*/
|
|
31
|
+
hostLost = null;
|
|
25
32
|
listener = (e) => {
|
|
26
33
|
if (e.action === 'token' || e.action === 'tool')
|
|
27
34
|
return;
|
|
@@ -34,6 +41,8 @@ export class TimingRecorder {
|
|
|
34
41
|
this.coldBoot = e.timing;
|
|
35
42
|
if (e.timing.server?.lostSuspend)
|
|
36
43
|
this.lostSuspend = e.timing;
|
|
44
|
+
if (e.timing.server?.hostLost?.restoredFrom === 'checkpoint')
|
|
45
|
+
this.hostLost = e.timing;
|
|
37
46
|
};
|
|
38
47
|
/**
|
|
39
48
|
* The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
|
|
@@ -48,13 +57,29 @@ export class TimingRecorder {
|
|
|
48
57
|
function restartedProcesses(t) {
|
|
49
58
|
return t.server?.resumePath === 'cold_boot' && t.server.memoryRestored === false;
|
|
50
59
|
}
|
|
60
|
+
/** Cold boot reasons the notice says in words (0.8.1+); any other reason is printed as the API sent it. */
|
|
61
|
+
const COLD_BOOT_WORDS = {
|
|
62
|
+
host_lost: 'the machine it ran on failed',
|
|
63
|
+
};
|
|
51
64
|
/**
|
|
52
65
|
* The one-line stderr notice after a command whose resume booted the workspace's saved disk (0.6.0+), with or without
|
|
53
|
-
* `--timing`: the workspace runs
|
|
66
|
+
* `--timing`: the workspace runs with its files kept, but running processes were restarted. The reason follows in
|
|
67
|
+
* parentheses: `runtime_changed`, or in words for `host_lost` (0.8.1+).
|
|
54
68
|
*/
|
|
55
69
|
export function coldBootNotice(t) {
|
|
56
70
|
const reason = t.server?.coldBootReason;
|
|
57
|
-
|
|
71
|
+
const why = reason ? (COLD_BOOT_WORDS[reason] ?? reason) : null;
|
|
72
|
+
return `note: ${t.workspaceId ? `workspace ${t.workspaceId} ` : ''}resumed from its saved disk; running processes were restarted${why ? ` (${why})` : ''}\n`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The one-line stderr notice after a command whose resume restored the workspace's newest checkpoint after the machine
|
|
76
|
+
* it ran on failed (0.8.1+; `host_lost.restored_from` `checkpoint`): the state is as of `state_as_of`.
|
|
77
|
+
*/
|
|
78
|
+
export function hostLostNotice(t) {
|
|
79
|
+
const h = t.server?.hostLost;
|
|
80
|
+
if (h?.restoredFrom !== 'checkpoint')
|
|
81
|
+
return '';
|
|
82
|
+
return `note: ${t.workspaceId ? `workspace ${t.workspaceId} ` : ''}resumed from checkpoint ${h.restoredCheckpointId ?? '-'}${h.stateAsOf ? ` (state as of ${h.stateAsOf})` : ''} after the machine it ran on failed; changes after that time are not in it\n`;
|
|
58
83
|
}
|
|
59
84
|
/**
|
|
60
85
|
* The one-line stderr notice after a command whose resume restored an earlier checkpoint because the latest suspend
|
|
@@ -66,9 +91,9 @@ export function lostSuspendNotice(t) {
|
|
|
66
91
|
return '';
|
|
67
92
|
return `note: ${t.workspaceId ? `workspace ${t.workspaceId} ` : ''}resumed from checkpoint ${l.restoredCheckpointId ?? '-'}${l.stateAsOf ? ` (state as of ${l.stateAsOf})` : ''}; the suspend of ${l.suspendedAt ?? l.checkpointId} was not kept${l.reason ? ` (${l.reason})` : ''}\n`;
|
|
68
93
|
}
|
|
69
|
-
/** The notices a command's traced calls leave on stderr (cold boot, lost suspend), in that order. */
|
|
94
|
+
/** The notices a command's traced calls leave on stderr (cold boot, lost suspend, host loss), in that order. */
|
|
70
95
|
export function notices(r) {
|
|
71
|
-
return `${r.coldBoot ? coldBootNotice(r.coldBoot) : ''}${r.lostSuspend ? lostSuspendNotice(r.lostSuspend) : ''}`;
|
|
96
|
+
return `${r.coldBoot ? coldBootNotice(r.coldBoot) : ''}${r.lostSuspend ? lostSuspendNotice(r.lostSuspend) : ''}${r.hostLost ? hostLostNotice(r.hostLost) : ''}`;
|
|
72
97
|
}
|
|
73
98
|
/** The human form (stderr): formatTiming() of the SDK, one block. */
|
|
74
99
|
export function timingText(t) {
|
|
@@ -125,6 +150,15 @@ export function timingJson(t) {
|
|
|
125
150
|
state_as_of: s.lostSuspend.stateAsOf,
|
|
126
151
|
}
|
|
127
152
|
: null,
|
|
153
|
+
// 0.8.1+: the machine the workspace ran on failed (resume: what it restored; suspend: detected_at only).
|
|
154
|
+
host_lost: s.hostLost
|
|
155
|
+
? {
|
|
156
|
+
detected_at: s.hostLost.detectedAt,
|
|
157
|
+
restored_from: s.hostLost.restoredFrom,
|
|
158
|
+
restored_checkpoint_id: s.hostLost.restoredCheckpointId,
|
|
159
|
+
state_as_of: s.hostLost.stateAsOf,
|
|
160
|
+
}
|
|
161
|
+
: null,
|
|
128
162
|
boot_to_ready_ms: s.bootToReadyMs,
|
|
129
163
|
host_timings_ms: s.hostTimingsMs,
|
|
130
164
|
}
|
|
@@ -196,7 +230,7 @@ export class BuildTimer {
|
|
|
196
230
|
}
|
|
197
231
|
const fmtMs = (ms) => (ms === null ? '-' : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`);
|
|
198
232
|
const short = (sha) => sha.slice(0, 12);
|
|
199
|
-
const bytes =
|
|
233
|
+
const bytes = byteSize;
|
|
200
234
|
/** One stderr line per build-from-file progress event. */
|
|
201
235
|
export function progressLine(e) {
|
|
202
236
|
if (e.type === 'pack')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork persistent agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"yaml": "2.9.1",
|
|
47
|
-
"@shardflux/sdk": "^0.
|
|
47
|
+
"@shardflux/sdk": "^0.13.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@eslint/js": "10.0.1",
|