@melaya/runner 1.0.118 → 1.1.1

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.
@@ -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.118",
3
+ "version": "1.1.1",
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.47.0",
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": {