@skrr-ai/cli 0.1.25 → 0.1.27

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.
Files changed (80) hide show
  1. package/dist/base-command.js +29 -5
  2. package/dist/commands/code/install.js +13 -0
  3. package/dist/commands/harnesses/leases/billing.d.ts +9 -6
  4. package/dist/commands/harnesses/leases/billing.js +19 -24
  5. package/dist/commands/machines/dedicated/attach.d.ts +28 -0
  6. package/dist/commands/machines/dedicated/attach.js +76 -0
  7. package/dist/commands/machines/dedicated/catalog.d.ts +17 -0
  8. package/dist/commands/machines/dedicated/catalog.js +75 -0
  9. package/dist/commands/machines/dedicated/cp.d.ts +22 -0
  10. package/dist/commands/machines/dedicated/cp.js +76 -0
  11. package/dist/commands/machines/dedicated/create.d.ts +6 -2
  12. package/dist/commands/machines/dedicated/create.js +29 -20
  13. package/dist/commands/machines/dedicated/current.d.ts +19 -0
  14. package/dist/commands/machines/dedicated/current.js +49 -0
  15. package/dist/commands/machines/dedicated/destroy.d.ts +6 -2
  16. package/dist/commands/machines/dedicated/destroy.js +43 -25
  17. package/dist/commands/machines/dedicated/exec.d.ts +32 -0
  18. package/dist/commands/machines/dedicated/exec.js +122 -0
  19. package/dist/commands/machines/dedicated/grow.d.ts +6 -2
  20. package/dist/commands/machines/dedicated/grow.js +16 -20
  21. package/dist/commands/machines/dedicated/health-check.d.ts +2 -2
  22. package/dist/commands/machines/dedicated/health-check.js +18 -15
  23. package/dist/commands/machines/dedicated/incident-close.d.ts +3 -2
  24. package/dist/commands/machines/dedicated/incident-close.js +13 -15
  25. package/dist/commands/machines/dedicated/index.js +39 -16
  26. package/dist/commands/machines/dedicated/list.d.ts +2 -2
  27. package/dist/commands/machines/dedicated/list.js +28 -17
  28. package/dist/commands/machines/dedicated/restart.d.ts +5 -2
  29. package/dist/commands/machines/dedicated/restart.js +10 -20
  30. package/dist/commands/machines/dedicated/restore.d.ts +26 -0
  31. package/dist/commands/machines/dedicated/restore.js +64 -0
  32. package/dist/commands/machines/dedicated/show.d.ts +2 -2
  33. package/dist/commands/machines/dedicated/show.js +30 -31
  34. package/dist/commands/machines/dedicated/sign-in.d.ts +19 -0
  35. package/dist/commands/machines/dedicated/sign-in.js +49 -0
  36. package/dist/commands/machines/dedicated/snapshot.d.ts +5 -2
  37. package/dist/commands/machines/dedicated/snapshot.js +13 -20
  38. package/dist/commands/machines/dedicated/spending-limit.d.ts +22 -0
  39. package/dist/commands/machines/dedicated/spending-limit.js +51 -0
  40. package/dist/commands/machines/dedicated/start.d.ts +5 -2
  41. package/dist/commands/machines/dedicated/start.js +10 -20
  42. package/dist/commands/machines/dedicated/stop.d.ts +5 -2
  43. package/dist/commands/machines/dedicated/stop.js +10 -20
  44. package/dist/commands/machines/dedicated/terminal.d.ts +22 -0
  45. package/dist/commands/machines/dedicated/terminal.js +42 -0
  46. package/dist/commands/tasks/list.js +27 -0
  47. package/dist/lib/daemonBroker.d.ts +8 -0
  48. package/dist/lib/daemonBroker.js +2 -2
  49. package/dist/lib/dedicated-copy.d.ts +22 -0
  50. package/dist/lib/dedicated-copy.js +115 -0
  51. package/dist/lib/dedicated-exec.d.ts +80 -0
  52. package/dist/lib/dedicated-exec.js +271 -0
  53. package/dist/lib/dedicated-lease-command.d.ts +59 -0
  54. package/dist/lib/dedicated-lease-command.js +181 -0
  55. package/dist/lib/dedicated-machines.d.ts +337 -12
  56. package/dist/lib/dedicated-machines.js +502 -25
  57. package/dist/lib/dedicated-terminal.d.ts +89 -0
  58. package/dist/lib/dedicated-terminal.js +275 -0
  59. package/dist/lib/dedicated-wait.d.ts +72 -0
  60. package/dist/lib/dedicated-wait.js +195 -0
  61. package/dist/lib/edge-error-page.d.ts +49 -0
  62. package/dist/lib/edge-error-page.js +120 -0
  63. package/dist/lib/harnesses.d.ts +15 -1
  64. package/dist/lib/harnesses.js +20 -2
  65. package/dist/lib/hosted-machines.js +7 -0
  66. package/dist/lib/node-adapter.js +1 -1
  67. package/dist/lib/sky-code.d.ts +30 -1
  68. package/dist/lib/sky-code.js +44 -2
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +2 -1
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.d.ts +10 -0
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.js +17 -1
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.d.ts +10 -0
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.js +16 -0
  77. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  78. package/dist/node_modules/@skrr-ai/data-provider/index.js +2625 -2608
  79. package/oclif.manifest.json +27851 -26936
  80. package/package.json +1 -1
