@shardflux/cli 0.8.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 CHANGED
@@ -3,7 +3,43 @@
3
3
  Every command and option the README shows is available from the version named here. Breaking changes ship in minor
4
4
  releases and are marked **Breaking**.
5
5
 
6
- ## 0.8.0 (not yet published)
6
+ ## 0.9.0 (not yet published)
7
+
8
+ Needs `@shardflux/sdk` 0.14.0 or later.
9
+
10
+ ### Workspaces working at once
11
+
12
+ - A command whose workspace call finds every workspace the plan allows to work at once busy is sent again after
13
+ `Retry-After` (the SDK's retries; the call did not run). A refusal that lasts exits 1 with 429 `quota_exceeded`
14
+ (`details.limit` `concurrent_workspaces`) and the hint `every workspace your plan allows to work at once is busy
15
+ and the call did not run (it was already retried); retry in 2 s, or upgrade: shard billing plans`.
16
+ - The API's 403 `quota_exceeded` with `details.limit` `retained_state` (opening a new key or forking while the
17
+ plan's Retained state is used up) hints `delete workspaces you no longer need (shard ws delete <key> --yes), or
18
+ upgrade: shard billing plans`. Neither suggests `shard feedback`.
19
+ - `--json` errors carry `retry_after_seconds` when the refusal has `Retry-After` (e.g. 429 `quota_exceeded`, 429
20
+ `rate_limited`).
21
+ - `shard usage` says `retained state used up: new workspaces and forks are refused (...)` when the Retained state
22
+ allowance's `cap_state` is `storage_blocked` (`enforcement` `storage_block`).
23
+
24
+ ## 0.8.1 (not yet published)
25
+
26
+ Needs `@shardflux/sdk` 0.13.1 or later.
27
+
28
+ ### Workspaces recover by themselves
29
+
30
+ When the machine a workspace runs on fails, the workspace is suspended, and the next command on it resumes it.
31
+
32
+ - A resume that booted the workspace's own disk (`cold_boot_reason` `host_lost`) prints the cold-boot note with the
33
+ reason in words: `note: workspace <id> resumed from its saved disk; running processes were restarted (the machine it
34
+ ran on failed)`.
35
+ - A resume that restored its newest checkpoint prints `note: workspace <id> resumed from checkpoint <id> (state as of
36
+ <time>) after the machine it ran on failed; changes after that time are not in it` on stderr (also with `--json`,
37
+ `ws resume --wait`, `operations wait` and a command's wake).
38
+ - `--timing --json`: `server.host_lost` (`detected_at`, `restored_from`, `restored_checkpoint_id`, `state_as_of`).
39
+ `--timing` prints the SDK's `restored checkpoint <id> (host_lost; state as of <time>)`.
40
+ - `hostLostNotice()` is exported next to `coldBootNotice()`.
41
+
42
+ ## 0.8.0
7
43
 
8
44
  Needs `@shardflux/sdk` 0.13.0 or later.
9
45
 
package/README.md CHANGED
@@ -607,11 +607,11 @@ without waking it (the state at suspension); `files search` then notes it on std
607
607
  refused at once (exit 1, `workspace_not_running`).
608
608
 
609
609
  **Detecting a cold resume (0.6.0+).** A resume restores memory and running processes. After a platform runtime
610
- update, a resume can boot from the saved disk instead of restoring memory (a cold boot), also when a command wakes the
611
- workspace; check `memory_restored`. The command still runs with the files as of the suspend, and processes start
612
- fresh, as after a reboot: start dev servers and background jobs again. `shard` then prints one line on stderr after
613
- the output (also with `--json`, except on a failure, whose stderr is only the error document), whether or not you
614
- pass `--timing`:
610
+ update, or after the machine the workspace ran on failed (0.8.1+), a resume can boot from the saved disk instead of
611
+ restoring memory (a cold boot), also when a command wakes the workspace; check `memory_restored`. The command still runs
612
+ with the files kept, and processes start fresh, as after a reboot: start dev servers and background jobs again. `shard`
613
+ then prints one line on stderr after the output (also with `--json`, except on a failure, whose stderr is only the
614
+ error document), whether or not you pass `--timing`:
615
615
 
616
616
  ```text
617
617
  note: workspace 01a0e5a8-3edd-74ba-b489-d62b8925e342 resumed from its saved disk; running processes were restarted (runtime_changed)
@@ -621,6 +621,12 @@ note: workspace 01a0e5a8-3edd-74ba-b489-d62b8925e342 resumed from its saved disk
621
621
  `cold_boot`, `memory_restored` false, `cold_boot_reason`), and `--timing` shows `resume from cold_boot: processes
622
622
  restarted (runtime_changed)`. The same applies to `operations wait` of such a resume.
