@jangmanj/pi-webtasks 0.1.0

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.
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Asynchronous Parallel Task research: submission and live collection.
3
+ *
4
+ * Contracts this module owns:
5
+ *
6
+ * - Submission is a recorded paid call (`paid-call.ts`): the effective request
7
+ * is recorded before money is spent, a replayed Pi invocation returns that
8
+ * record instead of submitting again, and an accepted run is handed off to
9
+ * collection.
10
+ * - Processor policy: `core` is the floor (`lite`/`base` are not offered for
11
+ * research) and `ultra2x` is the autonomous ceiling. A model-supplied
12
+ * argument is never treated as authorization for more.
13
+ * - One Task Group per Pi session. The run carries the local reference in its
14
+ * metadata, so a crash between submission and binding can be reconciled from
15
+ * the group instead of resubmitting.
16
+ * - Collection: every provider read (the run's event stream, its status, its
17
+ * result) happens here, and each fact goes to the Task run lifecycle
18
+ * (`task-run-lifecycle.ts`), which decides and commits. A closed stream is
19
+ * not completion: the run status decides.
20
+ */
21
+
22
+ import type { Parallel } from "parallel-web";
23
+ import type { RunInput, TaskRun, TaskRunEventsResponse, TaskRunResult, TaskSpec } from "parallel-web/resources";
24
+ import { OPEN_RUN_STATES, TERMINAL_RUN_STATES, UNCONFIRMED_RUN_STATES, type ResearchStore } from "./store.ts";
25
+ import { providerSourcePolicy, sourcePolicy } from "./source-policy.ts";
26
+ import { createPaidCalls } from "./paid-call.ts";
27
+ import {
28
+ createTaskRunLifecycle,
29
+ providerStatus,
30
+ TASK_FLOOR,
31
+ TASK_PROCESSORS,
32
+ type ProviderFact,
33
+ type TaskProcessor,
34
+ } from "./task-run-lifecycle.ts";
35
+ import { clean, errorText, oneLine, replayReceipt } from "./receipt.ts";
36
+
37
+ export const TASK_CEILING: TaskProcessor = "ultra2x";
38
+
39
+ export function validateProcessor(value: string | undefined): TaskProcessor {
40
+ const chosen = (value?.trim() || process.env.WEBTASKS_TASK_PROCESSOR?.trim() || TASK_FLOOR);
41
+ if ((TASK_PROCESSORS as readonly string[]).includes(chosen)) return chosen as TaskProcessor;
42
+ throw new Error(
43
+ `processor must be one of ${TASK_PROCESSORS.join(", ")}.`,
44
+ );
45
+ }
46
+
47
+ export type ResearchInput = {
48
+ input: string | Record<string, unknown>;
49
+ processor?: string;
50
+ /** JSON Schema for structured output. Omit for a cited text report. */
51
+ output_schema?: Record<string, unknown>;
52
+ /** What the cited text report should contain, when output_schema is omitted. */
53
+ output_description?: string;
54
+ include_domains?: string[];
55
+ exclude_domains?: string[];
56
+ after_date?: string;
57
+ /** Local reference of an earlier Task or answer to build on. */
58
+ continue_from?: string;
59
+ };
60
+
61
+ export type TaskReceipt = { text: string; details: Record<string, unknown> };
62
+
63
+ /** The effective request a submission records before it is sent. */
64
+ type TaskIntent = {
65
+ kind: "task";
66
+ input: string | Record<string, unknown>;
67
+ processor: TaskProcessor;
68
+ output_schema?: Record<string, unknown>;
69
+ output_description?: string;
70
+ source_policy?: { include_domains?: string[]; exclude_domains?: string[]; after_date?: string };
71
+ continue_from?: string;
72
+ };
73
+
74
+ type Options = {
75
+ store: ResearchStore;
76
+ sessionId: string;
77
+ client: () => Promise<Parallel>;
78
+ /** Wake the runtime's alert drain. */
79
+ onAlerts: () => void;
80
+ log?: (message: string) => void;
81
+ sweepMs?: number;
82
+ /** First reconnect delay for a run's event stream; it doubles up to five minutes. */
83
+ retryMs?: number;
84
+ };
85
+
86
+ /** Reconnect delay bounds for a run's event stream. */
87
+ const RETRY_MIN_MS = 3_000;
88
+ const RETRY_MAX_MS = 300_000;
89
+ /** Provider statuses after which a run's event stream has nothing more to say. */
90
+ const STREAM_ENDS: ReadonlySet<string> = new Set(["completed", "failed", "cancelled"]);
91
+
92
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
93
+ return new Promise(resolve => {
94
+ if (signal.aborted) return resolve();
95
+ const timer = setTimeout(done, ms);
96
+ function done() {
97
+ clearTimeout(timer);
98
+ signal.removeEventListener("abort", done);
99
+ resolve();
100
+ }
101
+ signal.addEventListener("abort", done, { once: true });
102
+ });
103
+ }
104
+
105
+ export function createTaskService(options: Options) {
106
+ const { store, sessionId } = options;
107
+ const log = options.log ?? (() => undefined);
108
+ const client = options.client;
109
+ const calls = createPaidCalls({ store, sessionId, client });
110
+ const lifecycle = createTaskRunLifecycle({ store, sessionId, onAlerts: options.onAlerts, log });
111
+ const active = new Map<string, AbortController>();
112
+ const collecting = new Set<Promise<void>>();
113
+ let groupCreation: Promise<string> | undefined;
114
+ let sweep: ReturnType<typeof setInterval> | undefined;
115
+ let stopping = false;
116
+
117
+ function receipt(reference: string, processor: string, kind: string, contract: string): TaskReceipt {
118
+ const lines = [
119
+ `Research submitted: ${reference} (${processor}, ${kind}).`,
120
+ `Contract: ${contract}`,
121
+ "It runs in the background, and results usually take 5-15 minutes: progress updates and a completion alert arrive as messages, even after you end your turn.",
122
+ "Keep working on the other parts of the task meanwhile. When nothing else is left, tell the user the research is running and end your turn; the completion alert brings you back.",
123
+ `Read partial findings as they arrive with web_recall { reference: "${reference}" }. This job is submitted once; there is nothing to poll or resubmit.`,
124
+ ];
125
+ return {
126
+ text: lines.join("\n"),
127
+ details: { reference, state: "submitted", replayed: false, processor, hits: 0 },
128
+ };
129
+ }
130
+
131
+ async function continuationId(reference: string): Promise<string> {
132
+ const run = await store.run(sessionId, reference);
133
+ if (!run) throw new Error(`${reference} is not a research record in this session`);
134
+ const id = run.provider_interaction_id ?? (run.kind === "answer" ? run.provider_run_id : undefined);
135
+ if (!id) throw new Error(`${reference} cannot be continued yet (state: ${run.state}); continue from a completed web_research run or web_answer`);
136
+ return id;
137
+ }
138
+
139
+ async function ensureGroup(parallel: Parallel): Promise<string> {
140
+ const existing = await store.groupForSession(sessionId);
141
+ if (existing?.group_id && existing.state === "ready") return existing.group_id;
142
+ // Concurrent first submissions share one creation, so the session keeps one group.
143
+ groupCreation ??= (async () => {
144
+ try {
145
+ const group = await parallel.taskGroup.create({}, { maxRetries: 0 });
146
+ await store.bindGroup(sessionId, group.taskgroup_id, "ready");
147
+ return group.taskgroup_id;
148
+ } catch (err) {
149
+ await store.bindGroup(sessionId, existing?.group_id, "uncertain", errorText(err));
150
+ throw err;
151
+ }
152
+ })().finally(() => { groupCreation = undefined; });
153
+ return groupCreation;
154
+ }
155
+
156
+ /**
157
+ * The output contract is the caller's decision, never the extension's: name a
158
+ * JSON Schema for fields, or describe the text report you want. There is no
159
+ * implicit schema, and no reliance on the provider's `auto` default.
160
+ */
161
+ function taskSpec(input: ResearchInput): { spec: TaskSpec; kind: string; described?: string } {
162
+ const hasSchema = input.output_schema !== undefined;
163
+ const description = clean(input.output_description);
164
+ if (hasSchema && description) {
165
+ throw new Error("Pass output_schema or output_description, not both: they are different output contracts");
166
+ }
167
+ if (!hasSchema && !description) {
168
+ throw new Error("Say what the run must produce: output_schema (a JSON Schema object) or output_description (what the cited text report should contain)");
169
+ }
170
+ if (hasSchema) {
171
+ if (!input.output_schema || typeof input.output_schema !== "object" || Array.isArray(input.output_schema)) {
172
+ throw new Error("output_schema must be a JSON Schema object");
173
+ }
174
+ if (!("type" in input.output_schema) && !("properties" in input.output_schema)) {
175
+ throw new Error("output_schema needs a JSON Schema `type` or `properties`");
176
+ }
177
+ return { spec: { output_schema: { type: "json", json_schema: input.output_schema } }, kind: "structured output" };
178
+ }
179
+ return { spec: { output_schema: { type: "text", description } }, kind: "cited text report", described: description };
180
+ }
181
+
182
+ async function submit(input: ResearchInput, call: { invocationId: string; signal?: AbortSignal }): Promise<TaskReceipt> {
183
+ const raw = input.input;
184
+ if (typeof raw === "string" ? !raw.trim() : !raw || typeof raw !== "object" || Array.isArray(raw) || !Object.keys(raw).length) {
185
+ throw new Error("input needs a non-empty goal string, or a non-empty object of structured inputs");
186
+ }
187
+ const processor = validateProcessor(input.processor);
188
+ const previousInteractionId = input.continue_from ? await continuationId(clean(input.continue_from)!) : undefined;
189
+ const { spec: task_spec, kind, described } = taskSpec(input);
190
+ const source_policy = providerSourcePolicy(sourcePolicy(input));
191
+
192
+ const intent: TaskIntent = {
193
+ kind: "task",
194
+ input: raw,
195
+ processor,
196
+ output_schema: input.output_schema,
197
+ output_description: described,
198
+ source_policy,
199
+ continue_from: clean(input.continue_from),
200
+ };
201
+ const outcome = await calls.run({
202
+ kind: "task",
203
+ label: "Research submission",
204
+ intent,
205
+ processor,
206
+ invocation: call,
207
+ // An accepted-but-unrecorded submission may exist: reconciliation finds it by its reference.
208
+ uncertainAdvice: reference => `It may have started anyway: ${reference} is being checked and reports its result if it did, so it needs no new submission.`,
209
+ send: async (parallel, { reference }) => {
210
+ const groupId = await ensureGroup(parallel);
211
+ const runInput: RunInput = {
212
+ input: raw,
213
+ processor,
214
+ enable_events: true,
215
+ task_spec,
216
+ metadata: { webtasks_ref: reference },
217
+ ...(source_policy ? { source_policy } : {}),
218
+ ...(previousInteractionId ? { previous_interaction_id: previousInteractionId } : {}),
219
+ };
220
+ const added = await parallel.taskGroup.addRuns(groupId, { inputs: [runInput], default_task_spec: task_spec }, { maxRetries: 0 });
221
+ const runId = added.run_ids?.[0];
222
+ if (!runId) throw new Error("Parallel accepted the job without returning a run");
223
+ try {
224
+ // Bind the accepted run before anything else can fail: from here on it exists and is paid for.
225
+ await lifecycle.bound(reference, { providerRunId: runId, groupId });
226
+ if (added.event_cursor) await store.setGroupCursor(sessionId, added.event_cursor);
227
+ } catch (err) {
228
+ // Accepted means the outcome can no longer be a rejection, whatever failed here:
229
+ // an error without an HTTP status is always settled as uncertain.
230
+ throw new Error(`Parallel accepted the job, but recording it failed: ${errorText(err) ?? "unknown error"}`, { cause: err });
231
+ }
232
+ return { runId, parallel };
233
+ },
234
+ // No settle: the accepted run is handed off to collection.
235
+ });
236
+
237
+ if (outcome.replayed) {
238
+ // Describe what was actually submitted, not the arguments of the replay.
239
+ const { reference, run, hits } = outcome;
240
+ const stored = outcome.intent ?? intent;
241
+ const storedProcessor = stored.processor ?? processor;
242
+ const storedKind = stored.output_schema !== undefined ? "structured output" : "cited text report";
243
+ const contract = contractLine(storedProcessor, storedKind, stored.output_description, stored.source_policy, stored.continue_from);
244
+ const { text, details } = replayReceipt({ noun: "research job", reference, state: run?.state ?? "unknown", hits, body: [`Contract: ${contract}`] });
245
+ return { text, details: { ...details, processor: storedProcessor } };
246
+ }
247
+ const { reference, response: { runId, parallel } } = outcome;
248
+
249
+ // The run is accepted and bound. Its first status read is best effort: collection reconciles the rest.
250
+ try {
251
+ const run = await parallel.taskRun.retrieve(runId, { maxRetries: 0 });
252
+ await lifecycle.observed(reference, await factFor(runId, run));
253
+ } catch (err) {
254
+ log(`first status check for ${reference} failed: ${errorText(err) ?? "unknown error"}`);
255
+ }
256
+
257
+ void track(reference);
258
+ options.onAlerts();
259
+ return receipt(reference, processor, kind, contractLine(processor, kind, described, source_policy, clean(input.continue_from)));
260
+ }
261
+
262
+ /** The effective request, stated plainly so the caller can see what it asked for. */
263
+ function contractLine(processor: string, kind: string, description: string | undefined, policy: TaskIntent["source_policy"], continueFrom: string | undefined): string {
264
+ const parts = [processor, kind === "structured output" ? "JSON schema output" : `text report: "${oneLine(description ?? "", 100)}"`];
265
+ const include = policy?.include_domains ?? [];
266
+ const exclude = policy?.exclude_domains ?? [];
267
+ if (include.length) parts.push(`only ${include.join(", ")}`);
268
+ if (exclude.length) parts.push(`excluding ${exclude.join(", ")}`);
269
+ if (policy?.after_date) parts.push(`published after ${policy.after_date}`);
270
+ if (continueFrom) parts.push(`continues ${continueFrom}`);
271
+ return parts.join("; ");
272
+ }
273
+
274
+ /**
275
+ * What Parallel says about a run, as a lifecycle fact. A completed run's
276
+ * result is fetched here; a failed fetch leaves it out, and the lifecycle
277
+ * decides whether to wait for another attempt.
278
+ */
279
+ async function factFor(runId: string, providerRun: TaskRun | undefined, reference?: string): Promise<ProviderFact> {
280
+ const status = providerStatus(providerRun);
281
+ let result: TaskRunResult | undefined;
282
+ if (status === "completed") {
283
+ try {
284
+ result = await (await client()).taskRun.result(runId, { timeout: 0 }, { maxRetries: 0, timeout: 60_000 });
285
+ } catch (err) {
286
+ log(`result fetch for ${reference ?? runId} failed: ${errorText(err) ?? "unknown error"}`);
287
+ }
288
+ }
289
+ return {
290
+ status,
291
+ interactionId: providerRun?.interaction_id ?? undefined,
292
+ error: (providerRun as { error?: unknown } | undefined)?.error,
293
+ run: providerRun,
294
+ result,
295
+ };
296
+ }
297
+
298
+ /** Store one received event. Returns whether it was a replay. */
299
+ async function observe(reference: string, event: TaskRunEventsResponse): Promise<boolean> {
300
+ const type = typeof (event as { type?: unknown }).type === "string" ? (event as { type: string }).type : "unknown";
301
+ const timestamp = (event as { timestamp?: unknown }).timestamp;
302
+ const message = (event as { message?: unknown }).message;
303
+ const eventId = (event as { event_id?: unknown }).event_id;
304
+ const dedupKey = typeof eventId === "string" && eventId
305
+ ? `id:${eventId}`
306
+ : type === "task_run.progress_stats"
307
+ ? "stats"
308
+ : `evt:${type}:${typeof timestamp === "string" ? timestamp : ""}:${typeof message === "string" ? oneLine(message, 400) : ""}:${JSON.stringify((event as { source_stats?: unknown }).source_stats ?? null)}`;
309
+
310
+ const { duplicate } = await store.appendObservation({
311
+ sessionId, reference, source: "run_sse", eventType: type,
312
+ providerEventId: typeof eventId === "string" && eventId ? eventId : undefined,
313
+ dedupKey,
314
+ replaceIfExists: type === "task_run.progress_stats",
315
+ payload: event,
316
+ });
317
+ if (duplicate) return true;
318
+
319
+ if (type === "task_run.progress_msg.result" && typeof message === "string" && message.trim()) {
320
+ await store.appendEvidence(sessionId, reference, {
321
+ kind: "finding",
322
+ origin: "provider_finding",
323
+ title: "Intermediate finding",
324
+ body: message,
325
+ pointer: `progress_msg.result@${typeof timestamp === "string" ? timestamp : "unknown"}`,
326
+ payload: event,
327
+ });
328
+ }
329
+ return false;
330
+ }
331
+
332
+ /** Collect one run's events until it settles or the runtime stops. */
333
+ async function track(reference: string): Promise<void> {
334
+ if (stopping || active.has(reference)) return;
335
+ const controller = new AbortController();
336
+ active.set(reference, controller);
337
+ const work = (async () => {
338
+ try {
339
+ await collect(reference, controller);
340
+ } catch (err) {
341
+ if (!stopping) log(`collection for ${reference} stopped: ${errorText(err) ?? "unknown error"}`);
342
+ } finally {
343
+ active.delete(reference);
344
+ }
345
+ })();
346
+ collecting.add(work);
347
+ try {
348
+ await work;
349
+ } finally {
350
+ collecting.delete(work);
351
+ }
352
+ }
353
+
354
+ async function collect(reference: string, controller: AbortController): Promise<void> {
355
+ // Back off while the provider or the network is unavailable, and report one failure per streak,
356
+ // so a missing key or an outage does not turn into a notification every few seconds.
357
+ const retryMinMs = options.retryMs ?? RETRY_MIN_MS;
358
+ let delayMs = retryMinMs;
359
+ let failures = 0;
360
+ const failed = (message: string) => {
361
+ if (failures++ === 0) log(message);
362
+ };
363
+ while (!stopping && !controller.signal.aborted) {
364
+ const run = await store.run(sessionId, reference);
365
+ const runId = run?.provider_run_id;
366
+ if (!runId || TERMINAL_RUN_STATES.has(run.state)) return;
367
+ let ended = false;
368
+ let fresh = false;
369
+ try {
370
+ const stream = await (await client()).taskRun.events(runId, { signal: controller.signal, maxRetries: 0 });
371
+ for await (const event of stream) {
372
+ if (controller.signal.aborted) return;
373
+ const duplicate = await observe(reference, event);
374
+ if (!duplicate) fresh = true;
375
+ const providerRun = (event as { run?: TaskRun }).run;
376
+ if (!providerRun) continue;
377
+ // A reconnect replays recorded events. Only a replayed end is worth another look:
378
+ // it may not have been committed (its result fetch failed); the rest were applied.
379
+ if (duplicate && !STREAM_ENDS.has(providerStatus(providerRun))) continue;
380
+ const { open } = await lifecycle.observed(reference, await factFor(runId, providerRun, reference));
381
+ if (!open) return;
382
+ if (STREAM_ENDS.has(providerStatus(providerRun))) {
383
+ // Seen, but not committed (a missing result or a failed commit): try again after a pause.
384
+ ended = true;
385
+ break;
386
+ }
387
+ }
388
+ } catch (err) {
389
+ if (controller.signal.aborted) return;
390
+ failed(`run stream for ${reference} ended: ${errorText(err) ?? "unknown error"}`);
391
+ }
392
+ if (controller.signal.aborted) return;
393
+ // A stream close is not completion: the provider's status decides.
394
+ if (!ended && await reconcileRun(reference)) return;
395
+ if (fresh) {
396
+ delayMs = retryMinMs;
397
+ failures = 0;
398
+ }
399
+ await sleep(delayMs, controller.signal);
400
+ delayMs = Math.min(RETRY_MAX_MS, delayMs * 2);
401
+ }
402
+ }
403
+
404
+ /** Ask the provider about one run and hand the answer to the lifecycle. Returns whether the run settled. */
405
+ async function reconcileRun(reference: string): Promise<boolean> {
406
+ const run = await store.run(sessionId, reference);
407
+ if (!run?.provider_run_id) return false;
408
+ if (TERMINAL_RUN_STATES.has(run.state)) return true;
409
+ try {
410
+ const providerRun = await (await client()).taskRun.retrieve(run.provider_run_id, { maxRetries: 0 });
411
+ const { open } = await lifecycle.observed(reference, await factFor(run.provider_run_id, providerRun, reference));
412
+ return !open;
413
+ } catch (err) {
414
+ log(`reconciliation for ${reference} failed: ${errorText(err) ?? "unknown error"}`);
415
+ return false;
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Recover a submission whose provider id was never bound, by matching the
421
+ * local reference carried in the run metadata inside the session's group.
422
+ */
423
+ async function recoverUnbound(reference: string): Promise<boolean> {
424
+ const group = await store.groupForSession(sessionId);
425
+ if (!group?.group_id) return false;
426
+ const controller = new AbortController();
427
+ const timer = setTimeout(() => controller.abort(), 10_000);
428
+ try {
429
+ const stream = await (await client()).taskGroup.getRuns(group.group_id, { include_input: true }, { signal: controller.signal, maxRetries: 0 });
430
+ for await (const event of stream) {
431
+ const run = (event as { run?: TaskRun }).run;
432
+ if (!run) continue;
433
+ const metadata = (run as { metadata?: Record<string, unknown> | null }).metadata;
434
+ if (metadata?.webtasks_ref !== reference) continue;
435
+ await lifecycle.bound(reference, { providerRunId: run.run_id, groupId: group.group_id, interactionId: run.interaction_id ?? undefined });
436
+ controller.abort();
437
+ return true;
438
+ }
439
+ } catch (err) {
440
+ if (!controller.signal.aborted) log(`group reconciliation failed: ${errorText(err) ?? "unknown error"}`);
441
+ } finally {
442
+ clearTimeout(timer);
443
+ }
444
+ return false;
445
+ }
446
+
447
+ /** Session start: settle what finished while Pi was closed, resume what is still open. */
448
+ async function reconcile(): Promise<void> {
449
+ const runs = (await store.listRuns(sessionId, { states: [...OPEN_RUN_STATES], limit: 200 })).filter(run => run.kind === "task");
450
+ for (const run of runs) {
451
+ if (stopping) return;
452
+ if (!run.provider_run_id && UNCONFIRMED_RUN_STATES.has(run.state)) await recoverUnbound(run.reference);
453
+ const settled = await reconcileRun(run.reference);
454
+ if (!settled) void track(run.reference);
455
+ }
456
+ options.onAlerts();
457
+ }
458
+
459
+ /** Queue due progress alerts. */
460
+ async function tick(): Promise<void> {
461
+ if (stopping) return;
462
+ await lifecycle.tick();
463
+ }
464
+
465
+ function start(): void {
466
+ stopping = false;
467
+ if (sweep) return;
468
+ sweep = setInterval(() => {
469
+ // A background sweep must never take Pi down: report and try again next time.
470
+ void tick().catch(err => log(`progress sweep failed: ${errorText(err) ?? "unknown error"}`));
471
+ }, options.sweepMs ?? 15_000);
472
+ // Never keep a Pi process alive just for the schedule.
473
+ (sweep as unknown as { unref?: () => void }).unref?.();
474
+ }
475
+
476
+ async function stop(): Promise<void> {
477
+ stopping = true;
478
+ if (sweep) clearInterval(sweep);
479
+ sweep = undefined;
480
+ for (const controller of active.values()) controller.abort();
481
+ // Let aborted collectors unwind before the caller closes the store under them.
482
+ await Promise.allSettled([...collecting]);
483
+ active.clear();
484
+ }
485
+
486
+ return { submit, reconcile, tick, track, stop, start };
487
+ }
488
+
489
+ export type TaskService = ReturnType<typeof createTaskService>;