@shardflux/cli 0.6.2 → 0.8.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,13 @@
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';
7
+ /** A size for people: `512 B`, `10.0 KiB`, `128.0 MiB`, `1.50 GiB`. */
8
+ export function byteSize(n) {
9
+ return 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`;
10
+ }
1
11
  export function json(value) {
2
12
  return `${JSON.stringify(value, null, 2)}\n`;
3
13
  }
@@ -122,6 +132,32 @@ export function suspendWhenIdleText(w, r) {
122
132
  const op = r.operation;
123
133
  return `A suspend of ${w.key} (${w.id}) is already in progress (operation ${op.id} ${op.kind} ${op.state}); nothing was scheduled.\nwait with: shard operations wait ${op.id}\n`;
124
134
  }
135
+ const mib = (n) => (n === null ? '-' : `${n} MiB`);
136
+ /** `fixed 4096 MiB`, or `elastic: promised 8192 MiB, held 1024 MiB, plugged +2048 MiB` (plugged: above the held floor now). */
137
+ export function memoryText(m) {
138
+ if (m.allocation_mode !== 'elastic')
139
+ return `fixed ${mib(m.promised_mib)}`;
140
+ return `elastic: promised ${mib(m.promised_mib)}, held ${mib(m.held_mib)}, plugged ${m.plugged_mib === null ? '- (not running)' : `+${m.plugged_mib} MiB`}`;
141
+ }
142
+ /** The grow an exec's start waited for: `delivered 1024 -> 4096 MiB in 175 ms`, `partial 1024 -> 3072 of 4096 MiB in 175 ms`. */
143
+ /** The burst line of `ws exec -v` (0.8.0). */
144
+ export function burstText(b) {
145
+ const size = [b.vcpus !== undefined ? `${b.vcpus} vCPU` : null, b.memory_mib !== undefined ? `${b.memory_mib} MiB` : null].filter(Boolean).join(' / ');
146
+ const head = `${b.host}${size ? ` ${size}` : ''}${b.method ? ` (${b.method})` : ''}`;
147
+ const changes = b.applied
148
+ ? `applied ${b.written_files ?? 0} files, ${b.written_dirs ?? 0} dirs, ${b.removed ?? 0} removed, ${b.written_bytes ?? 0} bytes`
149
+ : 'changes not applied';
150
+ const extra = [
151
+ b.leftover_killed ? `${b.leftover_killed} leftover processes killed` : null,
152
+ b.overhead_ms !== undefined ? `overhead ${b.overhead_ms} ms` : null,
153
+ b.replayed ? 'replayed' : null,
154
+ ].filter(Boolean);
155
+ return [head, changes, ...extra].join('; ');
156
+ }
157
+ export function memoryGrowText(g) {
158
+ const to = g.got_mib === g.want_mib ? `${g.got_mib} MiB` : `${g.got_mib} of ${g.want_mib} MiB`;
159
+ return `${g.outcome} ${g.from_mib} -> ${to} in ${g.deliver_ms} ms`;
160
+ }
125
161
  export function workspaceDetail(w) {
126
162
  const c = w.ceilings;
127
163
  const startup = startupText(w.startup);
@@ -132,6 +168,8 @@ export function workspaceDetail(w) {
132
168
  ['state', `${w.observed_state} (desired ${w.desired_state})`],
133
169
  ['ready', isReady(w) ? 'yes' : 'no'],
134
170
  ['template', `${w.template.slug} v${w.template.version} (${w.template.version_id})`],
171
+ // Immutable paths (0.8.0): the template version whose immutable paths the workspace has mounted, when it has one.
172
+ ...(typeof w.template.immutable_version === 'number' ? [['immutable paths', `v${w.template.immutable_version} (mounted read-only)`]] : []),
135
173
  // The mode (0.5.0) when the API reports it; a file-first workspace's state is its tree revision.
136
174
  ...(w.mode !== undefined ? [['mode', modeText(w.mode)]] : []),
137
175
  ...(w.mode === 'file_first' ? [['tree revision', String(w.tree_revision)]] : []),
@@ -139,6 +177,8 @@ export function workspaceDetail(w) {
139
177
  ['ceilings', c ? `cpu ${c.cpu_millis}m (${c.sources.cpu_millis}), memory ${c.memory_mib} MiB (${c.sources.memory_mib}), disk ${c.disk_gib} GiB (${c.sources.disk_gib})` : '-'],
140
178
  ['grants', pairs(w.grants)],
141
179
  ['caps', pairs(w.caps)],
180
+ // Elastic memory (0.8.0) when the API reports it.
181
+ ...(w.memory ? [['memory', memoryText(w.memory)]] : []),
142
182
  ['operation', w.active_operation ? `${operationSummary(w.active_operation)} ${w.active_operation.id}` : '-'],
143
183
  ['pending', w.pending_reason ?? '-'],
144
184
  ...(suspendRequest ? [['suspend when idle', suspendRequestText(suspendRequest)]] : []),
@@ -161,6 +201,28 @@ export const OPERATION_HEADERS = ['ID', 'KIND', 'STATE', 'REASON', 'CREATED', 'C
161
201
  export function operationTable(ops) {
162
202
  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
203
  }
204
+ /**
205
+ * A finished suspend or fork (0.7.0+): whether its copy is in durable storage, when the result says (`durable`,
206
+ * `durability`). Empty when the result has no durability block (stored durably before the operation completed).
207
+ */
208
+ export function durableText(op) {
209
+ const d = durabilityOf(op);
210
+ if (op.state !== 'succeeded' || !d)
211
+ return '';
212
+ if (d.state === 'durable')
213
+ return `durable: yes${d.localCommitToDurableMs !== null ? ` (${d.localCommitToDurableMs} ms after the suspend)` : ''}\n`;
214
+ if (d.state === 'pending')
215
+ return `durable: in progress, typically within a second (wait with: shard operations wait ${op.id} --durable)\n`;
216
+ return `durable: no (${d.reason ?? 'lost'}); see docs.shardflux.dev/concepts/lifecycle\n`;
217
+ }
218
+ /** `durable` of a finished suspend or fork for `operations get`/`wait`: yes, in progress, no, or '-' for other kinds. */
219
+ function durableField(op) {
220
+ const d = durabilityOf(op);
221
+ if (d)
222
+ return d.state === 'durable' ? 'yes' : d.state === 'pending' ? 'in progress' : `no (${d.reason ?? 'lost'})`;
223
+ const v = isDurable(op);
224
+ return v === null ? '-' : v ? 'yes' : 'no';
225
+ }
164
226
  export function operationDetail(op) {
165
227
  const err = op.error;
166
228
  return kv([
@@ -168,6 +230,7 @@ export function operationDetail(op) {
168
230
  ['kind', op.kind],
169
231
  ['state', op.state],
170
232
  ['reason', op.state_reason ?? '-'],
233
+ ...(op.kind === 'suspend' || op.kind === 'fork' ? [['durable', durableField(op)]] : []),
171
234
  ['workspace', op.workspace_id ?? '-'],
172
235
  ['created', time(op.created_at)],
173
236
  ['updated', time(op.updated_at)],
@@ -480,15 +543,30 @@ function buildSource(b) {
480
543
  const v2 = b.provenance.recipe_schema === 'shardflux.template-recipe.v2';
481
544
  return `recipe${v2 ? ' v2' : ''}${b.provenance.recipe_sha256 ? ` (sha256 ${b.provenance.recipe_sha256.slice(0, 12)})` : ''}`;
482
545
  }
546
+ /** What to do about a build that failed on its immutable paths (0.8.0; contracts §34.2), from failure.code and details. */
547
+ function immutableFailureFix(f) {
548
+ if (f.code === 'immutable_path_missing') {
549
+ const path = typeof f.details?.path === 'string' ? f.details.path : 'the path';
550
+ return `make ${path} a directory in the build (a step: mkdir -p ${path}), then build again`;
551
+ }
552
+ if (f.code === 'immutable_image_too_large')
553
+ return 'keep large data outside the immutable paths, then build again';
554
+ return null;
555
+ }
483
556
  export function buildText(b) {
484
557
  const settled = b.state === 'published' || b.state === 'failed' || b.state === 'canceled' || b.state === 'succeeded';
485
558
  const denied = b.denied_hosts ?? [];
559
+ const immutable = b.immutable_paths ?? [];
560
+ const fix = b.failure ? immutableFailureFix(b.failure) : null;
486
561
  return (kv([
487
562
  ['build', b.id],
488
563
  ['template', `${b.template.slug} v${b.target_version ?? '?'}`],
489
564
  ['state', `${b.state}${b.failure ? ` (${b.failure.code}: ${b.failure.message})` : ''}`],
565
+ ...(fix ? [['fix', fix]] : []),
490
566
  ['registration', `${b.registration.state}${b.registration.error ? ` (${b.registration.error.code}: ${b.registration.error.message})` : ''}`],
491
567
  ['source', buildSource(b)],
568
+ // Immutable paths (0.8.0): the list the produced version declares.
569
+ ...(immutable.length ? [['immutable paths', immutable.join(', ')]] : []),
492
570
  ['network', b.network.mode === 'none' ? 'none' : `allowlist ${b.network.allow_hosts.join(', ') || '(empty)'}`],
493
571
  ...(denied.length ? [['denied hosts', `${denied.join(', ')} (allow one: add it to build.network.extra_hosts and build again)`]] : []),
494
572
  ['version', b.template_version ? `${b.template_version.id}${b.template_version.published ? ' (published)' : ' (unpublished)'}` : '-'],
@@ -514,11 +592,25 @@ export function templateDetailText(t) {
514
592
  ['draft', t.draft ? `${t.draft.workspace_key} (${t.draft.observed_state})` : '-'],
515
593
  ['archived', time(t.archived_at)],
516
594
  ]);
595
+ // Immutable paths (0.8.0): the column appears when a version declares any.
596
+ const immutable = t.versions.some((v) => v.immutable);
517
597
  const versions = t.versions.length
518
- ? table(['VERSION', 'STATE', 'SOURCE', 'LAYOUTS', 'CREATED', 'DESCRIPTION'], t.versions.map((v) => [`v${v.version}${v.is_open_version ? ' *' : ''}`, v.state, v.source?.kind ?? '-', v.disk_layouts.join(',') || '-', time(v.created_at), v.description ?? '-']))
598
+ ? table(['VERSION', 'STATE', 'SOURCE', 'LAYOUTS', ...(immutable ? ['IMMUTABLE PATHS'] : []), 'CREATED', 'DESCRIPTION'], t.versions.map((v) => [
599
+ `v${v.version}${v.is_open_version ? ' *' : ''}`,
600
+ v.state,
601
+ v.source?.kind ?? '-',
602
+ v.disk_layouts.join(',') || '-',
603
+ ...(immutable ? [immutablePathsText(v.immutable)] : []),
604
+ time(v.created_at),
605
+ v.description ?? '-',
606
+ ]))
519
607
  : 'No versions.\n';
520
608
  return `${head}\n${versions}${open?.settings ? `\nsettings of v${open.version}:\n${settingsText(open.settings)}` : ''}`;
521
609
  }
610
+ /** A version's immutable paths and the size of their image: `/opt/acme, /srv/tools (128.0 MiB)`, or `-`. */
611
+ export function immutablePathsText(i) {
612
+ return i ? `${i.paths.join(', ')} (${byteSize(i.bytes)})` : '-';
613
+ }
522
614
  /** A version's settings, one line per part. */
523
615
  export function settingsText(s) {
524
616
  const inputs = Object.entries(s.inputs).map(([n, i]) => `${n} (${i.kind}${i.required ? ', required' : ''}${i.default !== null ? `, default ${JSON.stringify(i.default)}` : ''})`);
package/dist/http.d.ts CHANGED
@@ -1,11 +1,2 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch (API and cell gateway). By
3
- * default every request uses a fresh connection (`Connection: close`);
4
- * SHARDFLUX_HTTP_KEEPALIVE=1 reuses connections.
5
- *
6
- * `signal` (the CLI's Ctrl-C) is attached to held requests (`Prefer: wait=N`,
7
- * held by the server for up to 20 s). The SDK passes the caller's signal to its
8
- * held polls itself, but not to the held open (`workspaces.open()` with `wait`
9
- * sends its POST without one), so Ctrl-C would otherwise wait the hold out.
10
- */
1
+ /** Share the SDK pool and attach Ctrl-C to held API requests. */
11
2
  export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch, signal?: AbortSignal): typeof fetch;
package/dist/http.js CHANGED
@@ -1,24 +1,12 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch (API and cell gateway). By
3
- * default every request uses a fresh connection (`Connection: close`);
4
- * SHARDFLUX_HTTP_KEEPALIVE=1 reuses connections.
5
- *
6
- * `signal` (the CLI's Ctrl-C) is attached to held requests (`Prefer: wait=N`,
7
- * held by the server for up to 20 s). The SDK passes the caller's signal to its
8
- * held polls itself, but not to the held open (`workspaces.open()` with `wait`
9
- * sends its POST without one), so Ctrl-C would otherwise wait the hold out.
10
- */
11
- export function makeFetch(env, base = fetch, signal) {
12
- // Why a fresh connection by default (Node 26 keep-alive measurements): docs/progress/startup-latency.md.
13
- 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) {
14
4
  return (input, init) => {
15
5
  const headers = new Headers(init?.headers);
16
6
  const held = signal !== undefined && /\bwait\s*=/i.test(headers.get('prefer') ?? '');
17
- if (keepAlive && !held)
7
+ if (!held)
18
8
  return base(input, init);
19
- if (!keepAlive)
20
- headers.set('connection', 'close');
21
- const s = held && signal ? (init?.signal ? AbortSignal.any([init.signal, signal]) : signal) : init?.signal;
22
- 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 } : {}) });
23
11
  };
24
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.
@@ -79,6 +79,9 @@ function hintFor(info, cred, file) {
79
79
  const policyHint = spendPolicyHint(info);
80
80
  if (policyHint)
81
81
  return policyHint;
82
+ const immutable = immutableHint(info);
83
+ if (immutable)
84
+ return immutable;
82
85
  // File patches: what to do about each refusal. details.index is the 0-based edit (--old) index.
83
86
  const reason = info.details?.reason;
84
87
  const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
@@ -166,6 +169,35 @@ function spendPolicyHint(info) {
166
169
  return undefined;
167
170
  }
168
171
  }
172
+ /**
173
+ * Immutable paths (0.8.0; contracts §34): the build refusals (422 validation_failed per details.reason, with the paths
174
+ * and base the API names) and a files write under an immutable path (409 conflict read_only_path from the cell).
175
+ */
176
+ function immutableHint(info) {
177
+ const d = info.details ?? {};
178
+ const reason = reasonOf(info);
179
+ const list = (v) => (Array.isArray(v) ? v.filter((p) => typeof p === 'string').join(', ') : '');
180
+ if (info.code === 'conflict' && reason === 'read_only_path') {
181
+ 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)';
182
+ }
183
+ if (info.code !== 'validation_failed')
184
+ return undefined;
185
+ if (reason === 'immutable_path_removed') {
186
+ const removed = list(d.removed);
187
+ const open = typeof d.open_version === 'number' ? `v${d.open_version}` : 'the open version';
188
+ return `a new version keeps every immutable path of ${open}: add ${removed || 'them'} back to immutable in template.yaml (adding paths is fine)`;
189
+ }
190
+ if (reason === 'immutable_paths_unsupported_base') {
191
+ if (d.field === 'recipe')
192
+ 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)';
193
+ const base = typeof d.base === 'object' && d.base !== null ? d.base.name : undefined;
194
+ 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';
195
+ }
196
+ if (reason === 'invalid_path' && typeof d.field === 'string' && d.field.startsWith('recipe.immutable[')) {
197
+ return `fix ${d.field.slice('recipe.'.length)} in template.yaml (the list counts from 0)`;
198
+ }
199
+ return undefined;
200
+ }
169
201
  /**
170
202
  * 409 conflict host_feature_unavailable (0.5.0): the call is not available for this workspace. Not retryable, so the
171
203
  * hint names the fallback that works now.
@@ -258,6 +290,8 @@ const NO_NUDGE_REASONS = new Set([
258
290
  'subscription_exists',
259
291
  // A stale spend policy (If-Match): the hint names the version to retry with (0.5.1).
260
292
  'version_mismatch',
293
+ // A write under an immutable path (0.8.0): the hint says where to change it.
294
+ 'read_only_path',
261
295
  ]);
262
296
  /**
263
297
  * Whether a failure ends with the `feedback:` line (0.5.0+): every failure except the expected flow above (feedback
@@ -327,7 +361,7 @@ export async function run(argv, io, opts = {}) {
327
361
  const feedback = nudges(info, command) ? feedbackCommand(info) : undefined;
328
362
  const text = wantsJson
329
363
  ? json({ error: info, ...(showTiming ? { timing: timingJson(timing) } : {}), ...(feedback ? { feedback } : {}) })
330
- : `${timing ? timingText(timing) : ''}${traces.coldBoot ? coldBootNotice(traces.coldBoot) : ''}${errorText(info, hint, feedback)}`;
364
+ : `${timing ? timingText(timing) : ''}${notices(traces)}${errorText(info, hint, feedback)}`;
331
365
  io.stderr.write(redact(text, ...secrets));
332
366
  return code;
333
367
  };
@@ -359,7 +393,7 @@ export async function run(argv, io, opts = {}) {
359
393
  const wakeTimeoutMs = resolveWakeTimeout(parsed.global.wakeTimeout, io.env.SHARDFLUX_WAKE_TIMEOUT_MS);
360
394
  showTiming = parsed.values.timing === true;
361
395
  const userAgent = `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}${distribution.package === CLI_DISTRIBUTION.package ? '' : ` ${distribution.package}/${distribution.version}`}`;
362
- const base = makeFetch(io.env, fetch, signal);
396
+ const base = makeFetch(io.env, undefined, signal);
363
397
  // Notes which credential a 401 unauthenticated answered (the hint, and removing a dead saved session).
364
398
  const watchedFetch = async (input, init) => {
365
399
  const res = await base(input, init);
@@ -560,9 +594,11 @@ export async function run(argv, io, opts = {}) {
560
594
  // With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
561
595
  if (showTiming && !ctx.json && traces.last)
562
596
  io.stderr.write(timingText(traces.last));
563
- // A resume that booted the saved disk (0.6.0+): said on stderr with or without --timing, also with --json.
564
- if (traces.coldBoot)
565
- io.stderr.write(coldBootNotice(traces.coldBoot));
597
+ // A resume that booted the saved disk (0.6.0+), or restored an earlier checkpoint (0.7.0+): said on stderr with or
598
+ // without --timing, also with --json.
599
+ const notice = notices(traces);
600
+ if (notice)
601
+ io.stderr.write(notice);
566
602
  code = outcome.code;
567
603
  }
568
604
  // The update notice: after the output, never after Ctrl-C or a --json failure (stderr then holds only the error).
@@ -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
@@ -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
@@ -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;
@@ -17,6 +18,11 @@ export class TimingRecorder {
17
18
  * the wake of a command that found the workspace suspended. `coldBootNotice()` says it.
18
19
  */
19
20
  coldBoot = null;
21
+ /**
22
+ * (0.7.0+) The last traced resume whose result names a suspend it could not restore (`lost_suspend`): a `ws resume
23
+ * --wait`, an `operations wait` of that resume, or a command's wake. `lostSuspendNotice()` says it.
24
+ */
25
+ lostSuspend = null;
20
26
  listener = (e) => {
21
27
  if (e.action === 'token' || e.action === 'tool')
22
28
  return;
@@ -27,6 +33,8 @@ export class TimingRecorder {
27
33
  this.last = e.timing;
28
34
  if (restartedProcesses(e.timing))
29
35
  this.coldBoot = e.timing;
36
+ if (e.timing.server?.lostSuspend)
37
+ this.lostSuspend = e.timing;
30
38
  };
31
39
  /**
32
40
  * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
@@ -49,6 +57,20 @@ export function coldBootNotice(t) {
49
57
  const reason = t.server?.coldBootReason;
50
58
  return `note: ${t.workspaceId ? `workspace ${t.workspaceId} ` : ''}resumed from its saved disk; running processes were restarted${reason ? ` (${reason})` : ''}\n`;
51
59
  }
60
+ /**
61
+ * The one-line stderr notice after a command whose resume restored an earlier checkpoint because the latest suspend
62
+ * could not be kept (0.7.0+; `lost_suspend`): the state the workspace runs with is as of `state_as_of`.
63
+ */
64
+ export function lostSuspendNotice(t) {
65
+ const l = t.server?.lostSuspend;
66
+ if (!l)
67
+ return '';
68
+ 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
+ }
70
+ /** The notices a command's traced calls leave on stderr (cold boot, lost suspend), in that order. */
71
+ export function notices(r) {
72
+ return `${r.coldBoot ? coldBootNotice(r.coldBoot) : ''}${r.lostSuspend ? lostSuspendNotice(r.lostSuspend) : ''}`;
73
+ }
52
74
  /** The human form (stderr): formatTiming() of the SDK, one block. */
53
75
  export function timingText(t) {
54
76
  return `${formatTiming(t)}\n`;
@@ -79,6 +101,31 @@ export function timingJson(t) {
79
101
  resume_path: s.resumePath,
80
102
  memory_restored: s.memoryRestored ?? null,
81
103
  cold_boot_reason: s.coldBootReason ?? null,
104
+ durable: s.durable ?? null,
105
+ suspend_path: s.suspendPath ?? null,
106
+ durability: s.durability
107
+ ? {
108
+ state: s.durability.state,
109
+ checkpoint_id: s.durability.checkpointId,
110
+ generation_id: s.durability.generationId,
111
+ local_commit_at: s.durability.localCommitAt,
112
+ durable_by: s.durability.durableBy,
113
+ durable_at: s.durability.durableAt,
114
+ local_commit_to_durable_ms: s.durability.localCommitToDurableMs,
115
+ overdue_at: s.durability.overdueAt,
116
+ reason: s.durability.reason,
117
+ }
118
+ : null,
119
+ lost_suspend: s.lostSuspend
120
+ ? {
121
+ checkpoint_id: s.lostSuspend.checkpointId,
122
+ generation_id: s.lostSuspend.generationId,
123
+ reason: s.lostSuspend.reason,
124
+ suspended_at: s.lostSuspend.suspendedAt,
125
+ restored_checkpoint_id: s.lostSuspend.restoredCheckpointId,
126
+ state_as_of: s.lostSuspend.stateAsOf,
127
+ }
128
+ : null,
82
129
  boot_to_ready_ms: s.bootToReadyMs,
83
130
  host_timings_ms: s.hostTimingsMs,
84
131
  }
@@ -150,7 +197,7 @@ export class BuildTimer {
150
197
  }
151
198
  const fmtMs = (ms) => (ms === null ? '-' : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`);
152
199
  const short = (sha) => sha.slice(0, 12);
153
- 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`);
200
+ const bytes = byteSize;
154
201
  /** One stderr line per build-from-file progress event. */
155
202
  export function progressLine(e) {
156
203
  if (e.type === 'pack')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.6.2",
3
+ "version": "0.8.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.1"
47
+ "@shardflux/sdk": "^0.13.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",