@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.
- package/dist/base-command.js +29 -5
- package/dist/commands/code/install.js +13 -0
- package/dist/commands/harnesses/leases/billing.d.ts +9 -6
- package/dist/commands/harnesses/leases/billing.js +19 -24
- package/dist/commands/machines/dedicated/attach.d.ts +28 -0
- package/dist/commands/machines/dedicated/attach.js +76 -0
- package/dist/commands/machines/dedicated/catalog.d.ts +17 -0
- package/dist/commands/machines/dedicated/catalog.js +75 -0
- package/dist/commands/machines/dedicated/cp.d.ts +22 -0
- package/dist/commands/machines/dedicated/cp.js +76 -0
- package/dist/commands/machines/dedicated/create.d.ts +6 -2
- package/dist/commands/machines/dedicated/create.js +29 -20
- package/dist/commands/machines/dedicated/current.d.ts +19 -0
- package/dist/commands/machines/dedicated/current.js +49 -0
- package/dist/commands/machines/dedicated/destroy.d.ts +6 -2
- package/dist/commands/machines/dedicated/destroy.js +43 -25
- package/dist/commands/machines/dedicated/exec.d.ts +32 -0
- package/dist/commands/machines/dedicated/exec.js +122 -0
- package/dist/commands/machines/dedicated/grow.d.ts +6 -2
- package/dist/commands/machines/dedicated/grow.js +16 -20
- package/dist/commands/machines/dedicated/health-check.d.ts +2 -2
- package/dist/commands/machines/dedicated/health-check.js +18 -15
- package/dist/commands/machines/dedicated/incident-close.d.ts +3 -2
- package/dist/commands/machines/dedicated/incident-close.js +13 -15
- package/dist/commands/machines/dedicated/index.js +39 -16
- package/dist/commands/machines/dedicated/list.d.ts +2 -2
- package/dist/commands/machines/dedicated/list.js +28 -17
- package/dist/commands/machines/dedicated/restart.d.ts +5 -2
- package/dist/commands/machines/dedicated/restart.js +10 -20
- package/dist/commands/machines/dedicated/restore.d.ts +26 -0
- package/dist/commands/machines/dedicated/restore.js +64 -0
- package/dist/commands/machines/dedicated/show.d.ts +2 -2
- package/dist/commands/machines/dedicated/show.js +30 -31
- package/dist/commands/machines/dedicated/sign-in.d.ts +19 -0
- package/dist/commands/machines/dedicated/sign-in.js +49 -0
- package/dist/commands/machines/dedicated/snapshot.d.ts +5 -2
- package/dist/commands/machines/dedicated/snapshot.js +13 -20
- package/dist/commands/machines/dedicated/spending-limit.d.ts +22 -0
- package/dist/commands/machines/dedicated/spending-limit.js +51 -0
- package/dist/commands/machines/dedicated/start.d.ts +5 -2
- package/dist/commands/machines/dedicated/start.js +10 -20
- package/dist/commands/machines/dedicated/stop.d.ts +5 -2
- package/dist/commands/machines/dedicated/stop.js +10 -20
- package/dist/commands/machines/dedicated/terminal.d.ts +22 -0
- package/dist/commands/machines/dedicated/terminal.js +42 -0
- package/dist/commands/tasks/list.js +27 -0
- package/dist/lib/daemonBroker.d.ts +8 -0
- package/dist/lib/daemonBroker.js +2 -2
- package/dist/lib/dedicated-copy.d.ts +22 -0
- package/dist/lib/dedicated-copy.js +115 -0
- package/dist/lib/dedicated-exec.d.ts +80 -0
- package/dist/lib/dedicated-exec.js +271 -0
- package/dist/lib/dedicated-lease-command.d.ts +59 -0
- package/dist/lib/dedicated-lease-command.js +181 -0
- package/dist/lib/dedicated-machines.d.ts +337 -12
- package/dist/lib/dedicated-machines.js +502 -25
- package/dist/lib/dedicated-terminal.d.ts +89 -0
- package/dist/lib/dedicated-terminal.js +275 -0
- package/dist/lib/dedicated-wait.d.ts +72 -0
- package/dist/lib/dedicated-wait.js +195 -0
- package/dist/lib/edge-error-page.d.ts +49 -0
- package/dist/lib/edge-error-page.js +120 -0
- package/dist/lib/harnesses.d.ts +15 -1
- package/dist/lib/harnesses.js +20 -2
- package/dist/lib/hosted-machines.js +7 -0
- package/dist/lib/node-adapter.js +1 -1
- package/dist/lib/sky-code.d.ts +30 -1
- package/dist/lib/sky-code.js +44 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.d.ts +10 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.js +17 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.d.ts +10 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.js +16 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +2625 -2608
- package/oclif.manifest.json +27851 -26936
- 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;
|