@portel/photon-core 1.3.0 → 1.4.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,659 @@
1
+ /**
2
+ * Stateful Workflow Execution with JSONL Persistence
3
+ *
4
+ * Enables photon workflows to be paused, resumed, and recovered across daemon restarts.
5
+ *
6
+ * ══════════════════════════════════════════════════════════════════════════════
7
+ * DESIGN PHILOSOPHY
8
+ * ══════════════════════════════════════════════════════════════════════════════
9
+ *
10
+ * Stateful workflows use an append-only JSONL log for persistence:
11
+ * - Each line is a self-contained JSON entry (start, emit, checkpoint, ask, answer, return, error)
12
+ * - Checkpoints mark safe resume points with accumulated state
13
+ * - Developer places checkpoint AFTER side effects to ensure idempotency
14
+ * - Resume loads log, reconstructs state from last checkpoint, continues
15
+ *
16
+ * ══════════════════════════════════════════════════════════════════════════════
17
+ * CHECKPOINT PATTERN (Idempotent Resume)
18
+ * ══════════════════════════════════════════════════════════════════════════════
19
+ *
20
+ * ```typescript
21
+ * async *workflow() {
22
+ * // Step 1: Side effect (e.g., posting to Slack)
23
+ * const posted = await this.slack.post_message({ channel: '#eng', text: 'Hello' });
24
+ * yield { checkpoint: true, state: { step: 1, messageTs: posted.ts } };
25
+ *
26
+ * // Step 2: Another side effect (e.g., creating GitHub issue)
27
+ * const issue = await this.github.create_issue({ ... });
28
+ * yield { checkpoint: true, state: { step: 2, messageTs: posted.ts, issueNumber: issue.number } };
29
+ *
30
+ * return { posted, issue };
31
+ * }
32
+ * ```
33
+ *
34
+ * On resume: Load state from last checkpoint, skip to that step, continue execution.
35
+ *
36
+ * ══════════════════════════════════════════════════════════════════════════════
37
+ * JSONL LOG FORMAT
38
+ * ══════════════════════════════════════════════════════════════════════════════
39
+ *
40
+ * ~/.photon/runs/{runId}.jsonl
41
+ *
42
+ * ```jsonl
43
+ * {"t":"start","tool":"generate","params":{"week":"52"},"ts":1704067200}
44
+ * {"t":"emit","emit":"status","message":"Collecting data...","ts":1704067201}
45
+ * {"t":"checkpoint","id":"cp_1","state":{"commits":["a1b2c3"],"step":1},"ts":1704067205}
46
+ * {"t":"ask","id":"approve","ask":"confirm","message":"Continue?","ts":1704067211}
47
+ * {"t":"answer","id":"approve","value":true,"ts":1704067215}
48
+ * {"t":"return","value":{"status":"done"},"ts":1704067220}
49
+ * ```
50
+ *
51
+ * @module stateful
52
+ */
53
+
54
+ import * as fs from 'fs/promises';
55
+ import * as path from 'path';
56
+ import * as os from 'os';
57
+ import { createReadStream } from 'fs';
58
+ import { createInterface } from 'readline';
59
+ import type {
60
+ StateLogEntry,
61
+ StateLogStart,
62
+ StateLogEmit,
63
+ StateLogCheckpoint,
64
+ StateLogAsk,
65
+ StateLogAnswer,
66
+ StateLogReturn,
67
+ StateLogError,
68
+ WorkflowRun,
69
+ WorkflowStatus,
70
+ } from './types.js';
71
+ import {
72
+ type PhotonYield,
73
+ type AskYield,
74
+ type EmitYield,
75
+ type InputProvider,
76
+ type OutputHandler,
77
+ isAskYield,
78
+ isEmitYield,
79
+ isAsyncGenerator,
80
+ } from './generator.js';
81
+
82
+ // ══════════════════════════════════════════════════════════════════════════════
83
+ // CONSTANTS
84
+ // ══════════════════════════════════════════════════════════════════════════════
85
+
86
+ /**
87
+ * Default runs directory (~/.photon/runs)
88
+ */
89
+ export const RUNS_DIR = path.join(os.homedir(), '.photon', 'runs');
90
+
91
+ // ══════════════════════════════════════════════════════════════════════════════
92
+ // CHECKPOINT YIELD TYPE
93
+ // ══════════════════════════════════════════════════════════════════════════════
94
+
95
+ /**
96
+ * Checkpoint yield - marks a safe resume point
97
+ *
98
+ * @example
99
+ * // After a side effect, checkpoint to preserve state
100
+ * const posted = await this.slack.post_message({ ... });
101
+ * yield { checkpoint: true, state: { step: 1, messageTs: posted.ts } };
102
+ */
103
+ export interface CheckpointYield {
104
+ /** Marker for checkpoint yield */
105
+ checkpoint: true;
106
+ /** State snapshot to preserve */
107
+ state: Record<string, any>;
108
+ /** Optional checkpoint ID (auto-generated if not provided) */
109
+ id?: string;
110
+ }
111
+
112
+ /**
113
+ * Extended yield type including checkpoint
114
+ */
115
+ export type StatefulYield = PhotonYield | CheckpointYield;
116
+
117
+ /**
118
+ * Type guard for checkpoint yields
119
+ */
120
+ export function isCheckpointYield(y: StatefulYield): y is CheckpointYield {
121
+ return 'checkpoint' in y && (y as any).checkpoint === true;
122
+ }
123
+
124
+ // ══════════════════════════════════════════════════════════════════════════════
125
+ // STATE LOG - JSONL Persistence
126
+ // ══════════════════════════════════════════════════════════════════════════════
127
+
128
+ /**
129
+ * State log writer for a single workflow run
130
+ */
131
+ export class StateLog {
132
+ private runId: string;
133
+ private logPath: string;
134
+
135
+ constructor(runId: string, runsDir?: string) {
136
+ this.runId = runId;
137
+ this.logPath = path.join(runsDir || RUNS_DIR, `${runId}.jsonl`);
138
+ }
139
+
140
+ /**
141
+ * Ensure runs directory exists
142
+ */
143
+ async init(): Promise<void> {
144
+ await fs.mkdir(path.dirname(this.logPath), { recursive: true });
145
+ }
146
+
147
+ /**
148
+ * Append an entry to the log
149
+ */
150
+ async append(entry: Omit<StateLogEntry, 'ts'>): Promise<void> {
151
+ const line = JSON.stringify({ ...entry, ts: Date.now() }) + '\n';
152
+ await fs.appendFile(this.logPath, line, 'utf-8');
153
+ }
154
+
155
+ /**
156
+ * Write start entry
157
+ */
158
+ async writeStart(tool: string, params: Record<string, any>): Promise<void> {
159
+ await this.append({ t: 'start', tool, params } as StateLogStart);
160
+ }
161
+
162
+ /**
163
+ * Write emit entry
164
+ */
165
+ async writeEmit(emit: string, message?: string, data?: any): Promise<void> {
166
+ await this.append({ t: 'emit', emit, message, data } as StateLogEmit);
167
+ }
168
+
169
+ /**
170
+ * Write checkpoint entry
171
+ */
172
+ async writeCheckpoint(id: string, state: Record<string, any>): Promise<void> {
173
+ await this.append({ t: 'checkpoint', id, state } as StateLogCheckpoint);
174
+ }
175
+
176
+ /**
177
+ * Write ask entry
178
+ */
179
+ async writeAsk(id: string, ask: string, message: string): Promise<void> {
180
+ await this.append({ t: 'ask', id, ask, message } as StateLogAsk);
181
+ }
182
+
183
+ /**
184
+ * Write answer entry
185
+ */
186
+ async writeAnswer(id: string, value: any): Promise<void> {
187
+ await this.append({ t: 'answer', id, value } as StateLogAnswer);
188
+ }
189
+
190
+ /**
191
+ * Write return entry
192
+ */
193
+ async writeReturn(value: any): Promise<void> {
194
+ await this.append({ t: 'return', value } as StateLogReturn);
195
+ }
196
+
197
+ /**
198
+ * Write error entry
199
+ */
200
+ async writeError(message: string, stack?: string): Promise<void> {
201
+ await this.append({ t: 'error', message, stack } as StateLogError);
202
+ }
203
+
204
+ /**
205
+ * Read all entries from the log
206
+ */
207
+ async readAll(): Promise<StateLogEntry[]> {
208
+ try {
209
+ const content = await fs.readFile(this.logPath, 'utf-8');
210
+ return content
211
+ .trim()
212
+ .split('\n')
213
+ .filter(line => line.length > 0)
214
+ .map(line => JSON.parse(line) as StateLogEntry);
215
+ } catch (error: any) {
216
+ if (error.code === 'ENOENT') {
217
+ return [];
218
+ }
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Stream entries from the log (memory efficient for large logs)
225
+ */
226
+ async *stream(): AsyncGenerator<StateLogEntry> {
227
+ const fileStream = createReadStream(this.logPath);
228
+ const rl = createInterface({ input: fileStream });
229
+
230
+ for await (const line of rl) {
231
+ if (line.trim()) {
232
+ yield JSON.parse(line) as StateLogEntry;
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Get the log file path
239
+ */
240
+ getPath(): string {
241
+ return this.logPath;
242
+ }
243
+ }
244
+
245
+ // ══════════════════════════════════════════════════════════════════════════════
246
+ // RESUME STATE - Reconstructed from log
247
+ // ══════════════════════════════════════════════════════════════════════════════
248
+
249
+ /**
250
+ * Reconstructed state from a workflow log
251
+ */
252
+ export interface ResumeState {
253
+ /** Tool/method being executed */
254
+ tool: string;
255
+ /** Input parameters */
256
+ params: Record<string, any>;
257
+ /** Is workflow complete? */
258
+ isComplete: boolean;
259
+ /** Final result (if complete) */
260
+ result?: any;
261
+ /** Error (if failed) */
262
+ error?: string;
263
+ /** Last checkpoint state */
264
+ lastCheckpoint?: {
265
+ id: string;
266
+ state: Record<string, any>;
267
+ ts: number;
268
+ };
269
+ /** Answered asks (id -> value) */
270
+ answers: Record<string, any>;
271
+ /** All entries in order */
272
+ entries: StateLogEntry[];
273
+ }
274
+
275
+ /**
276
+ * Parse a workflow log and reconstruct resume state
277
+ */
278
+ export async function parseResumeState(runId: string, runsDir?: string): Promise<ResumeState | null> {
279
+ const log = new StateLog(runId, runsDir);
280
+ const entries = await log.readAll();
281
+
282
+ if (entries.length === 0) {
283
+ return null;
284
+ }
285
+
286
+ const state: ResumeState = {
287
+ tool: '',
288
+ params: {},
289
+ isComplete: false,
290
+ answers: {},
291
+ entries,
292
+ };
293
+
294
+ for (const entry of entries) {
295
+ switch (entry.t) {
296
+ case 'start':
297
+ state.tool = entry.tool;
298
+ state.params = entry.params;
299
+ break;
300
+ case 'checkpoint':
301
+ state.lastCheckpoint = {
302
+ id: entry.id,
303
+ state: entry.state,
304
+ ts: entry.ts,
305
+ };
306
+ break;
307
+ case 'answer':
308
+ state.answers[entry.id] = entry.value;
309
+ break;
310
+ case 'return':
311
+ state.isComplete = true;
312
+ state.result = entry.value;
313
+ break;
314
+ case 'error':
315
+ state.isComplete = true;
316
+ state.error = entry.message;
317
+ break;
318
+ }
319
+ }
320
+
321
+ return state;
322
+ }
323
+
324
+ // ══════════════════════════════════════════════════════════════════════════════
325
+ // STATEFUL EXECUTOR - Run generator with checkpointing
326
+ // ══════════════════════════════════════════════════════════════════════════════
327
+
328
+ /**
329
+ * Configuration for stateful generator execution
330
+ */
331
+ export interface StatefulExecutorConfig {
332
+ /** Run ID (generated if not provided) */
333
+ runId?: string;
334
+ /** Runs directory (defaults to ~/.photon/runs) */
335
+ runsDir?: string;
336
+ /** Photon name (for metadata) */
337
+ photon: string;
338
+ /** Tool name being executed */
339
+ tool: string;
340
+ /** Input parameters */
341
+ params: Record<string, any>;
342
+ /** Input provider for ask yields */
343
+ inputProvider: InputProvider;
344
+ /** Output handler for emit yields */
345
+ outputHandler?: OutputHandler;
346
+ /** Resume from existing run (skips to last checkpoint) */
347
+ resume?: boolean;
348
+ }
349
+
350
+ /**
351
+ * Result of stateful execution
352
+ */
353
+ export interface StatefulExecutionResult<T> {
354
+ /** Run ID */
355
+ runId: string;
356
+ /** Final result (if completed) */
357
+ result?: T;
358
+ /** Error message (if failed) */
359
+ error?: string;
360
+ /** Was this resumed from a previous run? */
361
+ resumed: boolean;
362
+ /** Final status */
363
+ status: WorkflowStatus;
364
+ }
365
+
366
+ /**
367
+ * Generate a unique run ID
368
+ */
369
+ export function generateRunId(): string {
370
+ const timestamp = Date.now().toString(36);
371
+ const random = Math.random().toString(36).substring(2, 8);
372
+ return `run_${timestamp}_${random}`;
373
+ }
374
+
375
+ /**
376
+ * Execute a stateful generator with checkpoint support
377
+ *
378
+ * @example
379
+ * const result = await executeStatefulGenerator(workflow(), {
380
+ * photon: 'weekly-report',
381
+ * tool: 'generate',
382
+ * params: { week: 52 },
383
+ * inputProvider: cliInputProvider,
384
+ * outputHandler: (emit) => console.log(emit.message)
385
+ * });
386
+ */
387
+ export async function executeStatefulGenerator<T>(
388
+ generatorFn: () => AsyncGenerator<StatefulYield, T, any>,
389
+ config: StatefulExecutorConfig
390
+ ): Promise<StatefulExecutionResult<T>> {
391
+ const runId = config.runId || generateRunId();
392
+ const log = new StateLog(runId, config.runsDir);
393
+ await log.init();
394
+
395
+ let resumed = false;
396
+ let resumeState: ResumeState | null = null;
397
+ let checkpointIndex = 0;
398
+ let askIndex = 0;
399
+
400
+ // Check if we should resume
401
+ if (config.resume) {
402
+ resumeState = await parseResumeState(runId, config.runsDir);
403
+ if (resumeState) {
404
+ resumed = true;
405
+ if (resumeState.isComplete) {
406
+ // Already complete, return cached result
407
+ return {
408
+ runId,
409
+ result: resumeState.result,
410
+ error: resumeState.error,
411
+ resumed: true,
412
+ status: resumeState.error ? 'failed' : 'completed',
413
+ };
414
+ }
415
+ }
416
+ }
417
+
418
+ // Write start entry (only if not resuming)
419
+ if (!resumed) {
420
+ await log.writeStart(config.tool, config.params);
421
+ }
422
+
423
+ try {
424
+ // Call the function and check if it returns a generator or a promise
425
+ const maybeGenerator = generatorFn();
426
+
427
+ // Handle non-generator functions (regular async methods)
428
+ if (!isAsyncGenerator(maybeGenerator)) {
429
+ // It's a promise, await it directly
430
+ const finalValue = await maybeGenerator;
431
+ await log.writeReturn(finalValue);
432
+
433
+ return {
434
+ runId,
435
+ result: finalValue,
436
+ resumed,
437
+ status: 'completed',
438
+ };
439
+ }
440
+
441
+ // It's a generator, proceed with generator execution
442
+ const generator = maybeGenerator;
443
+ let result = await generator.next();
444
+
445
+ // If resuming, fast-forward to last checkpoint
446
+ if (resumed && resumeState?.lastCheckpoint) {
447
+ const targetCheckpointId = resumeState.lastCheckpoint.id;
448
+ let foundCheckpoint = false;
449
+
450
+ // Fast-forward: run generator, skip until we hit the checkpoint
451
+ while (!result.done) {
452
+ const yielded = result.value;
453
+
454
+ if (isCheckpointYield(yielded)) {
455
+ const cpId = yielded.id || `cp_${checkpointIndex++}`;
456
+ if (cpId === targetCheckpointId) {
457
+ foundCheckpoint = true;
458
+ // Inject the saved state
459
+ result = await generator.next(resumeState.lastCheckpoint.state);
460
+ break;
461
+ }
462
+ // Not our checkpoint, continue
463
+ result = await generator.next(yielded.state);
464
+ } else if (isAskYield(yielded)) {
465
+ // Use saved answer
466
+ const askId = yielded.id || `ask_${askIndex++}`;
467
+ if (askId in resumeState.answers) {
468
+ result = await generator.next(resumeState.answers[askId]);
469
+ } else {
470
+ // No saved answer, this shouldn't happen if log is consistent
471
+ throw new Error(`Resume error: missing answer for ask '${askId}'`);
472
+ }
473
+ } else if (isEmitYield(yielded)) {
474
+ // Skip emits during fast-forward
475
+ result = await generator.next();
476
+ } else {
477
+ result = await generator.next();
478
+ }
479
+ }
480
+
481
+ if (!foundCheckpoint && !result.done) {
482
+ console.warn(`[stateful] Checkpoint '${targetCheckpointId}' not found during resume`);
483
+ }
484
+ }
485
+
486
+ // Normal execution loop
487
+ while (!result.done) {
488
+ const yielded = result.value;
489
+
490
+ if (isCheckpointYield(yielded)) {
491
+ const cpId = yielded.id || `cp_${checkpointIndex++}`;
492
+ await log.writeCheckpoint(cpId, yielded.state);
493
+
494
+ // Continue with the state (generator may use it)
495
+ result = await generator.next(yielded.state);
496
+ } else if (isAskYield(yielded as PhotonYield)) {
497
+ const askYield = yielded as AskYield;
498
+ const askId = askYield.id || `ask_${askIndex++}`;
499
+
500
+ // Check for pre-answered (from resume state)
501
+ if (resumeState && askId in resumeState.answers) {
502
+ result = await generator.next(resumeState.answers[askId]);
503
+ continue;
504
+ }
505
+
506
+ // Log ask and get input
507
+ await log.writeAsk(askId, askYield.ask, askYield.message);
508
+ const input = await config.inputProvider(askYield);
509
+ await log.writeAnswer(askId, input);
510
+
511
+ result = await generator.next(input);
512
+ } else if (isEmitYield(yielded as PhotonYield)) {
513
+ const emitYield = yielded as EmitYield;
514
+ await log.writeEmit(emitYield.emit, (emitYield as any).message, emitYield);
515
+
516
+ if (config.outputHandler) {
517
+ await config.outputHandler(emitYield);
518
+ }
519
+
520
+ result = await generator.next();
521
+ } else {
522
+ // Unknown yield, skip
523
+ result = await generator.next();
524
+ }
525
+ }
526
+
527
+ // Write return entry
528
+ await log.writeReturn(result.value);
529
+
530
+ return {
531
+ runId,
532
+ result: result.value,
533
+ resumed,
534
+ status: 'completed',
535
+ };
536
+ } catch (error: any) {
537
+ await log.writeError(error.message, error.stack);
538
+
539
+ return {
540
+ runId,
541
+ error: error.message,
542
+ resumed,
543
+ status: 'failed',
544
+ };
545
+ }
546
+ }
547
+
548
+ // ══════════════════════════════════════════════════════════════════════════════
549
+ // WORKFLOW RUN MANAGEMENT
550
+ // ══════════════════════════════════════════════════════════════════════════════
551
+
552
+ /**
553
+ * List all workflow runs
554
+ */
555
+ export async function listRuns(runsDir?: string): Promise<WorkflowRun[]> {
556
+ const dir = runsDir || RUNS_DIR;
557
+ const runs: WorkflowRun[] = [];
558
+
559
+ try {
560
+ const files = await fs.readdir(dir);
561
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
562
+
563
+ for (const file of jsonlFiles) {
564
+ const runId = file.replace('.jsonl', '');
565
+ const run = await getRunInfo(runId, dir);
566
+ if (run) {
567
+ runs.push(run);
568
+ }
569
+ }
570
+
571
+ // Sort by start time, most recent first
572
+ runs.sort((a, b) => b.startedAt - a.startedAt);
573
+
574
+ return runs;
575
+ } catch (error: any) {
576
+ if (error.code === 'ENOENT') {
577
+ return [];
578
+ }
579
+ throw error;
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Get info about a specific run
585
+ */
586
+ export async function getRunInfo(runId: string, runsDir?: string): Promise<WorkflowRun | null> {
587
+ const state = await parseResumeState(runId, runsDir);
588
+ if (!state) {
589
+ return null;
590
+ }
591
+
592
+ const firstEntry = state.entries[0];
593
+ const lastEntry = state.entries[state.entries.length - 1];
594
+
595
+ // Determine status
596
+ let status: WorkflowStatus = 'running';
597
+ if (state.isComplete) {
598
+ status = state.error ? 'failed' : 'completed';
599
+ } else if (state.entries.some(e => e.t === 'ask' && !state.answers[(e as StateLogAsk).id])) {
600
+ status = 'waiting';
601
+ }
602
+
603
+ return {
604
+ runId,
605
+ photon: '', // Would need to be stored in start entry
606
+ tool: state.tool,
607
+ params: state.params,
608
+ status,
609
+ startedAt: firstEntry.ts,
610
+ updatedAt: lastEntry.ts,
611
+ completedAt: state.isComplete ? lastEntry.ts : undefined,
612
+ result: state.result,
613
+ error: state.error,
614
+ lastCheckpoint: state.lastCheckpoint,
615
+ };
616
+ }
617
+
618
+ /**
619
+ * Delete a workflow run
620
+ */
621
+ export async function deleteRun(runId: string, runsDir?: string): Promise<void> {
622
+ const logPath = path.join(runsDir || RUNS_DIR, `${runId}.jsonl`);
623
+ await fs.unlink(logPath);
624
+ }
625
+
626
+ /**
627
+ * Clean up completed/failed runs older than specified age
628
+ */
629
+ export async function cleanupRuns(maxAgeMs: number, runsDir?: string): Promise<number> {
630
+ const runs = await listRuns(runsDir);
631
+ const cutoff = Date.now() - maxAgeMs;
632
+ let deleted = 0;
633
+
634
+ for (const run of runs) {
635
+ if ((run.status === 'completed' || run.status === 'failed') && run.updatedAt < cutoff) {
636
+ await deleteRun(run.runId, runsDir);
637
+ deleted++;
638
+ }
639
+ }
640
+
641
+ return deleted;
642
+ }
643
+
644
+ // ══════════════════════════════════════════════════════════════════════════════
645
+ // EXPORTS
646
+ // ══════════════════════════════════════════════════════════════════════════════
647
+
648
+ export {
649
+ type StateLogEntry,
650
+ type StateLogStart,
651
+ type StateLogEmit,
652
+ type StateLogCheckpoint,
653
+ type StateLogAsk,
654
+ type StateLogAnswer,
655
+ type StateLogReturn,
656
+ type StateLogError,
657
+ type WorkflowRun,
658
+ type WorkflowStatus,
659
+ } from './types.js';