@addai/node 0.27.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/assets/vault-extension/assets/inter.woff2 +0 -0
  2. package/assets/vault-extension/background.js +45 -0
  3. package/assets/vault-extension/content.js +62 -0
  4. package/assets/vault-extension/icons/icon-128.png +0 -0
  5. package/assets/vault-extension/icons/icon-16.png +0 -0
  6. package/assets/vault-extension/icons/icon-32.png +0 -0
  7. package/assets/vault-extension/icons/icon-48.png +0 -0
  8. package/assets/vault-extension/manifest.json +39 -0
  9. package/dist/capabilities.js +2 -1
  10. package/dist/command-runner.js +119 -7
  11. package/dist/desktop/docker.d.ts +4 -1
  12. package/dist/desktop/docker.js +7 -3
  13. package/dist/desktop/engine.d.ts +13 -0
  14. package/dist/desktop/engine.js +35 -4
  15. package/dist/desktop/install-engine.d.ts +33 -2
  16. package/dist/desktop/install-engine.js +138 -12
  17. package/dist/desktop/learn/browser.d.ts +41 -0
  18. package/dist/desktop/learn/browser.js +136 -0
  19. package/dist/desktop/learn/injected.mjs +173 -0
  20. package/dist/desktop/learn/recorder.mjs +151 -0
  21. package/dist/desktop/learn/session.d.ts +51 -0
  22. package/dist/desktop/learn/session.js +224 -0
  23. package/dist/desktop/manager.d.ts +4 -0
  24. package/dist/desktop/manager.js +31 -2
  25. package/dist/desktop/provider.d.ts +4 -0
  26. package/dist/desktop/spec.d.ts +4 -1
  27. package/dist/desktop/start-engine.d.ts +7 -4
  28. package/dist/desktop/start-engine.js +50 -6
  29. package/dist/desktop/vault-extension.d.ts +21 -0
  30. package/dist/desktop/vault-extension.js +112 -0
  31. package/dist/desktop/vault-seed.mjs +112 -0
  32. package/dist/desktop/vault-session.d.ts +41 -0
  33. package/dist/desktop/vault-session.js +164 -0
  34. package/package.json +4 -3
  35. package/scripts/copy-assets.js +18 -0
  36. package/dist/tui.d.ts +0 -1
  37. package/dist/tui.js +0 -314
