webhelm 1.0.18 → 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/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 +6 -9
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
|
@@ -621,13 +621,13 @@ async function pollExtensionControl() {
|
|
|
621
621
|
if (controlPollActive || shuttingDown) return;
|
|
622
622
|
controlPollActive = true;
|
|
623
623
|
try {
|
|
624
|
-
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});
|
|
625
625
|
if (!pendingAction) return;
|
|
626
626
|
const {id,action,sessionId} = pendingAction;
|
|
627
627
|
// Keep the status panel fresh while a lengthy Session stop waits for
|
|
628
628
|
// browser navigation or an in-flight task.
|
|
629
629
|
const controlHeartbeat=setInterval(()=>{
|
|
630
|
-
extensionCall(null,'workspace.control.heartbeat',{sessions:visibleSessions()})
|
|
630
|
+
extensionCall(null,'workspace.control.heartbeat',{sessions:visibleSessions(),queue:getChatGPTQueue().list().items})
|
|
631
631
|
.catch(error=>console.warn(`WebHelm control heartbeat: ${error.message}`));
|
|
632
632
|
},3000);
|
|
633
633
|
let response;
|
|
@@ -638,6 +638,8 @@ async function pollExtensionControl() {
|
|
|
638
638
|
response = await stopWorkspaceSessions(pendingAction.workspace);
|
|
639
639
|
} else if (action === 'stop') {
|
|
640
640
|
response = {ok:true,stopped:await stopSession(sessionId)};
|
|
641
|
+
} else if (action === 'queue-cancel') {
|
|
642
|
+
response = getChatGPTQueue().cancel(pendingAction.queueId);
|
|
641
643
|
} else {
|
|
642
644
|
throw Error('Unknown extension control action');
|
|
643
645
|
}
|
|
@@ -647,7 +649,7 @@ async function pollExtensionControl() {
|
|
|
647
649
|
clearInterval(controlHeartbeat);
|
|
648
650
|
}
|
|
649
651
|
await extensionCall(null,'workspace.control.complete',{id,response});
|
|
650
|
-
await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions()});
|
|
652
|
+
await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions(),queue:getChatGPTQueue().list().items});
|
|
651
653
|
} catch(error) {
|
|
652
654
|
console.warn(`WebHelm extension control sync failed: ${error.message}`);
|
|
653
655
|
} finally {
|
|
@@ -684,11 +686,6 @@ async function runPlatform(req) {
|
|
|
684
686
|
if(session.poisoned||session.closing)throw Error('Session expired while waiting for platform action');
|
|
685
687
|
const logs=[];
|
|
686
688
|
const helpers=makeHelpers(session);
|
|
687
|
-
helpers.enqueueChatGPTTurn=request=>{
|
|
688
|
-
if(session.poisoned||session.closing)throw Error('Session execution expired or closing');
|
|
689
|
-
return getChatGPTQueue().enqueue(request);
|
|
690
|
-
};
|
|
691
|
-
helpers.chatgptQueue={enqueue:helpers.enqueueChatGPTTurn};
|
|
692
689
|
try {
|
|
693
690
|
const actionTimeoutMs=Math.max(50,Math.min(300000,Number(process.env.WEBHELM_PLATFORM_TIMEOUT_MS)||180000));
|
|
694
691
|
const value=await withinDeadline(Promise.resolve().then(()=>invokePlatformAction(platform,action,{
|
|
@@ -719,7 +716,7 @@ async function runPlatform(req) {
|
|
|
719
716
|
self_heal_capable:!auto,result};
|
|
720
717
|
}
|
|
721
718
|
|
|
722
|
-
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}`)}}
|
|
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}`)}}
|
|
723
720
|
export function serve(conn) {
|
|
724
721
|
let buffer = '', dispatched = false;
|
|
725
722
|
conn.on('error',()=>{ /* A disconnected client must not crash the daemon. */ });
|