@@ -0,0 +1,89 @@
1
+ import { io, type Socket } from 'socket.io-client';
2
+ /**
3
+ * A real pseudo-terminal on a Dedicated Runtime, from a local terminal.
4
+ *
5
+ * This is the same lane the web lease card's "Open terminal" drives — the
6
+ * human terminal vocabulary (`HARNESS_TERMINAL_EVENTS`) relayed by `EngineTerminal/handlers.js` to the
7
+ * daemon's `TerminalSessionManager`, which runs an interactive login bash as
8
+ * `skrr-workload` through the PTY relay. Nothing here is a second way in: the
9
+ * relay admits a Dedicated Runtime terminal only when agent dispatch to that
10
+ * lease would be admitted (current generation, running, healthy volume).
11
+ *
12
+ * It is deliberately NOT a loop over `exec`. `exec` is one command with no TTY;
13
+ * a shell built from it would look like a shell and behave like none — no job
14
+ * control, no prompts, no state between lines. This is the honest version.
15
+ */
16
+ /** Ctrl-] — telnet's escape. Ctrl-C and Ctrl-D belong to the remote shell. */
17
+ export declare const TERMINAL_DETACH_BYTE = 29;
18
+ /** How long the machine has to answer a start before the CLI gives up. */
19
+ export declare const TERMINAL_START_TIMEOUT_MS = 30000;
20
+ /**
21
+ * When to type an initial command: once the shell has printed something and
22
+ * then gone quiet, or after a fallback — the same heuristic the web terminal
23
+ * uses for its Sign in menu, because both are waiting on the same login bash.
24
+ */
25
+ export declare const TERMINAL_READY_QUIET_MS = 300;
26
+ export declare const TERMINAL_READY_FALLBACK_MS = 2500;
27
+ export interface TerminalOutputFrame {
28
+ sessionId?: string;
29
+ daemonId?: string;
30
+ type?: 'stdout' | 'stderr' | 'exit' | 'error' | 'info';
31
+ data?: string;
32
+ dataBase64?: string;
33
+ encoding?: 'utf8' | 'base64';
34
+ exitCode?: number | null;
35
+ signal?: string | null;
36
+ }
37
+ export interface TerminalStartAck {
38
+ sessionId?: string;
39
+ daemonId?: string;
40
+ ok?: boolean;
41
+ error?: string;
42
+ code?: string;
43
+ }
44
+ /** Bytes of one output frame, without splitting a UTF-8 or ANSI sequence. */
45
+ export declare function decodeTerminalFrame(frame: TerminalOutputFrame): Buffer;
46
+ /**
47
+ * Types `command` once, when the shell looks ready.
48
+ *
49
+ * Returned as an object with injectable timers so the readiness rule is tested
50
+ * without a PTY. `onOutput` re-arms the quiet timer; the fallback fires
51
+ * regardless, because a shell with a silent prompt must not wait forever.
52
+ */
53
+ export declare function createInitialCommandTyper(options: {
54
+ command: string;
55
+ send: (data: string) => void;
56
+ quietMs?: number;
57
+ fallbackMs?: number;
58
+ setTimer?: (fn: () => void, ms: number) => unknown;
59
+ clearTimer?: (handle: unknown) => void;
60
+ }): {
61
+ start(): void;
62
+ onOutput(): void;
63
+ cancel(): void;
64
+ readonly typed: boolean;
65
+ };
66
+ export declare class DedicatedTerminalError extends Error {
67
+ readonly code: string;
68
+ constructor(message: string, code: string);
69
+ }
70
+ /** A start refusal in words that say what to do, keyed by the relay's codes. */
71
+ export declare function describeTerminalStartRefusal(ack: TerminalStartAck): string;
72
+ export interface DedicatedTerminalOptions {
73
+ baseURL: string;
74
+ token: string;
75
+ runtimeId: string;
76
+ daemonId: string;
77
+ cwd?: string;
78
+ initialCommand?: string;
79
+ input: NodeJS.ReadStream;
80
+ output: NodeJS.WriteStream;
81
+ errorOutput: NodeJS.WritableStream;
82
+ /** Injected for tests. */
83
+ connect?: (url: string, opts: Parameters<typeof io>[1]) => Socket;
84
+ }
85
+ /**
86
+ * Run one interactive session to completion. Resolves with the exit code the
87
+ * CLI should exit with: the shell's own, 0 for a detach, 1 for a lost session.
88
+ */
89
+ export declare function runDedicatedTerminal(options: DedicatedTerminalOptions): Promise<number>;
@@ -0,0 +1,275 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DedicatedTerminalError = exports.TERMINAL_READY_FALLBACK_MS = exports.TERMINAL_READY_QUIET_MS = exports.TERMINAL_START_TIMEOUT_MS = exports.TERMINAL_DETACH_BYTE = void 0;
4
+ exports.decodeTerminalFrame = decodeTerminalFrame;
5
+ exports.createInitialCommandTyper = createInitialCommandTyper;
6
+ exports.describeTerminalStartRefusal = describeTerminalStartRefusal;
7
+ exports.runDedicatedTerminal = runDedicatedTerminal;
8
+ const node_crypto_1 = require("node:crypto");
9
+ const socket_io_client_1 = require("socket.io-client");
10
+ const data_provider_1 = require("@skrr-ai/data-provider");
11
+ const agentic_stream_1 = require("./agentic-stream");
12
+ /**
13
+ * A real pseudo-terminal on a Dedicated Runtime, from a local terminal.
14
+ *
15
+ * This is the same lane the web lease card's "Open terminal" drives — the
16
+ * human terminal vocabulary (`HARNESS_TERMINAL_EVENTS`) relayed by `EngineTerminal/handlers.js` to the
17
+ * daemon's `TerminalSessionManager`, which runs an interactive login bash as
18
+ * `skrr-workload` through the PTY relay. Nothing here is a second way in: the
19
+ * relay admits a Dedicated Runtime terminal only when agent dispatch to that
20
+ * lease would be admitted (current generation, running, healthy volume).
21
+ *
22
+ * It is deliberately NOT a loop over `exec`. `exec` is one command with no TTY;
23
+ * a shell built from it would look like a shell and behave like none — no job
24
+ * control, no prompts, no state between lines. This is the honest version.
25
+ */
26
+ /** Ctrl-] — telnet's escape. Ctrl-C and Ctrl-D belong to the remote shell. */
27
+ exports.TERMINAL_DETACH_BYTE = 0x1d;
28
+ /** How long the machine has to answer a start before the CLI gives up. */
29
+ exports.TERMINAL_START_TIMEOUT_MS = 30_000;
30
+ /**
31
+ * When to type an initial command: once the shell has printed something and
32
+ * then gone quiet, or after a fallback — the same heuristic the web terminal
33
+ * uses for its Sign in menu, because both are waiting on the same login bash.
34
+ */
35
+ exports.TERMINAL_READY_QUIET_MS = 300;
36
+ exports.TERMINAL_READY_FALLBACK_MS = 2_500;
37
+ /** Bytes of one output frame, without splitting a UTF-8 or ANSI sequence. */
38
+ function decodeTerminalFrame(frame) {
39
+ const encoded = frame.dataBase64 ?? (frame.encoding === 'base64' ? frame.data : undefined);
40
+ if (typeof encoded === 'string') {
41
+ return Buffer.from(encoded, 'base64');
42
+ }
43
+ return Buffer.from(typeof frame.data === 'string' ? frame.data : '', 'utf8');
44
+ }
45
+ /**
46
+ * Types `command` once, when the shell looks ready.
47
+ *
48
+ * Returned as an object with injectable timers so the readiness rule is tested
49
+ * without a PTY. `onOutput` re-arms the quiet timer; the fallback fires
50
+ * regardless, because a shell with a silent prompt must not wait forever.
51
+ */
52
+ function createInitialCommandTyper(options) {
53
+ const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
54
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
55
+ const quietMs = options.quietMs ?? exports.TERMINAL_READY_QUIET_MS;
56
+ const fallbackMs = options.fallbackMs ?? exports.TERMINAL_READY_FALLBACK_MS;
57
+ let typed = false;
58
+ let quietTimer = null;
59
+ let fallbackTimer = null;
60
+ const type = () => {
61
+ if (typed)
62
+ return;
63
+ typed = true;
64
+ if (quietTimer)
65
+ clearTimer(quietTimer);
66
+ if (fallbackTimer)
67
+ clearTimer(fallbackTimer);
68
+ options.send(`${options.command}\r`);
69
+ };
70
+ return {
71
+ start() {
72
+ if (typed || fallbackTimer)
73
+ return;
74
+ fallbackTimer = setTimer(type, fallbackMs);
75
+ },
76
+ onOutput() {
77
+ if (typed)
78
+ return;
79
+ if (quietTimer)
80
+ clearTimer(quietTimer);
81
+ quietTimer = setTimer(type, quietMs);
82
+ },
83
+ cancel() {
84
+ typed = true;
85
+ if (quietTimer)
86
+ clearTimer(quietTimer);
87
+ if (fallbackTimer)
88
+ clearTimer(fallbackTimer);
89
+ },
90
+ get typed() {
91
+ return typed;
92
+ },
93
+ };
94
+ }
95
+ class DedicatedTerminalError extends Error {
96
+ code;
97
+ constructor(message, code) {
98
+ super(message);
99
+ this.name = 'DedicatedTerminalError';
100
+ this.code = code;
101
+ }
102
+ }
103
+ exports.DedicatedTerminalError = DedicatedTerminalError;
104
+ /** A start refusal in words that say what to do, keyed by the relay's codes. */
105
+ function describeTerminalStartRefusal(ack) {
106
+ const code = ack.code || '';
107
+ if (code === 'DEDICATED_RUNTIME_NOT_ASSIGNABLE') {
108
+ return ('The machine is not ready to take a terminal (it must be running with a healthy workspace). ' +
109
+ 'Check it with `skrr machines dedicated show <lease-id>`.');
110
+ }
111
+ if (code === 'DEDICATED_RUNTIME_GENERATION_STALE') {
112
+ return 'The machine was replaced since its daemon registered. Wait a minute and try again.';
113
+ }
114
+ if (code === 'not_found' || code === 'offline') {
115
+ return 'The machine’s daemon is not connected. Wait for it to come online and try again.';
116
+ }
117
+ return ack.error ? `The terminal did not start: ${ack.error}` : 'The terminal did not start.';
118
+ }
119
+ /**
120
+ * Run one interactive session to completion. Resolves with the exit code the
121
+ * CLI should exit with: the shell's own, 0 for a detach, 1 for a lost session.
122
+ */
123
+ function runDedicatedTerminal(options) {
124
+ const events = data_provider_1.HARNESS_TERMINAL_EVENTS;
125
+ const sessionId = (0, node_crypto_1.randomUUID)();
126
+ const connect = options.connect ?? socket_io_client_1.io;
127
+ const { input, output, errorOutput } = options;
128
+ return new Promise((resolve, reject) => {
129
+ let started = false;
130
+ let finished = false;
131
+ let rawMode = false;
132
+ const socket = connect((0, agentic_stream_1.toWebSocketOrigin)(options.baseURL), {
133
+ path: '/ws/agentic',
134
+ auth: {
135
+ token: options.token,
136
+ ...(0, data_provider_1.getClientIdentityAuth)(),
137
+ clientPlatform: 'cli',
138
+ clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
139
+ },
140
+ transports: ['websocket'],
141
+ // A reconnect cannot resume a PTY: the relay forgets the session with the
142
+ // socket, and silently opening a NEW shell would drop the user into a
143
+ // different process tree mid-command. Say it ended instead.
144
+ reconnection: false,
145
+ });
146
+ const typer = options.initialCommand
147
+ ? createInitialCommandTyper({
148
+ command: options.initialCommand,
149
+ send: (data) => socket.emit(events.input, { sessionId, data }),
150
+ })
151
+ : null;
152
+ const size = () => ({
153
+ cols: Math.max(2, Math.min(1000, output.columns || 80)),
154
+ rows: Math.max(1, Math.min(1000, output.rows || 24)),
155
+ });
156
+ const onResize = () => {
157
+ if (started)
158
+ socket.emit(events.resize, { sessionId, ...size() });
159
+ };
160
+ const onInput = (chunk) => {
161
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
162
+ const detachAt = buffer.indexOf(exports.TERMINAL_DETACH_BYTE);
163
+ if (detachAt >= 0) {
164
+ if (detachAt > 0) {
165
+ socket.emit(events.input, {
166
+ sessionId,
167
+ data: buffer.subarray(0, detachAt).toString('utf8'),
168
+ });
169
+ }
170
+ errorOutput.write('\r\n[closed the terminal]\r\n');
171
+ finish(0, true);
172
+ return;
173
+ }
174
+ socket.emit(events.input, { sessionId, data: buffer.toString('utf8') });
175
+ };
176
+ const onSignal = () => finish(130, true);
177
+ const startTimer = setTimeout(() => {
178
+ fail(new DedicatedTerminalError('The machine did not answer the terminal start in time. Its daemon may be busy or offline.', 'TERMINAL_START_TIMEOUT'));
179
+ }, exports.TERMINAL_START_TIMEOUT_MS);
180
+ function restoreTty() {
181
+ input.off('data', onInput);
182
+ output.off('resize', onResize);
183
+ process.off('SIGTERM', onSignal);
184
+ process.off('SIGHUP', onSignal);
185
+ if (rawMode && typeof input.setRawMode === 'function') {
186
+ input.setRawMode(false);
187
+ rawMode = false;
188
+ }
189
+ input.pause();
190
+ }
191
+ function finish(code, stopRemote) {
192
+ if (finished)
193
+ return;
194
+ finished = true;
195
+ clearTimeout(startTimer);
196
+ typer?.cancel();
197
+ restoreTty();
198
+ if (stopRemote && started)
199
+ socket.emit(events.stop, { sessionId });
200
+ socket.disconnect();
201
+ resolve(code);
202
+ }
203
+ function fail(error) {
204
+ if (finished)
205
+ return;
206
+ finished = true;
207
+ clearTimeout(startTimer);
208
+ typer?.cancel();
209
+ restoreTty();
210
+ socket.disconnect();
211
+ reject(error);
212
+ }
213
+ socket.on('connect_error', (err) => {
214
+ fail(new DedicatedTerminalError(`Could not connect to skrr: ${err.message}`, err.data?.code || 'TERMINAL_CONNECT_FAILED'));
215
+ });
216
+ socket.on('connect', () => {
217
+ socket.emit(events.start, {
218
+ runtimeId: options.runtimeId,
219
+ sessionId,
220
+ ...(options.cwd ? { cwd: options.cwd } : {}),
221
+ ...size(),
222
+ });
223
+ });
224
+ socket.on(events.startAck, (ack) => {
225
+ if (!ack || ack.sessionId !== sessionId || started || finished)
226
+ return;
227
+ if (ack.daemonId && ack.daemonId !== options.daemonId)
228
+ return;
229
+ if (!ack.ok) {
230
+ fail(new DedicatedTerminalError(describeTerminalStartRefusal(ack), ack.code || 'TERMINAL_START_REFUSED'));
231
+ return;
232
+ }
233
+ started = true;
234
+ clearTimeout(startTimer);
235
+ if (typeof input.setRawMode === 'function') {
236
+ input.setRawMode(true);
237
+ rawMode = true;
238
+ }
239
+ input.resume();
240
+ input.on('data', onInput);
241
+ output.on('resize', onResize);
242
+ process.on('SIGTERM', onSignal);
243
+ process.on('SIGHUP', onSignal);
244
+ typer?.start();
245
+ });
246
+ socket.on(events.output, (frame) => {
247
+ if (!frame || frame.sessionId !== sessionId || finished)
248
+ return;
249
+ if (frame.daemonId && frame.daemonId !== options.daemonId)
250
+ return;
251
+ if (frame.type === 'exit') {
252
+ finish(typeof frame.exitCode === 'number' ? frame.exitCode : 0, false);
253
+ return;
254
+ }
255
+ if (frame.type === 'error' || frame.type === 'info') {
256
+ const text = decodeTerminalFrame(frame).toString('utf8');
257
+ if (text)
258
+ errorOutput.write(`\r\n[${frame.type}] ${text}\r\n`);
259
+ return;
260
+ }
261
+ output.write(decodeTerminalFrame(frame));
262
+ typer?.onOutput();
263
+ });
264
+ socket.on('disconnect', () => {
265
+ if (finished)
266
+ return;
267
+ if (!started) {
268
+ fail(new DedicatedTerminalError('The connection to skrr closed before the terminal started.', 'TERMINAL_DISCONNECTED'));
269
+ return;
270
+ }
271
+ errorOutput.write('\r\n[lost the connection to skrr; the shell on the machine has ended]\r\n');
272
+ finish(1, false);
273
+ });
274
+ });
275
+ }
@@ -0,0 +1,72 @@
1
+ import { type DedicatedLease, type DedicatedRuntimeScope } from './dedicated-machines';
2
+ /**
3
+ * What a lifecycle command is waiting FOR, which is not always a state.
4
+ *
5
+ * `start`, `stop` and `destroy` settle into a state. `snapshot` and `grow` do
6
+ * not: a snapshot returns the lease to whatever state it left, and a grow never
7
+ * leaves it at all. For those the evidence is a field — a newer `lastBackupAt`,
8
+ * a larger `storageGb` — and waiting on the state alone would report success
9
+ * the instant a snapshot that failed returned the lease to `ready`.
10
+ */
11
+ export type DedicatedWaitGoal = {
12
+ kind: 'running';
13
+ } | {
14
+ kind: 'stopped';
15
+ } | {
16
+ kind: 'destroyed';
17
+ } | {
18
+ kind: 'snapshot';
19
+ previousBackupAt: string | null;
20
+ } | {
21
+ kind: 'grown';
22
+ storageGb: number;
23
+ };
24
+ export type DedicatedWaitVerdict = {
25
+ status: 'done';
26
+ } | {
27
+ status: 'failed';
28
+ reason: string;
29
+ } | {
30
+ status: 'pending';
31
+ progress: string;
32
+ };
33
+ /**
34
+ * Has this lease reached the goal, failed to, or is it still on its way?
35
+ *
36
+ * Pure, so every branch is pinned without a server. "Failed" is reserved for a
37
+ * reading that cannot turn into the goal without someone acting again; anything
38
+ * that might still settle is `pending`, and the caller's timeout decides how long
39
+ * that is worth watching.
40
+ */
41
+ export declare function evaluateDedicatedWait(goal: DedicatedWaitGoal, lease: DedicatedLease | undefined | null): DedicatedWaitVerdict;
42
+ /**
43
+ * The CLI stopped watching. The MACHINE did not stop.
44
+ *
45
+ * Distinct from a failure on purpose: the operation is still running
46
+ * server-side, and the natural wrong move after "timed out" is to issue the
47
+ * action again — or create a second machine — while the first is still billing.
48
+ */
49
+ export declare class DedicatedWaitTimeoutError extends Error {
50
+ readonly leaseId: string;
51
+ readonly lastProgress: string;
52
+ constructor(leaseId: string, lastProgress: string);
53
+ }
54
+ export declare class DedicatedWaitFailedError extends Error {
55
+ readonly leaseId: string;
56
+ readonly lease: DedicatedLease | undefined;
57
+ constructor(leaseId: string, reason: string, lease?: DedicatedLease);
58
+ }
59
+ /** Exit code for "stopped watching, still in progress" — `timeout(1)` uses 124. */
60
+ export declare const DEDICATED_WAIT_TIMEOUT_EXIT = 124;
61
+ export declare const DEDICATED_WAIT_DEFAULT_TIMEOUT_MS: number;
62
+ export declare const DEDICATED_WAIT_DEFAULT_INTERVAL_MS: number;
63
+ export declare const DEDICATED_WAIT_MAX_READ_FAILURES = 3;
64
+ export declare function waitForDedicatedLease(leaseId: string, goal: DedicatedWaitGoal, options?: DedicatedRuntimeScope & {
65
+ initial?: DedicatedLease;
66
+ intervalMs?: number;
67
+ timeoutMs?: number;
68
+ onProgress?: (progress: string) => void;
69
+ fetchLease?: (id: string, scope: DedicatedRuntimeScope) => Promise<DedicatedLease | undefined>;
70
+ sleep?: (ms: number) => Promise<void>;
71
+ now?: () => number;
72
+ }): Promise<DedicatedLease>;
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEDICATED_WAIT_MAX_READ_FAILURES = exports.DEDICATED_WAIT_DEFAULT_INTERVAL_MS = exports.DEDICATED_WAIT_DEFAULT_TIMEOUT_MS = exports.DEDICATED_WAIT_TIMEOUT_EXIT = exports.DedicatedWaitFailedError = exports.DedicatedWaitTimeoutError = void 0;
4
+ exports.evaluateDedicatedWait = evaluateDedicatedWait;
5
+ exports.waitForDedicatedLease = waitForDedicatedLease;
6
+ const dedicated_machines_1 = require("./dedicated-machines");
7
+ const RUNNING_STATES = new Set(['ready', 'active', 'idle']);
8
+ /** Terminal for the lease itself, so no goal but `destroyed` can still be reached. */
9
+ const DEAD_STATES = new Set(['failed', 'lost', 'terminated']);
10
+ function backupIsNewer(current, previous) {
11
+ if (!current)
12
+ return false;
13
+ if (!previous)
14
+ return true;
15
+ const a = Date.parse(current);
16
+ const b = Date.parse(previous);
17
+ return Number.isFinite(a) && Number.isFinite(b) ? a > b : current !== previous;
18
+ }
19
+ function progressOf(lease) {
20
+ const state = (0, dedicated_machines_1.dedicatedLeaseState)(lease);
21
+ if (state === 'recovering') {
22
+ // `recovering` is where a failed provision or action lands, retried with
23
+ // backoff. It can settle — or never, which is why destroy is allowed from it.
24
+ return 'recovering (the platform is retrying; destroy is available if it never settles)';
25
+ }
26
+ return lease.stateReason ? `${state} (${lease.stateReason})` : state;
27
+ }
28
+ /**
29
+ * Has this lease reached the goal, failed to, or is it still on its way?
30
+ *
31
+ * Pure, so every branch is pinned without a server. "Failed" is reserved for a
32
+ * reading that cannot turn into the goal without someone acting again; anything
33
+ * that might still settle is `pending`, and the caller's timeout decides how long
34
+ * that is worth watching.
35
+ */
36
+ function evaluateDedicatedWait(goal, lease) {
37
+ if (!lease) {
38
+ return { status: 'failed', reason: 'the lease is no longer visible' };
39
+ }
40
+ const state = (0, dedicated_machines_1.dedicatedLeaseState)(lease);
41
+ switch (goal.kind) {
42
+ case 'running': {
43
+ if (RUNNING_STATES.has(state))
44
+ return { status: 'done' };
45
+ if (DEAD_STATES.has(state) || state === 'archived' || state === 'terminating') {
46
+ return { status: 'failed', reason: `the machine is ${state}` };
47
+ }
48
+ // Every path to `running` leaves `stopped` before the 202 comes back, so
49
+ // reading it again means the start or restart rolled back.
50
+ if (state === 'stopped') {
51
+ return { status: 'failed', reason: 'the machine went back to stopped' };
52
+ }
53
+ return { status: 'pending', progress: progressOf(lease) };
54
+ }
55
+ case 'stopped': {
56
+ if (state === 'stopped')
57
+ return { status: 'done' };
58
+ if (DEAD_STATES.has(state) || state === 'archived') {
59
+ return { status: 'failed', reason: `the machine is ${state}` };
60
+ }
61
+ if (RUNNING_STATES.has(state)) {
62
+ return {
63
+ status: 'failed',
64
+ reason: `the machine is ${state} again; the stop did not complete`,
65
+ };
66
+ }
67
+ return { status: 'pending', progress: progressOf(lease) };
68
+ }
69
+ case 'destroyed': {
70
+ if (state === 'terminated' || state === 'archived')
71
+ return { status: 'done' };
72
+ if (state === 'failed' || state === 'lost') {
73
+ return { status: 'failed', reason: `the machine is ${state}` };
74
+ }
75
+ return { status: 'pending', progress: progressOf(lease) };
76
+ }
77
+ case 'snapshot': {
78
+ if (backupIsNewer(lease.storage?.lastBackupAt, goal.previousBackupAt)) {
79
+ return { status: 'done' };
80
+ }
81
+ if (DEAD_STATES.has(state) || state === 'archived') {
82
+ return { status: 'failed', reason: `the machine is ${state}` };
83
+ }
84
+ if (state === 'snapshotting' || state === 'recovering') {
85
+ return { status: 'pending', progress: progressOf(lease) };
86
+ }
87
+ return {
88
+ status: 'failed',
89
+ reason: `the snapshot ended (${state}) without recording a new recovery point`,
90
+ };
91
+ }
92
+ case 'grown': {
93
+ const size = lease.resources?.storageGb;
94
+ if (typeof size === 'number' && size >= goal.storageGb)
95
+ return { status: 'done' };
96
+ if (DEAD_STATES.has(state) || state === 'archived') {
97
+ return { status: 'failed', reason: `the machine is ${state}` };
98
+ }
99
+ return {
100
+ status: 'pending',
101
+ progress: `${progressOf(lease)} · workspace ${size ?? '?'} → ${goal.storageGb} GiB`,
102
+ };
103
+ }
104
+ default:
105
+ return { status: 'failed', reason: 'unknown wait goal' };
106
+ }
107
+ }
108
+ /**
109
+ * The CLI stopped watching. The MACHINE did not stop.
110
+ *
111
+ * Distinct from a failure on purpose: the operation is still running
112
+ * server-side, and the natural wrong move after "timed out" is to issue the
113
+ * action again — or create a second machine — while the first is still billing.
114
+ */
115
+ class DedicatedWaitTimeoutError extends Error {
116
+ leaseId;
117
+ lastProgress;
118
+ constructor(leaseId, lastProgress) {
119
+ super(`Stopped waiting for ${leaseId}` +
120
+ (lastProgress ? ` — it is still ${lastProgress}.` : '.') +
121
+ ' Nothing was cancelled: the operation continues server-side. ' +
122
+ `Check it with \`skrr machines dedicated show ${leaseId}\`.`);
123
+ this.name = 'DedicatedWaitTimeoutError';
124
+ this.leaseId = leaseId;
125
+ this.lastProgress = lastProgress;
126
+ }
127
+ }
128
+ exports.DedicatedWaitTimeoutError = DedicatedWaitTimeoutError;
129
+ class DedicatedWaitFailedError extends Error {
130
+ leaseId;
131
+ lease;
132
+ constructor(leaseId, reason, lease) {
133
+ super(`${leaseId}: ${reason}.`);
134
+ this.name = 'DedicatedWaitFailedError';
135
+ this.leaseId = leaseId;
136
+ this.lease = lease;
137
+ }
138
+ }
139
+ exports.DedicatedWaitFailedError = DedicatedWaitFailedError;
140
+ /** Exit code for "stopped watching, still in progress" — `timeout(1)` uses 124. */
141
+ exports.DEDICATED_WAIT_TIMEOUT_EXIT = 124;
142
+ exports.DEDICATED_WAIT_DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
143
+ exports.DEDICATED_WAIT_DEFAULT_INTERVAL_MS = 5 * 1000;
144
+ exports.DEDICATED_WAIT_MAX_READ_FAILURES = 3;
145
+ async function waitForDedicatedLease(leaseId, goal, options = {}) {
146
+ const intervalMs = options.intervalMs ?? exports.DEDICATED_WAIT_DEFAULT_INTERVAL_MS;
147
+ const timeoutMs = options.timeoutMs ?? exports.DEDICATED_WAIT_DEFAULT_TIMEOUT_MS;
148
+ const now = options.now ?? Date.now;
149
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
150
+ const fetchLease = options.fetchLease ??
151
+ (async (id, scope) => (await (0, dedicated_machines_1.getDedicatedRuntime)(id, scope)).lease);
152
+ const scope = { workspaceId: options.workspaceId };
153
+ const startedAt = now();
154
+ let lease = options.initial;
155
+ let lastProgress = '';
156
+ let consecutiveReadFailures = 0;
157
+ // One failed read is a blip, not an outcome: the operation is server-side and
158
+ // does not care that a poll 502'd. Three in a row is a real answer.
159
+ const read = async () => {
160
+ while (true) {
161
+ try {
162
+ const next = await fetchLease(leaseId, scope);
163
+ consecutiveReadFailures = 0;
164
+ return next;
165
+ }
166
+ catch (err) {
167
+ consecutiveReadFailures += 1;
168
+ if (consecutiveReadFailures >= exports.DEDICATED_WAIT_MAX_READ_FAILURES)
169
+ throw err;
170
+ await sleep(intervalMs);
171
+ }
172
+ }
173
+ };
174
+ // The action's own response is already a reading; checking it first means a
175
+ // goal it has already reached (a re-run stop on a stopped machine) returns
176
+ // without one extra request and one interval.
177
+ if (!lease)
178
+ lease = await read();
179
+ while (true) {
180
+ const verdict = evaluateDedicatedWait(goal, lease);
181
+ if (verdict.status === 'done')
182
+ return lease;
183
+ if (verdict.status === 'failed')
184
+ throw new DedicatedWaitFailedError(leaseId, verdict.reason, lease);
185
+ if (verdict.progress !== lastProgress) {
186
+ lastProgress = verdict.progress;
187
+ options.onProgress?.(verdict.progress);
188
+ }
189
+ if (now() - startedAt >= timeoutMs) {
190
+ throw new DedicatedWaitTimeoutError(leaseId, lastProgress);
191
+ }
192
+ await sleep(intervalMs);
193
+ lease = await read();
194
+ }
195
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * edge-error-page.ts — say what an HTML error page from the EDGE means, instead
3
+ * of printing it.
4
+ *
5
+ * Every API route answers in JSON. An HTML page in an error response therefore
6
+ * did not come from the API: it is CloudFront, its web application firewall, or
7
+ * a load balancer answering in the API's place. Printed as-is it is a screen of
8
+ * markup whose one useful line ("Request blocked", a request id) is buried, and
9
+ * treated as an API answer it is misleading in a specific way — a 403 reads as
10
+ * "you lack permission", and CloudFront's 504 page tells the reader to "try again
11
+ * later", which for a write that is still running repeats it.
12
+ *
13
+ * Both happened to `skrr machines dedicated exec` (OSK-8700, OSK-8705): a shell
14
+ * command containing `http://10.0.0.10/` came back as CloudFront's raw
15
+ * "403 ERROR / Request blocked" page, and a command running past 60 seconds as
16
+ * its raw 504 page while it kept running on the machine.
17
+ *
18
+ * General on purpose: nothing here knows which command asked.
19
+ */
20
+ export type EdgeErrorCode = 'EDGE_REQUEST_BLOCKED' | 'EDGE_GATEWAY_TIMEOUT' | 'EDGE_ORIGIN_UNAVAILABLE' | 'EDGE_ERROR_PAGE';
21
+ export interface EdgeErrorPage {
22
+ status: number;
23
+ code: EdgeErrorCode;
24
+ /** The edge's own id for the request (CloudFront prints one), when the page carries it. */
25
+ requestId?: string;
26
+ message: string;
27
+ /** Whether sending the identical request again can help. */
28
+ retryable: boolean;
29
+ }
30
+ /**
31
+ * True when an error response body is an HTML page rather than an API answer.
32
+ *
33
+ * A JSON body is never a page, whatever its text says: an API refusal that
34
+ * quotes "The request could not be satisfied" or an HTML fragment in its message
35
+ * must keep its own words (the base command's handler already gates on "no JSON
36
+ * body"; this makes every caller agree). The CloudFront wording counts only
37
+ * inside markup, never on its own.
38
+ */
39
+ export declare function isHtmlErrorPage(body: string | null | undefined): boolean;
40
+ /**
41
+ * Describe an HTML error page, or return null when the body is not one (a JSON
42
+ * refusal from the API must keep its own words).
43
+ */
44
+ export declare function describeEdgeErrorPage(input: {
45
+ status: number | undefined;
46
+ body: string | null | undefined;
47
+ method?: string | null;
48
+ host?: string | null;
49
+ }): EdgeErrorPage | null;