623
623
 
624
+ **Workspaces recover by themselves (0.8.1+).** When the machine a workspace runs on fails, the workspace is suspended,
625
+ and the next command on it resumes it; there is nothing extra to run. From its own disk, the note above ends in
626
+ `(the machine it ran on failed)`; from its newest checkpoint, the note names the checkpoint and the time its state is
627
+ from (`state as of <time>`). `--json` carries `result.host_lost` (`detected_at`, `restored_from` `disk` or
628
+ `checkpoint`, `restored_checkpoint_id`, `state_as_of`), and `--timing` adds `host_lost` to `server`.
629
+
624
630
  ## Suspend when idle (0.5.1+)
625
631
 
626
632
  A running workspace is billed while it is awake. When an agent's turn ends, ask for a suspend once the workspace has
@@ -778,7 +784,7 @@ With `--json`, stdout carries the timing as a `timing` field of the command's JS
778
784
  `workspace_id`, `operation_id`, `total_ms`, `phases` (`phase`, `reason`, `operation_id`, `start_ms`, `duration_ms`),
779
785
  `retries`, `server` (`queued_ms`, `run_ms`, `total_ms`, `start_path`, `resume_path`, `memory_restored` and
780
786
  `cold_boot_reason` (0.6.0+; `memory_restored` is `false` when a resume booted the saved disk, `null` when the API did
781
- not say), `boot_to_ready_ms`, ...) and `outside_server_ms`. It is `null` when the command made no lifecycle call. On a failure the error document on stderr
787
+ not say), `host_lost` (0.8.1+), `boot_to_ready_ms`, ...) and `outside_server_ms`. It is `null` when the command made no lifecycle call. On a failure the error document on stderr
782
788
  carries it: `{"error": {...}, "timing": {...}}`.
783
789
 
784
790
  For live progress events, use the SDK's `onProgress`.
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.8.0";
1
+ export declare const CLI_VERSION = "0.8.1";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.8.0';
15
+ export const CLI_VERSION = '0.8.1';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
package/dist/errors.d.ts CHANGED
@@ -98,6 +98,8 @@ export interface ErrorInfo {
98
98
  operation?: unknown;
99
99
  /** A file-first execution the error is about (failed, lost, still running, or left running by Ctrl-C). */
100
100
  execution_id?: string;
101
+ /** The refusal's Retry-After in seconds (0.9.0+), e.g. 429 quota_exceeded from the cell gateway. */
102
+ retry_after_seconds?: number;
101
103
  }
