webhelm 1.0.18 → 1.0.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,10 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { mkdirSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
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
- const ACTIVE = [
19
- "QUEUED",
20
- "CHECKING",
21
- "WAITING",
22
- "SENDING",
23
- "UNCERTAIN",
24
- "BLOCKED",
25
- ];
26
- export class ChatGPTQueue {
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
- this.root = resolve(persistenceRoot);
53
- mkdirSync(this.root, { recursive: true });
54
- this.dbPath = join(this.root, "chatgpt-new-turn.sqlite3");
55
- this.db = new DatabaseSync(this.dbPath);
56
- this.db.exec(
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
- this.db.exec(
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
- _rows() {
91
- return this.db.prepare("SELECT * FROM turns ORDER BY seq").all();
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
- rows() {
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
- start() {
274
- if (this.started) return this;
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.closed = true;
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
  }
@@ -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 heartbeat(sessions) {
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
- async function controlRpc(method, args) {
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
 
@@ -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. */ });