@melaya/runner 1.0.118 → 1.1.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/dist/assistantHost.py +144 -8
- package/dist/browserAuthz.d.ts +78 -0
- package/dist/browserAuthz.js +504 -0
- package/dist/browserBridge.d.ts +50 -0
- package/dist/browserBridge.js +1021 -0
- package/dist/browserGrantVerify.d.ts +91 -0
- package/dist/browserGrantVerify.js +353 -0
- package/dist/browserProvisioner.d.ts +33 -0
- package/dist/browserProvisioner.js +266 -0
- package/dist/codeWorker.d.ts +49 -0
- package/dist/codeWorker.js +538 -0
- package/dist/connection.js +270 -3
- package/dist/sessionManager.d.ts +108 -0
- package/dist/sessionManager.js +290 -0
- package/package.json +4 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// packages/runner/src/sessionManager.ts
|
|
2
|
+
//
|
|
3
|
+
// Melaya Browser, Phase 1 (plan Section 7): runner-side session, Space,
|
|
4
|
+
// target-lease, and ownership state machine.
|
|
5
|
+
//
|
|
6
|
+
// Core invariants:
|
|
7
|
+
// - OWNERSHIP: a session is "owned" (we launched the browser process
|
|
8
|
+
// into a dedicated Melaya user-data-dir) or "attached" (we connected
|
|
9
|
+
// over CDP to a browser the user controls). Teardown closes ONLY
|
|
10
|
+
// owned browsers/contexts; attached browsers are merely detached
|
|
11
|
+
// from (playwright.Browser.close() on a connectOverCDP handle only
|
|
12
|
+
// severs the connection, it does not kill the user's browser — we
|
|
13
|
+
// still guard it and NEVER close attached pages/contexts).
|
|
14
|
+
// - LEASES: every operated target is leased to exactly one run.
|
|
15
|
+
// A lease carries a snapshotGeneration (incremented on every
|
|
16
|
+
// re-snapshot) and a documentGeneration (incremented on every
|
|
17
|
+
// main-frame navigation). @eN refs bind to BOTH; a mismatch is a
|
|
18
|
+
// typed stale_ref error upstream (fail closed).
|
|
19
|
+
// - TTLs: idle sessions are swept; a hard max-lifetime cap bounds
|
|
20
|
+
// even a busy session. Crash recovery marks the session crashed so
|
|
21
|
+
// the next op returns a typed error instead of hanging.
|
|
22
|
+
// - Spaces: an ephemeral Space is a temp user-data-dir deleted at
|
|
23
|
+
// teardown; a persistent Space maps to a stable directory under
|
|
24
|
+
// ~/.melaya-runner/browser-spaces/<spaceId> that survives runs.
|
|
25
|
+
// NEVER the real default browser profile (Chrome 136+ ignores the
|
|
26
|
+
// debug port on the default dir; plan Section 7).
|
|
27
|
+
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { tmpdir, homedir } from "node:os";
|
|
30
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
31
|
+
// ---------------------------------------------------------------------
|
|
32
|
+
// Runner device identity (stable across restarts; advertised in the
|
|
33
|
+
// runner hello and asserted against grant.runnerDevice)
|
|
34
|
+
// ---------------------------------------------------------------------
|
|
35
|
+
let _deviceId = "";
|
|
36
|
+
export function getRunnerDeviceId() {
|
|
37
|
+
if (_deviceId)
|
|
38
|
+
return _deviceId;
|
|
39
|
+
const dir = join(homedir(), ".melaya-runner");
|
|
40
|
+
const file = join(dir, "device-id");
|
|
41
|
+
try {
|
|
42
|
+
if (existsSync(file)) {
|
|
43
|
+
const v = readFileSync(file, "utf-8").trim();
|
|
44
|
+
if (/^rnr_[a-f0-9]{32}$/.test(v)) {
|
|
45
|
+
_deviceId = v;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { /* regenerate below */ }
|
|
51
|
+
const fresh = `rnr_${randomBytes(16).toString("hex")}`;
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
writeFileSync(file, fresh, "utf-8");
|
|
55
|
+
}
|
|
56
|
+
catch { /* still usable in-memory for this process */ }
|
|
57
|
+
_deviceId = fresh;
|
|
58
|
+
return fresh;
|
|
59
|
+
}
|
|
60
|
+
export class SessionError extends Error {
|
|
61
|
+
code;
|
|
62
|
+
constructor(code, message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "SessionError";
|
|
65
|
+
this.code = code;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const DEFAULT_IDLE_TTL_MS = 10 * 60 * 1000; // 10 min without an op
|
|
69
|
+
const DEFAULT_MAX_LIFETIME_MS = 60 * 60 * 1000; // 60 min hard cap
|
|
70
|
+
// ---------------------------------------------------------------------
|
|
71
|
+
// Space directories
|
|
72
|
+
// ---------------------------------------------------------------------
|
|
73
|
+
export function spaceUserDataDir(space, runId) {
|
|
74
|
+
if (space.kind === "persistent") {
|
|
75
|
+
const id = String(space.id || "").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
76
|
+
if (!id)
|
|
77
|
+
throw new SessionError("session_not_found", "persistent Space requires a stable id");
|
|
78
|
+
const dir = join(homedir(), ".melaya-runner", "browser-spaces", id);
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
return { dir, ephemeral: false };
|
|
81
|
+
}
|
|
82
|
+
const dir = join(tmpdir(), `melaya-browser-${runId}-${randomBytes(4).toString("hex")}`);
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
return { dir, ephemeral: true };
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------
|
|
87
|
+
// SessionManager
|
|
88
|
+
// ---------------------------------------------------------------------
|
|
89
|
+
export class SessionManager {
|
|
90
|
+
sessions = new Map(); // by runId
|
|
91
|
+
sweeper = null;
|
|
92
|
+
log;
|
|
93
|
+
constructor(opts = {}) {
|
|
94
|
+
this.log = opts.log ?? (() => { });
|
|
95
|
+
this.sweeper = setInterval(() => this.sweep(), 60_000);
|
|
96
|
+
// Never keep the runner process alive just for the sweeper.
|
|
97
|
+
if (typeof this.sweeper.unref === "function")
|
|
98
|
+
this.sweeper.unref();
|
|
99
|
+
}
|
|
100
|
+
/** Create the session record; the caller (bridge) supplies the live
|
|
101
|
+
* Playwright handles once launch/attach succeeds. */
|
|
102
|
+
createSession(opts) {
|
|
103
|
+
if (this.sessions.has(opts.runId)) {
|
|
104
|
+
throw new SessionError("lease_conflict", `run ${opts.runId} already holds a browser session`);
|
|
105
|
+
}
|
|
106
|
+
const rec = {
|
|
107
|
+
id: randomUUID(),
|
|
108
|
+
runId: opts.runId,
|
|
109
|
+
ownership: opts.ownership,
|
|
110
|
+
engine: opts.engine,
|
|
111
|
+
state: "connecting",
|
|
112
|
+
space: opts.space,
|
|
113
|
+
browser: null,
|
|
114
|
+
context: null,
|
|
115
|
+
targets: new Map(),
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
lastActivity: Date.now(),
|
|
118
|
+
idleTtlMs: opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS,
|
|
119
|
+
maxLifetimeMs: opts.maxLifetimeMs ?? DEFAULT_MAX_LIFETIME_MS,
|
|
120
|
+
ephemeralUserDataDir: null,
|
|
121
|
+
cancelled: false,
|
|
122
|
+
};
|
|
123
|
+
this.sessions.set(opts.runId, rec);
|
|
124
|
+
return rec;
|
|
125
|
+
}
|
|
126
|
+
attachHandles(rec, handles) {
|
|
127
|
+
rec.browser = handles.browser ?? null;
|
|
128
|
+
rec.context = handles.context;
|
|
129
|
+
rec.ephemeralUserDataDir = handles.ephemeralUserDataDir ?? null;
|
|
130
|
+
rec.state = "ready";
|
|
131
|
+
// Crash recovery: a dying browser flips the session to "crashed" so
|
|
132
|
+
// subsequent ops return a typed error instead of a Playwright hang;
|
|
133
|
+
// leases and refs are dropped (they can never be valid again).
|
|
134
|
+
const onGone = () => {
|
|
135
|
+
if (rec.state === "closed")
|
|
136
|
+
return;
|
|
137
|
+
rec.state = "crashed";
|
|
138
|
+
rec.targets.clear();
|
|
139
|
+
this.log(`browser session for run ${rec.runId.slice(0, 10)} crashed/disconnected`);
|
|
140
|
+
};
|
|
141
|
+
if (rec.browser)
|
|
142
|
+
rec.browser.on("disconnected", onGone);
|
|
143
|
+
rec.context.on("close", () => {
|
|
144
|
+
if (rec.state !== "closed")
|
|
145
|
+
onGone();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
getSession(runId) {
|
|
149
|
+
const rec = this.sessions.get(runId);
|
|
150
|
+
if (!rec)
|
|
151
|
+
throw new SessionError("session_not_found", `no browser session for run ${runId}`);
|
|
152
|
+
if (rec.state === "crashed")
|
|
153
|
+
throw new SessionError("session_crashed", "browser session crashed; relaunch required");
|
|
154
|
+
if (rec.state === "closed" || rec.cancelled)
|
|
155
|
+
throw new SessionError("session_closed", "browser session is closed");
|
|
156
|
+
return rec;
|
|
157
|
+
}
|
|
158
|
+
peekSession(runId) {
|
|
159
|
+
return this.sessions.get(runId);
|
|
160
|
+
}
|
|
161
|
+
touch(rec) {
|
|
162
|
+
rec.lastActivity = Date.now();
|
|
163
|
+
}
|
|
164
|
+
// -- Target leases ---------------------------------------------------
|
|
165
|
+
leaseTarget(rec, ref, page) {
|
|
166
|
+
const existing = rec.targets.get(ref);
|
|
167
|
+
if (existing)
|
|
168
|
+
return existing;
|
|
169
|
+
const lease = {
|
|
170
|
+
ref,
|
|
171
|
+
page,
|
|
172
|
+
snapshotGeneration: 0,
|
|
173
|
+
documentGeneration: 0,
|
|
174
|
+
refs: new Map(),
|
|
175
|
+
lastActivity: Date.now(),
|
|
176
|
+
opChain: Promise.resolve(),
|
|
177
|
+
};
|
|
178
|
+
// Main-frame navigation invalidates every ref bound to the previous
|
|
179
|
+
// document (documentGeneration bump; plan Section 8 fail-closed rule).
|
|
180
|
+
page.on("framenavigated", (frame) => {
|
|
181
|
+
if (frame === page.mainFrame()) {
|
|
182
|
+
lease.documentGeneration += 1;
|
|
183
|
+
lease.refs.clear();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
page.on("close", () => {
|
|
187
|
+
rec.targets.delete(ref);
|
|
188
|
+
});
|
|
189
|
+
rec.targets.set(ref, lease);
|
|
190
|
+
return lease;
|
|
191
|
+
}
|
|
192
|
+
getLease(rec, ref) {
|
|
193
|
+
const lease = rec.targets.get(ref);
|
|
194
|
+
if (!lease)
|
|
195
|
+
throw new SessionError("target_not_found", `no leased target '${ref}' (tab closed or lease expired)`);
|
|
196
|
+
return lease;
|
|
197
|
+
}
|
|
198
|
+
/** New snapshot -> new generation; every previously issued @eN ref
|
|
199
|
+
* becomes stale by construction. */
|
|
200
|
+
beginSnapshot(lease) {
|
|
201
|
+
lease.snapshotGeneration += 1;
|
|
202
|
+
lease.refs.clear();
|
|
203
|
+
lease.lastActivity = Date.now();
|
|
204
|
+
return lease.snapshotGeneration;
|
|
205
|
+
}
|
|
206
|
+
bindRef(lease, binding) {
|
|
207
|
+
lease.refs.set(binding.ref, binding);
|
|
208
|
+
}
|
|
209
|
+
/** Resolve an @eN ref, failing closed on ANY generation mismatch. */
|
|
210
|
+
resolveRef(lease, ref) {
|
|
211
|
+
const b = lease.refs.get(ref);
|
|
212
|
+
if (!b) {
|
|
213
|
+
throw new SessionError("stale_ref", `ref '${ref}' is unknown in the current snapshot; call get_screen_tree and retry with a fresh ref`);
|
|
214
|
+
}
|
|
215
|
+
if (b.snapshotGeneration !== lease.snapshotGeneration || b.documentGeneration !== lease.documentGeneration) {
|
|
216
|
+
throw new SessionError("stale_ref", `ref '${ref}' is stale (snapshot ${b.snapshotGeneration}/${lease.snapshotGeneration}, document ${b.documentGeneration}/${lease.documentGeneration}); re-snapshot required`);
|
|
217
|
+
}
|
|
218
|
+
return b;
|
|
219
|
+
}
|
|
220
|
+
/** Serialize an operation on a target (one write at a time per lease). */
|
|
221
|
+
runOnTarget(lease, op) {
|
|
222
|
+
const next = lease.opChain.then(op, op);
|
|
223
|
+
lease.opChain = next.catch(() => { });
|
|
224
|
+
lease.lastActivity = Date.now();
|
|
225
|
+
return next;
|
|
226
|
+
}
|
|
227
|
+
// -- Teardown ----------------------------------------------------------
|
|
228
|
+
/** Tear down a run's session. Closes ONLY owned contexts/browsers; an
|
|
229
|
+
* attached (user-owned) browser is disconnected from, never closed.
|
|
230
|
+
* Idempotent; safe on every terminal path. */
|
|
231
|
+
async teardownRun(runId, reason) {
|
|
232
|
+
const rec = this.sessions.get(runId);
|
|
233
|
+
if (!rec)
|
|
234
|
+
return;
|
|
235
|
+
this.sessions.delete(runId);
|
|
236
|
+
if (rec.state === "closed")
|
|
237
|
+
return;
|
|
238
|
+
rec.cancelled = true; // cancels in-flight ops at their next gate
|
|
239
|
+
const wasState = rec.state;
|
|
240
|
+
rec.state = "closed";
|
|
241
|
+
rec.targets.clear();
|
|
242
|
+
this.log(`teardown browser session run=${runId.slice(0, 10)} ownership=${rec.ownership} reason=${reason} (was ${wasState})`);
|
|
243
|
+
try {
|
|
244
|
+
if (rec.ownership === "owned") {
|
|
245
|
+
// Owned: close context (persistent-context launches close the
|
|
246
|
+
// whole browser process with it), then the browser if separate.
|
|
247
|
+
if (rec.context)
|
|
248
|
+
await rec.context.close().catch(() => { });
|
|
249
|
+
if (rec.browser)
|
|
250
|
+
await rec.browser.close().catch(() => { });
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// Attached: sever the CDP connection ONLY. Browser.close() on a
|
|
254
|
+
// connectOverCDP browser disconnects without killing the user's
|
|
255
|
+
// browser (Playwright-documented behavior); we still avoid
|
|
256
|
+
// touching contexts/pages so nothing user-visible closes.
|
|
257
|
+
if (rec.browser)
|
|
258
|
+
await rec.browser.close().catch(() => { });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (rec.ephemeralUserDataDir) {
|
|
263
|
+
try {
|
|
264
|
+
rmSync(rec.ephemeralUserDataDir, { recursive: true, force: true, maxRetries: 3 });
|
|
265
|
+
}
|
|
266
|
+
catch { /* best-effort */ }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async teardownAll(reason) {
|
|
271
|
+
const ids = [...this.sessions.keys()];
|
|
272
|
+
await Promise.all(ids.map((id) => this.teardownRun(id, reason)));
|
|
273
|
+
}
|
|
274
|
+
/** TTL sweep: idle timeout + hard max lifetime. */
|
|
275
|
+
sweep() {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
for (const [runId, rec] of this.sessions) {
|
|
278
|
+
const idle = now - rec.lastActivity > rec.idleTtlMs;
|
|
279
|
+
const overMax = now - rec.createdAt > rec.maxLifetimeMs;
|
|
280
|
+
if (idle || overMax) {
|
|
281
|
+
void this.teardownRun(runId, idle ? "idle_ttl" : "max_lifetime");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
dispose() {
|
|
286
|
+
if (this.sweeper)
|
|
287
|
+
clearInterval(this.sweeper);
|
|
288
|
+
this.sweeper = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -20,17 +20,19 @@
|
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
|
+
"test": "node --test --import tsx \"src/**/*.test.ts\"",
|
|
23
24
|
"prepublishOnly": "npm run build"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"chalk": "^5.3.0",
|
|
27
28
|
"commander": "^12.0.0",
|
|
28
29
|
"ora": "^8.0.0",
|
|
29
|
-
"playwright": "^1.
|
|
30
|
+
"playwright": "^1.48.0",
|
|
30
31
|
"socket.io-client": "^4.8.0"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@types/node": "^20.0.0",
|
|
35
|
+
"tsx": "^4.19.2",
|
|
34
36
|
"typescript": "^5.5.0"
|
|
35
37
|
},
|
|
36
38
|
"engines": {
|