pi-background-tasks 0.6.0 → 0.7.2

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,453 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, fsyncSync, openSync, renameSync } from 'node:fs';
3
+ import { chmod, mkdir, open, rm } from 'node:fs/promises';
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
5
+ import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
6
+ import { sanitizePathSegment } from '../common.js';
7
+ import {
8
+ EMPTY_FUSION_USAGE,
9
+ FUSION_MANIFEST_SCHEMA_VERSION,
10
+ FusionError,
11
+ type FusionArtifactManifest,
12
+ type FusionArtifactRef,
13
+ type FusionAttemptArtifactRecord,
14
+ type FusionCandidateId,
15
+ type FusionChildRunResult,
16
+ type FusionModelConfigV1,
17
+ type FusionSource,
18
+ type FusionStage,
19
+ type FusionState,
20
+ type FusionTerminalState,
21
+ type FusionUsage,
22
+ type ResolvedFusionModels,
23
+ } from './types.js';
24
+
25
+ const RUN_ID_PATTERN = /^f[0-9a-f]{32}$/;
26
+
27
+ interface MutableFusionArtifactManifest {
28
+ schema_version: typeof FUSION_MANIFEST_SCHEMA_VERSION;
29
+ run_id: string;
30
+ source: FusionSource;
31
+ state: FusionState;
32
+ created_at: string;
33
+ updated_at: string;
34
+ cwd: string;
35
+ config: FusionModelConfigV1;
36
+ models: {
37
+ candidates: [string, string, string];
38
+ evaluator: string;
39
+ merger: string;
40
+ thinking_level: string;
41
+ };
42
+ usage: FusionUsage;
43
+ attempts: FusionAttemptArtifactRecord[];
44
+ artifacts: Record<string, FusionArtifactRef>;
45
+ anonymous_map?: Record<FusionCandidateId, 1 | 2 | 3>;
46
+ error?: string;
47
+ }
48
+
49
+ export interface CreateFusionArtifactStoreOptions {
50
+ cwd: string;
51
+ sessionId?: string | undefined;
52
+ runId?: string | undefined;
53
+ source: FusionSource;
54
+ config: FusionModelConfigV1;
55
+ models: ResolvedFusionModels;
56
+ now?: () => Date;
57
+ }
58
+
59
+ export interface RecordFusionChildAttemptInput {
60
+ result: FusionChildRunResult;
61
+ prompt: string;
62
+ responseKind: 'md' | 'txt';
63
+ }
64
+
65
+ export interface RecordFusionFailedAttemptInput {
66
+ stage: FusionStage;
67
+ slot?: 1 | 2 | 3;
68
+ attempt: number;
69
+ prompt: string;
70
+ events: Buffer;
71
+ partialResponse: Buffer;
72
+ stderr: Buffer;
73
+ error: string;
74
+ status: 'failed' | 'cancelled';
75
+ responseKind: 'md' | 'txt';
76
+ provider?: string;
77
+ model?: string;
78
+ qualifiedId?: string;
79
+ usage?: FusionUsage;
80
+ }
81
+
82
+ function makeRunId(): string {
83
+ return `f${randomBytes(16).toString('hex')}`;
84
+ }
85
+
86
+ function usageClone(usage: FusionUsage): FusionUsage {
87
+ const out: FusionUsage = {
88
+ input: usage.input,
89
+ output: usage.output,
90
+ cacheRead: usage.cacheRead,
91
+ cacheWrite: usage.cacheWrite,
92
+ totalTokens: usage.totalTokens,
93
+ };
94
+ if (usage.costTotal !== undefined) out.costTotal = usage.costTotal;
95
+ return out;
96
+ }
97
+
98
+ function modelsForManifest(models: ResolvedFusionModels): MutableFusionArtifactManifest['models'] {
99
+ const first = models.candidates[0].qualifiedId;
100
+ const second = models.candidates[1].qualifiedId;
101
+ const third = models.candidates[2].qualifiedId;
102
+ return {
103
+ candidates: [first, second, third],
104
+ evaluator: models.evaluator.qualifiedId,
105
+ merger: models.merger.qualifiedId,
106
+ thinking_level: models.evaluator.thinkingLevel,
107
+ };
108
+ }
109
+
110
+ function terminalStates(): ReadonlySet<FusionState> {
111
+ return new Set<FusionState>(['completed', 'failed', 'cancelled']);
112
+ }
113
+
114
+ const TERMINAL_STATES = terminalStates();
115
+
116
+ const NEXT_STATES: Readonly<Record<FusionState, readonly FusionState[]>> = {
117
+ initializing: ['candidates_running', 'failed', 'cancelled'],
118
+ candidates_running: ['candidates_complete', 'failed', 'cancelled'],
119
+ candidates_complete: ['evaluating', 'failed', 'cancelled'],
120
+ evaluating: ['evaluation_complete', 'failed', 'cancelled'],
121
+ evaluation_complete: ['merging', 'failed', 'cancelled'],
122
+ merging: ['completed', 'failed', 'cancelled'],
123
+ completed: [],
124
+ failed: [],
125
+ cancelled: [],
126
+ };
127
+
128
+ function canTransition(from: FusionState, to: FusionState): boolean {
129
+ return NEXT_STATES[from].includes(to);
130
+ }
131
+
132
+ function fsyncDirectory(path: string): void {
133
+ if (process.platform === 'win32') return;
134
+ const fd = openSync(path, 'r');
135
+ try {
136
+ fsyncSync(fd);
137
+ } finally {
138
+ closeSync(fd);
139
+ }
140
+ }
141
+
142
+ function pathInside(parent: string, child: string): boolean {
143
+ const rel = relative(parent, child);
144
+ return (
145
+ rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'))
146
+ );
147
+ }
148
+
149
+ function errorForArtifact(message: string): FusionError {
150
+ return new FusionError(message, { code: 'artifact_error', childCreated: false });
151
+ }
152
+
153
+ async function writeTempFile(absPath: string, data: Buffer | string): Promise<void> {
154
+ const handle = await open(absPath, 'wx', 0o600);
155
+ try {
156
+ await handle.writeFile(data);
157
+ await handle.sync();
158
+ } finally {
159
+ await handle.close();
160
+ }
161
+ }
162
+
163
+ async function writePrivateFile(
164
+ absPath: string,
165
+ data: Buffer | string,
166
+ ): Promise<FusionArtifactRef> {
167
+ const dir = dirname(absPath);
168
+ const tmp = join(
169
+ dir,
170
+ `.${basename(absPath)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
171
+ );
172
+ const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
173
+ try {
174
+ await writeTempFile(tmp, data);
175
+ renameSync(tmp, absPath);
176
+ fsyncDirectory(dir);
177
+ } catch (error) {
178
+ await rm(tmp, { force: true });
179
+ throw error;
180
+ }
181
+ return { path: basename(absPath), byte_length: bytes.length, sha256: sha256Buffer(bytes) };
182
+ }
183
+
184
+ async function writeJsonAtomic(absPath: string, value: unknown): Promise<FusionArtifactRef> {
185
+ return writePrivateFile(absPath, `${JSON.stringify(value, null, 2)}\n`);
186
+ }
187
+
188
+ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifactManifest {
189
+ const out: FusionArtifactManifest = {
190
+ schema_version: manifest.schema_version,
191
+ run_id: manifest.run_id,
192
+ source: manifest.source,
193
+ state: manifest.state,
194
+ created_at: manifest.created_at,
195
+ updated_at: manifest.updated_at,
196
+ cwd: manifest.cwd,
197
+ config: manifest.config,
198
+ models: manifest.models,
199
+ usage: usageClone(manifest.usage),
200
+ attempts: [...manifest.attempts],
201
+ artifacts: { ...manifest.artifacts },
202
+ };
203
+ if (manifest.anonymous_map !== undefined) out.anonymous_map = { ...manifest.anonymous_map };
204
+ if (manifest.error !== undefined) out.error = manifest.error;
205
+ return out;
206
+ }
207
+
208
+ function attemptPrefix(stage: FusionStage, slot: 1 | 2 | 3 | undefined, attempt: number): string {
209
+ if (stage === 'candidate') {
210
+ if (slot === undefined) throw errorForArtifact('candidate attempt requires slot');
211
+ return `candidate-${String(slot)}.attempt-${String(attempt)}`;
212
+ }
213
+ if (slot !== undefined)
214
+ throw errorForArtifact(`${stage} attempt must not include candidate slot`);
215
+ return `${stage === 'evaluation' ? 'evaluation' : 'merge'}.attempt-${String(attempt)}`;
216
+ }
217
+
218
+ function responseName(prefix: string, kind: 'md' | 'txt'): string {
219
+ return `${prefix}.response.${kind}`;
220
+ }
221
+
222
+ export class FusionArtifactStore {
223
+ private readonly runDirAbs: string;
224
+ private readonly runDirDisplay: string;
225
+ private readonly now: () => Date;
226
+ private manifest: MutableFusionArtifactManifest;
227
+ private manifestWriteChain: Promise<void> = Promise.resolve();
228
+
229
+ private constructor(
230
+ runDirAbs: string,
231
+ runDirDisplay: string,
232
+ now: () => Date,
233
+ manifest: MutableFusionArtifactManifest,
234
+ ) {
235
+ this.runDirAbs = runDirAbs;
236
+ this.runDirDisplay = runDirDisplay;
237
+ this.now = now;
238
+ this.manifest = manifest;
239
+ }
240
+
241
+ static async create(options: CreateFusionArtifactStoreOptions): Promise<FusionArtifactStore> {
242
+ const runId = options.runId ?? makeRunId();
243
+ if (!RUN_ID_PATTERN.test(runId)) throw errorForArtifact(`invalid fusion run id: ${runId}`);
244
+ const sessionSegment = sanitizePathSegment(
245
+ options.sessionId ?? `session-${String(process.pid)}`,
246
+ );
247
+ const sessionDirName = `${sessionSegment}-${String(process.pid)}`;
248
+ const runDirAbs = join(options.cwd, '.pi', 'fusion', sessionDirName, runId);
249
+ const runDirDisplay = join('.pi', 'fusion', sessionDirName, runId);
250
+ await mkdir(runDirAbs, { recursive: true, mode: 0o700 });
251
+ await chmod(runDirAbs, 0o700);
252
+ const timestamp = (options.now ?? (() => new Date()))().toISOString();
253
+ const manifest: MutableFusionArtifactManifest = {
254
+ schema_version: FUSION_MANIFEST_SCHEMA_VERSION,
255
+ run_id: runId,
256
+ source: options.source,
257
+ state: 'initializing',
258
+ created_at: timestamp,
259
+ updated_at: timestamp,
260
+ cwd: options.cwd,
261
+ config: options.config,
262
+ models: modelsForManifest(options.models),
263
+ usage: usageClone(EMPTY_FUSION_USAGE),
264
+ attempts: [],
265
+ artifacts: {},
266
+ };
267
+ const store = new FusionArtifactStore(
268
+ runDirAbs,
269
+ runDirDisplay,
270
+ options.now ?? (() => new Date()),
271
+ manifest,
272
+ );
273
+ await store.writeManifest();
274
+ return store;
275
+ }
276
+
277
+ get runId(): string {
278
+ return this.manifest.run_id;
279
+ }
280
+
281
+ get artifactDir(): string {
282
+ return this.runDirDisplay;
283
+ }
284
+
285
+ get artifactDirAbs(): string {
286
+ return this.runDirAbs;
287
+ }
288
+
289
+ snapshot(): FusionArtifactManifest {
290
+ return publicManifest(this.manifest);
291
+ }
292
+
293
+ async transition(to: FusionState): Promise<void> {
294
+ await this.updateManifest((manifest) => {
295
+ if (!canTransition(manifest.state, to)) {
296
+ throw new FusionError(`illegal fusion state transition ${manifest.state} -> ${to}`, {
297
+ code: 'state_transition_invalid',
298
+ childCreated: false,
299
+ });
300
+ }
301
+ if (to === 'completed' && manifest.artifacts['merged.md'] === undefined) {
302
+ throw new FusionError('fusion cannot complete before merged.md is durable', {
303
+ code: 'state_transition_invalid',
304
+ childCreated: false,
305
+ });
306
+ }
307
+ manifest.state = to;
308
+ });
309
+ }
310
+
311
+ async setAnonymousMap(map: Record<FusionCandidateId, 1 | 2 | 3>): Promise<void> {
312
+ await this.updateManifest((manifest) => {
313
+ manifest.anonymous_map = { ...map };
314
+ });
315
+ }
316
+
317
+ async setUsage(usage: FusionUsage): Promise<void> {
318
+ await this.updateManifest((manifest) => {
319
+ manifest.usage = usageClone(usage);
320
+ });
321
+ }
322
+
323
+ async writeCanonicalInput(serialized: string): Promise<void> {
324
+ await this.writeArtifact('canonical-input.json', serialized);
325
+ }
326
+
327
+ async writeBlindCandidates(serialized: string): Promise<void> {
328
+ await this.writeArtifact('blind-candidates.json', serialized);
329
+ }
330
+
331
+ async writeEvaluationJson(value: unknown): Promise<void> {
332
+ await this.writeArtifact('evaluation.json', canonicalJson(value));
333
+ }
334
+
335
+ async writeMerged(text: string): Promise<void> {
336
+ await this.writeArtifact('merged.md', text);
337
+ }
338
+
339
+ async writeError(state: Exclude<FusionTerminalState, 'completed'>, error: string): Promise<void> {
340
+ await this.writeArtifact('error.json', `${JSON.stringify({ state, error }, null, 2)}\n`);
341
+ await this.updateManifest((manifest) => {
342
+ if (!TERMINAL_STATES.has(state)) throw errorForArtifact(`invalid terminal state ${state}`);
343
+ if (!canTransition(manifest.state, state)) {
344
+ throw new FusionError(`illegal fusion state transition ${manifest.state} -> ${state}`, {
345
+ code: 'state_transition_invalid',
346
+ childCreated: false,
347
+ });
348
+ }
349
+ manifest.state = state;
350
+ manifest.error = error;
351
+ });
352
+ }
353
+
354
+ async recordChildAttempt(input: RecordFusionChildAttemptInput): Promise<void> {
355
+ const prefix = attemptPrefix(input.result.stage, input.result.slot, input.result.attempt);
356
+ const promptRef = await this.writeArtifact(`${prefix}.prompt.txt`, input.prompt);
357
+ const eventsRef = await this.writeArtifact(`${prefix}.events.jsonl`, input.result.events);
358
+ const stderrRef = await this.writeArtifact(`${prefix}.stderr.txt`, input.result.stderr);
359
+ const responseRef = await this.writeArtifact(
360
+ responseName(prefix, input.responseKind),
361
+ input.result.text,
362
+ );
363
+ await this.updateManifest((manifest) => {
364
+ const record: FusionAttemptArtifactRecord = {
365
+ stage: input.result.stage,
366
+ attempt: input.result.attempt,
367
+ status: 'completed',
368
+ prompt_path: promptRef.path,
369
+ events_path: eventsRef.path,
370
+ stderr_path: stderrRef.path,
371
+ response_path: responseRef.path,
372
+ provider: input.result.provider,
373
+ model: input.result.model,
374
+ qualifiedId: input.result.qualifiedId,
375
+ usage: usageClone(input.result.usage),
376
+ };
377
+ if (input.result.slot !== undefined) record.slot = input.result.slot;
378
+ manifest.attempts.push(record);
379
+ });
380
+ }
381
+
382
+ async recordFailedAttempt(input: RecordFusionFailedAttemptInput): Promise<void> {
383
+ const prefix = attemptPrefix(input.stage, input.slot, input.attempt);
384
+ const promptRef = await this.writeArtifact(`${prefix}.prompt.txt`, input.prompt);
385
+ const eventsRef = await this.writeArtifact(`${prefix}.events.jsonl`, input.events);
386
+ const stderrRef = await this.writeArtifact(`${prefix}.stderr.txt`, input.stderr);
387
+ const responseRef = await this.writeArtifact(responseName(prefix, input.responseKind), '');
388
+ const partialResponseRef =
389
+ input.partialResponse.length === 0
390
+ ? undefined
391
+ : await this.writeArtifact(
392
+ `${prefix}.response.partial.${input.responseKind}`,
393
+ input.partialResponse,
394
+ );
395
+ await this.updateManifest((manifest) => {
396
+ const record: FusionAttemptArtifactRecord = {
397
+ stage: input.stage,
398
+ attempt: input.attempt,
399
+ status: input.status,
400
+ prompt_path: promptRef.path,
401
+ events_path: eventsRef.path,
402
+ stderr_path: stderrRef.path,
403
+ response_path: responseRef.path,
404
+ error: input.error,
405
+ };
406
+ if (partialResponseRef !== undefined)
407
+ record.partial_response_path = partialResponseRef.path;
408
+ if (input.provider !== undefined) record.provider = input.provider;
409
+ if (input.model !== undefined) record.model = input.model;
410
+ if (input.qualifiedId !== undefined) record.qualifiedId = input.qualifiedId;
411
+ if (input.usage !== undefined) record.usage = usageClone(input.usage);
412
+ if (input.slot !== undefined) record.slot = input.slot;
413
+ manifest.attempts.push(record);
414
+ });
415
+ }
416
+
417
+ private async writeArtifact(name: string, data: Buffer | string): Promise<FusionArtifactRef> {
418
+ const absPath = this.artifactPath(name);
419
+ const ref = await writePrivateFile(absPath, data);
420
+ await this.updateManifest((manifest) => {
421
+ manifest.artifacts[name] = ref;
422
+ });
423
+ return ref;
424
+ }
425
+
426
+ private artifactPath(name: string): string {
427
+ if (name.length === 0 || name.includes('/') || name.includes('\\')) {
428
+ throw errorForArtifact(`invalid fusion artifact name: ${name}`);
429
+ }
430
+ const absPath = join(this.runDirAbs, name);
431
+ if (!pathInside(this.runDirAbs, absPath)) {
432
+ throw errorForArtifact(`fusion artifact path escapes run directory: ${name}`);
433
+ }
434
+ return absPath;
435
+ }
436
+
437
+ private async writeManifest(): Promise<void> {
438
+ await writeJsonAtomic(join(this.runDirAbs, 'manifest.json'), publicManifest(this.manifest));
439
+ }
440
+
441
+ private async updateManifest(
442
+ mutator: (manifest: MutableFusionArtifactManifest) => void,
443
+ ): Promise<void> {
444
+ const write = async () => {
445
+ mutator(this.manifest);
446
+ this.manifest.updated_at = this.now().toISOString();
447
+ await this.writeManifest();
448
+ };
449
+ const next = this.manifestWriteChain.then(write, write);
450
+ this.manifestWriteChain = next.catch(() => undefined);
451
+ await next;
452
+ }
453
+ }