@skrr-ai/cli 0.1.24 → 0.1.26
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.d.ts +4 -4
- package/dist/base-command.js +11 -10
- 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 +75 -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/destroy.d.ts +6 -2
- package/dist/commands/machines/dedicated/destroy.js +41 -24
- package/dist/commands/machines/dedicated/exec.d.ts +28 -0
- package/dist/commands/machines/dedicated/exec.js +105 -0
- package/dist/commands/machines/dedicated/grow.d.ts +6 -2
- package/dist/commands/machines/dedicated/grow.js +14 -19
- package/dist/commands/machines/dedicated/health-check.d.ts +2 -2
- package/dist/commands/machines/dedicated/health-check.js +16 -14
- package/dist/commands/machines/dedicated/incident-close.d.ts +3 -2
- package/dist/commands/machines/dedicated/incident-close.js +11 -14
- package/dist/commands/machines/dedicated/index.js +37 -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 +8 -19
- package/dist/commands/machines/dedicated/show.d.ts +2 -2
- package/dist/commands/machines/dedicated/show.js +28 -30
- 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 +11 -19
- package/dist/commands/machines/dedicated/spending-limit.d.ts +22 -0
- package/dist/commands/machines/dedicated/spending-limit.js +50 -0
- package/dist/commands/machines/dedicated/start.d.ts +5 -2
- package/dist/commands/machines/dedicated/start.js +8 -19
- package/dist/commands/machines/dedicated/stop.d.ts +5 -2
- package/dist/commands/machines/dedicated/stop.js +8 -19
- package/dist/commands/machines/dedicated/terminal.d.ts +22 -0
- package/dist/commands/machines/dedicated/terminal.js +42 -0
- package/dist/commands/spaces/auto-executor/preview.d.ts +8 -0
- package/dist/commands/spaces/auto-executor/preview.js +41 -0
- package/dist/commands/spaces/task-rollups.d.ts +14 -0
- package/dist/commands/spaces/task-rollups.js +66 -0
- package/dist/commands/spaces/work-sync/telemetry.js +22 -0
- package/dist/commands/tasks/assign.js +1 -1
- package/dist/commands/tasks/create.js +10 -4
- package/dist/commands/tasks/list.js +27 -0
- package/dist/lib/api-fetch.js +9 -1
- package/dist/lib/assignee-resolver.d.ts +7 -6
- package/dist/lib/daemonBroker.d.ts +28 -0
- package/dist/lib/daemonBroker.js +60 -19
- package/dist/lib/dedicated-copy.d.ts +22 -0
- package/dist/lib/dedicated-copy.js +115 -0
- package/dist/lib/dedicated-lease-command.d.ts +50 -0
- package/dist/lib/dedicated-lease-command.js +158 -0
- package/dist/lib/dedicated-machines.d.ts +248 -12
- package/dist/lib/dedicated-machines.js +385 -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/harnesses.d.ts +6 -0
- package/dist/lib/idempotency-scope.d.ts +10 -0
- package/dist/lib/idempotency-scope.js +16 -0
- package/dist/lib/node-adapter.d.ts +1 -1
- package/dist/lib/node-adapter.js +13 -11
- package/dist/lib/sky-code.d.ts +30 -1
- package/dist/lib/sky-code.js +44 -2
- package/dist/lib/triggers.js +7 -0
- package/dist/lib/work-sync.d.ts +35 -0
- 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/kek/linux.js +71 -21
- 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/kek/linux.js +71 -21
- 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 +2657 -2596
- package/oclif.manifest.json +5075 -4294
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/lib/harnesses.d.ts
CHANGED
|
@@ -50,6 +50,12 @@ export interface Harness {
|
|
|
50
50
|
version?: number;
|
|
51
51
|
claudeEngine?: 'cli' | 'sdk';
|
|
52
52
|
lastSeenAt?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Registration metadata. A managed machine's rows carry `machineProduct` and
|
|
55
|
+
* `machineLeaseId`, which is the only link from a lease to the harness rows its
|
|
56
|
+
* daemon registered — the lease payload names no daemon.
|
|
57
|
+
*/
|
|
58
|
+
metadata?: Record<string, unknown>;
|
|
53
59
|
}
|
|
54
60
|
/**
|
|
55
61
|
* One open lease, as `/api/machines/leases` actually returns it.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A process running one autonomous ACTION binds every write to it, so the same
|
|
3
|
+
* action re-run in a new process converges on the server instead of writing
|
|
4
|
+
* twice. The rule itself lives in `@skrr-ai/data-provider` (`idempotency.ts`),
|
|
5
|
+
* shared with the web client; the CLI only says which action it is.
|
|
6
|
+
*
|
|
7
|
+
* Bound by both transports — the `dataService` adapter and `apiFetch` — because
|
|
8
|
+
* either may be the first to write.
|
|
9
|
+
*/
|
|
10
|
+
export declare function bindAutonomousIdempotencyScope(env?: NodeJS.ProcessEnv): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.bindAutonomousIdempotencyScope = bindAutonomousIdempotencyScope;
|
|
4
|
+
const data_provider_1 = require("@skrr-ai/data-provider");
|
|
5
|
+
/**
|
|
6
|
+
* A process running one autonomous ACTION binds every write to it, so the same
|
|
7
|
+
* action re-run in a new process converges on the server instead of writing
|
|
8
|
+
* twice. The rule itself lives in `@skrr-ai/data-provider` (`idempotency.ts`),
|
|
9
|
+
* shared with the web client; the CLI only says which action it is.
|
|
10
|
+
*
|
|
11
|
+
* Bound by both transports — the `dataService` adapter and `apiFetch` — because
|
|
12
|
+
* either may be the first to write.
|
|
13
|
+
*/
|
|
14
|
+
function bindAutonomousIdempotencyScope(env = process.env) {
|
|
15
|
+
(0, data_provider_1.setIdempotencyScope)(env.OVERSKY_AUTONOMOUS_ACTION_KEY?.trim() || null);
|
|
16
|
+
}
|
package/dist/lib/node-adapter.js
CHANGED
|
@@ -5,7 +5,8 @@ exports.clearAdapterCredentialOverride = clearAdapterCredentialOverride;
|
|
|
5
5
|
exports.getAdapterCredentialOverride = getAdapterCredentialOverride;
|
|
6
6
|
exports.createNodeAdapter = createNodeAdapter;
|
|
7
7
|
exports.__recomputeCredentialForTest = __recomputeCredentialForTest;
|
|
8
|
-
const
|
|
8
|
+
const data_provider_1 = require("@skrr-ai/data-provider");
|
|
9
|
+
const idempotency_scope_1 = require("./idempotency-scope");
|
|
9
10
|
const auth_core_1 = require("@skrr-ai/auth-core");
|
|
10
11
|
const config_1 = require("./config");
|
|
11
12
|
const auth_storage_1 = require("./auth-storage");
|
|
@@ -72,6 +73,7 @@ function currentCredential() {
|
|
|
72
73
|
}
|
|
73
74
|
function createNodeAdapter(opts = {}) {
|
|
74
75
|
const { onTokenRefresh, onAuthFailure } = opts;
|
|
76
|
+
(0, idempotency_scope_1.bindAutonomousIdempotencyScope)();
|
|
75
77
|
// In-process single-flight: collapses N parallel 401s into one refresh
|
|
76
78
|
// round-trip. Cross-process serialization happens inside refresh.ts.
|
|
77
79
|
let refreshInFlight = null;
|
|
@@ -157,15 +159,6 @@ function createNodeAdapter(opts = {}) {
|
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
const finalUrl = appendQuery(resolveUrl(url), options?.params);
|
|
160
|
-
const actionKey = process.env.OVERSKY_AUTONOMOUS_ACTION_KEY?.trim();
|
|
161
|
-
const hasIdempotencyKey = Object.keys(headers).some((key) => key.toLowerCase() === 'x-idempotency-key');
|
|
162
|
-
if (actionKey && method !== 'GET' && !isFormData && !hasIdempotencyKey) {
|
|
163
|
-
const requestUrl = new URL(finalUrl);
|
|
164
|
-
const scope = `${actionKey}\u001f${method}\u001f${requestUrl.pathname}${requestUrl.search}\u001f${typeof finalBody === 'string' ? finalBody : ''}`;
|
|
165
|
-
headers['X-Idempotency-Key'] = `autonomous:${(0, node_crypto_1.createHash)('sha256')
|
|
166
|
-
.update(scope)
|
|
167
|
-
.digest('hex')}`;
|
|
168
|
-
}
|
|
169
162
|
return fetch(finalUrl, { method, headers, body: finalBody });
|
|
170
163
|
}
|
|
171
164
|
/**
|
|
@@ -284,13 +277,22 @@ function createNodeAdapter(opts = {}) {
|
|
|
284
277
|
const accessToken = outcome.outcome.accessToken;
|
|
285
278
|
setAdapterCredentialOverride({
|
|
286
279
|
token: accessToken,
|
|
287
|
-
source: 'keychain',
|
|
280
|
+
source: outcome.outcome.storedIn === 'keychain' ? 'keychain' : 'file',
|
|
288
281
|
kind: (0, auth_core_1.classifyTokenKind)(accessToken),
|
|
289
282
|
});
|
|
290
283
|
onTokenRefresh?.(accessToken, outcome.outcome.refreshToken);
|
|
291
284
|
return true;
|
|
292
285
|
}
|
|
293
286
|
async function request(method, url, body, options, isFormData = false) {
|
|
287
|
+
// Writes that arrive through `request.ts` already carry a key. A direct
|
|
288
|
+
// adapter call gets one by the same shared rule, chosen HERE, once, so the
|
|
289
|
+
// 401-refresh retries below resend the same key rather than a new one.
|
|
290
|
+
if (method !== 'GET' && !isFormData && !(0, data_provider_1.hasIdempotencyKey)(options?.headers)) {
|
|
291
|
+
options = {
|
|
292
|
+
...(options ?? {}),
|
|
293
|
+
headers: (0, data_provider_1.withIdempotencyKey)(options?.headers, { method, url, body }),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
294
296
|
let res = await rawRequest(method, url, body, options, isFormData);
|
|
295
297
|
// Never retry auth endpoints themselves — prevents refresh recursion
|
|
296
298
|
// and lets login/logout/refresh surface their real errors.
|