@shardflux/cli 0.6.1 → 0.7.0

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/format.js CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Human-readable output (aligned tables, key/value blocks) and the error line.
3
+ * `--json` output is the API's own objects (see README "JSON output"), so
4
+ * these functions only shape what people read.
5
+ */
6
+ import { durabilityOf, isDurable } from '@shardflux/sdk';
1
7
  export function json(value) {
2
8
  return `${JSON.stringify(value, null, 2)}\n`;
3
9
  }
@@ -85,7 +91,7 @@ function originText(o) {
85
91
  }
86
92
  }
87
93
  /**
88
- * The startup of a workspace's template version (contracts §24.4) in one line: state, trigger, and for a failure the
94
+ * The startup of a workspace's template version in one line: state, trigger, and for a failure the
89
95
  * step or service, exit code and reason. Null when the version has no start commands or services.
90
96
  */
91
97
  export function startupText(s) {
@@ -107,7 +113,7 @@ export function startupTail(s) {
107
113
  const lines = s.output_tail.replace(/\n$/, '').split('\n');
108
114
  return `startup output (last ${Buffer.byteLength(s.output_tail)} bytes):\n${lines.map((l) => ` | ${l}`).join('\n')}\n`;
109
115
  }
110
- /** A pending suspend-when-idle request in one line (0.5.1; contracts §20.6). */
116
+ /** A pending suspend-when-idle request in one line (0.5.1). */
111
117
  export function suspendRequestText(r) {
112
118
  return `${r.after_seconds === 0 ? 'as soon as idle' : `after ${r.after_seconds} s idle`}, not before ${time(r.not_before)} (requested ${time(r.requested_at)})`;
113
119
  }
@@ -132,7 +138,7 @@ export function workspaceDetail(w) {
132
138
  ['state', `${w.observed_state} (desired ${w.desired_state})`],
133
139
  ['ready', isReady(w) ? 'yes' : 'no'],
134
140
  ['template', `${w.template.slug} v${w.template.version} (${w.template.version_id})`],
135
- // The mode (0.5.0; contracts §29) when the API reports it; a file-first workspace's state is its tree revision.
141
+ // The mode (0.5.0) when the API reports it; a file-first workspace's state is its tree revision.
136
142
  ...(w.mode !== undefined ? [['mode', modeText(w.mode)]] : []),
137
143
  ...(w.mode === 'file_first' ? [['tree revision', String(w.tree_revision)]] : []),
138
144
  ['cell', w.cell_id ? `${w.cell_id} ${w.cell_endpoint ?? '(endpoint unresolved)'}` : '-'],
@@ -161,6 +167,28 @@ export const OPERATION_HEADERS = ['ID', 'KIND', 'STATE', 'REASON', 'CREATED', 'C
161
167
  export function operationTable(ops) {
162
168
  return table(OPERATION_HEADERS, ops.map((o) => [o.id, o.kind, o.state, o.state_reason ?? '-', time(o.created_at), time(o.completed_at)]));
163
169
  }
170
+ /**
171
+ * A finished suspend or fork (0.7.0+): whether its copy is in durable storage, when the result says (`durable`,
172
+ * `durability`). Empty when the result has no durability block (stored durably before the operation completed).
173
+ */
174
+ export function durableText(op) {
175
+ const d = durabilityOf(op);
176
+ if (op.state !== 'succeeded' || !d)
177
+ return '';
178
+ if (d.state === 'durable')
179
+ return `durable: yes${d.localCommitToDurableMs !== null ? ` (${d.localCommitToDurableMs} ms after the suspend)` : ''}\n`;
180
+ if (d.state === 'pending')
181
+ return `durable: in progress, typically within a second (wait with: shard operations wait ${op.id} --durable)\n`;
182
+ return `durable: no (${d.reason ?? 'lost'}); see docs.shardflux.dev/concepts/lifecycle\n`;
183
+ }
184
+ /** `durable` of a finished suspend or fork for `operations get`/`wait`: yes, in progress, no, or '-' for other kinds. */
185
+ function durableField(op) {
186
+ const d = durabilityOf(op);
187
+ if (d)
188
+ return d.state === 'durable' ? 'yes' : d.state === 'pending' ? 'in progress' : `no (${d.reason ?? 'lost'})`;
189
+ const v = isDurable(op);
190
+ return v === null ? '-' : v ? 'yes' : 'no';
191
+ }
164
192
  export function operationDetail(op) {
165
193
  const err = op.error;
166
194
  return kv([
@@ -168,6 +196,7 @@ export function operationDetail(op) {
168
196
  ['kind', op.kind],
169
197
  ['state', op.state],
170
198
  ['reason', op.state_reason ?? '-'],
199
+ ...(op.kind === 'suspend' || op.kind === 'fork' ? [['durable', durableField(op)]] : []),
171
200
  ['workspace', op.workspace_id ?? '-'],
172
201
  ['created', time(op.created_at)],
173
202
  ['updated', time(op.updated_at)],
@@ -317,8 +346,12 @@ export function usageText(summary) {
317
346
  const head = [];
318
347
  if (isObject(s.plan))
319
348
  head.push(['plan', `${scalar(s.plan.plan_key)}${s.plan.catalog_version !== undefined ? ` (catalog ${scalar(s.plan.catalog_version)})` : ''}`]);
320
- if (isObject(s.period))
321
- head.push(['period', `${time(scalar(s.period.start))} .. ${time(scalar(s.period.end))}`]);
349
+ if (isObject(s.period)) {
350
+ // A plan grant's own window (source `grant`, 0.6.2+): the trial's 60 days rather than the calendar month.
351
+ const trial = isObject(s.plan) && s.plan.plan_key === 'trial';
352
+ const grant = s.period.source === 'grant' ? (trial ? ' (the trial; then free)' : ' (the plan grant; it ends then)') : '';
353
+ head.push(['period', `${time(scalar(s.period.start))} .. ${time(scalar(s.period.end))}${grant}`]);
354
+ }
322
355
  const m = isObject(s.measurement) ? s.measurement : s;
323
356
  if (m.measured_through !== undefined)
324
357
  head.push(['measured through', time(m.measured_through)]);
@@ -326,7 +359,8 @@ export function usageText(summary) {
326
359
  head.push(['estimate', scalar(s.measurement.estimate_status)]);
327
360
  if (typeof s.allowance_exhausted === 'boolean') {
328
361
  const reason = typeof s.exhausted_reason === 'string' ? `${s.exhausted_reason}: ` : '';
329
- head.push(['allowance exhausted', s.allowance_exhausted ? `yes (${reason}new opens/resumes are refused)` : 'no']);
362
+ const trialEnded = isObject(s.plan) && s.plan.plan_key === 'trial';
363
+ 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']);
330
364
  }
331
365
  for (const [k, v] of Object.entries(s))
332
366
  if (!USAGE_KNOWN.has(k) && (typeof v !== 'object' || v === null))
@@ -363,8 +397,8 @@ export function usageText(summary) {
363
397
  /** Request ids and error codes pasted unquoted into the suggested command must be shell-inert. */
364
398
  const SHELL_SAFE = /^[A-Za-z0-9._:-]{1,200}$/;
365
399
  /**
366
- * The `shard feedback` command a failed command suggests (0.5.0+), with the error's request id and code so the founder
367
- * can find the logs (a failed operation's or execution's own error code, e.g. capacity_unavailable); the agent fills in
400
+ * The `shard feedback` command a failed command suggests (0.5.0+), with the error's request id and code so the team
401
+ * can find the logs (a failed operation's or execution's own error code, e.g. template_not_found); the agent fills in
368
402
  * the text.
369
403
  */
370
404
  export function feedbackCommand(e) {
@@ -402,7 +436,7 @@ export function errorText(e, hint, feedback) {
402
436
  : '';
403
437
  return `error: ${e.message}${reason} [${meta.join(' ')}]\n${issues}${hint ? `hint: ${hint}\n` : ''}${feedback ? `feedback: ${feedback}\n` : ''}`;
404
438
  }
405
- // ---- Templates v2 (contracts §19) ---------------------------------------------------------------
439
+ // ---- Templates v2 ---------------------------------------------------------------
406
440
  export function templateTable(rows) {
407
441
  return table(['SLUG', 'OWNER', 'OPEN VERSION', 'LAYOUTS', 'FILES', 'DRAFT', 'ARCHIVED'], rows.map((t) => {
408
442
  const v = t.open_version;
@@ -514,7 +548,7 @@ export function templateDetailText(t) {
514
548
  : 'No versions.\n';
515
549
  return `${head}\n${versions}${open?.settings ? `\nsettings of v${open.version}:\n${settingsText(open.settings)}` : ''}`;
516
550
  }
517
- /** A version's settings (contracts §24.3), one line per part. */
551
+ /** A version's settings, one line per part. */
518
552
  export function settingsText(s) {
519
553
  const inputs = Object.entries(s.inputs).map(([n, i]) => `${n} (${i.kind}${i.required ? ', required' : ''}${i.default !== null ? `, default ${JSON.stringify(i.default)}` : ''})`);
520
554
  const d = s.defaults;
@@ -528,7 +562,7 @@ export function settingsText(s) {
528
562
  ['egress', egress],
529
563
  ]).replace(/^/gm, ' ');
530
564
  }
531
- // ---- File-first executions (0.5.0; contracts §29.8) ----------------------------------------------------------------------
565
+ // ---- File-first executions (0.5.0) ----------------------------------------------------------------------
532
566
  const CHANGE_LETTER = { added: 'A', modified: 'M', deleted: 'D' };
533
567
  /** How many changed paths the human output lists before "... N more" (--json lists every one the result has). */
534
568
  export const MAX_CHANGED_SHOWN = 100;
@@ -730,8 +764,25 @@ export function money(amountMinor, currency) {
730
764
  const c = currency.toLowerCase();
731
765
  return `${zeroDecimal.has(c) ? String(amountMinor) : (amountMinor / 100).toFixed(2)} ${c.toUpperCase()}`;
732
766
  }
767
+ /**
768
+ * Plans that are only ever granted, never bought: the signup trial and the design-partner plan (0.6.2+). The API's
769
+ * catalog marks them with `grant_days`; the keys cover an API that lists them without it.
770
+ */
771
+ const GRANT_PLANS = new Set(['trial', 'partner']);
772
+ export function isGrantPlan(plan) {
773
+ return GRANT_PLANS.has(plan.plan_key) || (typeof plan.grant_days === 'number' && plan.grant_days > 0);
774
+ }
775
+ /** "trial, ends 2026-11-29 14:03:00Z, then free (trial, catalog live-v7)"; a plan without an end as before. */
776
+ export function planInForceText(plan) {
777
+ if (!plan)
778
+ return '-';
779
+ const endsAt = typeof plan.ends_at === 'string' && plan.ends_at !== '' ? plan.ends_at : null;
780
+ const ends = endsAt ? `, ends ${time(endsAt)}${plan.plan_key === 'trial' ? ', then free' : ''}` : '';
781
+ return `${plan.plan_key}${ends} (${plan.source}, catalog ${plan.catalog_version})`;
782
+ }
783
+ /** The public plans: granted plans (the trial, design partners) are never listed or offered. */
733
784
  export function planTable(catalog) {
734
- const rows = catalog.plans.map((p) => {
785
+ const rows = catalog.plans.filter((p) => !isGrantPlan(p)).map((p) => {
735
786
  const prices = p.prices.length ? p.prices.map((x) => `${money(x.amount_minor, x.currency)}/${x.interval}`).join(', ') : 'free';
736
787
  const buy = p.prices.some((x) => x.purchasable) ? 'yes' : '-';
737
788
  return [p.plan_key, p.name, prices, buy];
@@ -743,7 +794,7 @@ export function billingStatusText(s, org) {
743
794
  const pending = s.pending_checkout;
744
795
  return kv([
745
796
  ['organization', `${org.name} (${org.slug})`],
746
- ['plan', s.plan ? `${s.plan.plan_key} (${s.plan.source}, catalog ${s.plan.catalog_version})` : '-'],
797
+ ['plan', planInForceText(s.plan)],
747
798
  ['billing state', s.billing_state],
748
799
  ['subscription', sub ? `${sub.status}${sub.plan_key ? ` ${sub.plan_key}` : ''}${sub.cancel_at_period_end ? ', cancels at period end' : ''}` : 'none'],
749
800
  ['period end', sub ? time(sub.current_period_end) : '-'],
package/dist/http.d.ts CHANGED
@@ -1,18 +1,2 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch with `Connection: close` on
3
- * every request (API and cell gateway).
4
- *
5
- * Why: Node 26.7's bundled undici (8.9.0) intermittently stalls a request
6
- * sent on a reused keep-alive connection until an unrelated timer fires (up to
7
- * ~30 s, the SDK's request timeout, after which the SDK retries). Reproduced
8
- * with bare fetch against both node:http and Fastify servers; Node 22 (undici
9
- * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
- * costs one TCP/TLS handshake, which is negligible for these call rates.
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.
17
- */
1
+ /** Share the SDK pool and attach Ctrl-C to held API requests. */
18
2
  export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch, signal?: AbortSignal): typeof fetch;
package/dist/http.js CHANGED
@@ -1,30 +1,12 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch with `Connection: close` on
3
- * every request (API and cell gateway).
4
- *
5
- * Why: Node 26.7's bundled undici (8.9.0) intermittently stalls a request
6
- * sent on a reused keep-alive connection until an unrelated timer fires (up to
7
- * ~30 s, the SDK's request timeout, after which the SDK retries). Reproduced
8
- * with bare fetch against both node:http and Fastify servers; Node 22 (undici
9
- * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
- * costs one TCP/TLS handshake, which is negligible for these call rates.
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.
17
- */
18
- export function makeFetch(env, base = fetch, signal) {
19
- const keepAlive = env.SHARDFLUX_HTTP_KEEPALIVE === '1';
1
+ import { defaultFetch } from '@shardflux/sdk';
2
+ /** Share the SDK pool and attach Ctrl-C to held API requests. */
3
+ export function makeFetch(env, base = defaultFetch(env), signal) {
20
4
  return (input, init) => {
21
5
  const headers = new Headers(init?.headers);
22
6
  const held = signal !== undefined && /\bwait\s*=/i.test(headers.get('prefer') ?? '');
23
- if (keepAlive && !held)
7
+ if (!held)
24
8
  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 } : {}) });
9
+ const s = init?.signal && signal ? AbortSignal.any([init.signal, signal]) : signal;
10
+ return base(input, { ...init, ...(s ? { signal: s } : {}) });
29
11
  };
30
12
  }
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, timingJson, timingText } from './timing.js';
25
+ export { TimingRecorder, coldBootNotice, 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, timingJson, timingText } from "./timing.js";
17
+ export { TimingRecorder, coldBootNotice, lostSuspendNotice, notices, timingJson, timingText } from "./timing.js";
package/dist/main.js CHANGED
@@ -19,7 +19,7 @@ import { errorText, feedbackCommand, json, time } from "./format.js";
19
19
  import { HANDLERS } from "./handlers.js";
20
20
  import { makeFetch } from "./http.js";
21
21
  import { Input } from "./input.js";
22
- import { TimingRecorder, coldBootNotice, timingJson, timingText } from "./timing.js";
22
+ import { TimingRecorder, notices, timingJson, timingText } from "./timing.js";
23
23
  import { CLI_DISTRIBUTION, NOTICE_WAIT_MS, UpdateCheck, noticeOf, updateCheckDisabled, updateView } from "./update-check.js";
24
24
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
25
25
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
@@ -47,7 +47,7 @@ function hintFor(info, cred, file) {
47
47
  if (info.code === 'timeout' && info.operation_id)
48
48
  return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
49
49
  if (info.code === 'operation_failed' && info.retryable) {
50
- 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.';
50
+ return info.details?.error_code === 'capacity_unavailable' ? 'nothing was started; run the same command again' : 'the operation may succeed if you retry it later.';
51
51
  }
52
52
  if (info.code === 'interrupted' && info.operation_id)
53
53
  return `keep waiting with: shard operations wait ${info.operation_id}`;
@@ -79,7 +79,7 @@ function hintFor(info, cred, file) {
79
79
  const policyHint = spendPolicyHint(info);
80
80
  if (policyHint)
81
81
  return policyHint;
82
- // File patches (contracts §26.2): what to do about each refusal. details.index is the 0-based edit (--old) index.
82
+ // File patches: what to do about each refusal. details.index is the 0-based edit (--old) index.
83
83
  const reason = info.details?.reason;
84
84
  const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
85
85
  if (reason === 'revision_mismatch') {
@@ -93,7 +93,7 @@ function hintFor(info, cred, file) {
93
93
  if (reason === 'edit_not_text')
94
94
  return 'the file is not UTF-8 text; use files write for binary data';
95
95
  if (reason === 'host_capacity')
96
- return 'the workspace’s host has no room to run it right now; retry in a few seconds';
96
+ return 'the workspace cannot be woken right now and the call did not run; retry in a few seconds';
97
97
  if (reason === 'host_feature_unavailable')
98
98
  return hostFeatureHint(info.details?.feature);
99
99
  // A processful command that could not start (0.5.1): what to change, from the workspace's reason.
@@ -167,17 +167,15 @@ function spendPolicyHint(info) {
167
167
  }
168
168
  }
169
169
  /**
170
- * 409 conflict host_feature_unavailable (contracts §26.7; 0.5.0): the workspace runs on a host agent that predates the
171
- * call. Not retryable: it lasts until the workspace runs on an upgraded host (minutes to hours), so the hint names
172
- * another way to do it now.
170
+ * 409 conflict host_feature_unavailable (0.5.0): the call is not available for this workspace. Not retryable, so the
171
+ * hint names the fallback that works now.
173
172
  */
174
173
  function hostFeatureHint(feature) {
175
- const later = 'it will once the workspace runs on an upgraded host (retrying now does not help)';
176
174
  if (feature === 'file_search')
177
- return `the workspace’s host does not support files search yet; ${later}. Search with a command instead: shard ws exec <id|key> -- grep -rn <pattern> <path>`;
175
+ return 'files search is not available for this workspace (retrying does not help); search with a command instead: shard ws exec <id|key> -- grep -rn <pattern> <path>';
178
176
  if (feature === 'file_patch')
179
- return `the workspace’s host does not support files patch yet; ${later}. Rewrite the file instead: shard files read <id|key> <path> --out F, edit F, then shard files write <id|key> <path> --from F`;
180
- return `the workspace’s host does not support this call yet; ${later}`;
177
+ return 'files patch is not available for this workspace (retrying does not help); rewrite the file instead: shard files read <id|key> <path> --out F, edit F, then shard files write <id|key> <path> --from F';
178
+ return 'this call is not available for this workspace (retrying does not help)';
181
179
  }
182
180
  /** What an execution that ended failed or lost ran into (error.details.reason), where the reason alone says little. */
183
181
  const EXECUTION_CAUSES = {
@@ -186,10 +184,10 @@ const EXECUTION_CAUSES = {
186
184
  host_unreachable: 'its host stopped answering',
187
185
  host_restarted: 'its host restarted',
188
186
  tree_moved: 'the tree changed while it ran',
189
- no_execution_host: 'no host had room to run it',
187
+ no_execution_host: 'the execution could not be placed',
190
188
  workspace_deleted: 'the workspace was deleted',
191
189
  };
192
- /** Hints for file-first workspaces (0.5.0; contracts §29.7, §29.8): the mode refusals, tree revisions and executions. */
190
+ /** Hints for file-first workspaces (0.5.0): the mode refusals, tree revisions and executions. */
193
191
  function fileFirstHint(info) {
194
192
  const d = info.details ?? {};
195
193
  const reason = d.reason;
@@ -211,7 +209,7 @@ function fileFirstHint(info) {
211
209
  return `this key already belongs to a ${has} workspace and a workspace’s mode never changes; use another key for a ${wants} workspace (or reopen this one without --mode)`;
212
210
  }
213
211
  case 'mode_not_available':
214
- return 'this deployment does not offer file-first workspaces yet; leave out --mode file-first to open a processful workspace';
212
+ return 'this deployment does not offer file-first workspaces; leave out --mode file-first to open a processful workspace';
215
213
  case 'layout_unsupported':
216
214
  return d.mode === 'file_first' ? 'file-first workspaces run on layered template versions only; open a template version whose disk layouts include layered (shard templates get <slug> lists them)' : undefined;
217
215
  case 'tree_revision_mismatch': {
@@ -229,7 +227,7 @@ function fileFirstHint(info) {
229
227
  case 'execution_id_reused':
230
228
  return 'this --execution-id was already used for a different request (the same id with the same request returns its recorded result); pass a new --execution-id';
231
229
  case 'no_execution_host':
232
- return 'no host had room to run the execution, also after retries with the same execution id; nothing ran. Retry later';
230
+ return 'the execution cannot be placed right now, also after retries with the same execution id; nothing ran. Retry';
233
231
  default:
234
232
  return undefined;
235
233
  }
@@ -271,7 +269,7 @@ function nudges(info, command) {
271
269
  const reason = reasonOf(info);
272
270
  return !NO_NUDGE_CODES.has(info.code) && !(reason !== undefined && NO_NUDGE_REASONS.has(reason));
273
271
  }
274
- /** `shard feedback` could not send: how to fix it, and the way to still reach the founder. */
272
+ /** `shard feedback` could not send: how to fix it, and the way to still reach the Shardflux team. */
275
273
  function feedbackHint(info, credentialHint) {
276
274
  if (info.code === 'usage')
277
275
  return 'run "shard feedback --help"';
@@ -329,7 +327,7 @@ export async function run(argv, io, opts = {}) {
329
327
  const feedback = nudges(info, command) ? feedbackCommand(info) : undefined;
330
328
  const text = wantsJson
331
329
  ? json({ error: info, ...(showTiming ? { timing: timingJson(timing) } : {}), ...(feedback ? { feedback } : {}) })
332
- : `${timing ? timingText(timing) : ''}${traces.coldBoot ? coldBootNotice(traces.coldBoot) : ''}${errorText(info, hint, feedback)}`;
330
+ : `${timing ? timingText(timing) : ''}${notices(traces)}${errorText(info, hint, feedback)}`;
333
331
  io.stderr.write(redact(text, ...secrets));
334
332
  return code;
335
333
  };
@@ -361,7 +359,7 @@ export async function run(argv, io, opts = {}) {
361
359
  const wakeTimeoutMs = resolveWakeTimeout(parsed.global.wakeTimeout, io.env.SHARDFLUX_WAKE_TIMEOUT_MS);
362
360
  showTiming = parsed.values.timing === true;
363
361
  const userAgent = `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}${distribution.package === CLI_DISTRIBUTION.package ? '' : ` ${distribution.package}/${distribution.version}`}`;
364
- const base = makeFetch(io.env, fetch, signal);
362
+ const base = makeFetch(io.env, undefined, signal);
365
363
  // Notes which credential a 401 unauthenticated answered (the hint, and removing a dead saved session).
366
364
  const watchedFetch = async (input, init) => {
367
365
  const res = await base(input, init);
@@ -562,9 +560,11 @@ export async function run(argv, io, opts = {}) {
562
560
  // With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
563
561
  if (showTiming && !ctx.json && traces.last)
564
562
  io.stderr.write(timingText(traces.last));
565
- // A resume that booted the saved disk (0.6.0+): said on stderr with or without --timing, also with --json.
566
- if (traces.coldBoot)
567
- io.stderr.write(coldBootNotice(traces.coldBoot));
563
+ // A resume that booted the saved disk (0.6.0+), or restored an earlier checkpoint (0.7.0+): said on stderr with or
564
+ // without --timing, also with --json.
565
+ const notice = notices(traces);
566
+ if (notice)
567
+ io.stderr.write(notice);
568
568
  code = outcome.code;
569
569
  }
570
570
  // The update notice: after the output, never after Ctrl-C or a --json failure (stderr then holds only the error).
@@ -1,5 +1,5 @@
1
1
  /**
2
- * template.yaml text (contracts §24.1: the recipe v2 document in YAML; only clients parse YAML): the starter file of
2
+ * template.yaml text (the recipe v2 document in YAML; only clients parse YAML): the starter file of
3
3
  * `shard templates init` and the export of a version (`shard templates export`), written like the dashboard's
4
4
  * Template file section (keys in the recipe's order, long scripts as block literals, no line folding).
5
5
  */
package/dist/timing.d.ts CHANGED
@@ -10,6 +10,11 @@ export declare class TimingRecorder {
10
10
  * the wake of a command that found the workspace suspended. `coldBootNotice()` says it.
11
11
  */
12
12
  coldBoot: LifecycleTiming | null;
13
+ /**
14
+ * (0.7.0+) The last traced resume whose result names a suspend it could not restore (`lost_suspend`): a `ws resume
15
+ * --wait`, an `operations wait` of that resume, or a command's wake. `lostSuspendNotice()` says it.
16
+ */
17
+ lostSuspend: LifecycleTiming | null;
13
18
  readonly listener: ProgressListener;
14
19
  /**
15
20
  * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
@@ -22,6 +27,13 @@ export declare class TimingRecorder {
22
27
  * `--timing`: the workspace runs and its files are as of the suspend, but running processes were restarted.
23
28
  */
24
29
  export declare function coldBootNotice(t: LifecycleTiming): string;
30
+ /**
31
+ * The one-line stderr notice after a command whose resume restored an earlier checkpoint because the latest suspend
32
+ * could not be kept (0.7.0+; `lost_suspend`): the state the workspace runs with is as of `state_as_of`.
33
+ */
34
+ 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. */
36
+ export declare function notices(r: TimingRecorder): string;
25
37
  /** The human form (stderr): formatTiming() of the SDK, one block. */
26
38
  export declare function timingText(t: LifecycleTiming): string;
27
39
  /** The `--json` form: the SDK's LifecycleTiming with the CLI's snake_case keys (the rest of its JSON is the API's). */
package/dist/timing.js CHANGED
@@ -17,6 +17,11 @@ export class TimingRecorder {
17
17
  * the wake of a command that found the workspace suspended. `coldBootNotice()` says it.
18
18
  */
19
19
  coldBoot = null;
20
+ /**
21
+ * (0.7.0+) The last traced resume whose result names a suspend it could not restore (`lost_suspend`): a `ws resume
22
+ * --wait`, an `operations wait` of that resume, or a command's wake. `lostSuspendNotice()` says it.
23
+ */
24
+ lostSuspend = null;
20
25
  listener = (e) => {
21
26
  if (e.action === 'token' || e.action === 'tool')
22
27
  return;
@@ -27,6 +32,8 @@ export class TimingRecorder {
27
32
  this.last = e.timing;
28
33
  if (restartedProcesses(e.timing))
29
34
  this.coldBoot = e.timing;
35
+ if (e.timing.server?.lostSuspend)
36
+ this.lostSuspend = e.timing;
30
37
  };
31
38
  /**
32
39
  * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
@@ -49,6 +56,20 @@ export function coldBootNotice(t) {
49
56
  const reason = t.server?.coldBootReason;
50
57
  return `note: ${t.workspaceId ? `workspace ${t.workspaceId} ` : ''}resumed from its saved disk; running processes were restarted${reason ? ` (${reason})` : ''}\n`;
51
58
  }
59
+ /**
60
+ * The one-line stderr notice after a command whose resume restored an earlier checkpoint because the latest suspend
61
+ * could not be kept (0.7.0+; `lost_suspend`): the state the workspace runs with is as of `state_as_of`.
62
+ */
63
+ export function lostSuspendNotice(t) {
64
+ const l = t.server?.lostSuspend;
65
+ if (!l)
66
+ return '';
67
+ 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
+ }
69
+ /** The notices a command's traced calls leave on stderr (cold boot, lost suspend), in that order. */
70
+ export function notices(r) {
71
+ return `${r.coldBoot ? coldBootNotice(r.coldBoot) : ''}${r.lostSuspend ? lostSuspendNotice(r.lostSuspend) : ''}`;
72
+ }
52
73
  /** The human form (stderr): formatTiming() of the SDK, one block. */
53
74
  export function timingText(t) {
54
75
  return `${formatTiming(t)}\n`;
@@ -79,6 +100,31 @@ export function timingJson(t) {
79
100
  resume_path: s.resumePath,
80
101
  memory_restored: s.memoryRestored ?? null,
81
102
  cold_boot_reason: s.coldBootReason ?? null,
103
+ durable: s.durable ?? null,
104
+ suspend_path: s.suspendPath ?? null,
105
+ durability: s.durability
106
+ ? {
107
+ state: s.durability.state,
108
+ checkpoint_id: s.durability.checkpointId,
109
+ generation_id: s.durability.generationId,
110
+ local_commit_at: s.durability.localCommitAt,
111
+ durable_by: s.durability.durableBy,
112
+ durable_at: s.durability.durableAt,
113
+ local_commit_to_durable_ms: s.durability.localCommitToDurableMs,
114
+ overdue_at: s.durability.overdueAt,
115
+ reason: s.durability.reason,
116
+ }
117
+ : null,
118
+ lost_suspend: s.lostSuspend
119
+ ? {
120
+ checkpoint_id: s.lostSuspend.checkpointId,
121
+ generation_id: s.lostSuspend.generationId,
122
+ reason: s.lostSuspend.reason,
123
+ suspended_at: s.lostSuspend.suspendedAt,
124
+ restored_checkpoint_id: s.lostSuspend.restoredCheckpointId,
125
+ state_as_of: s.lostSuspend.stateAsOf,
126
+ }
127
+ : null,
82
128
  boot_to_ready_ms: s.bootToReadyMs,
83
129
  host_timings_ms: s.hostTimingsMs,
84
130
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `shard` tells its user when it is outdated or no longer supported (0.5.0+; contracts §30.4). The API's
2
+ * `shard` tells its user when it is outdated or no longer supported (0.5.0+). The API's
3
3
  * GET /v1/client-versions names the latest published version of every client and the oldest one it still serves.
4
4
  *
5
5
  * - At most one request per 24 hours per API URL and package: the answer is cached in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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.11.0"
47
+ "@shardflux/sdk": "^0.12.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",