@@ -0,0 +1,151 @@
1
+ // The CDP recorder. Runs INSIDE the desktop container, next to the browser.
2
+ //
3
+ // node /conf/learn/recorder.mjs
4
+ //
5
+ // Writes NDJSON on stdout, one event per line, `t` in ms since it attached.
6
+ // The daemon reads that stream directly — no file, no polling — and batches it
7
+ // into the lesson row.
8
+ //
9
+ // Nothing is installed to make this work: the image already has node 22, whose
10
+ // global WebSocket is enough for CDP, and Chrome, which the browser wrapper has
11
+ // already given a debug port. If the port is not there (a browser opened before
12
+ // the wrapper existed) it says so in one line and exits 3, and the lesson goes
13
+ // on without the browser track rather than failing.
14
+ import { injectedSource } from './injected.mjs';
15
+
16
+ const PORT = Number(process.env.ADDAI_LEARN_PORT || 9222);
17
+ const t0 = Date.now();
18
+
19
+ const emit = (o) => {
20
+ try { process.stdout.write(JSON.stringify({ t: Date.now() - t0, ...o }) + '\n'); }
21
+ catch { /* the daemon went away; nothing to do about it from here */ }
22
+ };
23
+
24
+ async function version() {
25
+ // A browser that is starting up refuses for a moment. Three tries over ~3s
26
+ // covers the case where a lesson begins in the same breath as the browser.
27
+ for (let i = 0; i < 3; i++) {
28
+ try {
29
+ const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
30
+ if (r.ok) return await r.json();
31
+ } catch { /* not up yet */ }
32
+ await new Promise((r) => setTimeout(r, 1000));
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function client(url) {
38
+ const ws = new WebSocket(url);
39
+ let next = 1;
40
+ const pending = new Map();
41
+ const handlers = [];
42
+
43
+ const send = (method, params = {}, sessionId) =>
44
+ new Promise((resolve) => {
45
+ const id = next++;
46
+ pending.set(id, resolve);
47
+ const msg = { id, method, params };
48
+ if (sessionId) msg.sessionId = sessionId;
49
+ try { ws.send(JSON.stringify(msg)); } catch { pending.delete(id); resolve(null); }
50
+ });
51
+
52
+ ws.addEventListener('message', (ev) => {
53
+ let m;
54
+ try { m = JSON.parse(ev.data); } catch { return; }
55
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? null); pending.delete(m.id); return; }
56
+ for (const h of handlers) h(m);
57
+ });
58
+
59
+ return {
60
+ ws,
61
+ send,
62
+ on: (fn) => handlers.push(fn),
63
+ ready: new Promise((resolve, reject) => {
64
+ ws.addEventListener('open', resolve);
65
+ ws.addEventListener('error', () => reject(new Error('cdp socket refused')));
66
+ }),
67
+ };
68
+ }
69
+
70
+ async function main() {
71
+ const v = await version();
72
+ if (!v || !v.webSocketDebuggerUrl) {
73
+ emit({ kind: 'no_port' });
74
+ process.exit(3);
75
+ }
76
+
77
+ const cdp = client(v.webSocketDebuggerUrl);
78
+ await cdp.ready;
79
+
80
+ const source = injectedSource();
81
+ const attached = new Set();
82
+
83
+ const arm = async (sessionId) => {
84
+ if (attached.has(sessionId)) return;
85
+ attached.add(sessionId);
86
+ await cdp.send('Runtime.enable', {}, sessionId);
87
+ await cdp.send('Page.enable', {}, sessionId);
88
+ // The binding is how the page talks back. Added before the script that
89
+ // calls it, or the first click on an already-open page is lost.
90
+ await cdp.send('Runtime.addBinding', { name: '__addaiLearn' }, sessionId);
91
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId);
92
+ // …and once for the document that is already loaded, which the line above
93
+ // does not cover.
94
+ await cdp.send('Runtime.evaluate', { expression: source }, sessionId);
95
+ };
96
+
97
+ cdp.on(async (m) => {
98
+ const s = m.sessionId;
99
+ if (m.method === 'Target.targetCreated' && m.params?.targetInfo?.type === 'page') {
100
+ await cdp.send('Target.attachToTarget', { targetId: m.params.targetInfo.targetId, flatten: true });
101
+ return;
102
+ }
103
+ if (m.method === 'Target.attachedToTarget') {
104
+ const info = m.params?.targetInfo ?? {};
105
+ if (info.type !== 'page') return;
106
+ emit({ kind: 'tab_open', url: info.url ?? null });
107
+ await arm(m.params.sessionId);
108
+ return;
109
+ }
110
+ if (m.method === 'Target.detachedFromTarget') {
111
+ attached.delete(m.params?.sessionId);
112
+ emit({ kind: 'tab_close' });
113
+ return;
114
+ }
115
+ if (m.method === 'Runtime.bindingCalled' && m.params?.name === '__addaiLearn') {
116
+ let ev;
117
+ try { ev = JSON.parse(m.params.payload); } catch { return; }
118
+ emit(ev);
119
+ return;
120
+ }
121
+ if (m.method === 'Page.frameNavigated' && !m.params?.frame?.parentId) {
122
+ emit({ kind: 'url', url: m.params.frame.url ?? null, title: null });
123
+ // A navigation replaces the execution context; re-arming is cheap and
124
+ // idempotent, and skipping it loses every click on the new page.
125
+ if (s) { attached.delete(s); await arm(s); }
126
+ return;
127
+ }
128
+ if (m.method === 'Page.loadEventFired' && s) {
129
+ const r = await cdp.send('Runtime.evaluate',
130
+ { expression: 'document.title', returnByValue: true }, s);
131
+ emit({ kind: 'load', title: r?.result?.value ?? null });
132
+ return;
133
+ }
134
+ if (m.method === 'Page.javascriptDialogOpening') {
135
+ emit({ kind: 'dialog', dtype: m.params?.type ?? null, message: m.params?.message ?? null });
136
+ }
137
+ });
138
+
139
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
140
+ emit({ kind: 'watching' });
141
+
142
+ const bye = () => { try { cdp.ws.close(); } catch { /* already gone */ } process.exit(0); };
143
+ process.on('SIGTERM', bye);
144
+ process.on('SIGINT', bye);
145
+ cdp.ws.addEventListener('close', () => process.exit(0));
146
+ }
147
+
148
+ main().catch((e) => {
149
+ emit({ kind: 'recorder_error', message: String(e && e.message ? e.message : e) });
150
+ process.exit(1);
151
+ });
@@ -0,0 +1,51 @@
1
+ import { DesktopProvider } from '../provider';
2
+ import { DesktopRow } from '../spec';
3
+ export interface PageEvent {
4
+ t: number;
5
+ kind: string;
6
+ [k: string]: unknown;
7
+ }
8
+ /**
9
+ * Split whole NDJSON lines out of a stream chunk.
10
+ *
11
+ * A chunk boundary lands in the middle of a line often enough to matter, so
12
+ * the remainder comes back for the next call rather than being parsed as junk
13
+ * and dropped. Junk that is genuinely junk IS dropped: a browser writing to
14
+ * stderr must not be able to kill a lesson.
15
+ */
16
+ export declare function parseNdjson(buf: string): {
17
+ events: PageEvent[];
18
+ rest: string;
19
+ };
20
+ export type SendEvents = (events: PageEvent[]) => Promise<{
21
+ status?: string;
22
+ full?: boolean;
23
+ } | null>;
24
+ export interface Batcher {
25
+ push(ev: PageEvent): void;
26
+ flush(): Promise<void>;
27
+ stop(): void;
28
+ /** Set once the server says the lesson is over or the row is full. */
29
+ done: boolean;
30
+ }
31
+ /**
32
+ * Events go up in batches, not one at a time: a page that fires an input event
33
+ * per keystroke would otherwise be one HTTP request per keystroke.
34
+ */
35
+ export declare function createBatcher(send: SendEvents, opts?: {
36
+ everyMs?: number;
37
+ max?: number;
38
+ }): Batcher;
39
+ export declare const lessonIsRunning: (recordingId: string) => boolean;
40
+ export interface StartResult {
41
+ browserTrack: boolean;
42
+ note?: string;
43
+ }
44
+ export declare function startLesson(opts: {
45
+ provider: DesktopProvider;
46
+ row: DesktopRow;
47
+ recordingId: string;
48
+ send: SendEvents;
49
+ onLog?: (s: string) => void;
50
+ }): Promise<StartResult>;
51
+ export declare function stopLesson(recordingId: string): Promise<void>;
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.lessonIsRunning = void 0;
37
+ exports.parseNdjson = parseNdjson;
38
+ exports.createBatcher = createBatcher;
39
+ exports.startLesson = startLesson;
40
+ exports.stopLesson = stopLesson;
41
+ // Running a lesson's container half.
42
+ //
43
+ // The daemon spawns recorder.mjs inside the desktop and reads its NDJSON on
44
+ // stdout. Two commands bracket it — desktop_record_start and
45
+ // desktop_record_stop — and between them this module owns one child process
46
+ // and one batch of events on its way to the lesson row.
47
+ //
48
+ // Everything the container needs is copied into /conf, which the daemon owns on
49
+ // the host and the container sees as a bind mount. That is why no image change
50
+ // is needed and why there is no `docker cp` anywhere in this feature.
51
+ const child_process_1 = require("child_process");
52
+ const fs = __importStar(require("fs"));
53
+ const path = __importStar(require("path"));
54
+ const provider_1 = require("../provider");
55
+ const win_1 = require("../../win");
56
+ const creds_1 = require("../creds");
57
+ const manager_1 = require("../manager");
58
+ const browser_1 = require("./browser");
59
+ /**
60
+ * Split whole NDJSON lines out of a stream chunk.
61
+ *
62
+ * A chunk boundary lands in the middle of a line often enough to matter, so
63
+ * the remainder comes back for the next call rather than being parsed as junk
64
+ * and dropped. Junk that is genuinely junk IS dropped: a browser writing to
65
+ * stderr must not be able to kill a lesson.
66
+ */
67
+ function parseNdjson(buf) {
68
+ const events = [];
69
+ const parts = buf.split('\n');
70
+ const rest = parts.pop() ?? '';
71
+ for (const line of parts) {
72
+ const s = line.trim();
73
+ if (!s)
74
+ continue;
75
+ try {
76
+ const o = JSON.parse(s);
77
+ if (o && typeof o === 'object' && typeof o.kind === 'string')
78
+ events.push(o);
79
+ }
80
+ catch { /* not ours */ }
81
+ }
82
+ return { events, rest };
83
+ }
84
+ /**
85
+ * Events go up in batches, not one at a time: a page that fires an input event
86
+ * per keystroke would otherwise be one HTTP request per keystroke.
87
+ */
88
+ function createBatcher(send, opts = {}) {
89
+ const everyMs = opts.everyMs ?? 2_000;
90
+ const max = opts.max ?? 50;
91
+ let queue = [];
92
+ let timer = null;
93
+ const b = {
94
+ done: false,
95
+ push(ev) {
96
+ if (b.done)
97
+ return;
98
+ queue.push(ev);
99
+ if (queue.length >= max)
100
+ void b.flush();
101
+ else if (!timer)
102
+ timer = setTimeout(() => { void b.flush(); }, everyMs);
103
+ },
104
+ async flush() {
105
+ if (timer) {
106
+ clearTimeout(timer);
107
+ timer = null;
108
+ }
109
+ if (queue.length === 0)
110
+ return;
111
+ const batch = queue;
112
+ queue = [];
113
+ try {
114
+ const res = await send(batch);
115
+ // The row said the lesson is over, or it has taken all it will take.
116
+ if (res && (res.full || (res.status && res.status !== 'recording' && res.status !== 'compiling'))) {
117
+ b.done = true;
118
+ }
119
+ }
120
+ catch {
121
+ // Best effort. Losing a batch costs detail in one lesson; throwing here
122
+ // would take down the command runner that owns the desktop.
123
+ }
124
+ },
125
+ stop() {
126
+ if (timer) {
127
+ clearTimeout(timer);
128
+ timer = null;
129
+ }
130
+ b.done = true;
131
+ queue = [];
132
+ },
133
+ };
134
+ return b;
135
+ }
136
+ const live = new Map();
137
+ const lessonIsRunning = (recordingId) => live.has(recordingId);
138
+ exports.lessonIsRunning = lessonIsRunning;
139
+ /** Assets the container runs. Copied rather than mounted from anywhere clever:
140
+ * /conf is already there, and a copy means the version that started a lesson
141
+ * is the version that finishes it even if the daemon rolls mid-recording. */
142
+ function stageAssets(confDir) {
143
+ const dir = path.join(confDir, 'learn');
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ for (const f of ['recorder.mjs', 'injected.mjs']) {
146
+ fs.copyFileSync(path.join(__dirname, f), path.join(dir, f));
147
+ }
148
+ return dir;
149
+ }
150
+ async function startLesson(opts) {
151
+ const { provider, row, recordingId, send } = opts;
152
+ const log = opts.onLog ?? (() => { });
153
+ if (live.has(recordingId))
154
+ return { browserTrack: live.get(recordingId).browserTrack };
155
+ const { confDir } = (0, manager_1.ensureDirs)(row.id);
156
+ stageAssets(confDir);
157
+ const run = async (cmd) => {
158
+ const r = await (0, creds_1.execInDesktopCapture)(provider, row, cmd, 20_000);
159
+ return { ok: r.exitCode === 0, out: r.stdout.toString('utf8') };
160
+ };
161
+ const wrapper = await (0, browser_1.ensureBrowserWrapper)(run, confDir);
162
+ if (wrapper.note)
163
+ log(`${wrapper.note}\n`);
164
+ const args = (0, provider_1.buildExecArgs)(row, ['node', '/conf/learn/recorder.mjs'], { cwd: '/work', env: {}, tty: false });
165
+ const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
166
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, { env: process.env, windowsHide: true });
167
+ const batcher = createBatcher(send);
168
+ const entry = { child, batcher, browserTrack: false };
169
+ live.set(recordingId, entry);
170
+ let settle = null;
171
+ const settled = new Promise((resolve) => { settle = resolve; });
172
+ const answer = (r) => { if (settle) {
173
+ settle(r);
174
+ settle = null;
175
+ } };
176
+ let rest = '';
177
+ child.stdout?.on('data', (b) => {
178
+ const parsed = parseNdjson(rest + b.toString('utf8'));
179
+ rest = parsed.rest;
180
+ for (const ev of parsed.events) {
181
+ if (ev.kind === 'no_port') {
182
+ answer({
183
+ browserTrack: false,
184
+ note: 'The browser was open before recording started, so it had no debugging '
185
+ + 'port and the page detail could not be captured.',
186
+ });
187
+ continue;
188
+ }
189
+ if (ev.kind === 'watching') {
190
+ entry.browserTrack = true;
191
+ answer({ browserTrack: true });
192
+ continue;
193
+ }
194
+ batcher.push(ev);
195
+ if (batcher.done) {
196
+ stopLesson(recordingId);
197
+ return;
198
+ }
199
+ }
200
+ });
201
+ child.stderr?.on('data', (b) => log(b.toString('utf8').slice(0, 500)));
202
+ child.on('error', (err) => answer({ browserTrack: false, note: `recorder failed to start: ${err.message}` }));
203
+ child.on('exit', () => {
204
+ answer({ browserTrack: false, note: 'the browser recorder stopped early' });
205
+ void batcher.flush();
206
+ live.delete(recordingId);
207
+ });
208
+ // Do not wait forever for a first line: a lesson without the browser track is
209
+ // still a lesson, and the person is already recording.
210
+ const timeout = new Promise((resolve) => setTimeout(() => resolve({ browserTrack: entry.browserTrack }), 8_000).unref?.());
211
+ return Promise.race([settled, timeout]);
212
+ }
213
+ async function stopLesson(recordingId) {
214
+ const entry = live.get(recordingId);
215
+ if (!entry)
216
+ return;
217
+ live.delete(recordingId);
218
+ await entry.batcher.flush();
219
+ entry.batcher.stop();
220
+ try {
221
+ entry.child.kill('SIGTERM');
222
+ }
223
+ catch { /* already gone */ }
224
+ }
@@ -35,6 +35,10 @@ export declare function maxConcurrentDesktops(): number;
35
35
  * tracked in a counter. A counter would drift the moment somebody stopped a
