@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.
Files changed (87) hide show
  1. package/dist/base-command.d.ts +4 -4
  2. package/dist/base-command.js +11 -10
  3. package/dist/commands/code/install.js +13 -0
  4. package/dist/commands/harnesses/leases/billing.d.ts +9 -6
  5. package/dist/commands/harnesses/leases/billing.js +19 -24
  6. package/dist/commands/machines/dedicated/attach.d.ts +28 -0
  7. package/dist/commands/machines/dedicated/attach.js +75 -0
  8. package/dist/commands/machines/dedicated/catalog.d.ts +17 -0
  9. package/dist/commands/machines/dedicated/catalog.js +75 -0
  10. package/dist/commands/machines/dedicated/cp.d.ts +22 -0
  11. package/dist/commands/machines/dedicated/cp.js +76 -0
  12. package/dist/commands/machines/dedicated/create.d.ts +6 -2
  13. package/dist/commands/machines/dedicated/create.js +29 -20
  14. package/dist/commands/machines/dedicated/destroy.d.ts +6 -2
  15. package/dist/commands/machines/dedicated/destroy.js +41 -24
  16. package/dist/commands/machines/dedicated/exec.d.ts +28 -0
  17. package/dist/commands/machines/dedicated/exec.js +105 -0
  18. package/dist/commands/machines/dedicated/grow.d.ts +6 -2
  19. package/dist/commands/machines/dedicated/grow.js +14 -19
  20. package/dist/commands/machines/dedicated/health-check.d.ts +2 -2
  21. package/dist/commands/machines/dedicated/health-check.js +16 -14
  22. package/dist/commands/machines/dedicated/incident-close.d.ts +3 -2
  23. package/dist/commands/machines/dedicated/incident-close.js +11 -14
  24. package/dist/commands/machines/dedicated/index.js +37 -16
  25. package/dist/commands/machines/dedicated/list.d.ts +2 -2
  26. package/dist/commands/machines/dedicated/list.js +28 -17
  27. package/dist/commands/machines/dedicated/restart.d.ts +5 -2
  28. package/dist/commands/machines/dedicated/restart.js +8 -19
  29. package/dist/commands/machines/dedicated/show.d.ts +2 -2
  30. package/dist/commands/machines/dedicated/show.js +28 -30
  31. package/dist/commands/machines/dedicated/sign-in.d.ts +19 -0
  32. package/dist/commands/machines/dedicated/sign-in.js +49 -0
  33. package/dist/commands/machines/dedicated/snapshot.d.ts +5 -2
  34. package/dist/commands/machines/dedicated/snapshot.js +11 -19
  35. package/dist/commands/machines/dedicated/spending-limit.d.ts +22 -0
  36. package/dist/commands/machines/dedicated/spending-limit.js +50 -0
  37. package/dist/commands/machines/dedicated/start.d.ts +5 -2
  38. package/dist/commands/machines/dedicated/start.js +8 -19
  39. package/dist/commands/machines/dedicated/stop.d.ts +5 -2
  40. package/dist/commands/machines/dedicated/stop.js +8 -19
  41. package/dist/commands/machines/dedicated/terminal.d.ts +22 -0
  42. package/dist/commands/machines/dedicated/terminal.js +42 -0
  43. package/dist/commands/spaces/auto-executor/preview.d.ts +8 -0
  44. package/dist/commands/spaces/auto-executor/preview.js +41 -0
  45. package/dist/commands/spaces/task-rollups.d.ts +14 -0
  46. package/dist/commands/spaces/task-rollups.js +66 -0
  47. package/dist/commands/spaces/work-sync/telemetry.js +22 -0
  48. package/dist/commands/tasks/assign.js +1 -1
  49. package/dist/commands/tasks/create.js +10 -4
  50. package/dist/commands/tasks/list.js +27 -0
  51. package/dist/lib/api-fetch.js +9 -1
  52. package/dist/lib/assignee-resolver.d.ts +7 -6
  53. package/dist/lib/daemonBroker.d.ts +28 -0
  54. package/dist/lib/daemonBroker.js +60 -19
  55. package/dist/lib/dedicated-copy.d.ts +22 -0
  56. package/dist/lib/dedicated-copy.js +115 -0
  57. package/dist/lib/dedicated-lease-command.d.ts +50 -0
  58. package/dist/lib/dedicated-lease-command.js +158 -0
  59. package/dist/lib/dedicated-machines.d.ts +248 -12
  60. package/dist/lib/dedicated-machines.js +385 -25
  61. package/dist/lib/dedicated-terminal.d.ts +89 -0
  62. package/dist/lib/dedicated-terminal.js +275 -0
  63. package/dist/lib/dedicated-wait.d.ts +72 -0
  64. package/dist/lib/dedicated-wait.js +195 -0
  65. package/dist/lib/harnesses.d.ts +6 -0
  66. package/dist/lib/idempotency-scope.d.ts +10 -0
  67. package/dist/lib/idempotency-scope.js +16 -0
  68. package/dist/lib/node-adapter.d.ts +1 -1
  69. package/dist/lib/node-adapter.js +13 -11
  70. package/dist/lib/sky-code.d.ts +30 -1
  71. package/dist/lib/sky-code.js +44 -2
  72. package/dist/lib/triggers.js +7 -0
  73. package/dist/lib/work-sync.d.ts +35 -0
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +2 -1
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +71 -21
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.d.ts +10 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.js +17 -1
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +71 -21
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.d.ts +10 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.js +16 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  85. package/dist/node_modules/@skrr-ai/data-provider/index.js +2657 -2596
  86. package/oclif.manifest.json +5075 -4294
  87. 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
+ }
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import type { HttpAdapter } from '@skrr-ai/data-provider';
1
+ import { type HttpAdapter } from '@skrr-ai/data-provider';
2
2
  import { type ResolvedCredential } from '@skrr-ai/auth-core';
3
3
  import './auth-core-init';
4
4
  /**
@@ -5,7 +5,8 @@ exports.clearAdapterCredentialOverride = clearAdapterCredentialOverride;
5
5
  exports.getAdapterCredentialOverride = getAdapterCredentialOverride;
6
6
  exports.createNodeAdapter = createNodeAdapter;
7
7
  exports.__recomputeCredentialForTest = __recomputeCredentialForTest;
8
- const node_crypto_1 = require("node:crypto");
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.