@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/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
  }
@@ -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 and its files are as of the suspend, but running processes were restarted.
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 and its files are as of the suspend, but running processes were restarted.
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
- return `note: ${t.workspaceId ? `workspace ${t.workspaceId} ` : ''}resumed from its saved disk; running processes were restarted${reason ? ` (${reason})` : ''}\n`;
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 = (n) => (n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KiB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MiB` : `${(n / 1024 ** 3).toFixed(2)} GiB`);
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.7.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.12.0"
47
+ "@shardflux/sdk": "^0.13.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",