102
104
  /**
103
105
  * Removes anything shaped like a Shardflux API key or user session token from text, and each of the given secrets
package/dist/errors.js CHANGED
@@ -147,6 +147,7 @@ export function describeError(err) {
147
147
  ...(err.operationId ? { operation_id: err.operationId } : {}),
148
148
  ...(err.details ? { details: err.details } : {}),
149
149
  source: err.source,
150
+ ...(err.retryAfterSeconds !== undefined ? { retry_after_seconds: err.retryAfterSeconds } : {}),
150
151
  };
151
152
  }
152
153
  if (err instanceof OperationTimeoutError) {
package/dist/format.js CHANGED
@@ -396,6 +396,10 @@ export function usageText(summary) {
396
396
  const trialEnded = isObject(s.plan) && s.plan.plan_key === 'trial';
397
397
  head.push(['allowance exhausted', s.allowance_exhausted ? (trialEnded ? `yes (${reason}the trial ends; the organization is back on free)` : `yes (${reason}new opens/resumes are refused)`) : 'no']);
398
398
  }
399
+ // cap_state storage_blocked (0.9.0): the Retained state allowance is used up; existing workspaces keep working.
400
+ if (Array.isArray(s.allowances) && s.allowances.some((a) => isObject(a) && a.cap_state === 'storage_blocked')) {
401
+ head.push(['retained state', 'used up: new workspaces and forks are refused (delete workspaces you no longer need, or upgrade)']);
402
+ }
399
403
  for (const [k, v] of Object.entries(s))
400
404
  if (!USAGE_KNOWN.has(k) && (typeof v !== 'object' || v === null))
401
405
  head.push([k.replace(/_/g, ' '), scalar(v)]);
package/dist/index.d.ts CHANGED
@@ -22,4 +22,4 @@ export { openUrl, openerFor } from './browser.js';
22
22
  export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, ExecutionPendingError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from './errors.js';
23
23
  export type { ErrorInfo } from './errors.js';
24
24
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
25
- export { TimingRecorder, coldBootNotice, lostSuspendNotice, notices, timingJson, timingText } from './timing.js';
25
+ export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from './timing.js';
package/dist/index.js CHANGED
@@ -14,4 +14,4 @@ export { CHECK_INTERVAL_MS, CLI_DISTRIBUTION, NOTICE_WAIT_MS, UPDATE_CHECK_FILE,
14
14
  export { openUrl, openerFor } from "./browser.js";
15
15
  export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, ExecutionPendingError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
16
16
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
17
- export { TimingRecorder, coldBootNotice, lostSuspendNotice, notices, timingJson, timingText } from "./timing.js";
17
+ export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from "./timing.js";
package/dist/main.js CHANGED
@@ -76,6 +76,11 @@ 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;
@@ -135,6 +140,21 @@ function allowanceHint(info) {
135
140
  return undefined;
136
141
  }
137
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
+ }
138
158
  /** Dollars for a hint: `9` for 900 minor units, `9.50` for 950. */
139
159
  const dollars = (minor) => (typeof minor === 'number' ? (minor % 100 === 0 ? String(minor / 100) : (minor / 100).toFixed(2)) : undefined);
140
160
  /**
@@ -300,6 +320,9 @@ const NO_NUDGE_REASONS = new Set([
300
320
  function nudges(info, command) {
301
321
  if (command === undefined || command === 'feedback')
302
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;
303
326
  const reason = reasonOf(info);
304
327
  return !NO_NUDGE_CODES.has(info.code) && !(reason !== undefined && NO_NUDGE_REASONS.has(reason));
305
328
  }
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
@@ -23,6 +23,12 @@ export class TimingRecorder {
23
23
  * --wait`, an `operations wait` of that resume, or a command's wake. `lostSuspendNotice()` says it.
24
24
  */
25
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;
26
32
  listener = (e) => {
27
33
  if (e.action === 'token' || e.action === 'tool')
28
34
  return;
@@ -35,6 +41,8 @@ export class TimingRecorder {
35
41
  this.coldBoot = e.timing;
36
42
  if (e.timing.server?.lostSuspend)
37
43
  this.lostSuspend = e.timing;
44
+ if (e.timing.server?.hostLost?.restoredFrom === 'checkpoint')
45
+ this.hostLost = e.timing;
38
46
  };
39
47
  /**
40
48
  * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
@@ -49,13 +57,29 @@ export class TimingRecorder {
49
57
  function restartedProcesses(t) {
50
58
  return t.server?.resumePath === 'cold_boot' && t.server.memoryRestored === false;
51
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
+ };
52
64
  /**
53
65
  * The one-line stderr notice after a command whose resume booted the workspace's saved disk (0.6.0+), with or without
54
- * `--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+).
55
68
  */
56
69
  export function coldBootNotice(t) {
57
70
  const reason = t.server?.coldBootReason;
58
- 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`;
59
83
  }
60
84
  /**
61
85
  * The one-line stderr notice after a command whose resume restored an earlier checkpoint because the latest suspend
@@ -67,9 +91,9 @@ export function lostSuspendNotice(t) {
67
91
  return '';
68
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`;
69
93
  }
70
- /** 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. */
71
95
  export function notices(r) {
72
- 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) : ''}`;
73
97
  }
74
98
  /** The human form (stderr): formatTiming() of the SDK, one block. */
75
99
  export function timingText(t) {
@@ -126,6 +150,15 @@ export function timingJson(t) {
126
150
  state_as_of: s.lostSuspend.stateAsOf,
127
151
  }
128
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,
129
162
  boot_to_ready_ms: s.bootToReadyMs,
130
163
  host_timings_ms: s.hostTimingsMs,
131
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.8.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.13.0"
47
+ "@shardflux/sdk": "^0.13.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",