webhelm 1.0.17 → 1.0.19
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/README.md +2 -2
- package/package.json +1 -1
- package/runtime/chatgpt/chatgpt-queue.mjs +80 -196
- package/runtime/cli-help.mjs +11 -0
- package/runtime/cli.mjs +9 -0
- package/runtime/durable-queue-core.mjs +82 -0
- package/runtime/extension/control-channel.mjs +22 -6
- package/runtime/extension/control-rpc.mjs +26 -0
- package/runtime/extension/popup.html +6 -0
- package/runtime/extension/popup.mjs +20 -0
- package/runtime/extension/service-worker.mjs +3 -23
- package/runtime/extension-manager.mjs +1 -1
- package/runtime/node-daemon.mjs +128 -30
- package/runtime/session-lifecycle.mjs +25 -2
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ The Actions checkout is `~/.local/webhelm`, with site modules at `~/.local/webhe
|
|
|
47
47
|
|
|
48
48
|
## Architecture and compatibility
|
|
49
49
|
|
|
50
|
-
The Runtime stores its managed Chrome profile, persistent FIFO queue, and IPC socket under `~/.config/webhelm/`. Run `webhelm migrate` with the legacy daemon and Chrome stopped to migrate existing `~/.config/browser-workspace/` data without dropping logins or queued turns. The command leaves a compatibility symlink for legacy maintenance scripts. Session idle GC runs on Node before new Session allocation (5 minutes without operations or explicit heartbeat). Managed tabs are navigated back to their Extension idle URL instead of closing CDP targets; the Extension does not use `tab.lastAccessed` GC. Only one daemon is permitted to own the queue. A CLI package update does not kill a running daemon or close another agent's tabs; upgrade daemons when idle.
|
|
50
|
+
The Runtime stores its managed Chrome profile, persistent FIFO queue, and IPC socket under `~/.config/webhelm/`. Run `webhelm migrate` with the legacy daemon and Chrome stopped to migrate existing `~/.config/browser-workspace/` data without dropping logins or queued turns. The command leaves a compatibility symlink for legacy maintenance scripts. Session idle GC runs on Node before new Session allocation (5 minutes without operations or explicit heartbeat); no background sweep is started. A timed-out Session refuses subsequent exec, platform Actions and heartbeat, while already-issued browser effects retain tab ownership until settled. A stop that times out while queued cannot execute a late release, and a stop already physically releasing keeps its Session closing until the release finishes. Managed tabs are navigated back to their Extension idle URL instead of closing CDP targets; the Extension does not use `tab.lastAccessed` GC. An async Session execution has a finite deadline: if it expires, its helper capabilities are revoked and the poisoned Session is collected on the next Tab request (or immediately by an explicit stop). Session stop also has a bounded drain/release deadline and cannot leave a permanent closing flag. Client IPC requests have an independent absolute timeout and return an error if the daemon does not respond. These safeguards do not introduce periodic background GC. Only one daemon is permitted to own the queue. A CLI package update does not kill a running daemon or close another agent's tabs; upgrade daemons when idle.
|
|
51
51
|
|
|
52
52
|
NeoY and Relay can continue using `webhelm` session commands, `result.stdout` JSON receipts, and `admin_helper` returned by `status`. An `admin_helper` inside the installed Skill is a stable connector to the same Node IPC socket. `webhelm capacity` replaces the legacy Python capacity probe.
|
|
53
53
|
|
|
@@ -63,7 +63,7 @@ Keep platform-specific code and tests in `lalalic/webhelm-actions`. Runtime test
|
|
|
63
63
|
|
|
64
64
|
### Adding an unsupported platform
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
For new platforms, follow the canonical [`platforms/NEW_PLATFORM.md`](https://github.com/lalalic/webhelm-actions/blob/main/platforms/NEW_PLATFORM.md) in the installed Actions checkout. All new platform code belongs in **`~/.local/webhelm/platforms/<slug>`**, checked out from `lalalic/webhelm-actions`, including tests and docs. There is no separate `platforms/` directory in the Skill or npm package. Publishing an Action uses Git, not npm.
|
|
67
67
|
|
|
68
68
|
## npm publishing
|
|
69
69
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { classifyProbe, PROBE_JS, targetUrl } from "./chatgpt-probe.mjs";
|
|
6
6
|
import { CLICK_SEND_JS, SEND_ENABLED_JS } from "./lifecycle-common.mjs";
|
|
7
|
+
import { DurableQueueCore } from "../durable-queue-core.mjs";
|
|
7
8
|
|
|
8
9
|
const transitions = new Set([
|
|
9
10
|
"CHECKING>WAITING",
|
|
@@ -15,15 +16,63 @@ const transitions = new Set([
|
|
|
15
16
|
"UNCERTAIN>WAITING",
|
|
16
17
|
"BLOCKED>WAITING",
|
|
17
18
|
]);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
|
|
20
|
+
// ChatGPT owns its legacy turns/merged_requests schema and all turn payload,
|
|
21
|
+
// idempotency, merge, FIFO partition, and recovery semantics.
|
|
22
|
+
const chatgptQueuePolicy = {
|
|
23
|
+
one(db, sql, ...args) { return db.prepare(sql).get(...args) || null; },
|
|
24
|
+
rows(db) { return db.prepare("SELECT * FROM turns ORDER BY seq").all(); },
|
|
25
|
+
enqueue(db, request = {}, now) {
|
|
26
|
+
const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false} = request;
|
|
27
|
+
const key=idempotency_key ?? idempotencyKey;
|
|
28
|
+
if (!thread_id || !message || !key) throw new TypeError("thread_id, message, idempotency_key required");
|
|
29
|
+
if (typeof mergeable !== "boolean") throw new TypeError("mergeable must be a boolean");
|
|
30
|
+
const merged=this.one(db, "SELECT * FROM merged_requests WHERE idem_key=?", key);
|
|
31
|
+
if (merged) {
|
|
32
|
+
if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id) throw new Error("idempotency key reused with different payload");
|
|
33
|
+
const row=this.one(db, "SELECT * FROM turns WHERE id=?", merged.queue_id);
|
|
34
|
+
return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
|
|
35
|
+
}
|
|
36
|
+
const same=this.one(db, "SELECT * FROM turns WHERE idem_key=?", key);
|
|
37
|
+
if (same) {
|
|
38
|
+
if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id) throw new Error("idempotency key reused with different payload");
|
|
39
|
+
return {ok:true,status:"queued",queue_id:same.id,thread_id:same.thread_id,state:same.state};
|
|
40
|
+
}
|
|
41
|
+
const tail=mergeable ? this.one(db, "SELECT * FROM turns WHERE thread_id=? ORDER BY seq DESC LIMIT 1", thread_id) : null;
|
|
42
|
+
// WAITING denotes admitted work being retried; only QUEUED is cancellable
|
|
43
|
+
// and eligible for a new enqueue-time merge.
|
|
44
|
+
if (tail && tail.mergeable && tail.project_id===project_id && tail.state==="QUEUED") {
|
|
45
|
+
db.prepare("INSERT OR IGNORE INTO merged_requests VALUES(?,?,?,?,?)").run(tail.idem_key,tail.project_id,thread_id,tail.message,tail.id);
|
|
46
|
+
db.prepare("INSERT INTO merged_requests VALUES(?,?,?,?,?)").run(key,project_id,thread_id,message,tail.id);
|
|
47
|
+
db.prepare("UPDATE turns SET message=message || ? WHERE id=?").run("\n"+message,tail.id);
|
|
48
|
+
const row=this.one(db, "SELECT * FROM turns WHERE id=?", tail.id);
|
|
49
|
+
return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
|
|
50
|
+
}
|
|
51
|
+
const id=randomUUID();
|
|
52
|
+
db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable) VALUES(?,?,?,?,?,'QUEUED',?,?,?)").run(id,key,project_id,thread_id,message,now,now,mergeable?1:0);
|
|
53
|
+
return {ok:true,status:"queued",queue_id:id,thread_id,state:"QUEUED"};
|
|
54
|
+
},
|
|
55
|
+
claim(db, owner, now, leaseSeconds) {
|
|
56
|
+
db.prepare("UPDATE turns SET state='UNCERTAIN',lease_owner=NULL,lease_until=NULL WHERE state='SENDING' AND lease_until<=?").run(now);
|
|
57
|
+
db.prepare("UPDATE turns SET state='WAITING',lease_owner=NULL,lease_until=NULL,next_check_at=? WHERE state='CHECKING' AND lease_until<=?").run(now,now);
|
|
58
|
+
const row=this.one(db, `SELECT t.* FROM turns t WHERE t.state IN ('QUEUED','WAITING') AND t.next_check_at<=? AND NOT EXISTS(SELECT 1 FROM turns older WHERE older.thread_id=t.thread_id AND older.seq<t.seq AND (older.state NOT IN ('SENT','CANCELLED') OR (older.state='SENT' AND older.next_check_at>?))) ORDER BY t.seq LIMIT 1`,now,now);
|
|
59
|
+
if (!row) return null;
|
|
60
|
+
db.prepare("UPDATE turns SET state='CHECKING',lease_owner=?,lease_until=?,attempts=attempts+1 WHERE id=?").run(owner,now+leaseSeconds,row.id);
|
|
61
|
+
return this.one(db, "SELECT * FROM turns WHERE id=?", row.id);
|
|
62
|
+
},
|
|
63
|
+
cancel(db, id) {
|
|
64
|
+
const row=this.one(db, "SELECT * FROM turns WHERE id=?", id);
|
|
65
|
+
if (!row) return {ok:false,queue_id:id,error:"queue item not found"};
|
|
66
|
+
if (row.state!=="QUEUED") return {ok:false,queue_id:id,state:row.state,error:"only queued items can be cancelled"};
|
|
67
|
+
db.prepare("UPDATE turns SET state='CANCELLED',lease_owner=NULL,lease_until=NULL WHERE id=? AND state='QUEUED'").run(id);
|
|
68
|
+
return {ok:true,queue_id:id,state:"CANCELLED"};
|
|
69
|
+
},
|
|
70
|
+
list(db) {
|
|
71
|
+
return {ok:true,items:this.rows(db).filter(row=>!["SENT","CANCELLED"].includes(row.state)).map(({id,thread_id,state,attempts,created_at,receipt})=>({id,queue_id:id,thread_id,state,attempts,created_at,receipt}))};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export class ChatGPTQueue extends DurableQueueCore {
|
|
27
76
|
constructor(
|
|
28
77
|
{
|
|
29
78
|
persistenceRoot,
|
|
@@ -49,155 +98,36 @@ export class ChatGPTQueue {
|
|
|
49
98
|
throw new TypeError(`${name} callback is required`);
|
|
50
99
|
}
|
|
51
100
|
}
|
|
52
|
-
|
|
53
|
-
mkdirSync(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
101
|
+
const root = resolve(persistenceRoot);
|
|
102
|
+
mkdirSync(root, { recursive: true });
|
|
103
|
+
const dbPath = join(root, "chatgpt-new-turn.sqlite3");
|
|
104
|
+
const db = new DatabaseSync(dbPath);
|
|
105
|
+
db.exec(
|
|
57
106
|
"PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000",
|
|
58
107
|
);
|
|
59
|
-
|
|
108
|
+
db.exec(
|
|
60
109
|
`CREATE TABLE IF NOT EXISTS turns(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,idem_key TEXT NOT NULL UNIQUE,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,state TEXT NOT NULL,lease_owner TEXT,lease_until REAL,next_check_at REAL NOT NULL,created_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,receipt TEXT,mergeable INTEGER NOT NULL DEFAULT 0); CREATE INDEX IF NOT EXISTS turns_thread_order ON turns(thread_id,seq); CREATE TABLE IF NOT EXISTS merged_requests(idem_key TEXT PRIMARY KEY,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,queue_id TEXT NOT NULL);`,
|
|
61
110
|
);
|
|
111
|
+
super(db, chatgptQueuePolicy, now, {
|
|
112
|
+
concurrency: Math.min(4, Math.max(1, Number(concurrency) || 4)), intervalMs,
|
|
113
|
+
});
|
|
114
|
+
this.root = root;
|
|
115
|
+
this.dbPath = dbPath;
|
|
116
|
+
this.db = db;
|
|
62
117
|
this.startSession = startSession;
|
|
63
118
|
this.execSession = execSession;
|
|
64
119
|
this.stopSession = stopSession;
|
|
65
|
-
this.intervalMs = intervalMs;
|
|
66
|
-
this.concurrency = concurrency;
|
|
67
120
|
this.leaseSeconds = leaseSeconds;
|
|
68
|
-
this.now = now;
|
|
69
121
|
this.logger = logger;
|
|
70
|
-
this.active = new Map();
|
|
71
122
|
this.sessions = new Map();
|
|
72
|
-
this.started = false;
|
|
73
|
-
this.closed = false;
|
|
74
|
-
this.wakeTimer = null;
|
|
75
|
-
}
|
|
76
|
-
_transaction(fn) {
|
|
77
|
-
this.db.exec("BEGIN IMMEDIATE");
|
|
78
|
-
try {
|
|
79
|
-
const v = fn();
|
|
80
|
-
this.db.exec("COMMIT");
|
|
81
|
-
return v;
|
|
82
|
-
} catch (e) {
|
|
83
|
-
this.db.exec("ROLLBACK");
|
|
84
|
-
throw e;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
_one(sql, ...args) {
|
|
88
|
-
return this.db.prepare(sql).get(...args) || null;
|
|
89
123
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
enqueue(
|
|
94
|
-
{
|
|
95
|
-
thread_id,
|
|
96
|
-
message,
|
|
97
|
-
idempotency_key,
|
|
98
|
-
idempotencyKey,
|
|
99
|
-
project_id = null,
|
|
100
|
-
mergeable = false,
|
|
101
|
-
} = {},
|
|
102
|
-
) {
|
|
103
|
-
const key = idempotency_key ?? idempotencyKey;
|
|
104
|
-
if (!thread_id || !message || !key) {
|
|
105
|
-
throw new TypeError("thread_id, message, idempotency_key required");
|
|
106
|
-
}
|
|
107
|
-
if (typeof mergeable !== "boolean") {
|
|
108
|
-
throw new TypeError("mergeable must be a boolean");
|
|
109
|
-
}
|
|
110
|
-
const now = this.now();
|
|
111
|
-
const row = this._transaction(() => {
|
|
112
|
-
const merged = this._one(
|
|
113
|
-
"SELECT * FROM merged_requests WHERE idem_key=?",
|
|
114
|
-
key,
|
|
115
|
-
);
|
|
116
|
-
if (merged) {
|
|
117
|
-
if (
|
|
118
|
-
merged.thread_id !== thread_id || merged.message !== message ||
|
|
119
|
-
merged.project_id !== project_id
|
|
120
|
-
) throw new Error("idempotency key reused with different payload");
|
|
121
|
-
return this._one("SELECT * FROM turns WHERE id=?", merged.queue_id);
|
|
122
|
-
}
|
|
123
|
-
const same = this._one("SELECT * FROM turns WHERE idem_key=?", key);
|
|
124
|
-
if (same) {
|
|
125
|
-
if (
|
|
126
|
-
same.thread_id !== thread_id || same.message !== message ||
|
|
127
|
-
same.project_id !== project_id
|
|
128
|
-
) throw new Error("idempotency key reused with different payload");
|
|
129
|
-
return same;
|
|
130
|
-
}
|
|
131
|
-
const tail = mergeable
|
|
132
|
-
? this._one(
|
|
133
|
-
"SELECT * FROM turns WHERE thread_id=? ORDER BY seq DESC LIMIT 1",
|
|
134
|
-
thread_id,
|
|
135
|
-
)
|
|
136
|
-
: null;
|
|
137
|
-
if (
|
|
138
|
-
tail && tail.mergeable && tail.project_id === project_id &&
|
|
139
|
-
["QUEUED", "WAITING"].includes(tail.state)
|
|
140
|
-
) {
|
|
141
|
-
this.db.prepare(
|
|
142
|
-
"INSERT OR IGNORE INTO merged_requests VALUES(?,?,?,?,?)",
|
|
143
|
-
).run(tail.idem_key, tail.project_id, thread_id, tail.message, tail.id);
|
|
144
|
-
this.db.prepare("INSERT INTO merged_requests VALUES(?,?,?,?,?)").run(
|
|
145
|
-
key,
|
|
146
|
-
project_id,
|
|
147
|
-
thread_id,
|
|
148
|
-
message,
|
|
149
|
-
tail.id,
|
|
150
|
-
);
|
|
151
|
-
this.db.prepare("UPDATE turns SET message=message || ? WHERE id=?").run(
|
|
152
|
-
"\n" + message,
|
|
153
|
-
tail.id,
|
|
154
|
-
);
|
|
155
|
-
return this._one("SELECT * FROM turns WHERE id=?", tail.id);
|
|
156
|
-
}
|
|
157
|
-
const id = randomUUID();
|
|
158
|
-
this.db.prepare(
|
|
159
|
-
"INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable) VALUES(?,?,?,?,? ,'QUEUED',?,?,?)",
|
|
160
|
-
).run(
|
|
161
|
-
id,
|
|
162
|
-
key,
|
|
163
|
-
project_id,
|
|
164
|
-
thread_id,
|
|
165
|
-
message,
|
|
166
|
-
now,
|
|
167
|
-
now,
|
|
168
|
-
mergeable ? 1 : 0,
|
|
169
|
-
);
|
|
170
|
-
return this._one("SELECT * FROM turns WHERE id=?", id);
|
|
171
|
-
});
|
|
124
|
+
enqueue(request) {
|
|
125
|
+
const result = super.enqueue(request);
|
|
172
126
|
if (this.started) this._wake(0);
|
|
173
|
-
return
|
|
174
|
-
ok: true,
|
|
175
|
-
status: "queued",
|
|
176
|
-
queue_id: row.id,
|
|
177
|
-
thread_id: row.thread_id,
|
|
178
|
-
state: row.state,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
claim(owner, now = this.now()) {
|
|
182
|
-
return this._transaction(() => {
|
|
183
|
-
this.db.prepare(
|
|
184
|
-
"UPDATE turns SET state='UNCERTAIN',lease_owner=NULL,lease_until=NULL WHERE state='SENDING' AND lease_until<=?",
|
|
185
|
-
).run(now);
|
|
186
|
-
this.db.prepare(
|
|
187
|
-
"UPDATE turns SET state='WAITING',lease_owner=NULL,lease_until=NULL,next_check_at=? WHERE state='CHECKING' AND lease_until<=?",
|
|
188
|
-
).run(now, now);
|
|
189
|
-
const row = this._one(
|
|
190
|
-
`SELECT t.* FROM turns t WHERE t.state IN ('QUEUED','WAITING') AND t.next_check_at<=? AND NOT EXISTS(SELECT 1 FROM turns older WHERE older.thread_id=t.thread_id AND older.seq<t.seq AND (older.state NOT IN ('SENT','CANCELLED') OR (older.state='SENT' AND older.next_check_at>?))) ORDER BY t.seq LIMIT 1`,
|
|
191
|
-
now,
|
|
192
|
-
now,
|
|
193
|
-
);
|
|
194
|
-
if (!row) return null;
|
|
195
|
-
this.db.prepare(
|
|
196
|
-
"UPDATE turns SET state='CHECKING',lease_owner=?,lease_until=?,attempts=attempts+1 WHERE id=?",
|
|
197
|
-
).run(owner, now + this.leaseSeconds, row.id);
|
|
198
|
-
return this._one("SELECT * FROM turns WHERE id=?", row.id);
|
|
199
|
-
});
|
|
127
|
+
return result;
|
|
200
128
|
}
|
|
129
|
+
_one(sql, ...args) { return this.db.prepare(sql).get(...args) || null; }
|
|
130
|
+
claim(owner, now = this.now()) { return super.claim(owner, now, this.leaseSeconds); }
|
|
201
131
|
transition(
|
|
202
132
|
id,
|
|
203
133
|
owner,
|
|
@@ -238,24 +168,7 @@ export class ChatGPTQueue {
|
|
|
238
168
|
);
|
|
239
169
|
});
|
|
240
170
|
}
|
|
241
|
-
|
|
242
|
-
return this._rows();
|
|
243
|
-
}
|
|
244
|
-
inspect() {
|
|
245
|
-
return {
|
|
246
|
-
ok: true,
|
|
247
|
-
items: this.rows().filter((r) => !["SENT", "CANCELLED"].includes(r.state))
|
|
248
|
-
.map(({ id, thread_id, state, attempts, created_at, receipt }) => ({
|
|
249
|
-
id,
|
|
250
|
-
queue_id: id,
|
|
251
|
-
thread_id,
|
|
252
|
-
state,
|
|
253
|
-
attempts,
|
|
254
|
-
created_at,
|
|
255
|
-
receipt,
|
|
256
|
-
})),
|
|
257
|
-
};
|
|
258
|
-
}
|
|
171
|
+
inspect() { return this.list(); }
|
|
259
172
|
reconcile({ queue_id, evidence, accepted } = {}) {
|
|
260
173
|
if (!queue_id || !evidence || typeof accepted !== "boolean") {
|
|
261
174
|
throw new TypeError("queue_id, evidence and boolean accepted required");
|
|
@@ -270,35 +183,8 @@ export class ChatGPTQueue {
|
|
|
270
183
|
if (this.started) this._wake(0);
|
|
271
184
|
return { ok: true, queue_id, state: accepted ? "SENT" : "WAITING" };
|
|
272
185
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
this.started = true;
|
|
276
|
-
this.closed = false;
|
|
277
|
-
this._wake(0);
|
|
278
|
-
return this;
|
|
279
|
-
}
|
|
280
|
-
_wake(ms = this.intervalMs) {
|
|
281
|
-
if (this.closed) return;
|
|
282
|
-
clearTimeout(this.wakeTimer);
|
|
283
|
-
this.wakeTimer = setTimeout(() => this._tick(), ms);
|
|
284
|
-
this.wakeTimer.unref?.();
|
|
285
|
-
}
|
|
286
|
-
async _tick() {
|
|
287
|
-
if (this.closed) return;
|
|
288
|
-
while (this.active.size < this.concurrency) {
|
|
289
|
-
const owner = `dispatcher-${process.pid}-${randomUUID()}`;
|
|
290
|
-
const item = this.claim(owner);
|
|
291
|
-
if (!item) break;
|
|
292
|
-
const promise = this._process(item).catch((e) =>
|
|
293
|
-
this.logger({ event: "dispatcher_error", error: String(e) })
|
|
294
|
-
).finally(() => {
|
|
295
|
-
this.active.delete(item.id);
|
|
296
|
-
this._wake(0);
|
|
297
|
-
});
|
|
298
|
-
this.active.set(item.id, promise);
|
|
299
|
-
}
|
|
300
|
-
if (!this.closed) this._wake(this.intervalMs);
|
|
301
|
-
}
|
|
186
|
+
process(item) { return this._process(item); }
|
|
187
|
+
onDispatchError(error) { this.logger({event:"dispatcher_error",error:String(error)}); }
|
|
302
188
|
async _exec(sid, code) {
|
|
303
189
|
const response = await this.execSession(sid, code);
|
|
304
190
|
if (response?.ok === false) {
|
|
@@ -426,9 +312,7 @@ export class ChatGPTQueue {
|
|
|
426
312
|
}
|
|
427
313
|
async shutdown() {
|
|
428
314
|
if (this.closed) return;
|
|
429
|
-
this.
|
|
430
|
-
clearTimeout(this.wakeTimer);
|
|
431
|
-
await Promise.allSettled([...this.active.values()]);
|
|
315
|
+
await this.stopScheduler();
|
|
432
316
|
for (const id of [...this.sessions.keys()]) await this._finish(id);
|
|
433
317
|
this.db.close();
|
|
434
318
|
}
|
package/runtime/cli-help.mjs
CHANGED
|
@@ -14,6 +14,8 @@ Commands:
|
|
|
14
14
|
webhelm status Report browser and daemon status (JSON)
|
|
15
15
|
webhelm ensure Check and recover managed Chrome health
|
|
16
16
|
webhelm capacity Report available browser capacity (JSON)
|
|
17
|
+
webhelm queue list List active ChatGPT new-turn queue items
|
|
18
|
+
webhelm queue cancel QUEUE_ID Cancel an item that is still queued
|
|
17
19
|
webhelm session start [--url URL] Start a persistent browser Session
|
|
18
20
|
webhelm session list List CLI-owned Sessions
|
|
19
21
|
webhelm session exec ID [--code-file F] Run JavaScript in a Session
|
|
@@ -87,6 +89,15 @@ Output: JSON capacity information.
|
|
|
87
89
|
|
|
88
90
|
Example:
|
|
89
91
|
webhelm capacity`,
|
|
92
|
+
queue: `webhelm queue — inspect or cancel ChatGPT new-turn queue items
|
|
93
|
+
|
|
94
|
+
Usage:
|
|
95
|
+
webhelm queue list
|
|
96
|
+
webhelm queue cancel QUEUE_ID
|
|
97
|
+
|
|
98
|
+
Cancellation succeeds only while an item remains QUEUED. Items already being
|
|
99
|
+
checked, sent, or awaiting reconciliation cannot be cancelled.
|
|
100
|
+
Output: JSON queue state or cancellation receipt.`,
|
|
90
101
|
migrate: `webhelm migrate — migrate supported legacy local browser data
|
|
91
102
|
|
|
92
103
|
Usage:
|
package/runtime/cli.mjs
CHANGED
|
@@ -149,6 +149,15 @@ async function main(args = process.argv.slice(2)) {
|
|
|
149
149
|
// reload an older daemon's Extension while its Sessions are still active.
|
|
150
150
|
return emit(await request(payload));
|
|
151
151
|
}
|
|
152
|
+
if (group === 'queue') {
|
|
153
|
+
if (command === 'list') return emit(await request({op:'queue-list'}));
|
|
154
|
+
if (command === 'cancel') {
|
|
155
|
+
const queueId=args[2];
|
|
156
|
+
if (!queueId) throw Error('queue cancel requires QUEUE_ID');
|
|
157
|
+
return emit(await request({op:'queue-cancel',queue_id:queueId}));
|
|
158
|
+
}
|
|
159
|
+
throw Error('queue requires list or cancel QUEUE_ID');
|
|
160
|
+
}
|
|
152
161
|
if (group === 'session') {
|
|
153
162
|
if (command === 'start') return emit(await request({op:'start', workspace:CLI_WORKSPACE, url:arg(args,'--url')}));
|
|
154
163
|
if (command === 'list') {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Queue-neutral transaction boundary for durable queue policies.
|
|
5
|
+
*
|
|
6
|
+
* Policies own their persistence schema, idempotency and payload semantics,
|
|
7
|
+
* merge rules, partition ordering, recovery rules, and public receipts. Every
|
|
8
|
+
* mutating operation is run in one immediate transaction so policy decisions
|
|
9
|
+
* (including optional enqueue-time merges) are atomic with persistence.
|
|
10
|
+
*/
|
|
11
|
+
export class DurableQueueCore {
|
|
12
|
+
constructor(db, policy, now = () => Date.now() / 1000, {concurrency = 1, intervalMs = 1000} = {}) {
|
|
13
|
+
if (!db || !policy) throw new TypeError("db and queue policy are required");
|
|
14
|
+
this.db = db;
|
|
15
|
+
this.policy = policy;
|
|
16
|
+
this.now = now;
|
|
17
|
+
this.concurrency = Math.max(1, Number(concurrency) || 1);
|
|
18
|
+
this.intervalMs = intervalMs;
|
|
19
|
+
this.active = new Map();
|
|
20
|
+
this.started = false;
|
|
21
|
+
this.closed = false;
|
|
22
|
+
this.wakeTimer = null;
|
|
23
|
+
}
|
|
24
|
+
_transaction(fn) {
|
|
25
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
26
|
+
try {
|
|
27
|
+
const value = fn();
|
|
28
|
+
this.db.exec("COMMIT");
|
|
29
|
+
return value;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
this.db.exec("ROLLBACK");
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
rows() { return this.policy.rows(this.db); }
|
|
36
|
+
enqueue(request) {
|
|
37
|
+
return this._transaction(() => this.policy.enqueue(this.db, request, this.now()));
|
|
38
|
+
}
|
|
39
|
+
claim(owner, now = this.now(), ...policyArgs) {
|
|
40
|
+
return this._transaction(() => this.policy.claim(this.db, owner, now, ...policyArgs));
|
|
41
|
+
}
|
|
42
|
+
cancel(id) {
|
|
43
|
+
return this._transaction(() => this.policy.cancel(this.db, id));
|
|
44
|
+
}
|
|
45
|
+
list() { return this.policy.list(this.db); }
|
|
46
|
+
start() {
|
|
47
|
+
if (this.started) return this;
|
|
48
|
+
this.started = true;
|
|
49
|
+
this.closed = false;
|
|
50
|
+
this._wake(0);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
_wake(ms = this.intervalMs) {
|
|
54
|
+
if (this.closed) return;
|
|
55
|
+
clearTimeout(this.wakeTimer);
|
|
56
|
+
this.wakeTimer = setTimeout(() => this._tick(), ms);
|
|
57
|
+
this.wakeTimer.unref?.();
|
|
58
|
+
}
|
|
59
|
+
async _tick() {
|
|
60
|
+
if (this.closed) return;
|
|
61
|
+
while (this.active.size < this.concurrency) {
|
|
62
|
+
const owner = `queue-worker-${process.pid}-${randomUUID()}`;
|
|
63
|
+
const item = this.claim(owner);
|
|
64
|
+
if (!item) break;
|
|
65
|
+
const promise = Promise.resolve().then(() => this.process(item)).catch(error => {
|
|
66
|
+
this.onDispatchError(error);
|
|
67
|
+
}).finally(() => {
|
|
68
|
+
this.active.delete(item.id);
|
|
69
|
+
this._wake(0);
|
|
70
|
+
});
|
|
71
|
+
this.active.set(item.id, promise);
|
|
72
|
+
}
|
|
73
|
+
if (!this.closed) this._wake(this.intervalMs);
|
|
74
|
+
}
|
|
75
|
+
async process() { throw new Error("queue process handler is not implemented"); }
|
|
76
|
+
onDispatchError() {}
|
|
77
|
+
async stopScheduler() {
|
|
78
|
+
this.closed = true;
|
|
79
|
+
clearTimeout(this.wakeTimer);
|
|
80
|
+
await Promise.allSettled([...this.active.values()]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -53,6 +53,7 @@ export class ControlChannel {
|
|
|
53
53
|
sessions: connected ? state.daemon.sessions || [] : [],
|
|
54
54
|
request: state.request || null,
|
|
55
55
|
lastResult: state.lastResult || null,
|
|
56
|
+
queue: state.queue || [],
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -77,16 +78,31 @@ export class ControlChannel {
|
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
async
|
|
81
|
+
async requestQueueCancel(queueId) {
|
|
82
|
+
return this.serialize(async () => {
|
|
83
|
+
if (typeof queueId !== 'string' || !queueId) throw new Error('Queue item is required');
|
|
84
|
+
const state = this.normalize(await this.read());
|
|
85
|
+
if (state.request) throw new Error('Another WebHelm operation is already running');
|
|
86
|
+
if (!Number.isFinite(state.daemon?.seenAt) || this.now() - state.daemon.seenAt >= FRESH_MS)
|
|
87
|
+
throw new Error('WebHelm daemon is not responding');
|
|
88
|
+
if (!(state.queue || []).some(item => item.id === queueId && item.state === 'QUEUED'))
|
|
89
|
+
throw new Error('Queue item is no longer cancellable');
|
|
90
|
+
const request = {id:this.makeId(), action:'queue-cancel', queueId, state:'pending', createdAt:this.now()};
|
|
91
|
+
await this.save({...state, request, lastResult:null});
|
|
92
|
+
return {id:request.id,state:'pending'};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async heartbeat(sessions, queue = []) {
|
|
81
97
|
return this.serialize(async()=>{
|
|
82
98
|
if (!Array.isArray(sessions)) throw new Error('Invalid sessions');
|
|
83
99
|
const state=this.normalize(await this.read());
|
|
84
|
-
await this.save({...state,daemon:{seenAt:this.now(),sessions}});
|
|
100
|
+
await this.save({...state,daemon:{seenAt:this.now(),sessions},queue});
|
|
85
101
|
return {updated:true};
|
|
86
102
|
});
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
async poll(sessions) {
|
|
105
|
+
async poll(sessions, queue = []) {
|
|
90
106
|
return this.serialize(async () => {
|
|
91
107
|
if (!Array.isArray(sessions) || sessions.some(s =>
|
|
92
108
|
typeof s?.session_id !== 'string' || typeof s?.workspace !== 'string'))
|
|
@@ -95,10 +111,10 @@ export class ControlChannel {
|
|
|
95
111
|
const request = state.request?.state === 'pending'
|
|
96
112
|
? {...state.request, state: 'processing'}
|
|
97
113
|
: state.request || null;
|
|
98
|
-
await this.save({...state, request, daemon: {seenAt: this.now(), sessions}});
|
|
114
|
+
await this.save({...state, request, daemon: {seenAt: this.now(), sessions}, queue});
|
|
99
115
|
return request?.state === 'processing' && state.request?.state === 'pending'
|
|
100
116
|
? {id: request.id, action: request.action, ...(request.sessionId ? {sessionId:request.sessionId} : {}),
|
|
101
|
-
...(request.workspace ? {workspace:request.workspace} : {})}
|
|
117
|
+
...(request.workspace ? {workspace:request.workspace} : {}), ...(request.queueId ? {queueId:request.queueId} : {})}
|
|
102
118
|
: null;
|
|
103
119
|
});
|
|
104
120
|
}
|
|
@@ -111,7 +127,7 @@ export class ControlChannel {
|
|
|
111
127
|
const errors = response?.errors || [];
|
|
112
128
|
const errorText = response?.error || errors.map(e => e.error || String(e)).join('; ');
|
|
113
129
|
const lastResult = response?.ok
|
|
114
|
-
? {id, ok: errors.length === 0, stoppedCount, errors, ...(errors.length ? {error: errorText} : {})}
|
|
130
|
+
? {id, ok: errors.length === 0, stoppedCount, errors, ...(response.queue_id ? {queue_id:response.queue_id,state:response.state} : {}), ...(errors.length ? {error: errorText} : {})}
|
|
115
131
|
: {id, ok: false, stoppedCount, errors, error: String(errorText || 'Unable to stop sessions')};
|
|
116
132
|
await this.save({...state, request: null, lastResult});
|
|
117
133
|
return lastResult;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createControlRpc({control, manager, summarizeWorkspaces, scheduleBadge}) {
|
|
2
|
+
return async function controlRpc(method, args = {}) {
|
|
3
|
+
switch (method) {
|
|
4
|
+
case "workspace.control.status": {
|
|
5
|
+
const state = await control.status();
|
|
6
|
+
const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
|
|
7
|
+
return {...state,...summary};
|
|
8
|
+
}
|
|
9
|
+
case "workspace.control.requestStop":
|
|
10
|
+
return control.requestStop(args.action, args.sessionId, args.workspace);
|
|
11
|
+
case "workspace.control.queueCancel":
|
|
12
|
+
return control.requestQueueCancel(args.queueId);
|
|
13
|
+
case "workspace.control.heartbeat":
|
|
14
|
+
return control.heartbeat(args.sessions, args.queue);
|
|
15
|
+
case "workspace.control.poll": {
|
|
16
|
+
const result = await control.poll(args.sessions, args.queue);
|
|
17
|
+
scheduleBadge();
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
case "workspace.control.complete":
|
|
21
|
+
return control.complete(args.id, args.response);
|
|
22
|
+
default:
|
|
23
|
+
throw new Error("Unknown workspace control method");
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
.session-info strong { display:block; font-size:12px; }
|
|
34
34
|
.session-info span { display:block; color:light-dark(var(--muted),#acb8c9); font-size:11px; }
|
|
35
35
|
.session-row button { width:auto; padding:5px 9px; font-size:11px; }
|
|
36
|
+
.queue-row { display:flex; align-items:center; gap:8px; padding:7px 0; border-bottom:1px solid light-dark(var(--border),#334155); }
|
|
37
|
+
.queue-info { min-width:0; flex:1; overflow-wrap:anywhere; }
|
|
38
|
+
.queue-info span { display:block; color:light-dark(var(--muted),#acb8c9); font-size:11px; }
|
|
39
|
+
.queue-row button { width:auto; padding:5px 9px; font-size:11px; }
|
|
36
40
|
#message { margin-top:8px; font-size:11px; overflow-wrap:anywhere; }
|
|
37
41
|
#message:empty { display:none; }
|
|
38
42
|
[hidden] { display:none !important; }
|
|
@@ -44,6 +48,8 @@
|
|
|
44
48
|
<span id="daemon-status" class="daemon-state" role="status">Connecting…</span>
|
|
45
49
|
</header>
|
|
46
50
|
<div id="workspace-list" class="session-list" aria-label="Workspaces" aria-live="polite">Connecting…</div>
|
|
51
|
+
<strong>ChatGPT queue</strong>
|
|
52
|
+
<div id="queue-list" class="session-list" aria-label="ChatGPT queue" aria-live="polite">No queued turns</div>
|
|
47
53
|
<section id="confirmation" hidden aria-label="Confirm clear sessions">
|
|
48
54
|
<p id="confirm-text">Stop the selected WebHelm session(s)? Tasks in progress may be interrupted. Queued jobs remain and may create new sessions.</p>
|
|
49
55
|
<div class="buttons">
|
|
@@ -74,6 +74,25 @@ function renderWorkspaces(workspaces, busy, connected) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
function renderQueue(items, busy, connected) {
|
|
78
|
+
const target=el('queue-list');
|
|
79
|
+
target.replaceChildren();
|
|
80
|
+
if (!items.length) { target.textContent='No active turns'; return; }
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
const row=document.createElement('div'); row.className='queue-row';
|
|
83
|
+
const info=document.createElement('div'); info.className='queue-info';
|
|
84
|
+
const title=document.createElement('strong'); title.textContent=`Thread ${item.thread_id}`;
|
|
85
|
+
const detail=document.createElement('span'); detail.textContent=`${item.state.toLowerCase()} · ${item.attempts} attempt${item.attempts===1?'':'s'}`;
|
|
86
|
+
info.append(title,detail); row.append(info);
|
|
87
|
+
if (item.state==='QUEUED') row.append(actionButton('Cancel',busy || !connected,async()=>{
|
|
88
|
+
try { const result=await rpc('workspace.control.queueCancel',{queueId:item.id}); if(!result.id) throw new Error('Missing queue request ID'); el('message').textContent='Waiting for WebHelm daemon…'; }
|
|
89
|
+
catch(error) { el('message').textContent=error.message; }
|
|
90
|
+
refresh();
|
|
91
|
+
}));
|
|
92
|
+
target.append(row);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
async function refresh() {
|
|
78
97
|
if (refreshRunning) return;
|
|
79
98
|
refreshRunning = true;
|
|
@@ -83,6 +102,7 @@ async function refresh() {
|
|
|
83
102
|
el('daemon-status').className = `daemon-state ${state.connected ? 'online' : 'offline'}`;
|
|
84
103
|
const busy = Boolean(state.request);
|
|
85
104
|
renderWorkspaces(state.workspaces || [], busy, state.connected);
|
|
105
|
+
renderQueue(state.queue || [], busy, state.connected);
|
|
86
106
|
el('confirm').disabled = !state.connected || busy;
|
|
87
107
|
if (busy) {
|
|
88
108
|
el('message').textContent = 'Stopping sessions…';
|
|
@@ -2,6 +2,7 @@ import { WorkspaceManager } from "./workspace-manager.mjs";
|
|
|
2
2
|
import { ControlChannel } from "./control-channel.mjs";
|
|
3
3
|
import { summarizeWorkspaces } from "./workspace-summary.mjs";
|
|
4
4
|
import { sessionBadge } from "./session-badge.mjs";
|
|
5
|
+
import { createControlRpc } from "./control-rpc.mjs";
|
|
5
6
|
|
|
6
7
|
const manager = new WorkspaceManager(chrome);
|
|
7
8
|
const control = new ControlChannel(chrome);
|
|
@@ -52,28 +53,7 @@ chrome.runtime.onStartup.addListener(() => {
|
|
|
52
53
|
scheduleBadge();
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
switch (method) {
|
|
57
|
-
case "workspace.control.status": {
|
|
58
|
-
const state = await control.status();
|
|
59
|
-
const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
|
|
60
|
-
return {...state,...summary};
|
|
61
|
-
}
|
|
62
|
-
case "workspace.control.requestStop":
|
|
63
|
-
return control.requestStop(args.action, args.sessionId, args.workspace);
|
|
64
|
-
case "workspace.control.heartbeat":
|
|
65
|
-
return control.heartbeat(args.sessions);
|
|
66
|
-
case "workspace.control.poll": {
|
|
67
|
-
const result = await control.poll(args.sessions);
|
|
68
|
-
scheduleBadge();
|
|
69
|
-
return result;
|
|
70
|
-
}
|
|
71
|
-
case "workspace.control.complete":
|
|
72
|
-
return control.complete(args.id, args.response);
|
|
73
|
-
default:
|
|
74
|
-
throw new Error("Unknown workspace control method");
|
|
75
|
-
}
|
|
76
|
-
}
|
|
56
|
+
const controlRpc = createControlRpc({control, manager, summarizeWorkspaces, scheduleBadge});
|
|
77
57
|
|
|
78
58
|
globalThis.browserWorkspaceManagerRpc = async (request) => {
|
|
79
59
|
try {
|
|
@@ -112,7 +92,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|
|
112
92
|
}
|
|
113
93
|
if (request?.method?.startsWith("workspace.control.") &&
|
|
114
94
|
(sender?.url !== chrome.runtime.getURL("popup.html") ||
|
|
115
|
-
!["workspace.control.requestStop", "workspace.control.status"].includes(request.method))) {
|
|
95
|
+
!["workspace.control.requestStop", "workspace.control.queueCancel", "workspace.control.status"].includes(request.method))) {
|
|
116
96
|
sendResponse({ok:false,error:{message:"WebHelm control actions require the toolbar popup"}});
|
|
117
97
|
return false;
|
|
118
98
|
}
|
|
@@ -12,7 +12,7 @@ export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME ||
|
|
|
12
12
|
(SOURCE_MODE ? SOURCE : path.join(LOCAL_ROOT,'extension')));
|
|
13
13
|
const FILES=[
|
|
14
14
|
'manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','helm.html','workspace-page.mjs',
|
|
15
|
-
'control-channel.mjs','workspace-summary.mjs','session-badge.mjs','popup.html','popup.mjs',
|
|
15
|
+
'control-channel.mjs','control-rpc.mjs','workspace-summary.mjs','session-badge.mjs','popup.html','popup.mjs',
|
|
16
16
|
...[16,32,48,128].map(size => `icons/webhelm-${size}.png`),
|
|
17
17
|
];
|
|
18
18
|
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import crypto from 'node:crypto';
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
import { invokePlatformAction } from './platforms.mjs';
|
|
10
10
|
import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
|
|
11
|
-
import { idleUrlFromIdentity, sameIdleUrl, staleSession } from './session-lifecycle.mjs';
|
|
11
|
+
import { idleUrlFromIdentity, sameIdleUrl, staleSession, withinDeadline, SessionDeadlineError } from './session-lifecycle.mjs';
|
|
12
12
|
import { stopMatchingSessions } from './session-control.mjs';
|
|
13
13
|
import { ensureManagedChrome } from './chrome.mjs';
|
|
14
14
|
import { ensureExtensionLoaded } from './extension-manager.mjs';
|
|
@@ -173,8 +173,8 @@ const managedChromeGuard=createManagedChromeGuard(guardManagedChrome,{
|
|
|
173
173
|
function adoptPopup(info){if(info?.type!=='page'||!info.openerId)return;const owner=targets.get(info.openerId);if(owner){owner.targets.add(info.targetId);targets.set(info.targetId,owner)}}
|
|
174
174
|
async function attach(s,targetId,b=null){b ||= await connectBrowser();const {sessionId}=await b.send('Target.attachToTarget',{targetId,flatten:true});s.targets.add(targetId);s.attachments.set(targetId,sessionId);targets.set(targetId,s);for(const d of ['Page','DOM','Runtime','Network'])await b.send(`${d}.enable`,{},sessionId);for(const dispose of s.eventDisposers?.get(targetId)||[])dispose();const removers=[];for(const method of ['Page.loadEventFired','Page.domContentEventFired','Page.javascriptDialogOpening','Page.javascriptDialogClosed','Runtime.exceptionThrown','Network.requestWillBeSent','Network.responseReceived'])removers.push(b.on(method,(params,owner)=>{if(owner===sessionId){if(s.events.length>=500)s.events.shift();s.events.push({method,params,session_id:sessionId,target_id:targetId})}}));s.eventDisposers?.set(targetId,removers);return sessionId}
|
|
175
175
|
function sidFor(s,targetId=s.targetId){const sid=s.attachments.get(targetId);if(!sid)throw new Error(`target ${targetId} is not attached to this session`);return sid}
|
|
176
|
-
async function evaluate(s,expression,targetId=s.targetId,awaitPromise=true){if(!s.targets.has(targetId))throw Error('cannot evaluate a target outside the active WebHelm session');const b=await connectBrowser();const r=await b.send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise,userGesture:true},sidFor(s,targetId));if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result?.value}
|
|
177
|
-
async function cdp(s,method,params={},targetId=s.targetId){if(method==='Target.createTarget')throw Error('Target.createTarget is disabled; use new_tab(url)');if(method==='Target.attachToTarget'||method==='Target.detachFromTarget')throw Error('direct target attachment changes are disabled in WebHelm sessions');if(['Target.closeTarget','Target.activateTarget'].includes(method)&&!s.targets.has(params.targetId))throw Error('cannot operate on a tab outside the active WebHelm session');if(method.startsWith('Target.')||method.startsWith('Browser.')||method.startsWith('ServiceWorker.'))return
|
|
176
|
+
async function evaluate(s,expression,targetId=s.targetId,awaitPromise=true){if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(!s.targets.has(targetId))throw Error('cannot evaluate a target outside the active WebHelm session');const b=await connectBrowser();if(s.poisoned || s.closing)throw Error('Session execution expired or closing');const r=await b.send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise,userGesture:true},sidFor(s,targetId));if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result?.value}
|
|
177
|
+
async function cdp(s,method,params={},targetId=s.targetId){if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(method==='Target.createTarget')throw Error('Target.createTarget is disabled; use new_tab(url)');if(method==='Target.attachToTarget'||method==='Target.detachFromTarget')throw Error('direct target attachment changes are disabled in WebHelm sessions');if(['Target.closeTarget','Target.activateTarget'].includes(method)&&!s.targets.has(params.targetId))throw Error('cannot operate on a tab outside the active WebHelm session');return connectBrowser().then(browser=>{if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(method.startsWith('Target.')||method.startsWith('Browser.')||method.startsWith('ServiceWorker.'))return browser.send(method,params);return browser.send(method,params,sidFor(s,targetId));})}
|
|
178
178
|
function makeHelpers(s){
|
|
179
179
|
const h={
|
|
180
180
|
js:(expression,target_id)=>evaluate(s,expression,target_id||s.targetId),
|
|
@@ -183,9 +183,11 @@ function makeHelpers(s){
|
|
|
183
183
|
async current_tab(){const info=await cdp(s,'Target.getTargetInfo',{targetId:s.targetId});return info.targetInfo},
|
|
184
184
|
async new_tab(url='about:blank') {
|
|
185
185
|
const browser=await connectBrowser();
|
|
186
|
+
if(s.poisoned || s.closing)throw Error('Session execution expired or closing');
|
|
186
187
|
let targetId,lease;
|
|
187
188
|
if(s.workspaceSupported){
|
|
188
189
|
({lease,targetId}=await acquireIdentityLease(s));
|
|
190
|
+
if(s.poisoned || s.closing){await extensionCall(s,'workspace.release',{name:s.workspace,tabId:lease.tabId});throw Error('Session execution expired');}
|
|
189
191
|
s.leasedTabs.set(targetId,lease.tabId);
|
|
190
192
|
s.idleUrls.set(targetId,lease.idleUrl);
|
|
191
193
|
}else ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
|
|
@@ -200,6 +202,7 @@ function makeHelpers(s){
|
|
|
200
202
|
if(!s.targets.has(tid))throw Error('cannot close a tab outside the active WebHelm Session');
|
|
201
203
|
return leaseMutex(s.workspace).run(async()=>{
|
|
202
204
|
const browser=await connectBrowser();
|
|
205
|
+
if(s.poisoned || s.closing)throw Error('Session execution expired or closing');
|
|
203
206
|
if(s.leasedTabs.has(tid))await navigateIdle(browser,s,tid,s.idleUrls.get(tid));
|
|
204
207
|
else await browser.send('Target.closeTarget',{targetId:tid}); // Popup without pool slot.
|
|
205
208
|
const attachment=s.attachments.get(tid);
|
|
@@ -227,7 +230,7 @@ function makeHelpers(s){
|
|
|
227
230
|
},
|
|
228
231
|
async fill_input(selector,text){if(!await h.wait_for_element(selector))throw Error(`element not found: ${selector}`);return evaluate(s,`(()=>{const e=document.querySelector(${JSON.stringify(selector)});const set=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),'value')?.set;if(set)set.call(e,${JSON.stringify(String(text))});else e.textContent=${JSON.stringify(String(text))};e.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:${JSON.stringify(String(text))}}));e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`)},
|
|
229
232
|
async click(selector){if(!await h.wait_for_element(selector))throw Error(`element not found: ${selector}`);return evaluate(s,`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.click();return true})()`)},
|
|
230
|
-
async capture_screenshot(pathname){const b=await connectBrowser();const data=await b.send('Page.captureScreenshot',{format:'png'},sidFor(s));if(pathname)fs.writeFileSync(pathname,Buffer.from(data.data,'base64'));return data.data},
|
|
233
|
+
async capture_screenshot(pathname){const b=await connectBrowser();if(s.poisoned || s.closing)throw Error('Session execution expired or closing');const data=await b.send('Page.captureScreenshot',{format:'png'},sidFor(s));if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(pathname)fs.writeFileSync(pathname,Buffer.from(data.data,'base64'));return data.data},
|
|
231
234
|
async snapshot(interactive_only=false){const result=await evaluate(s,`(()=>{let n=0;const xs=[...document.querySelectorAll('button,a,input,textarea,select,[role],[tabindex]')].filter(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width&&r.height&&s.visibility!=='hidden'&&s.display!=='none'});return xs.map(e=>{const id='e'+(++n);e.setAttribute('data-ref',id);const role=e.getAttribute('role')||({BUTTON:'button',A:'link',INPUT:'textbox',TEXTAREA:'textbox',SELECT:'combobox'}[e.tagName]||e.tagName.toLowerCase());const name=e.getAttribute('aria-label')||e.innerText||e.value||e.placeholder||'';return '- '+role+' '+JSON.stringify(name.trim().slice(0,100))+' [ref='+id+']'}).join('\\n')})()`);return result||''},
|
|
232
235
|
async workspace_status(){return extensionCall(s,'workspace.status',{name:s.workspace})},
|
|
233
236
|
async workspace_capacity(){const r=await h.workspace_status();const idle=Array.isArray(r.idleTabIds)?r.idleTabIds.length:null,leased=Array.isArray(r.leasedTabIds)?r.leasedTabIds.length:null;return {name:r.name,poolSize:r.poolSize??null,maxCapacity:r.maxCapacity??r.poolSize??null,physicalTabs:r.physicalTabs??(idle!==null&&leased!==null?idle+leased:null),idle,leased,available:Number.isInteger(r.maxCapacity??r.poolSize)&&leased!==null?Math.max(0,(r.maxCapacity??r.poolSize)-leased):null}} ,
|
|
@@ -263,6 +266,22 @@ function makeHelpers(s){
|
|
|
263
266
|
async current_url(){return (await h.page_info()).url},
|
|
264
267
|
async title(){return (await h.page_info()).title}
|
|
265
268
|
};
|
|
269
|
+
// Revoke even helper references retained by unresolved async VM scripts.
|
|
270
|
+
// A rejected async VM execution does not cancel browser operations it
|
|
271
|
+
// already started. Keep their tab ownership until those operations settle.
|
|
272
|
+
const effectHelpers=new Set(['js','cdp','goto_url','new_tab','close_tab','fill_input',
|
|
273
|
+
'click','capture_screenshot','snapshot','type_text','upload_file','switch_tab','activate_tab']);
|
|
274
|
+
for(const [name,helper] of Object.entries(h)){
|
|
275
|
+
if(typeof helper!=='function')continue;
|
|
276
|
+
h[name]=(...args)=>{
|
|
277
|
+
if(s.poisoned || s.closing)throw Error('Session execution expired or closing');
|
|
278
|
+
if(!effectHelpers.has(name))return helper(...args);
|
|
279
|
+
s.pendingEffects++;
|
|
280
|
+
try{
|
|
281
|
+
return Promise.resolve(helper(...args)).finally(()=>{s.pendingEffects--;});
|
|
282
|
+
}catch(error){s.pendingEffects--;throw error;}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
266
285
|
Object.defineProperty(h,'target_id',{get:()=>s.targetId});
|
|
267
286
|
Object.defineProperty(h,'targetId',{get:()=>s.targetId});
|
|
268
287
|
return h;
|
|
@@ -363,7 +382,7 @@ async function createSession(req={}) {
|
|
|
363
382
|
const s={session_id:sid, workspace:req.workspace||await defaultWorkspaceName(), targetId:null,
|
|
364
383
|
rootTargetId:null, workspaceSupported:false,targets:new Set(), attachments:new Map(), eventDisposers:new Map(),
|
|
365
384
|
leasedTabs:new Map(), idleUrls:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,
|
|
366
|
-
inFlight:0, closing:false, last_used_at:Date.now()};
|
|
385
|
+
inFlight:0, pendingEffects:0, closing:false, poisoned:false, last_used_at:Date.now()};
|
|
367
386
|
let targetId,lease;
|
|
368
387
|
try {
|
|
369
388
|
if(await extensionSupported()) {
|
|
@@ -411,10 +430,10 @@ async function extensionSupported() {
|
|
|
411
430
|
|
|
412
431
|
async function execute(req) {
|
|
413
432
|
const s=sessions.get(req.session_id);
|
|
414
|
-
if(!s || s.closing)throw Error(`unknown session_id: ${req.session_id} (missing or closing)`);
|
|
433
|
+
if(!s || s.closing || s.poisoned)throw Error(`unknown session_id: ${req.session_id} (missing or closing)`);
|
|
415
434
|
s.inFlight++;
|
|
416
435
|
try {return await s.mutex.run(async()=>{
|
|
417
|
-
if(s.closing)throw Error('session closing');
|
|
436
|
+
if(s.closing || s.poisoned)throw Error('session closing or expired');
|
|
418
437
|
s.last_used_at=Date.now();
|
|
419
438
|
const logs=[],errors=[];
|
|
420
439
|
const sandbox={...s.namespace};
|
|
@@ -427,12 +446,15 @@ async function execute(req) {
|
|
|
427
446
|
if(last&&!last.endsWith(';')&&!/^(return|throw|if|for|while|function|class|\}|\/\/)/.test(last))lines[lines.length-1]=`return (${last})`;
|
|
428
447
|
source=lines.join('\n');
|
|
429
448
|
const script=new vm.Script(`(async()=>{${source}\n})()`,{filename:'<webhelm-session>'});
|
|
430
|
-
const
|
|
449
|
+
const timeoutMs=Math.max(30,Math.min(30000,Number(process.env.WEBHELM_SESSION_EXEC_TIMEOUT_MS)||30000));
|
|
450
|
+
const result=await withinDeadline(
|
|
451
|
+
Promise.resolve().then(()=>script.runInContext(s.context,{timeout:timeoutMs})),timeoutMs,'Session exec');
|
|
452
|
+
if(s.poisoned)throw Error('Session expired during execution');
|
|
431
453
|
Object.assign(s.namespace,sandbox);
|
|
432
454
|
const out={session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:true,stdout:logs.join('\n'),stderr:errors.join('\n')};
|
|
433
455
|
if(result!==undefined)Object.assign(out,jsonSafe(result));
|
|
434
456
|
return out;
|
|
435
|
-
}catch(e){return {session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:false,stdout:logs.join('\n'),stderr:errors.join('\n'),error:`${e.name}: ${e.message}`}}
|
|
457
|
+
}catch(e){if(e instanceof SessionDeadlineError || (e?.message||'').includes('Script execution timed out'))s.poisoned=true;return {session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:false,stdout:logs.join('\n'),stderr:errors.join('\n'),error:`${e.name}: ${e.message}`}}
|
|
436
458
|
});} finally {s.inFlight--;s.last_used_at=Date.now();}
|
|
437
459
|
}
|
|
438
460
|
|
|
@@ -456,6 +478,7 @@ async function navigateIdle(browser,s,targetId,idleUrl){
|
|
|
456
478
|
async function closeSessionOwned(s){
|
|
457
479
|
if(sessions.get(s.session_id)!==s)return {session_id:s.session_id,already_closed:true};
|
|
458
480
|
s.closing=true;
|
|
481
|
+
s.poisoned=true; // Revoke delayed helpers after terminal close.
|
|
459
482
|
let release_error=null;
|
|
460
483
|
try {
|
|
461
484
|
const returnedTabs=s.leasedTabs.size;
|
|
@@ -487,7 +510,7 @@ async function closeSessionOwned(s){
|
|
|
487
510
|
async function reconcileWorkspaceSessions(workspace){
|
|
488
511
|
const browser=await connectBrowser();
|
|
489
512
|
for(const s of [...sessions.values()]){
|
|
490
|
-
if(s.workspace!==workspace||s.closing||s.inFlight>0)continue;
|
|
513
|
+
if(s.workspace!==workspace||s.closing||s.inFlight>0||s.pendingEffects>0)continue;
|
|
491
514
|
let alreadyIdle=false;
|
|
492
515
|
for(const [tid,idleUrl] of s.idleUrls){
|
|
493
516
|
try {if(sameIdleUrl(await targetUrl(browser,tid),idleUrl)){alreadyIdle=true;break;}}
|
|
@@ -503,15 +526,39 @@ async function stopSession(sid){
|
|
|
503
526
|
const s=sessions.get(sid);
|
|
504
527
|
if(!s)throw Error(`unknown session_id: ${sid}`);
|
|
505
528
|
s.closing=true;
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
529
|
+
// A timeout does not cancel a queued mutex callback. Fence this stop
|
|
530
|
+
// attempt before it can touch a Session whose close request failed.
|
|
531
|
+
const attempt={valid:true,releaseStarted:false};
|
|
532
|
+
const timeoutMs=Math.max(50,Math.min(90000,Number(process.env.WEBHELM_SESSION_STOP_TIMEOUT_MS)||60000));
|
|
533
|
+
const deadline=performance.now()+timeoutMs;
|
|
534
|
+
try {
|
|
535
|
+
await withinDeadline(s.mutex.run(async()=>{}),timeoutMs,'Session stop drain');
|
|
536
|
+
// Browser effects already dispatched by an expired VM may outlive its
|
|
537
|
+
// mutex. They must settle before an idle Tab can be reassigned.
|
|
538
|
+
while(s.pendingEffects>0){
|
|
539
|
+
const remaining=deadline-performance.now();
|
|
540
|
+
if(remaining<=0)throw new SessionDeadlineError('Session stop effects',timeoutMs);
|
|
541
|
+
await withinDeadline(delay(Math.min(25,remaining)),remaining,'Session stop effects');
|
|
542
|
+
}
|
|
543
|
+
return await withinDeadline(leaseMutex(s.workspace).run(async()=>{
|
|
544
|
+
if(!attempt.valid)throw new SessionDeadlineError('Cancelled Session stop',timeoutMs);
|
|
545
|
+
attempt.releaseStarted=true;
|
|
546
|
+
return closeSessionOwned(s);
|
|
547
|
+
}),Math.max(1,deadline-performance.now()),'Session stop release');
|
|
548
|
+
}catch(error){
|
|
549
|
+
attempt.valid=false;
|
|
550
|
+
if(sessions.get(sid)===s && !attempt.releaseStarted){
|
|
551
|
+
s.closing=false;s.poisoned=true;
|
|
552
|
+
}
|
|
553
|
+
// If physical close already started, keep closing=true until its own
|
|
554
|
+
// success/failure path finishes; otherwise GC could race its navigation.
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
510
557
|
}
|
|
511
558
|
|
|
512
559
|
function heartbeatSession(sid){
|
|
513
560
|
const s=sessions.get(sid);
|
|
514
|
-
if(!s||s.closing)throw Error(`unknown session_id: ${sid} (missing or
|
|
561
|
+
if(!s||s.closing||s.poisoned)throw Error(`unknown session_id: ${sid} (missing, closing or expired)`);
|
|
515
562
|
s.last_used_at=Date.now();
|
|
516
563
|
return {session_id:sid,alive:true,last_used_at:s.last_used_at};
|
|
517
564
|
}
|
|
@@ -574,13 +621,13 @@ async function pollExtensionControl() {
|
|
|
574
621
|
if (controlPollActive || shuttingDown) return;
|
|
575
622
|
controlPollActive = true;
|
|
576
623
|
try {
|
|
577
|
-
const pendingAction = await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions()});
|
|
624
|
+
const pendingAction = await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions(),queue:getChatGPTQueue().list().items});
|
|
578
625
|
if (!pendingAction) return;
|
|
579
626
|
const {id,action,sessionId} = pendingAction;
|
|
580
627
|
// Keep the status panel fresh while a lengthy Session stop waits for
|
|
581
628
|
// browser navigation or an in-flight task.
|
|
582
629
|
const controlHeartbeat=setInterval(()=>{
|
|
583
|
-
extensionCall(null,'workspace.control.heartbeat',{sessions:visibleSessions()})
|
|
630
|
+
extensionCall(null,'workspace.control.heartbeat',{sessions:visibleSessions(),queue:getChatGPTQueue().list().items})
|
|
584
631
|
.catch(error=>console.warn(`WebHelm control heartbeat: ${error.message}`));
|
|
585
632
|
},3000);
|
|
586
633
|
let response;
|
|
@@ -591,6 +638,8 @@ async function pollExtensionControl() {
|
|
|
591
638
|
response = await stopWorkspaceSessions(pendingAction.workspace);
|
|
592
639
|
} else if (action === 'stop') {
|
|
593
640
|
response = {ok:true,stopped:await stopSession(sessionId)};
|
|
641
|
+
} else if (action === 'queue-cancel') {
|
|
642
|
+
response = getChatGPTQueue().cancel(pendingAction.queueId);
|
|
594
643
|
} else {
|
|
595
644
|
throw Error('Unknown extension control action');
|
|
596
645
|
}
|
|
@@ -600,7 +649,7 @@ async function pollExtensionControl() {
|
|
|
600
649
|
clearInterval(controlHeartbeat);
|
|
601
650
|
}
|
|
602
651
|
await extensionCall(null,'workspace.control.complete',{id,response});
|
|
603
|
-
await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions()});
|
|
652
|
+
await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions(),queue:getChatGPTQueue().list().items});
|
|
604
653
|
} catch(error) {
|
|
605
654
|
console.warn(`WebHelm extension control sync failed: ${error.message}`);
|
|
606
655
|
} finally {
|
|
@@ -629,25 +678,26 @@ async function runPlatform(req) {
|
|
|
629
678
|
const sid=started.session_id;
|
|
630
679
|
if (!sid) throw Error('platform run requires a session_id or auto_session');
|
|
631
680
|
const session=sessions.get(sid);
|
|
632
|
-
if (!session) throw Error(`unknown session_id: ${sid}`);
|
|
681
|
+
if (!session || session.poisoned || session.closing) throw Error(`unknown session_id: ${sid}`);
|
|
633
682
|
let result, cleanup;
|
|
634
683
|
session.inFlight++;
|
|
635
684
|
try {
|
|
636
685
|
result=await session.mutex.run(async()=>{
|
|
686
|
+
if(session.poisoned||session.closing)throw Error('Session expired while waiting for platform action');
|
|
637
687
|
const logs=[];
|
|
638
688
|
const helpers=makeHelpers(session);
|
|
639
|
-
helpers.enqueueChatGPTTurn=request=>getChatGPTQueue().enqueue(request);
|
|
640
|
-
helpers.chatgptQueue=getChatGPTQueue();
|
|
641
689
|
try {
|
|
642
|
-
const
|
|
690
|
+
const actionTimeoutMs=Math.max(50,Math.min(300000,Number(process.env.WEBHELM_PLATFORM_TIMEOUT_MS)||180000));
|
|
691
|
+
const value=await withinDeadline(Promise.resolve().then(()=>invokePlatformAction(platform,action,{
|
|
643
692
|
helpers, config,
|
|
644
693
|
log:(...items)=>logs.push(items.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),
|
|
645
|
-
});
|
|
694
|
+
})),actionTimeoutMs,'Platform action');
|
|
646
695
|
// Keep the legacy stdout JSON receipt for NeoY/Relay callers, while
|
|
647
696
|
// retaining the structured `value` for new integrations. Do not print
|
|
648
697
|
// a duplicate receipt if the Action already logged its JSON result.
|
|
649
698
|
return {ok:true,stdout:actionOutput(logs,value),stderr:'',value};
|
|
650
699
|
} catch(error) {
|
|
700
|
+
if(error instanceof SessionDeadlineError)session.poisoned=true;
|
|
651
701
|
return {ok:false,stdout:logs.join('\n').slice(-40000),stderr:'',error:`${error.name}: ${error.message}`};
|
|
652
702
|
}
|
|
653
703
|
});
|
|
@@ -666,20 +716,28 @@ async function runPlatform(req) {
|
|
|
666
716
|
self_heal_capable:!auto,result};
|
|
667
717
|
}
|
|
668
718
|
|
|
669
|
-
export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':return stopAllSessions();case'stop-workspace':{if(typeof req.workspace!=='string'||!req.workspace.trim())throw Error('workspace required');return stopWorkspaceSessions(req.workspace)}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-resize':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
|
|
670
|
-
function serve(conn) {
|
|
671
|
-
let buffer = '';
|
|
719
|
+
export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':return stopAllSessions();case'stop-workspace':{if(typeof req.workspace!=='string'||!req.workspace.trim())throw Error('workspace required');return stopWorkspaceSessions(req.workspace)}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-resize':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'queue-list':return getChatGPTQueue().list();case'queue-cancel':return getChatGPTQueue().cancel(req.queue_id);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
|
|
720
|
+
export function serve(conn) {
|
|
721
|
+
let buffer = '', dispatched = false;
|
|
722
|
+
conn.on('error',()=>{ /* A disconnected client must not crash the daemon. */ });
|
|
672
723
|
conn.setEncoding('utf8');
|
|
673
724
|
conn.on('data', async (chunk) => {
|
|
725
|
+
if(dispatched)return;
|
|
674
726
|
buffer += chunk;
|
|
727
|
+
if(buffer.length>8_000_000){
|
|
728
|
+
dispatched=true;
|
|
729
|
+
if(!conn.destroyed)conn.end(JSON.stringify({error:'WebHelm IPC request too large'})+'\n');
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
675
732
|
const newline = buffer.indexOf('\n');
|
|
676
|
-
if (newline < 0) return;
|
|
733
|
+
if (newline < 0 || dispatched) return;
|
|
734
|
+
dispatched=true;
|
|
677
735
|
const raw = buffer.slice(0, newline);
|
|
678
736
|
try {
|
|
679
737
|
const reply = await handle(JSON.parse(raw));
|
|
680
|
-
conn.end(JSON.stringify(reply) + '\n');
|
|
738
|
+
if(!conn.destroyed)conn.end(JSON.stringify(reply) + '\n');
|
|
681
739
|
} catch (error) {
|
|
682
|
-
conn.end(JSON.stringify({ error: `${error.name}: ${error.message}` }) + '\n');
|
|
740
|
+
if(!conn.destroyed)conn.end(JSON.stringify({ error: `${error.name}: ${error.message}` }) + '\n');
|
|
683
741
|
}
|
|
684
742
|
});
|
|
685
743
|
}
|
|
@@ -688,7 +746,47 @@ async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lock
|
|
|
688
746
|
const controlTimer=setInterval(()=>pollExtensionControl(),3000);
|
|
689
747
|
pollExtensionControl().catch(()=>{});
|
|
690
748
|
const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;clearInterval(controlTimer);managedChromeGuard.stop();if(chatgptQueue)await chatgptQueue.shutdown().catch(()=>{});await handle({op:'stop-all'}).catch(()=>{});socket?.close();try{if(fs.existsSync(SOCKET))fs.unlinkSync(SOCKET)}catch{}try{fs.closeSync(lockfd);fs.unlinkSync(LOCK)}catch{}process.exit(0)};process.on('SIGTERM',cleanup);process.on('SIGINT',cleanup)}
|
|
691
|
-
|
|
749
|
+
// IPC duration is independent of the daemon's JS/Action execution timeout.
|
|
750
|
+
// A wedged daemon can no longer strand CLI processes indefinitely.
|
|
751
|
+
export function ipcTimeoutMs(op) {
|
|
752
|
+
const override=Number(process.env.WEBHELM_IPC_TIMEOUT_MS);
|
|
753
|
+
if(Number.isFinite(override) && override>0)
|
|
754
|
+
return Math.max(50,Math.min(300000,override));
|
|
755
|
+
return ({ping:5000,heartbeat:15000,list:15000,capacity:30000,
|
|
756
|
+
stop:90000,'stop-all':120000,'stop-workspace':120000,
|
|
757
|
+
exec:45000,'platform-run':240000,start:90000}[op] || 60000);
|
|
758
|
+
}
|
|
759
|
+
async function request(payload,ensure=true){
|
|
760
|
+
if(ensure)await ensureDaemon();
|
|
761
|
+
return new Promise((resolve,reject)=>{
|
|
762
|
+
const conn=net.createConnection(SOCKET);
|
|
763
|
+
const op=payload?.op||'unknown';
|
|
764
|
+
let data='',settled=false,deadlineTimer;
|
|
765
|
+
const finish=(error,result)=>{
|
|
766
|
+
if(settled)return;
|
|
767
|
+
settled=true;
|
|
768
|
+
clearTimeout(deadlineTimer);
|
|
769
|
+
conn.destroy();
|
|
770
|
+
if(error)reject(error);
|
|
771
|
+
else resolve(result);
|
|
772
|
+
};
|
|
773
|
+
conn.setEncoding('utf8');
|
|
774
|
+
deadlineTimer=setTimeout(()=>finish(Error('WebHelm IPC '+op+' timed out')),ipcTimeoutMs(op));
|
|
775
|
+
conn.on('connect',()=>conn.write(JSON.stringify(payload)+'\n'));
|
|
776
|
+
conn.on('data',chunk=>{
|
|
777
|
+
data+=chunk;
|
|
778
|
+
if(data.length>8_000_000)return finish(Error('WebHelm IPC response too large'));
|
|
779
|
+
const newline=data.indexOf('\n');
|
|
780
|
+
if(newline<0)return;
|
|
781
|
+
try{
|
|
782
|
+
const result=JSON.parse(data.slice(0,newline));
|
|
783
|
+
result.error?finish(Error(String(result.error))):finish(null,result);
|
|
784
|
+
}catch(error){finish(error)}
|
|
785
|
+
});
|
|
786
|
+
conn.on('end',()=>{if(!settled)finish(Error('WebHelm IPC '+op+' closed without response'))});
|
|
787
|
+
conn.on('error',error=>finish(error));
|
|
788
|
+
});
|
|
789
|
+
}
|
|
692
790
|
export async function ensureDaemon(){try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}if(!process.env.WEBHELM_CDP_URL)requireMigratedConfig();fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});if(fs.existsSync(LOCK)){let owner=0;try{owner=Number(fs.readFileSync(LOCK,'utf8').trim())}catch{};let alive=false;if(owner>0)try{process.kill(owner,0);alive=true}catch{};if(alive)throw Error(`daemon owner lock exists but socket is unreachable; refusing duplicate startup (${LOCK})`);fs.unlinkSync(LOCK)}const child=await import('node:child_process');const log=fs.openSync(path.join(RUNTIME,'session-node.log'),'a');child.spawn(process.execPath,[fileURLToPath(import.meta.url),'daemon'],{detached:true,stdio:['ignore',log,log],env:process.env}).unref();for(let i=0;i<100;i++){await delay(100);try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}}throw Error(`WebHelm Node daemon did not start; see ${path.join(RUNTIME,'session-node.log')}`)}
|
|
693
791
|
if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
|
|
694
792
|
export {request,SOCKET,RUNTIME};
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
// A Session is live only while its owner performs operations or renews its heartbeat.
|
|
2
2
|
export const SESSION_IDLE_MS = 5 * 60 * 1000;
|
|
3
3
|
|
|
4
|
+
export class SessionDeadlineError extends Error {
|
|
5
|
+
constructor(operation, milliseconds) {
|
|
6
|
+
super(operation + ' exceeded ' + milliseconds + 'ms deadline');
|
|
7
|
+
this.name = 'SessionDeadlineError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A VM timeout cannot terminate an unresolved async Promise.
|
|
12
|
+
// Callers must revoke Session helpers when a deadline expires.
|
|
13
|
+
export async function withinDeadline(promise, milliseconds, operation) {
|
|
14
|
+
let timer;
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([
|
|
17
|
+
promise,
|
|
18
|
+
new Promise((_, reject) => {
|
|
19
|
+
timer = setTimeout(() => reject(new SessionDeadlineError(operation, milliseconds)), milliseconds);
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
} finally {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
export function staleSession(session, now = Date.now(), ttl = SESSION_IDLE_MS) {
|
|
5
|
-
return !session.closing && !(session.inFlight > 0) &&
|
|
6
|
-
now - session.last_used_at >= ttl;
|
|
28
|
+
return !session.closing && !(session.inFlight > 0) && !(session.pendingEffects > 0) &&
|
|
29
|
+
(session.poisoned === true || now - session.last_used_at >= ttl);
|
|
7
30
|
}
|
|
8
31
|
|
|
9
32
|
export function idleUrlFromIdentity(identityUrl) {
|