@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/CHANGELOG.md +96 -0
- package/README.md +121 -10
- package/dist/args.d.ts +1 -1
- package/dist/args.js +76 -7
- package/dist/commands.js +284 -10
- package/dist/errors.d.ts +11 -2
- package/dist/errors.js +28 -7
- package/dist/format.d.ts +16 -6
- package/dist/format.js +93 -1
- package/dist/http.d.ts +1 -10
- package/dist/http.js +6 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/main.js +42 -6
- package/dist/template-yaml.js +3 -0
- package/dist/timing.d.ts +12 -0
- package/dist/timing.js +48 -1
- package/package.json +2 -2
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 ?
|
|
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 =
|
|
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([
|
|
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
|
-
|
|
858
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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). */
|