webhelm 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +21 -0
- package/README.md +54 -2
- package/bin/webhelm.mjs +2 -0
- package/package.json +32 -4
- package/runtime/actions-repo.mjs +62 -0
- package/runtime/admin.mjs +7 -0
- package/runtime/chatgpt/chatgpt-delivery-errors.mjs +94 -0
- package/runtime/chatgpt/chatgpt-probe.mjs +23 -0
- package/runtime/chatgpt/chatgpt-queue.mjs +441 -0
- package/runtime/chatgpt/lifecycle-common.mjs +79 -0
- package/runtime/chatgpt/readiness.mjs +120 -0
- package/runtime/chrome.mjs +125 -0
- package/runtime/cli.mjs +161 -0
- package/runtime/config-paths.mjs +14 -0
- package/runtime/migrate.mjs +47 -0
- package/runtime/node-daemon.mjs +476 -0
- package/runtime/platforms.mjs +89 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { classifyProbe, PROBE_JS, targetUrl } from "./chatgpt-probe.mjs";
|
|
6
|
+
import { CLICK_SEND_JS, SEND_ENABLED_JS } from "./lifecycle-common.mjs";
|
|
7
|
+
|
|
8
|
+
const transitions = new Set([
|
|
9
|
+
"CHECKING>WAITING",
|
|
10
|
+
"CHECKING>SENDING",
|
|
11
|
+
"CHECKING>BLOCKED",
|
|
12
|
+
"SENDING>SENT",
|
|
13
|
+
"SENDING>UNCERTAIN",
|
|
14
|
+
"UNCERTAIN>SENT",
|
|
15
|
+
"UNCERTAIN>WAITING",
|
|
16
|
+
"BLOCKED>WAITING",
|
|
17
|
+
]);
|
|
18
|
+
const ACTIVE = [
|
|
19
|
+
"QUEUED",
|
|
20
|
+
"CHECKING",
|
|
21
|
+
"WAITING",
|
|
22
|
+
"SENDING",
|
|
23
|
+
"UNCERTAIN",
|
|
24
|
+
"BLOCKED",
|
|
25
|
+
];
|
|
26
|
+
export class ChatGPTQueue {
|
|
27
|
+
constructor(
|
|
28
|
+
{
|
|
29
|
+
persistenceRoot,
|
|
30
|
+
startSession,
|
|
31
|
+
execSession,
|
|
32
|
+
stopSession,
|
|
33
|
+
intervalMs = 60000,
|
|
34
|
+
concurrency = 4,
|
|
35
|
+
leaseSeconds = 120,
|
|
36
|
+
now = () => Date.now() / 1000,
|
|
37
|
+
logger = () => {},
|
|
38
|
+
} = {},
|
|
39
|
+
) {
|
|
40
|
+
if (!persistenceRoot) throw new TypeError("persistenceRoot is required");
|
|
41
|
+
for (
|
|
42
|
+
const [name, fn] of Object.entries({
|
|
43
|
+
startSession,
|
|
44
|
+
execSession,
|
|
45
|
+
stopSession,
|
|
46
|
+
})
|
|
47
|
+
) {
|
|
48
|
+
if (typeof fn !== "function") {
|
|
49
|
+
throw new TypeError(`${name} callback is required`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
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(
|
|
57
|
+
"PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000",
|
|
58
|
+
);
|
|
59
|
+
this.db.exec(
|
|
60
|
+
`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
|
+
);
|
|
62
|
+
this.startSession = startSession;
|
|
63
|
+
this.execSession = execSession;
|
|
64
|
+
this.stopSession = stopSession;
|
|
65
|
+
this.intervalMs = intervalMs;
|
|
66
|
+
this.concurrency = concurrency;
|
|
67
|
+
this.leaseSeconds = leaseSeconds;
|
|
68
|
+
this.now = now;
|
|
69
|
+
this.logger = logger;
|
|
70
|
+
this.active = new Map();
|
|
71
|
+
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
|
+
}
|
|
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
|
+
});
|
|
172
|
+
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
|
+
});
|
|
200
|
+
}
|
|
201
|
+
transition(
|
|
202
|
+
id,
|
|
203
|
+
owner,
|
|
204
|
+
source,
|
|
205
|
+
target,
|
|
206
|
+
{ now = this.now(), delay = 60, receipt = null } = {},
|
|
207
|
+
) {
|
|
208
|
+
if (!transitions.has(`${source}>${target}`)) {
|
|
209
|
+
throw new Error("invalid transition");
|
|
210
|
+
}
|
|
211
|
+
if (source === "UNCERTAIN" && !receipt) {
|
|
212
|
+
throw new Error("UNCERTAIN requires reconciliation evidence");
|
|
213
|
+
}
|
|
214
|
+
if (source === "BLOCKED" && !receipt) {
|
|
215
|
+
throw new Error("BLOCKED requires unblock evidence");
|
|
216
|
+
}
|
|
217
|
+
return this._transaction(() => {
|
|
218
|
+
const row = this._one("SELECT * FROM turns WHERE id=?", id);
|
|
219
|
+
if (!row || row.state !== source) throw new Error("stale state");
|
|
220
|
+
if (
|
|
221
|
+
["CHECKING", "SENDING"].includes(source) && row.lease_owner !== owner
|
|
222
|
+
) throw new Error("stale owner");
|
|
223
|
+
if (["CHECKING", "SENDING"].includes(source) && row.lease_until <= now) {
|
|
224
|
+
throw new Error("expired lease");
|
|
225
|
+
}
|
|
226
|
+
const release = ["WAITING", "SENT", "UNCERTAIN", "BLOCKED"].includes(
|
|
227
|
+
target,
|
|
228
|
+
);
|
|
229
|
+
this.db.prepare(
|
|
230
|
+
"UPDATE turns SET state=?,lease_owner=?,lease_until=?,next_check_at=?,receipt=? WHERE id=?",
|
|
231
|
+
).run(
|
|
232
|
+
target,
|
|
233
|
+
release ? null : owner,
|
|
234
|
+
release ? null : now + 120,
|
|
235
|
+
["WAITING", "SENT"].includes(target) ? now + delay : now,
|
|
236
|
+
receipt,
|
|
237
|
+
id,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
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
|
+
}
|
|
259
|
+
reconcile({ queue_id, evidence, accepted } = {}) {
|
|
260
|
+
if (!queue_id || !evidence || typeof accepted !== "boolean") {
|
|
261
|
+
throw new TypeError("queue_id, evidence and boolean accepted required");
|
|
262
|
+
}
|
|
263
|
+
this.transition(
|
|
264
|
+
queue_id,
|
|
265
|
+
"reconciler",
|
|
266
|
+
"UNCERTAIN",
|
|
267
|
+
accepted ? "SENT" : "WAITING",
|
|
268
|
+
{ receipt: evidence, delay: 0 },
|
|
269
|
+
);
|
|
270
|
+
if (this.started) this._wake(0);
|
|
271
|
+
return { ok: true, queue_id, state: accepted ? "SENT" : "WAITING" };
|
|
272
|
+
}
|
|
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
|
+
}
|
|
302
|
+
async _exec(sid, code) {
|
|
303
|
+
const response = await this.execSession(sid, code);
|
|
304
|
+
if (response?.ok === false) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
response.error || response.stderr || "browser action failed",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return response?.value ?? response;
|
|
310
|
+
}
|
|
311
|
+
async _probe(item) {
|
|
312
|
+
const url = targetUrl(item.project_id, item.thread_id, item.message);
|
|
313
|
+
const session = await this.startSession({ url });
|
|
314
|
+
const sid = session.session_id ?? session.sessionId;
|
|
315
|
+
if (!sid) throw new Error("session start returned no session_id");
|
|
316
|
+
this.sessions.set(item.id, sid);
|
|
317
|
+
const deadline = Date.now() + 45000;
|
|
318
|
+
let state = "NOT_READY";
|
|
319
|
+
while (Date.now() < deadline) {
|
|
320
|
+
const source =
|
|
321
|
+
`await (async()=>{ const page=await page_info(); const state=await js(${
|
|
322
|
+
JSON.stringify(PROBE_JS)
|
|
323
|
+
}); if(state?.state==='READY' && !(await js(${
|
|
324
|
+
JSON.stringify(SEND_ENABLED_JS)
|
|
325
|
+
}))) state.state='NOT_READY'; return {url:page.url||'',state}; })()`;
|
|
326
|
+
const result = await this._exec(sid, source);
|
|
327
|
+
state = classifyProbe(result.url, url, result.state);
|
|
328
|
+
if (["READY", "RUNNING", "BLOCKED"].includes(state)) return state;
|
|
329
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
330
|
+
}
|
|
331
|
+
return state;
|
|
332
|
+
}
|
|
333
|
+
async _send(item) {
|
|
334
|
+
const sid = this.sessions.get(item.id);
|
|
335
|
+
if (!sid) throw new Error("missing prepared browser session");
|
|
336
|
+
const expected =
|
|
337
|
+
targetUrl(item.project_id, item.thread_id, item.message).split("?")[0];
|
|
338
|
+
const source = `await (async()=>{ const gate=await js(${
|
|
339
|
+
JSON.stringify(PROBE_JS)
|
|
340
|
+
}); const gateUrl=(await page_info()).url||''; let receipt={diagnostic:'pre_send_gate_not_ready'}; if(gate?.state==='READY' && await js(${
|
|
341
|
+
JSON.stringify(SEND_ENABLED_JS)
|
|
342
|
+
}) && Boolean((gate.composer_text||'').trim()) && gateUrl===${
|
|
343
|
+
JSON.stringify(expected)
|
|
344
|
+
} && await js(${
|
|
345
|
+
JSON.stringify(CLICK_SEND_JS)
|
|
346
|
+
})){ const until=Date.now()+30000; let disabled=false; while(Date.now()<until){ disabled=!(await js(${
|
|
347
|
+
JSON.stringify(SEND_ENABLED_JS)
|
|
348
|
+
})); if(disabled) break; await new Promise(resolve=>setTimeout(resolve,200)); } receipt=disabled?{verified_by:'send-button-disabled'}:{diagnostic:'post_submit_send_state_unverified'}; } return receipt; })()`;
|
|
349
|
+
return this._exec(sid, source);
|
|
350
|
+
}
|
|
351
|
+
async _finish(id) {
|
|
352
|
+
const sid = this.sessions.get(id);
|
|
353
|
+
this.sessions.delete(id);
|
|
354
|
+
if (sid) {
|
|
355
|
+
try {
|
|
356
|
+
await this.stopSession(sid);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
this.logger({ event: "stop_session_error", error: String(e) });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
_safeDiagnostic(error) {
|
|
363
|
+
const raw = String(error);
|
|
364
|
+
const safe =
|
|
365
|
+
/^(?:workspace\.acquireIdentity|session_identity_timeout|chatgpt_queue_unavailable|daemon_retiring)(?::[A-Za-z0-9_.-]{1,32})?$/
|
|
366
|
+
.test(raw)
|
|
367
|
+
? raw
|
|
368
|
+
: "redacted";
|
|
369
|
+
const fingerprint = createHash("sha256").update(raw).digest("hex").slice(
|
|
370
|
+
0,
|
|
371
|
+
16,
|
|
372
|
+
);
|
|
373
|
+
return `${
|
|
374
|
+
error?.name || "Error"
|
|
375
|
+
};message=${safe};fingerprint=${fingerprint}`.slice(0, 480);
|
|
376
|
+
}
|
|
377
|
+
async _process(item) {
|
|
378
|
+
const owner = item.lease_owner;
|
|
379
|
+
try {
|
|
380
|
+
const state = await this._probe(item);
|
|
381
|
+
if (state === "RUNNING") {
|
|
382
|
+
this.transition(item.id, owner, "CHECKING", "WAITING", {
|
|
383
|
+
delay: this.intervalMs / 1000,
|
|
384
|
+
receipt: "stop_visible",
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (state === "BLOCKED") {
|
|
389
|
+
this.transition(item.id, owner, "CHECKING", "BLOCKED", {
|
|
390
|
+
receipt: "page blocked",
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (state !== "READY") {
|
|
395
|
+
this.transition(item.id, owner, "CHECKING", "WAITING", {
|
|
396
|
+
delay: this.intervalMs / 1000,
|
|
397
|
+
receipt: `probe_${state.toLowerCase()}`,
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.transition(item.id, owner, "CHECKING", "SENDING");
|
|
402
|
+
const receipt = await this._send(item);
|
|
403
|
+
if (receipt?.verified_by !== "send-button-disabled") {
|
|
404
|
+
this.transition(item.id, owner, "SENDING", "UNCERTAIN", {
|
|
405
|
+
receipt: receipt?.diagnostic || "missing_user_turn_receipt",
|
|
406
|
+
});
|
|
407
|
+
} else {this.transition(item.id, owner, "SENDING", "SENT", {
|
|
408
|
+
receipt: "send-button-disabled",
|
|
409
|
+
});}
|
|
410
|
+
} catch (e) {
|
|
411
|
+
const diagnostic = this._safeDiagnostic(e),
|
|
412
|
+
current = this._one("SELECT * FROM turns WHERE id=?", item.id);
|
|
413
|
+
if (current?.state === "SENDING") {
|
|
414
|
+
this.transition(item.id, owner, "SENDING", "UNCERTAIN", {
|
|
415
|
+
receipt: diagnostic,
|
|
416
|
+
});
|
|
417
|
+
} else if (current?.state === "CHECKING") {
|
|
418
|
+
this.transition(item.id, owner, "CHECKING", "WAITING", {
|
|
419
|
+
delay: this.intervalMs / 1000,
|
|
420
|
+
receipt: `probe_error_${diagnostic}`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
await this._finish(item.id);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async shutdown() {
|
|
428
|
+
if (this.closed) return;
|
|
429
|
+
this.closed = true;
|
|
430
|
+
clearTimeout(this.wakeTimer);
|
|
431
|
+
await Promise.allSettled([...this.active.values()]);
|
|
432
|
+
for (const id of [...this.sessions.keys()]) await this._finish(id);
|
|
433
|
+
this.db.close();
|
|
434
|
+
}
|
|
435
|
+
close() {
|
|
436
|
+
return this.shutdown();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
export function createChatGPTQueue(options) {
|
|
440
|
+
return new ChatGPTQueue(options);
|
|
441
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { waitUntilStable } from "./readiness.mjs";
|
|
2
|
+
export const SEND_ENABLED_JS =
|
|
3
|
+
`(() => { const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]'); return !!b && !b.disabled && b.getAttribute('aria-disabled') !== 'true'; })()`;
|
|
4
|
+
export const CLICK_SEND_JS =
|
|
5
|
+
`(() => { const visible=e=>!!e&&!!e.getClientRects().length; const running=[...document.querySelectorAll('button')].some(b=>{ if(!visible(b))return false; const l=(b.getAttribute('aria-label')||'').trim().toLowerCase(),t=(b.getAttribute('title')||'').trim().toLowerCase(),d=(b.getAttribute('data-testid')||'').trim().toLowerCase(); return ['stop generating','stop answering','stop response','stop'].includes(l)||['stop generating','stop answering'].includes(t)||['stop-button','stop-generating-button'].includes(d); }); if(running)return false; const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]'); if(!b||b.disabled||b.getAttribute('aria-disabled')==='true')return false; b.click(); return true; })()`;
|
|
6
|
+
export function targetUrl(
|
|
7
|
+
{ project_id, thread_id, temporary = false, prompt } = {},
|
|
8
|
+
) {
|
|
9
|
+
if (temporary && (project_id || thread_id)) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"temporary cannot be combined with project_id or thread_id",
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
const enc = encodeURIComponent;
|
|
15
|
+
const base = temporary
|
|
16
|
+
? "https://chatgpt.com/"
|
|
17
|
+
: thread_id
|
|
18
|
+
? `https://chatgpt.com${project_id ? `/g/${enc(project_id)}` : ""}/c/${
|
|
19
|
+
enc(thread_id)
|
|
20
|
+
}`
|
|
21
|
+
: project_id
|
|
22
|
+
? `https://chatgpt.com/g/${enc(project_id)}/project`
|
|
23
|
+
: "https://chatgpt.com/";
|
|
24
|
+
const params = new URLSearchParams();
|
|
25
|
+
if (temporary) params.set("temporary-chat", "true");
|
|
26
|
+
if (prompt !== undefined) params.set("prompt", prompt);
|
|
27
|
+
return base + (params.size ? `?${params}` : "");
|
|
28
|
+
}
|
|
29
|
+
export function visibleDeliveryError(js) {
|
|
30
|
+
return js(
|
|
31
|
+
`(() => { const as=[...document.querySelectorAll('[role="alert"],[role="status"],[data-sonner-toast],[data-testid*="toast"],[class*="toast"],[class*="error-banner"]')]; for(const e of as){if(!e.getClientRects().length)continue; const t=(e.innerText||'').toLowerCase(); if(t.includes('this conversation is too long to continue')||t.includes('conversation is too long to continue'))return 'conversation_too_long'; if(t.includes('could not load this chatgpt conversation'))return 'thread_load_failed';} if((document.body?.innerText||'').toLowerCase().includes('could not load this chatgpt conversation'))return 'thread_load_failed'; return null; })()`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
export async function prefilledSubmit(helpers, prompt, { send = true } = {}) {
|
|
35
|
+
const { js } = helpers;
|
|
36
|
+
const sendEnabled = async () => !!(await js(SEND_ENABLED_JS));
|
|
37
|
+
try {
|
|
38
|
+
await waitUntilStable(
|
|
39
|
+
async () => ({ send: await sendEnabled() }),
|
|
40
|
+
(s) => s.send,
|
|
41
|
+
{
|
|
42
|
+
timeout: 15,
|
|
43
|
+
interval: 0.25,
|
|
44
|
+
stableSamples: 3,
|
|
45
|
+
phase: "prefilled Send readiness",
|
|
46
|
+
sleep: helpers.sleep,
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
return send
|
|
51
|
+
? Promise.reject(
|
|
52
|
+
new Error("URL-prefilled Send button was not observed ready"),
|
|
53
|
+
)
|
|
54
|
+
: { send_enabled: false };
|
|
55
|
+
}
|
|
56
|
+
if (!send) return { send_enabled: true };
|
|
57
|
+
if (!(await js(CLICK_SEND_JS))) {
|
|
58
|
+
throw new Error("URL-prefilled Send became unavailable before click");
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await waitUntilStable(
|
|
62
|
+
async () => ({ send_disabled: !(await sendEnabled()) }),
|
|
63
|
+
(s) => s.send_disabled,
|
|
64
|
+
{
|
|
65
|
+
timeout: 30,
|
|
66
|
+
interval: 0.2,
|
|
67
|
+
stableSamples: 2,
|
|
68
|
+
phase: "post-click Send disabled",
|
|
69
|
+
sleep: helpers.sleep,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
} catch {
|
|
73
|
+
const e = new Error("Send did not become disabled after click");
|
|
74
|
+
e.name = "SubmissionVerificationError";
|
|
75
|
+
e.diagnostic = { reason: "post_submit_send_state_unverified" };
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
return { verified_by: "send-button-disabled", turn: { id: null } };
|
|
79
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export class SubmissionVerificationError extends Error {
|
|
2
|
+
constructor(message, diagnostic = {}) {
|
|
3
|
+
super(`${message}: ${JSON.stringify(diagnostic)}`);
|
|
4
|
+
this.name = "SubmissionVerificationError";
|
|
5
|
+
this.diagnostic = diagnostic;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function waitUntilStable(
|
|
10
|
+
readState,
|
|
11
|
+
ready,
|
|
12
|
+
{
|
|
13
|
+
timeout = 15,
|
|
14
|
+
interval = 0.25,
|
|
15
|
+
stableSamples = 2,
|
|
16
|
+
phase = "readiness",
|
|
17
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
18
|
+
} = {},
|
|
19
|
+
) {
|
|
20
|
+
return (async () => {
|
|
21
|
+
const end = Date.now() + timeout * 1000;
|
|
22
|
+
let stable = 0, previous;
|
|
23
|
+
while (Date.now() < end) {
|
|
24
|
+
let state;
|
|
25
|
+
try {
|
|
26
|
+
state = await readState();
|
|
27
|
+
} catch {
|
|
28
|
+
stable = 0;
|
|
29
|
+
await sleep(interval * 1000);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ready(state)) {
|
|
33
|
+
const key = JSON.stringify(state);
|
|
34
|
+
if (key === previous) stable++;
|
|
35
|
+
else {
|
|
36
|
+
previous = key;
|
|
37
|
+
stable = 1;
|
|
38
|
+
}
|
|
39
|
+
if (stable >= stableSamples) return state;
|
|
40
|
+
} else {
|
|
41
|
+
stable = 0;
|
|
42
|
+
previous = undefined;
|
|
43
|
+
}
|
|
44
|
+
await sleep(interval * 1000);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`ChatGPT ${phase} was not observed ready`);
|
|
47
|
+
})();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function promptTextMatches(observed, expected) {
|
|
51
|
+
const norm = (value) =>
|
|
52
|
+
String(value || "").replace(/\s+/g, " ").replace(/>\s+/g, ">").replace(
|
|
53
|
+
/\s+</g,
|
|
54
|
+
"<",
|
|
55
|
+
).trim();
|
|
56
|
+
const a = norm(observed), b = norm(expected);
|
|
57
|
+
if (a.includes(b)) return true;
|
|
58
|
+
if (!b) return !a;
|
|
59
|
+
const span = Math.min(256, Math.max(48, Math.floor(b.length / 8)));
|
|
60
|
+
return a.length >= Math.floor(b.length * 0.9) &&
|
|
61
|
+
a.includes(b.slice(0, span)) && a.includes(b.slice(-span));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sendReady(state, { prompt, attachments = [] } = {}) {
|
|
65
|
+
const button = state?.send;
|
|
66
|
+
const enabled = typeof button === "object" ? !!button.enabled : !!button;
|
|
67
|
+
if (!enabled) return false;
|
|
68
|
+
if (prompt !== undefined && !promptTextMatches(state.text || "", prompt)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const names = Array.isArray(state.attachments?.names)
|
|
72
|
+
? state.attachments.names
|
|
73
|
+
: [];
|
|
74
|
+
return attachments.every((name) => names.includes(name));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function submissionReceipt(
|
|
78
|
+
turns,
|
|
79
|
+
beforeCount,
|
|
80
|
+
prompt,
|
|
81
|
+
composerText,
|
|
82
|
+
{ allowComposerCleared = false } = {},
|
|
83
|
+
) {
|
|
84
|
+
for (const turn of turns.slice(beforeCount)) {
|
|
85
|
+
if (promptTextMatches(turn.text, prompt)) {
|
|
86
|
+
return { verified_by: "user-turn", turn };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (allowComposerCleared && !String(composerText || "").trim()) {
|
|
90
|
+
return {
|
|
91
|
+
verified_by: "composer-cleared",
|
|
92
|
+
turn: { id: null, text: prompt },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function submissionFailureDiagnostic(
|
|
99
|
+
turns,
|
|
100
|
+
beforeCount,
|
|
101
|
+
prompt,
|
|
102
|
+
composerText,
|
|
103
|
+
uiBefore,
|
|
104
|
+
uiAfter,
|
|
105
|
+
) {
|
|
106
|
+
return {
|
|
107
|
+
accepted: false,
|
|
108
|
+
reason: "no_accepted_user_turn",
|
|
109
|
+
user_turn_matched: false,
|
|
110
|
+
before_user_turn_count: beforeCount,
|
|
111
|
+
user_turn_count: turns.length,
|
|
112
|
+
composer_populated: !!String(composerText || "").trim(),
|
|
113
|
+
composer_text_length: String(composerText || "").trim().length,
|
|
114
|
+
ui_changed: uiBefore == null || uiAfter == null
|
|
115
|
+
? null
|
|
116
|
+
: JSON.stringify(uiBefore) !== JSON.stringify(uiAfter),
|
|
117
|
+
ui_before: uiBefore,
|
|
118
|
+
ui_after: uiAfter,
|
|
119
|
+
};
|
|
120
|
+
}
|