36
36
  * container by hand, and drift upward means refusing to start anything. */
37
37
  export declare function runningDesktopCount(rows: DesktopRow[]): Promise<number>;
38
+ /** Oldest waiter first, everything else after. Without this the order is
39
+ * whatever the listing happened to return, which is how a desktop ends up
40
+ * waiting behind one that asked after it. */
41
+ export declare function queueOrder<T extends Pick<DesktopRow, 'status' | 'queued_at'>>(rows: T[]): T[];
38
42
  /** Whether one more may start. Returns the reason when it may not, so the
39
43
  * caller can say something better than "failed". */
40
44
  export declare function desktopStartAllowed(rows: DesktopRow[], excludeId?: string): Promise<{
@@ -43,6 +43,7 @@ exports.clampMaxDesktops = clampMaxDesktops;
43
43
  exports.setMaxConcurrentDesktops = setMaxConcurrentDesktops;
44
44
  exports.maxConcurrentDesktops = maxConcurrentDesktops;
45
45
  exports.runningDesktopCount = runningDesktopCount;
46
+ exports.queueOrder = queueOrder;
46
47
  exports.desktopStartAllowed = desktopStartAllowed;
47
48
  exports.startDesktopManager = startDesktopManager;
48
49
  exports.stopDesktopManager = stopDesktopManager;
@@ -68,6 +69,11 @@ function reconcileAction(row, actual) {
68
69
  || row.status === 'deleting' || row.status === 'pending'
69
70
  || row.status === 'failed')
70
71
  return 'none';
72
+ // Waiting for a slot. The tick retries it every pass; whether there is room
73
+ // yet is decided there, because it depends on every OTHER desktop, not this
74
+ // one. Somebody starting it by hand in the meantime just resolves the wait.
75
+ if (row.status === 'queued')
76
+ return actual?.running ? 'mark_running' : 'start';
71
77
  if (actual === null) {
72
78
  // The row says this exists and it does not. Someone pruned it by hand.
73
79
  return row.status === 'running' || row.status === 'stopped' ? 'mark_failed' : 'none';
@@ -176,6 +182,23 @@ async function runningDesktopCount(rows) {
176
182
  }
177
183
  return count;
178
184
  }
185
+ /** When a desktop asked for its slot, as a sortable number. A waiter whose
186
+ * stamp is missing or unreadable goes to the BACK, not the front: Date.parse
187
+ * returns NaN for those, every NaN comparison is false, and a comparator that
188
+ * returns NaN is treated as 0 — so the unstamped row would quietly keep
189
+ * whatever position the listing gave it and cut in front of real waiters. */
190
+ function waitingSince(row) {
191
+ if (row.status !== 'queued')
192
+ return Number.POSITIVE_INFINITY;
193
+ const at = Date.parse(row.queued_at ?? '');
194
+ return Number.isFinite(at) ? at : Number.POSITIVE_INFINITY;
195
+ }
196
+ /** Oldest waiter first, everything else after. Without this the order is
197
+ * whatever the listing happened to return, which is how a desktop ends up
198
+ * waiting behind one that asked after it. */
199
+ function queueOrder(rows) {
200
+ return [...rows].sort((a, b) => waitingSince(a) - waitingSince(b));
201
+ }
179
202
  /** Whether one more may start. Returns the reason when it may not, so the
180
203
  * caller can say something better than "failed". */
181
204
  async function desktopStartAllowed(rows, excludeId) {
@@ -199,7 +222,7 @@ async function tick() {
199
222
  if (!provider)
200
223
  return; // no engine: nothing to reconcile
201
224
  const rows = await listDesktops();
202
- for (const row of rows) {
225
+ for (const row of queueOrder(rows)) {
203
226
  const actual = await provider.inspect(row);
204
227
  const action = reconcileAction(row, actual);
205
228
  if (action === 'none')
@@ -212,7 +235,13 @@ async function tick() {
212
235
  // failing — nothing is wrong with the desktop, there is just no room.
213
236
  const room = await desktopStartAllowed(rows, row.id);
214
237
  if (!room.allowed) {
215
- await setStatus(row.id, { status: 'stopped', status_message: room.reason });
238
+ // Stay in the queue rather than dropping out of it. A desktop that
239
+ // fell back to 'stopped' here would need asking for all over again,
240
+ // which is exactly what waiting is supposed to save you.
241
+ // queued_at is stamped by the status RPC, which keeps the
242
+ // original on a re-queue — a desktop must not lose its place in
243
+ // the line every time the reconciler looks at it.
244
+ await setStatus(row.id, { status: 'queued', status_message: room.reason });
216
245
  continue;
217
246
  }
218
247
  await provider.start(row);
@@ -7,6 +7,10 @@ export interface EngineInfo {
7
7
  running: boolean;
8
8
  /** Set when running is false: what to do about it, in words. */
9
9
  reason?: string;
10
+ /** Where the CLI actually is. Carried through so container calls spawn the
11
+ * same binary the probe found, not a bare name off a PATH that may not
12
+ * have it — see engineBinary(). */
13
+ binary?: string;
10
14
  }
11
15
  export interface ExecOpts {
12
16
  cwd: string;
@@ -13,8 +13,11 @@ export interface DesktopRow {
13
13
  enabled_skills: string[];
14
14
  enabled_packages: string[];
15
15
  autostart: boolean;
16
- status: 'pending' | 'creating' | 'starting' | 'running' | 'stopped' | 'failed' | 'deleting';
16
+ status: 'pending' | 'creating' | 'starting' | 'running' | 'stopped' | 'failed' | 'deleting' | 'queued';
17
17
  status_message: string | null;
18
+ /** Set while this desktop is waiting for a slot on a full machine. The node
19
+ * serves its queue oldest-first, so this is the place in the line. */
20
+ queued_at?: string | null;
18
21
  vnc_port: number | null;
19
22
  vnc_password: string | null;
20
23
  }
@@ -9,14 +9,17 @@ export interface StartPlan {
9
9
  /** Launch and walk away — Docker Desktop is a GUI app that outlives us. */
10
10
  detached: boolean;
11
11
  }
12
+ export declare const MAC_APP = "/Applications/Docker.app";
12
13
  /**
13
14
  * Pure, so the choice is testable on any machine.
14
15
  *
15
- * @param programFiles %ProgramFiles% on Windows. Passed in rather than read
16
- * from the environment so the Windows path is testable
17
- * from a Mac, which is where it is usually written.
16
+ * @param programFiles %ProgramFiles% on Windows. Passed in rather than read
17
+ * from the environment so the Windows path is testable
18
+ * from a Mac, which is where it is usually written.
19
+ * @param macAppInstalled same reason: a Mac without Docker must still be able
20
+ * to assert what a Mac with Docker does.
18
21
  */
19
- export declare function startPlan(platform: NodeJS.Platform, isRoot: boolean, programFiles?: string): StartPlan;
22
+ export declare function startPlan(platform: NodeJS.Platform, isRoot: boolean, programFiles?: string, macAppInstalled?: boolean): StartPlan;
20
23
  /** Build the argv for a privileged start. Password on stdin, never argv —
21
24
  * argv is world-readable through /proc. */
22
25
  export declare function sudoStartArgv(): {
@@ -1,6 +1,39 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.START_TIMEOUT_MS = void 0;
36
+ exports.START_TIMEOUT_MS = exports.MAC_APP = void 0;
4
37
  exports.startPlan = startPlan;
5
38
  exports.sudoStartArgv = sudoStartArgv;
6
39
  exports.runningAsRoot = runningAsRoot;
@@ -19,21 +52,32 @@ exports.startEngine = startEngine;
19
52
  // inheriting the install button's sudo round trip on machines that never need
20
53
  // it.
21
54
  const child_process_1 = require("child_process");
55
+ const fs = __importStar(require("fs"));
22
56
  const win_1 = require("../win");
23
57
  const engine_1 = require("./engine");
58
+ exports.MAC_APP = '/Applications/Docker.app';
24
59
  /**
25
60
  * Pure, so the choice is testable on any machine.
26
61
  *
27
- * @param programFiles %ProgramFiles% on Windows. Passed in rather than read
28
- * from the environment so the Windows path is testable
29
- * from a Mac, which is where it is usually written.
62
+ * @param programFiles %ProgramFiles% on Windows. Passed in rather than read
63
+ * from the environment so the Windows path is testable
64
+ * from a Mac, which is where it is usually written.
65
+ * @param macAppInstalled same reason: a Mac without Docker must still be able
66
+ * to assert what a Mac with Docker does.
30
67
  */
31
- function startPlan(platform, isRoot, programFiles) {
68
+ function startPlan(platform, isRoot, programFiles, macAppInstalled = platform === 'darwin' && fs.existsSync(exports.MAC_APP)) {
32
69
  if (platform === 'darwin') {
33
70
  // `open -a` returns as soon as the app is launching; the daemon comes up
34
71
  // a while later, which is why the caller polls rather than trusting exit 0.
72
+ //
73
+ // By path when the app is where it should be: `-a Docker` is a
74
+ // LaunchServices name lookup, and that database has not always caught up
75
+ // minutes after a fresh install — which is exactly when this button gets
76
+ // pressed. The name stays as the fallback for a Docker installed
77
+ // somewhere else.
78
+ const target = macAppInstalled ? exports.MAC_APP : 'Docker';
35
79
  return {
36
- file: 'open', args: ['-a', 'Docker'],
80
+ file: 'open', args: ['-a', target],
37
81
  manual: 'open -a Docker', needsRoot: false, detached: false,
38
82
  };
39
83
  }
@@ -0,0 +1,21 @@
1
+ /** Where the extension lives inside a desktop. /conf is the host's confDir. */
2
+ export declare const EXTENSION_DIR = "/conf/vault/extension";
3
+ /** Every file the browser needs. Sourcemaps are deliberately absent: they are
4
+ * four times the size of the code and nobody debugs this from inside a
5
+ * container. */
6
+ export declare const EXTENSION_FILES: string[];
7
+ /** The vendored copy that ships inside this package. */
8
+ export declare function packagedExtensionDir(): string;
9
+ export interface VaultExtensionResult {
10
+ /** Whether anything was written this time. */
11
+ changed: boolean;
12
+ /** False when this build has no vendored extension to install. */
13
+ available: boolean;
14
+ }
15
+ /**
16
+ * Put the extension where the desktop's browser can load it.
17
+ *
18
+ * Copies only what differs, by size and mtime, so calling this on every
19
+ * desktop start is cheap after the first one.
20
+ */
21
+ export declare function ensureVaultExtension(confDir: string): VaultExtensionResult;