返回 AiToEarn
live-poll.mjs
1 /**
2 * CLI client for the live variant mode poll/reply protocol.
3 *
4 * Usage:
5 * npx impeccable poll # Block until browser event, print JSON
6 * npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
7 * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
8 * npx impeccable poll --reply <id> done # Reply "done" to event <id>
9 * npx impeccable poll --reply <id> error "msg" # Reply with error
10 */
11
12 import { execFileSync } from 'node:child_process';
13 import path from 'node:path';
14 import { fileURLToPath } from 'node:url';
15 import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
16 import { readLiveServerInfo } from './impeccable-paths.mjs';
17
18 // Node's built-in fetch (undici under the hood) enforces a 300s headers
19 // timeout that can't be lowered per-request. We cap each request below
20 // that ceiling and loop in `pollOnce` to synthesize a long poll without
21 // depending on the standalone undici package.
22 export const PER_REQUEST_TIMEOUT_MS = 270_000;
23 export const DEFAULT_EVENT_LEASE_MS = 600_000;
24
25 const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
26
27 function readServerInfo() {
28 const record = readLiveServerInfo(process.cwd());
29 if (!record) {
30 console.error('No running live server found. Start one with: npx impeccable live');
31 process.exit(1);
32 }
33 return record.info;
34 }
35
36 export function buildPollReplyPayload(token, { id, type, message, file, data }) {
37 return { token, id, type, message, file, data };
38 }
39
40 export function manualApplyPollBanner(event = {}) {
41 const id = event.id || 'EVENT_ID';
42 return [
43 `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply ${id} done --data '<json>'\`.`,
44 'The JSON data must include status, appliedEntryIds, failed, files, and notes; summary counters are only a recovery fallback.',
45 'Do not run live-commit-manual-edits.mjs for this leased event.',
46 'Do not poll again before replying.',
47 ].join('\n') + '\n';
48 }
49
50 /**
51 * Parse `--reply <id> <status> [--file path] [--data '<json>'] [message]` argv
52 * into a reply object. Returns null when `--reply` is absent. Throws (code
53 * INVALID_REPLY_ARGS) when the reply shape is missing its event id/status and
54 * INVALID_DATA_JSON when `--data` is present but not valid JSON.
55 */
56 export function parseReplyArgs(args) {
57 const replyIdx = args.indexOf('--reply');
58 if (replyIdx === -1) return null;
59 const id = args[replyIdx + 1];
60 const status = args[replyIdx + 2];
61 validateReplyArgs({ id, status });
62 const fileIdx = args.indexOf('--file');
63 const file = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
64 const dataIdx = args.indexOf('--data');
65 let data;
66 if (dataIdx !== -1 && dataIdx + 1 < args.length) {
67 try {
68 data = JSON.parse(args[dataIdx + 1]);
69 } catch (err) {
70 const wrapped = new Error('--data must be valid JSON: ' + err.message);
71 wrapped.code = 'INVALID_DATA_JSON';
72 throw wrapped;
73 }
74 }
75 const message = args.find((a, i) =>
76 i > replyIdx + 2
77 && !a.startsWith('--')
78 && i !== fileIdx + 1
79 && i !== dataIdx + 1
80 ) || undefined;
81 return { id, type: status, message, file, data };
82 }
83
84 function validateReplyArgs({ id, status }) {
85 const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
86 if (!id || id.startsWith('--')) {
87 const err = new Error(`${usage}\nMissing event id after --reply.`);
88 err.code = 'INVALID_REPLY_ARGS';
89 throw err;
90 }
91 if (['done', 'error', 'complete', 'discard', 'discarded'].includes(id)) {
92 const err = new Error(`${usage}\nThe value after --reply must be the event id, not the status ${JSON.stringify(id)}. Use --reply EVENT_ID ${id}.`);
93 err.code = 'INVALID_REPLY_ARGS';
94 throw err;
95 }
96 if (!status || status.startsWith('--')) {
97 const err = new Error(`${usage}\nMissing reply status after event id ${JSON.stringify(id)}.`);
98 err.code = 'INVALID_REPLY_ARGS';
99 throw err;
100 }
101 }
102
103 export function requiresAgentReply(event) {
104 return EVENT_TYPES_NEEDING_AGENT_REPLY.has(event?.type);
105 }
106
107 export async function postReply(base, token, reply) {
108 const res = await fetch(`${base}/poll`, {
109 method: 'POST',
110 headers: { 'Content-Type': 'application/json' },
111 body: JSON.stringify(buildPollReplyPayload(token, reply)),
112 });
113 if (!res.ok) {
114 const body = await res.json().catch(() => ({}));
115 const parts = [body.error || res.statusText, body.reason, body.hint].filter(Boolean);
116 throw new Error(parts.join(': '));
117 }
118 }
119
120 export async function fetchServerStatus(base, token) {
121 const res = await fetch(`${base}/status?token=${token}`);
122 if (res.status === 401) {
123 const err = new Error('Authentication failed. The server token may have changed.');
124 err.code = 'AUTH_FAILED';
125 throw err;
126 }
127 if (!res.ok) {
128 throw new Error(`Status failed: ${res.status} ${res.statusText}`);
129 }
130 return res.json();
131 }
132
133 export function isEventPending(status, eventId) {
134 return (status.pendingEvents || []).some((entry) => entry.id === eventId);
135 }
136
137 export async function waitForEventAck(base, token, eventId, {
138 pollIntervalMs = 400,
139 maxWaitMs = 600_000,
140 } = {}) {
141 const deadline = Date.now() + maxWaitMs;
142 while (Date.now() < deadline) {
143 const status = await fetchServerStatus(base, token);
144 if (!isEventPending(status, eventId)) return true;
145 await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
146 }
147 return false;
148 }
149
150 export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
151 while (true) {
152 if (totalDeadline && Date.now() >= totalDeadline) {
153 return { type: 'timeout' };
154 }
155
156 const remaining = totalDeadline
157 ? totalDeadline - Date.now()
158 : PER_REQUEST_TIMEOUT_MS;
159 const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
160 const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
161
162 if (res.status === 401) {
163 const err = new Error('Authentication failed. The server token may have changed.');
164 err.code = 'AUTH_FAILED';
165 throw err;
166 }
167
168 if (!res.ok) {
169 throw new Error(`Poll failed: ${res.status} ${res.statusText}`);
170 }
171
172 const next = await res.json();
173 if (next?.type === 'timeout') {
174 if (totalDeadline && Date.now() < totalDeadline) continue;
175 if (!totalDeadline) continue;
176 return next;
177 }
178 return next;
179 }
180 }
181
182 export async function augmentEventWithAcceptHandling(event, base, token) {
183 if (event.type !== 'accept' && event.type !== 'discard') return event;
184
185 const __dirname = path.dirname(fileURLToPath(import.meta.url));
186 const acceptScript = path.join(__dirname, 'live-accept.mjs');
187 const scriptArgs = buildAcceptScriptArgs(event);
188
189 try {
190 const out = execFileSync(
191 'node',
192 [acceptScript, ...scriptArgs],
193 { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 },
194 );
195 event._acceptResult = JSON.parse(out.trim());
196 } catch (err) {
197 event._acceptResult = { handled: false, mode: 'error', error: err.message };
198 }
199
200 const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
201 try {
202 await postReply(base, token, {
203 id: event.id,
204 type: completionType,
205 message: event._acceptResult?.error,
206 file: event._acceptResult?.file,
207 data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
208 });
209 } catch (err) {
210 event._completionAck = { ok: false, error: err.message };
211 }
212 if (!event._completionAck) {
213 event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
214 }
215
216 return event;
217 }
218
219 export function buildAcceptScriptArgs(event) {
220 const scriptArgs = event.type === 'discard'
221 ? ['--id', String(event.id), '--discard']
222 : ['--id', String(event.id), '--variant', String(event.variantId)];
223 if (event.pageUrl) scriptArgs.push('--page-url', String(event.pageUrl));
224 if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
225 scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
226 }
227 return scriptArgs;
228 }
229
230 export function writeCarbonizeBanner(event) {
231 if (event.type === 'manual_edit_apply') {
232 process.stderr.write('\n' + manualApplyPollBanner(event) + '\n');
233 }
234 if (event._acceptResult?.carbonize === true) {
235 process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
236 }
237 }
238
239 export function printPollEvent(event) {
240 console.log(JSON.stringify(event));
241 }
242
243 export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) {
244 const deadline = Date.now() + totalTimeout;
245 const event = await fetchNextEvent(base, token, { totalDeadline: deadline });
246 await augmentEventWithAcceptHandling(event, base, token);
247 writeCarbonizeBanner(event);
248 printPollEvent(event);
249 return event;
250 }
251
252 export async function runPollStream(base, token, {
253 ackTimeoutMs = 600_000,
254 ackPollIntervalMs = 400,
255 shouldContinue = () => true,
256 } = {}) {
257 process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
258
259 while (shouldContinue()) {
260 const event = await fetchNextEvent(base, token);
261 await augmentEventWithAcceptHandling(event, base, token);
262 writeCarbonizeBanner(event);
263 printPollEvent(event);
264
265 if (event.type === 'exit') return event;
266
267 if (requiresAgentReply(event)) {
268 const acked = await waitForEventAck(base, token, event.id, {
269 pollIntervalMs: ackPollIntervalMs,
270 maxWaitMs: ackTimeoutMs,
271 });
272 if (!acked) {
273 const err = new Error(`Timed out waiting for --reply on event ${event.id}`);
274 err.code = 'ACK_TIMEOUT';
275 throw err;
276 }
277 }
278 }
279
280 return null;
281 }
282
283 function handlePollError(err) {
284 if (err.code === 'AUTH_FAILED') {
285 console.error(err.message);
286 console.error('Try restarting: npx impeccable live stop && npx impeccable live');
287 process.exit(1);
288 }
289 if (err.cause?.code === 'ECONNREFUSED') {
290 console.error('Live server not running. Start one with: npx impeccable live');
291 process.exit(1);
292 }
293 if (err.code === 'ACK_TIMEOUT') {
294 console.error(err.message);
295 process.exit(1);
296 }
297 console.error('Poll failed:', err.message);
298 process.exit(1);
299 }
300
301 export async function pollCli() {
302 const args = process.argv.slice(2);
303
304 if (args.includes('--help') || args.includes('-h')) {
305 console.log(`Usage: impeccable poll [options]
306
307 Wait for a browser event from the live variant server, or reply to one.
308
309 Modes:
310 poll Block until a browser event arrives, print JSON, exit
311 poll --stream Keep polling; print one JSON line per event (see live.md)
312 poll --reply <id> done Reply "done" to event <id> (replace or insert generate)
313 poll --reply <id> steer_done Reply after handling a steer event (unlocks Steer bar)
314 poll --reply <id> error "msg" Reply with an error message
315 poll --reply <id> done --data '<json>'
316 Reply with a structured JSON result (manual_edit_apply)
317
318 Options:
319 --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
320 --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
321 --file PATH Attach a source file path to the reply (generate/steer flow)
322 --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
323 --help Show this help message
324
325 Harness note:
326 Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor.
327 --stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`);
328 process.exit(0);
329 }
330
331 const info = readServerInfo();
332 const base = `http://localhost:${info.port}`;
333
334 // Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
335 if (args.includes('--reply')) {
336 let reply;
337 try {
338 reply = parseReplyArgs(args);
339 } catch (err) {
340 console.error(err.message);
341 process.exit(1);
342 }
343
344 try {
345 await postReply(base, info.token, reply);
346 } catch (err) {
347 if (err.cause?.code === 'ECONNREFUSED') {
348 console.error('Live server not running. Start one with: npx impeccable live');
349 } else {
350 console.error('Reply failed:', err.message);
351 }
352 process.exit(1);
353 }
354 return;
355 }
356
357 const streamMode = args.includes('--stream');
358 const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
359 const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
360
361 try {
362 if (streamMode) {
363 await runPollStream(base, info.token, { ackTimeoutMs });
364 return;
365 }
366
367 const timeoutArg = args.find((a) => a.startsWith('--timeout='));
368 const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
369 await runPollOnce(base, info.token, { totalTimeout });
370 } catch (err) {
371 handlePollError(err);
372 }
373 }
374
375 // Auto-execute when run directly
376 const _running = process.argv[1];
377 if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
378 pollCli();
379 }
380
380 lines Plain Text