@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/commands.js CHANGED
@@ -8,15 +8,15 @@
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { access, readFile, writeFile } from 'node:fs/promises';
10
10
  import { basename, dirname, resolve as resolvePath } from 'node:path';
11
- import { EXECUTION_ID, FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, ShardfluxApiError, newExecutionId } from '@shardflux/sdk';
11
+ import { EXECUTION_ID, ExecStartError, FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, ShardfluxApiError, ShardfluxProtocolError, newExecutionId } from '@shardflux/sdk';
12
12
  import { parse as parseYaml } from 'yaml';
13
13
  import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
14
- import { ExecutionFailedError, ExecutionPendingError, InterruptedError, NotFoundError, UsageError } from "./errors.js";
14
+ import { ExecSessionRunningError, ExecutionFailedError, ExecutionPendingError, InterruptedError, NotFoundError, UsageError } from "./errors.js";
15
15
  import { updateCheckDisabled } from "./update-check.js";
16
16
  import { initTemplateYaml, recipeYaml } from "./template-yaml.js";
17
17
  import { BuildTimer, buildTimingJson, buildTimingText, progressLine } from "./timing.js";
18
18
  import { timingJson } from "./timing.js";
19
- import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, executionJson, executionSummary, fileInfoText, fileTable, languageTable, json, kv, modeText, operationDetail, pairs, operationTable, packageTable, searchText, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, suspendRequestText, suspendWhenIdleText, templateDetailText, templateEntryText, templateFileTable, templateTable, usageEstimateText, usageSeriesText, usageText, workspaceDetail, workspaceTable, } from "./format.js";
19
+ import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, executionJson, executionSummary, fileInfoText, fileTable, languageTable, json, kv, memoryGrowText, burstText, modeText, operationDetail, pairs, operationTable, packageTable, searchText, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, durableText, suspendRequestText, suspendWhenIdleText, templateDetailText, templateEntryText, templateFileTable, templateTable, usageEstimateText, usageSeriesText, usageText, workspaceDetail, workspaceTable, } from "./format.js";
20
20
  const DEFAULT_WAIT_MS = 300_000;
21
21
  const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
22
22
  const MAX_STDIN = 1024 * 1024;
@@ -66,6 +66,47 @@ function caps(values) {
66
66
  c.disk_gib = parsePositiveInt(disk, 'disk-gib', 1_000_000);
67
67
  return Object.keys(c).length ? c : undefined;
68
68
  }
69
+ const ALLOCATION_MODES = ['fixed', 'elastic'];
70
+ /**
71
+ * The caps of `workspaces open` and `workspaces fork` (0.8.0): the resource caps plus the memory allocation. Given caps
72
+ * replace the stored ones, so they are sent only when a flag was given.
73
+ */
74
+ function workspaceCaps(values) {
75
+ const c = caps(values) ?? {};
76
+ const mode = oneOf(values, 'allocation-mode', ALLOCATION_MODES);
77
+ const held = str(values['memory-held']);
78
+ if (held !== undefined && mode !== 'elastic')
79
+ throw new UsageError('--memory-held needs --allocation-mode elastic (a fixed workspace holds all of --memory-mib)');
80
+ if (mode !== undefined)
81
+ c.allocation_mode = mode;
82
+ if (held !== undefined)
83
+ c.memory_mib_held = parsePositiveInt(held, 'memory-held', 16_777_216);
84
+ return Object.keys(c).length ? c : undefined;
85
+ }
86
+ /**
87
+ * `--burst [--burst-vcpus N] [--burst-memory MIB]` of `workspaces exec` (0.8.0): the exec.run options,
88
+ * or undefined without --burst.
89
+ */
90
+ function burstOptions(values, withStdin) {
91
+ const vcpus = str(values['burst-vcpus']);
92
+ const mem = str(values['burst-memory']);
93
+ if (values.burst !== true) {
94
+ if (vcpus !== undefined || mem !== undefined)
95
+ throw new UsageError('--burst-vcpus and --burst-memory need --burst');
96
+ return undefined;
97
+ }
98
+ if (withStdin)
99
+ throw new UsageError('--burst runs the command to completion without stdin; drop --stdin');
100
+ const o = { burst: 'always' };
101
+ if (vcpus !== undefined)
102
+ o.burstVcpus = parsePositiveInt(vcpus, 'burst-vcpus', 32);
103
+ if (mem !== undefined) {
104
+ o.burstMemoryMib = parsePositiveInt(mem, 'burst-memory', 65_536);
105
+ if (o.burstMemoryMib < 512)
106
+ throw new UsageError('--burst-memory must be between 512 and 65536');
107
+ }
108
+ return o;
109
+ }
69
110
  /** `--when-idle <duration>`: whole seconds from 0 (as soon as it is idle; 0.6.1) to 3600 (the API's after_seconds bounds). */
