@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.
- package/assets/vault-extension/assets/inter.woff2 +0 -0
- package/assets/vault-extension/background.js +45 -0
- package/assets/vault-extension/content.js +62 -0
- package/assets/vault-extension/icons/icon-128.png +0 -0
- package/assets/vault-extension/icons/icon-16.png +0 -0
- package/assets/vault-extension/icons/icon-32.png +0 -0
- package/assets/vault-extension/icons/icon-48.png +0 -0
- package/assets/vault-extension/manifest.json +39 -0
- package/dist/capabilities.js +2 -1
- package/dist/command-runner.js +119 -7
- package/dist/desktop/docker.d.ts +4 -1
- package/dist/desktop/docker.js +7 -3
- package/dist/desktop/engine.d.ts +13 -0
- package/dist/desktop/engine.js +35 -4
- package/dist/desktop/install-engine.d.ts +33 -2
- package/dist/desktop/install-engine.js +138 -12
- package/dist/desktop/learn/browser.d.ts +41 -0
- package/dist/desktop/learn/browser.js +136 -0
- package/dist/desktop/learn/injected.mjs +173 -0
- package/dist/desktop/learn/recorder.mjs +151 -0
- package/dist/desktop/learn/session.d.ts +51 -0
- package/dist/desktop/learn/session.js +224 -0
- package/dist/desktop/manager.d.ts +4 -0
- package/dist/desktop/manager.js +31 -2
- package/dist/desktop/provider.d.ts +4 -0
- package/dist/desktop/spec.d.ts +4 -1
- package/dist/desktop/start-engine.d.ts +7 -4
- package/dist/desktop/start-engine.js +50 -6
- package/dist/desktop/vault-extension.d.ts +21 -0
- package/dist/desktop/vault-extension.js +112 -0
- package/dist/desktop/vault-seed.mjs +112 -0
- package/dist/desktop/vault-session.d.ts +41 -0
- package/dist/desktop/vault-session.js +164 -0
- package/package.json +4 -3
- package/scripts/copy-assets.js +18 -0
- package/dist/tui.d.ts +0 -1
- 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<{
|
package/dist/desktop/manager.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/desktop/spec.d.ts
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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',
|
|
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;
|