70
111
  export function whenIdleSeconds(text) {
71
112
  if (/^0+(?:\.0+)?(?:ms|s|m|h)?$/.test(text.trim()))
@@ -414,6 +455,75 @@ export function execExitCode(r) {
414
455
  return 128 + r.termSignal;
415
456
  return 1;
416
457
  }
458
+ /** The longest cancel grace the workspace applies (it waits 5 s without one). */
459
+ const MAX_CANCEL_GRACE_MS = 60_000;
460
+ /** Error codes of a burst's own failure (an output stream's final error event; the session carries it too). */
461
+ const BURST_FAILURES = new Set(['burst_unavailable', 'burst_apply_failed']);
462
+ /**
463
+ * `shard ws exec-read` (0.8.0): an exec session's output from offset 0, and the session. Without `follow`, the output
464
+ * that exists now; with it, until the session ends, resuming from the byte offsets read after a dropped stream.
465
+ * `maxBytes` of each stream are kept for the result (the rest is counted); `onOutput` gets every new chunk.
466
+ */
467
+ async function readSession(cell, sessionId, opts) {
468
+ let session = await cell.exec.get(sessionId);
469
+ const kept = { stdout: [], stderr: [] };
470
+ const size = { stdout: 0, stderr: 0 };
471
+ let total = { stdout: 0, stderr: 0 };
472
+ let reconnects = 0;
473
+ const live = (s) => s.state === 'starting' || s.state === 'running';
474
+ for (;;) {
475
+ let ended;
476
+ try {
477
+ const events = await cell.exec.output(sessionId, { stdoutOffset: total.stdout, stderrOffset: total.stderr, follow: opts.follow && live(session), signal: opts.signal });
478
+ for await (const ev of events) {
479
+ if (ev.type === 'output' && ev.data !== undefined) {
480
+ const stream = ev.stream === 'stderr' ? 'stderr' : 'stdout';
481
+ const bytes = Buffer.from(ev.data, 'base64');
482
+ const start = ev.offset ?? total[stream];
483
+ // A reconnect may resend bytes already printed.
484
+ const fresh = start < total[stream] ? bytes.subarray(Math.min(total[stream] - start, bytes.length)) : bytes;
485
+ if (fresh.length === 0)
486
+ continue;
487
+ total = { ...total, [stream]: Math.max(total[stream], start + bytes.length) };
488
+ const room = opts.maxBytes - size[stream];
489
+ if (room > 0) {
490
+ kept[stream].push(fresh.subarray(0, room));
491
+ size[stream] += Math.min(room, fresh.length);
492
+ }
493
+ opts.onOutput?.(stream, fresh);
494
+ }
495
+ else if (ev.type === 'exit') {
496
+ ended = ev.session ?? (await cell.exec.get(sessionId));
497
+ break;
498
+ }
499
+ else if (ev.type === 'error' && ev.error && !BURST_FAILURES.has(ev.error.error.code)) {
500
+ throw new ShardfluxApiError(502, ev.error, 'cell');
501
+ }
502
+ }
503
+ }
504
+ catch (err) {
505
+ if (opts.signal.aborted)
506
+ throw err;
507
+ const transient = !(err instanceof ShardfluxApiError) || err.retryable;
508
+ if (!opts.follow || !transient || reconnects >= 10)
509
+ throw err;
510
+ }
511
+ if (ended)
512
+ session = ended;
513
+ else if (opts.follow) {
514
+ // The stream ended without the exit (a dropped connection, a restarted gateway): continue from the offsets.
515
+ reconnects += 1;
516
+ if (reconnects > 10)
517
+ throw new ShardfluxProtocolError(`exec ${sessionId}: output stream kept dropping`, 0, 'cell');
518
+ await new Promise((r) => setTimeout(r, Math.min(2_000, 100 * 2 ** reconnects)).unref());
519
+ session = await cell.exec.get(sessionId);
520
+ if (live(session) || total.stdout < session.stdout_size || total.stderr < session.stderr_size)
521
+ continue;
522
+ }
523
+ const text = (b) => new TextDecoder().decode(Buffer.concat(b));
524
+ return { session, stdout: text(kept.stdout), stderr: text(kept.stderr), truncated: total.stdout > size.stdout || total.stderr > size.stderr, reconnects };
525
+ }
526
+ }
417
527
  async function bestEffortCancel(cell, sessionId) {
418
528
  await Promise.race([cell.exec.cancel(sessionId).catch(() => undefined), new Promise((r) => setTimeout(r, 3_000).unref())]);
419
529
  }
@@ -496,7 +606,7 @@ function reportExecution(ctx, ref, r, opts) {
496
606
  }
497
607
  function lifecycleText(verb, w, op) {
498
608
  const finished = op.state === 'succeeded' || op.state === 'failed' || op.state === 'canceled';
499
- return `${verb} ${w.key} (${w.id}): operation ${op.id} ${op.kind} ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''}\n${finished ? '' : `wait with: shard operations wait ${op.id}\n`}`;
609
+ return `${verb} ${w.key !== undefined ? `${w.key} (${w.id})` : w.id}: operation ${op.id} ${op.kind} ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''}\n${finished ? durableText(op) : `wait with: shard operations wait ${op.id}\n`}`;
500
610
  }
501
611
  /** The project-key commands; handlers.ts adds the account plane's. */
502
612
  export const KEY_HANDLERS = {
@@ -565,11 +675,119 @@ export const KEY_HANDLERS = {
565
675
  out(ctx, usageText(summary), summary);
566
676
  return 0;
567
677
  },
678
+ async 'workspaces labels'(ctx, values, [ref]) {
679
+ const ws = await resolveWorkspace(ctx, ref);
680
+ const labels = labelMap(str(values.set));
681
+ if (labels !== undefined)
682
+ await ws.setLabels(labels);
683
+ out(ctx, JSON.stringify(ws.labels, null, 2) + '\n', ws.labels);
684
+ return 0;
685
+ },
686
+ async 'workspaces idle'(ctx, values, [ref]) {
687
+ const ws = await resolveWorkspace(ctx, ref);
688
+ const policy = str(values.policy);
689
+ const keepalive = str(values.keepalive);
690
+ if (policy !== undefined && keepalive !== undefined)
691
+ throw new UsageError('--policy and --keepalive cannot be combined');
692
+ let result;
693
+ if (policy !== undefined)
694
+ result = (await ws.setIdlePolicy(policy === 'default' ? null : policy)).data;
695
+ else if (keepalive !== undefined)
696
+ result = await ws.keepalive(parsePositiveInt(keepalive, 'keepalive', 86400));
697
+ else
698
+ result = await ws.idle(ctx.signal);
699
+ out(ctx, JSON.stringify(result, null, 2) + '\n', result);
700
+ return 0;
701
+ },
702
+ async 'workspaces exec-start'(ctx, values, [ref, ...argv]) {
703
+ const timeout = str(values.timeout);
704
+ const timeoutMs = timeout === undefined ? undefined : parseDuration(timeout, 'timeout');
705
+ const ws = await resolveWorkspace(ctx, ref);
706
+ const session = await cellOf(ctx, ws).exec.start({ argv, stdin_open: values['stdin-open'] === true,
707
+ ...(str(values['session-id']) ? { session_id: str(values['session-id']) } : {}), ...(str(values.cwd) ? { cwd: str(values.cwd) } : {}),
708
+ ...(timeoutMs !== undefined ? { timeout_ms: timeoutMs } : {}) }, ctx.signal);
709
+ out(ctx, JSON.stringify(session, null, 2) + '\n', session);
710
+ // 0.8.0: what to do next (stderr, so stdout stays the session's JSON).
711
+ if (!ctx.json && session.state !== 'failed_to_start')
712
+ ctx.io.stderr.write(`read it with: shard ws exec-read ${ref} ${session.session_id} --follow; stop it with: shard ws exec-cancel ${ref} ${session.session_id}\n`);
713
+ return 0;
714
+ },
715
+ async 'workspaces exec-read'(ctx, values, [ref, sessionId]) {
716
+ const ws = await resolveWorkspace(ctx, ref);
717
+ const cell = cellOf(ctx, ws);
718
+ const follow = values.follow === true;
719
+ let r;
720
+ try {
721
+ r = await readSession(cell, sessionId, {
722
+ follow,
723
+ signal: ctx.signal,
724
+ maxBytes: ctx.json ? MAX_JSON_OUTPUT : 0,
725
+ ...(ctx.json ? {} : { onOutput: (stream, chunk) => void (stream === 'stderr' ? ctx.io.stderr : ctx.io.stdout).write(chunk) }),
726
+ });
727
+ }
728
+ catch (err) {
729
+ if (ctx.signal.aborted && follow) {
730
+ throw new InterruptedError(`Stopped following exec session ${sessionId}; the command keeps running.`, undefined, { hint: `stop it with: shard ws exec-cancel ${ref} ${sessionId}` });
731
+ }
732
+ throw err;
733
+ }
734
+ const s = r.session;
735
+ // Nothing ran (a cwd that is not a directory, a program not on PATH): exit 6 with the workspace's reason, as ws exec.
736
+ if (s.burst?.error)
737
+ throw new ShardfluxApiError(409, { error: { code: s.burst.error.code, message: s.burst.error.message, request_id: '', retryable: s.burst.error.retryable, ...(s.burst.error.details ? { details: s.burst.error.details } : {}) } }, 'cell');
738
+ if (s.state === 'failed_to_start')
739
+ throw new ExecStartError(s);
740
+ if (ctx.json) {
741
+ ctx.io.stdout.write(json(withTiming(ctx, {
742
+ session_id: s.session_id,
743
+ state: s.state,
744
+ exit_code: s.exit_code ?? null,
745
+ term_signal: s.term_signal ?? null,
746
+ timed_out: s.timed_out ?? false,
747
+ canceled: s.canceled ?? false,
748
+ stdout: r.stdout,
749
+ stderr: r.stderr,
750
+ truncated: r.truncated,
751
+ reconnects: r.reconnects,
752
+ ...(s.error ? { error: s.error } : {}),
753
+ ...(s.burst ? { burst: s.burst } : {}),
754
+ })));
755
+ }
756
+ if (s.state === 'starting' || s.state === 'running') {
757
+ throw new ExecSessionRunningError(`Exec session ${s.session_id} is still ${s.state}.`, s, `follow it to its end with: shard ws exec-read ${ref} ${s.session_id} --follow`);
758
+ }
759
+ const result = { exitCode: s.exit_code ?? null, termSignal: s.term_signal ?? null, timedOut: s.timed_out ?? false };
760
+ if (!ctx.json && result.exitCode === null && !result.timedOut && !(result.termSignal !== null && result.termSignal > 0)) {
761
+ ctx.io.stderr.write(`exec session ${s.session_id} ended ${s.state} without an exit code${s.error ? `: ${s.error}` : ''}\n`);
762
+ }
763
+ return execExitCode(result);
764
+ },
765
+ async 'workspaces exec-cancel'(ctx, values, [ref, sessionId]) {
766
+ const grace = str(values.grace);
767
+ const graceMs = grace === undefined ? undefined : parseDuration(grace, 'grace');
768
+ if (graceMs !== undefined && graceMs > MAX_CANCEL_GRACE_MS)
769
+ throw new UsageError(`--grace must be at most 60s (got "${grace}")`);
770
+ const ws = await resolveWorkspace(ctx, ref);
771
+ const s = await cellOf(ctx, ws).exec.cancel(sessionId, graceMs);
772
+ const ended = s.state !== 'starting' && s.state !== 'running';
773
+ const status = !ended ? `${s.state} (cancel sent)` : s.canceled ? `canceled${s.term_signal ? ` (signal ${s.term_signal})` : ''}` : `already ${s.state}${typeof s.exit_code === 'number' ? ` (exit code ${s.exit_code})` : ''}`;
774
+ out(ctx, `exec session ${s.session_id}: ${status}\n`, s);
775
+ return 0;
776
+ },
777
+ async 'workspaces exec-input'(ctx, values, [ref, sessionId, data]) {
778
+ const offset = Number(str(values.offset));
779
+ if (str(values.offset) === undefined || !Number.isSafeInteger(offset) || offset < 0)
780
+ throw new UsageError('--offset must be a non-negative integer');
781
+ const ws = await resolveWorkspace(ctx, ref);
782
+ const ack = await cellOf(ctx, ws).exec.input(sessionId, data ?? '', { offset, close: values.close === true, signal: ctx.signal });
783
+ out(ctx, JSON.stringify(ack) + '\n', ack);
784
+ return 0;
785
+ },
568
786
  async 'workspaces open'(ctx, values, [key]) {
569
787
  const template = str(values.template);
570
788
  if (!template)
571
789
  throw new UsageError('--template <slug> is required for "shard workspaces open"');
572
- const c = caps(values);
790
+ const c = workspaceCaps(values);
573
791
  const bind = secretNames(strs(values.secret), '--secret');
574
792
  const lifetime = oneOf(values, 'lifetime', LIFETIMES);
575
793
  const mode = modeFlag(values);
@@ -583,6 +801,8 @@ export const KEY_HANDLERS = {
583
801
  const ws = await waited(ctx, 'the open continues server side (open the key again to keep waiting).', () => cloud.workspaces.open({
584
802
  key: key,
585
803
  template,
804
+ ...(str(values.labels) !== undefined ? { labels: labelMap(str(values.labels)) } : {}),
805
+ ...(str(values['idle-policy']) ? { idlePolicy: str(values['idle-policy']) } : {}),
586
806
  ...(c ? { caps: c } : {}),
587
807
  ...(bind.length ? { secrets: bind } : {}),
588
808
  ...(inputs ? { inputs } : {}),
@@ -609,6 +829,7 @@ export const KEY_HANDLERS = {
609
829
  const params = {
610
830
  ...(state ? { state: state } : {}),
611
831
  ...(prefix ? { keyPrefix: prefix } : {}),
832
+ ...(str(values.labels) !== undefined ? { labels: labelMap(str(values.labels)) } : {}),
612
833
  ...(lifetime ? { lifetime } : {}),
613
834
  ...(purpose ? { purpose } : {}),
614
835
  ...(values['include-deleted'] === true ? { includeDeleted: true } : {}),
@@ -656,11 +877,14 @@ export const KEY_HANDLERS = {
656
877
  const limitText = str(values['output-limit']);
657
878
  const outputLimit = limitText === undefined ? undefined : parsePositiveInt(limitText, 'output-limit', MAX_OUTPUT_LIMIT);
658
879
  const stdinSource = str(values.stdin);
880
+ const burst = burstOptions(values, stdinSource !== undefined);
659
881
  const stdin = stdinSource === undefined ? undefined : await readInput(ctx, stdinSource, MAX_STDIN, '--stdin');
660
882
  const ws = await resolveWorkspace(ctx, ref);
661
883
  const cell = cellOf(ctx, ws);
662
884
  const cwd = str(values.cwd);
663
885
  if (ws.mode === 'file_first') {
886
+ if (burst)
887
+ throw new UsageError(`--burst applies to processful workspaces only; ${ws.key} is ${modeText(ws.mode)}`);
664
888
  // An execution: answered when the command ended. The id is ours, so Ctrl-C can name it.
665
889
  const id = executionId ?? newExecutionId();
666
890
  let r;
@@ -696,6 +920,7 @@ export const KEY_HANDLERS = {
696
920
  ...(Object.keys(env).length ? { env } : {}),
697
921
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
698
922
  ...(stdin !== undefined ? { stdin } : {}),
923
+ ...(burst ?? {}),
699
924
  maxOutputBytes: ctx.json ? MAX_JSON_OUTPUT : 0,
700
925
  ...(ctx.json ? {} : { onOutput: (stream, chunk) => void (stream === 'stderr' ? ctx.io.stderr : ctx.io.stdout).write(chunk) }),
701
926
  signal: ctx.signal,
@@ -703,9 +928,14 @@ export const KEY_HANDLERS = {
703
928
  }
704
929
  catch (err) {
705
930
  if (ctx.signal.aborted) {
931
+ if (burst)
932
+ throw new InterruptedError(`Interrupted; burst exec session ${sessionId} keeps running server side (a burst cannot be canceled).`);
706
933
  await bestEffortCancel(cell, sessionId);
707
934
  throw new InterruptedError(`Interrupted; sent cancel to exec session ${sessionId}.`);
708
935
  }
936
+ // A burst's apply that did not complete is finished by a retry of the same session (SDK exec.run / the API).
937
+ if (burst && err instanceof ShardfluxApiError && err.code === 'burst_apply_failed')
938
+ ctx.io.stderr.write(`burst exec session ${sessionId}\n`);
709
939
  throw err;
710
940
  }
711
941
  const code = execExitCode(r);
@@ -714,7 +944,14 @@ export const KEY_HANDLERS = {
714
944
  const unexplained = r.exitCode === null && !r.timedOut && !(r.termSignal !== null && r.termSignal > 0);
715
945
  if (values.verbose === true && !ctx.json) {
716
946
  const status = r.timedOut ? 'timed out' : r.canceled ? 'canceled' : r.termSignal ? `killed by signal ${r.termSignal}` : unexplained ? r.session.state : `exit code ${r.exitCode ?? '-'}`;
717
- ctx.io.stderr.write(kv([['session', r.sessionId], ['status', status], ['output', `${r.stdoutBytes} bytes stdout, ${r.stderrBytes} bytes stderr`], ['reconnects', String(r.reconnects)]]));
947
+ ctx.io.stderr.write(kv([
948
+ ['session', r.sessionId],
949
+ ['status', status],
950
+ ['output', `${r.stdoutBytes} bytes stdout, ${r.stderrBytes} bytes stderr`],
951
+ ['reconnects', String(r.reconnects)],
952
+ ...(r.memoryGrow ? [['memory grow', memoryGrowText(r.memoryGrow)]] : []),
953
+ ...(r.burst ? [['burst', burstText(r.burst)]] : []),
954
+ ]));
718
955
  }
719
956
  if (ctx.json) {
720
957
  ctx.io.stdout.write(json(withTiming(ctx, {
@@ -729,6 +966,8 @@ export const KEY_HANDLERS = {
729
966
  truncated: r.truncated,
730
967
  reconnects: r.reconnects,
731
968
  ...(r.session.error ? { error: r.session.error } : {}),
969
+ ...(r.memoryGrow ? { memory_grow: r.memoryGrow } : {}),
970
+ ...(r.burst ? { burst: r.burst } : {}),
732
971
  })));
733
972
  }
734
973
  else if (unexplained) {
@@ -854,8 +1093,26 @@ export const KEY_HANDLERS = {
854
1093
  async 'workspaces suspend'(ctx, values, [ref]) {
855
1094
  const whenIdle = str(values['when-idle']);
856
1095
  const cancel = values['cancel-when-idle'] === true;
857
- if (whenIdle === undefined && !cancel)
858
- return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o), ['suspend', 'Suspend']);
1096
+ const durable = values.durable === true;
1097
+ if (whenIdle === undefined && !cancel) {
1098
+ const suspend = (id, o) => ctx.cloud().workspaces.suspend(id, durable ? { ...o, durable: true, wait: o.wait ?? { timeoutMs: waitTimeout(values), signal: ctx.signal } } : o);
1099
+ if (UUID.test(ref)) {
1100
+ // An id needs no resolve request: the suspend goes straight to it (the API refuses a file-first workspace
1101
+ // itself). Only a UUID that is no workspace id here (404, nothing started) is looked up as a key.
1102
+ try {
1103
+ const op = await waited(ctx, 'the operation continues server side.', () => suspend(ref, lifecycleOptions(ctx, values, waitTimeout(values))));
1104
+ out(ctx, lifecycleText('Suspending', { id: ref }, op), op);
1105
+ return 0;
1106
+ }
1107
+ catch (err) {
1108
+ if (!(err instanceof ShardfluxApiError && err.status === 404 && err.operationId === undefined))
1109
+ throw err;
1110
+ }
1111
+ }
1112
+ return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => suspend(ws.id, o), ['suspend', 'Suspend']);
1113
+ }
1114
+ if (durable)
1115
+ throw new UsageError(`${whenIdle !== undefined ? '--when-idle' : '--cancel-when-idle'} cannot be combined with --durable (nothing is suspended now)`);
859
1116
  if (whenIdle !== undefined && cancel)
860
1117
  throw new UsageError('--when-idle and --cancel-when-idle cannot be combined');
861
1118
  const flag = cancel ? '--cancel-when-idle' : '--when-idle';
@@ -1376,7 +1633,7 @@ export const KEY_HANDLERS = {
1376
1633
  return 0;
1377
1634
  },
1378
1635
  async 'workspaces fork'(ctx, values, [ref, newKey]) {
1379
- const c = caps(values);
1636
+ const c = workspaceCaps(values);
1380
1637
  const timeoutMs = waitTimeout(values);
1381
1638
  const source = await resolveWorkspace(ctx, ref);
1382
1639
  requireProcessful(source, 'fork', 'A fork');
@@ -1526,7 +1783,10 @@ export const KEY_HANDLERS = {
1526
1783
  async 'operations wait'(ctx, values, [id]) {
1527
1784
  if (!UUID.test(id))
1528
1785
  throw new UsageError(`<operation-id> must be a UUID (got "${id}")`);
1529
- const op = await waitOperation(ctx, id, waitTimeout(values));
1786
+ const timeoutMs = waitTimeout(values);
1787
+ const op = values.durable === true
1788
+ ? await waited(ctx, `operation ${id} continues server side.`, () => ctx.cloud().workspaces.waitForDurable(id, { timeoutMs, signal: ctx.signal }))
1789
+ : await waitOperation(ctx, id, timeoutMs);
1530
1790
  out(ctx, operationDetail(op), op);
1531
1791
  return 0;
1532
1792
  },
@@ -1681,3 +1941,17 @@ async function lifecycle(ctx, values, ref, verb, start, processfulOnly) {
1681
1941
  out(ctx, lifecycleText(verb, ws, op), op);
1682
1942
  return 0;
1683
1943
  }
1944
+ function labelMap(value) {
1945
+ if (value === undefined)
1946
+ return undefined;
1947
+ let parsed;
1948
+ try {
1949
+ parsed = JSON.parse(value);
1950
+ }
1951
+ catch {
1952
+ throw new UsageError('labels must be a JSON object of strings');
1953
+ }
1954
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((v) => typeof v !== 'string'))
1955
+ throw new UsageError('labels must be a JSON object of strings');
1956
+ return parsed;
1957
+ }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CheckoutStatus, ExecutionResult } from '@shardflux/sdk';
1
+ import type { CheckoutStatus, ExecSession, ExecutionResult } from '@shardflux/sdk';
2
2
  export declare const EXIT: {
3
3
  readonly ok: 0;
4
4
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -76,6 +76,15 @@ export declare class ExecutionPendingError extends Error {
76
76
  readonly hint: string;
77
77
  constructor(message: string, result: ExecutionResult, hint: string);
78
78
  }
79
+ /**
80
+ * `shard ws exec-read` without --follow (0.8.0): the background command still runs (exit 5, as a file-first execution
81
+ * that still runs); `hint` names the command that follows it to its end.
82
+ */
83
+ export declare class ExecSessionRunningError extends HintedError {
84
+ name: string;
85
+ readonly session: ExecSession;
86
+ constructor(message: string, session: ExecSession, hint: string);
87
+ }
79
88
  export interface ErrorInfo {
80
89
  code: string;
81
90
  message: string;
@@ -84,7 +93,7 @@ export interface ErrorInfo {
84
93
  retryable?: boolean;
85
94
  operation_id?: string;
86
95
  details?: Record<string, unknown>;
87
- source?: 'api' | 'cell';
96
+ source?: 'api' | 'cell' | 'unknown';
88
97
  last_state?: string;
89
98
  operation?: unknown;
90
99
  /** A file-first execution the error is about (failed, lost, still running, or left running by Ctrl-C). */
package/dist/errors.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * stderr with the API's error code, message and request id; the process exit
4
4
  * code tells scripts what kind of failure it was (README "Exit codes").
5
5
  */
6
- import { CheckoutTimeoutError, ExecStartError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
6
+ import { CheckoutTimeoutError, DurabilityLostError, ExecStartError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
7
7
  export const EXIT = {
8
8
  ok: 0,
9
9
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -97,6 +97,18 @@ export class ExecutionPendingError extends Error {
97
97
  this.hint = hint;
98
98
  }
99
99
  }
100
+ /**
101
+ * `shard ws exec-read` without --follow (0.8.0): the background command still runs (exit 5, as a file-first execution
102
+ * that still runs); `hint` names the command that follows it to its end.
103
+ */
104
+ export class ExecSessionRunningError extends HintedError {
105
+ name = 'ExecSessionRunningError';
106
+ session;
107
+ constructor(message, session, hint) {
108
+ super(message, hint);
109
+ this.session = session;
110
+ }
111
+ }
100
112
  const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed']);
101
113
  const KEY_PATTERN = /sfk_[A-Za-z0-9]{8,}_[A-Za-z0-9]+/g;
102
114
  /** User session tokens (`sfu_` + 43 base64url characters), also cut short. */
@@ -137,15 +149,22 @@ export function describeError(err) {
137
149
  source: err.source,
138
150
  };
139
151
  }
140
- if (err instanceof OperationTimeoutError)
141
- return { code: 'timeout', message: err.message, operation_id: err.operationId, last_state: err.lastState };
152
+ if (err instanceof OperationTimeoutError) {
153
+ return { code: 'timeout', message: err.message, operation_id: err.operationId, last_state: err.lastState, ...(err.durable ? { details: { durable: false, state: 'pending' } } : {}) };
154
+ }
155
+ // --durable (0.7.0+): the suspend succeeded; its durable copy could not be made (see the lifecycle reference).
156
+ if (err instanceof DurabilityLostError) {
157
+ return { code: 'durability_lost', message: err.message, operation_id: err.operationId, retryable: false, details: { state: 'lost', ...(err.durability.reason ? { reason: err.durability.reason } : {}), checkpoint_id: err.durability.checkpointId }, operation: err.operation };
158
+ }
142
159
  if (err instanceof OperationFailedError) {
143
160
  // retryable: the operation error's own flag (true for capacity_unavailable: nothing was started, retry later).
144
161
  // details.reason: the operation error's reason (e.g. the scheduler's no_ready_hosts), printed after the message.
145
162
  const opDetails = err.operation.error?.details;
146
163
  const reason = typeof opDetails === 'object' && opDetails !== null ? opDetails.reason : undefined;
147
164
  const details = { ...(err.errorCode ? { error_code: err.errorCode } : {}), ...(typeof reason === 'string' ? { reason } : {}) };
148
- return { code: 'operation_failed', message: err.message, operation_id: err.operationId, retryable: err.retryable, ...(Object.keys(details).length ? { details } : {}), operation: err.operation };
165
+ // A suspend-when-idle canceled workspace_active (0.7.0+): the workspace was in use and keeps running.
166
+ const message = err.workspaceActive ? `${err.message}: the workspace was in use, so the suspend-when-idle was canceled; it keeps running` : err.message;
167
+ return { code: 'operation_failed', message, operation_id: err.operationId, retryable: err.retryable, ...(Object.keys(details).length ? { details } : {}), operation: err.operation };
149
168
  }
150
169
  // File-first executions (0.5.0): failed or lost like a failed operation (details.reason says why;
151
170
  // retryable: a new execution id may succeed), still running like a wait that gave up.
@@ -156,6 +175,8 @@ export function describeError(err) {
156
175
  }
157
176
  if (err instanceof ExecutionPendingError)
158
177
  return { code: 'timeout', message: err.message, execution_id: err.result.executionId, last_state: err.result.state };
178
+ if (err instanceof ExecSessionRunningError)
179
+ return { code: 'timeout', message: err.message, last_state: err.session.state, details: { session_id: err.session.session_id } };
159
180
  if (err instanceof UsageError)
160
181
  return { code: 'usage', message: err.message };
161
182
  if (err instanceof CheckoutTimeoutError) {
@@ -180,7 +201,7 @@ export function describeError(err) {
180
201
  return { code: 'interrupted', message: err.message, ...(err.operationId ? { operation_id: err.operationId } : {}), ...(err.executionId ? { execution_id: err.executionId } : {}) };
181
202
  }
182
203
  if (err instanceof ShardfluxProtocolError)
183
- return { code: 'protocol_error', message: err.message, status: err.status };
204
+ return { code: 'protocol_error', message: err.message, status: err.status, source: err.source };
184
205
  if (isNamed(err, 'TimeoutError'))
185
206
  return { code: 'timeout', message: 'The request timed out.' };
186
207
  if (isNamed(err, 'AbortError'))
@@ -205,9 +226,9 @@ export function exitCodeFor(err, interrupted = false) {
205
226
  return EXIT.notFound;
206
227
  if (err instanceof OperationTimeoutError || err instanceof CheckoutTimeoutError || isNamed(err, 'TimeoutError'))
207
228
  return EXIT.timeout;
208
- if (err instanceof OperationFailedError || err instanceof ExecutionFailedError || err instanceof CheckoutEndedError)
229
+ if (err instanceof OperationFailedError || err instanceof DurabilityLostError || err instanceof ExecutionFailedError || err instanceof CheckoutEndedError)
209
230
  return EXIT.operationFailed;
210
- if (err instanceof ExecutionPendingError)
231
+ if (err instanceof ExecutionPendingError || err instanceof ExecSessionRunningError)
211
232
  return EXIT.timeout;
212
233
  // A command that could not start exits 6 in both modes, as a failed execution does (0.5.1).
213
234
  if (err instanceof ExecStartError)
package/dist/format.d.ts CHANGED
@@ -1,10 +1,7 @@
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 type { AgentSession, ApiKey, AuditEvent, AuthSessionInfo, BillingCatalog, BillingInvoicePage, BillingSubscription, DataExport, DraftState, ExecutionChange, ExecutionResult, FileInfo, EgressPolicyVersion, Invitation, Member, Organization, Project, ProjectEgressPolicy, Volume, VolumeAttachment, WorkspaceEgressPolicy, FileList, FileSearchResponse, Operation, Secret, SecretAccessEvent, SecretVersion, SpendPolicy, SuspendRequest, TemplateBuild, TemplateDetail, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateLanguages, TemplatePackagePage, TemplateSettings, TemplateSummary, WorkspaceChange, WorkspaceSecretBindings, WorkspaceView } from '@shardflux/sdk';
1
+ import type { AgentSession, ApiKey, AuditEvent, AuthSessionInfo, BillingCatalog, BillingInvoicePage, BillingSubscription, DataExport, DraftState, ExecutionChange, ExecutionResult, FileInfo, EgressPolicyVersion, Invitation, Member, Organization, Project, ProjectEgressPolicy, Volume, VolumeAttachment, WorkspaceEgressPolicy, FileList, FileSearchResponse, Operation, Secret, SecretAccessEvent, SecretVersion, SpendPolicy, SuspendRequest, TemplateBuild, TemplateDetail, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateLanguages, TemplatePackagePage, TemplateSettings, TemplateSummary, TemplateVersion, WorkspaceChange, WorkspaceSecretBindings, WorkspaceMemory, WorkspaceView, MemoryGrow, BurstSummary } from '@shardflux/sdk';
7
2
  import type { ErrorInfo } from './errors.js';
3
+ /** A size for people: `512 B`, `10.0 KiB`, `128.0 MiB`, `1.50 GiB`. */
4
+ export declare function byteSize(n: number): string;
8
5
  export declare function json(value: unknown): string;
9
6
  export declare function table(headers: readonly string[], rows: readonly (readonly string[])[]): string;
10
7
  export declare function kv(pairs: readonly (readonly [string, string])[]): string;
@@ -38,9 +35,20 @@ export declare function suspendWhenIdleText(w: {
38
35
  suspend_request: SuspendRequest | null;
39
36
  operation: Operation | null;
40
37
  }): string;
38
+ /** `fixed 4096 MiB`, or `elastic: promised 8192 MiB, held 1024 MiB, plugged +2048 MiB` (plugged: above the held floor now). */
39
+ export declare function memoryText(m: WorkspaceMemory): string;
40
+ /** The grow an exec's start waited for: `delivered 1024 -> 4096 MiB in 175 ms`, `partial 1024 -> 3072 of 4096 MiB in 175 ms`. */
41
+ /** The burst line of `ws exec -v` (0.8.0). */
42
+ export declare function burstText(b: BurstSummary): string;
43
+ export declare function memoryGrowText(g: MemoryGrow): string;
41
44
  export declare function workspaceDetail(w: WorkspaceView): string;
42
45
  export declare const OPERATION_HEADERS: readonly ["ID", "KIND", "STATE", "REASON", "CREATED", "COMPLETED"];
43
46
  export declare function operationTable(ops: readonly Operation[]): string;
47
+ /**
48
+ * A finished suspend or fork (0.7.0+): whether its copy is in durable storage, when the result says (`durable`,
49
+ * `durability`). Empty when the result has no durability block (stored durably before the operation completed).
50
+ */
51
+ export declare function durableText(op: Operation): string;
44
52
  export declare function operationDetail(op: Operation): string;
45
53
  export declare function sessionTable(sessions: readonly AgentSession[]): string;
46
54
  /** `files stat`: one path's metadata; the revision only when requested and returned. */
@@ -90,6 +98,8 @@ export declare function languageTable(l: TemplateLanguages): string;
90
98
  export declare function packageTable(page: TemplatePackagePage): string;
91
99
  /** A template: identity, what open resolves to, its versions, and the open version's settings. */
92
100
  export declare function templateDetailText(t: TemplateDetail): string;
101
+ /** A version's immutable paths and the size of their image: `/opt/acme, /srv/tools (128.0 MiB)`, or `-`. */
102
+ export declare function immutablePathsText(i: TemplateVersion['immutable'] | undefined): string;
93
103
  /** A version's settings, one line per part. */
94
104
  export declare function settingsText(s: TemplateSettings): string;
95
105
  /** How many changed paths the human output lists before "... N more" (--json lists every one the result has). */