@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.
- package/dist/generator.d.ts +236 -2
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +30 -0
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-client.d.ts +39 -0
- package/dist/mcp-client.d.ts.map +1 -1
- package/dist/mcp-client.js +213 -0
- package/dist/mcp-client.js.map +1 -1
- package/dist/photon-config.d.ts +86 -0
- package/dist/photon-config.d.ts.map +1 -0
- package/dist/photon-config.js +156 -0
- package/dist/photon-config.js.map +1 -0
- package/dist/schema-extractor.d.ts +99 -1
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +311 -5
- package/dist/schema-extractor.js.map +1 -1
- package/dist/stateful.d.ts +238 -0
- package/dist/stateful.d.ts.map +1 -0
- package/dist/stateful.js +469 -0
- package/dist/stateful.js.map +1 -0
- package/dist/types.d.ts +260 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -2
- package/src/generator.ts +270 -2
- package/src/index.ts +73 -1
- package/src/mcp-client.ts +254 -0
- package/src/photon-config.ts +201 -0
- package/src/schema-extractor.ts +353 -6
- package/src/stateful.ts +659 -0
- package/src/types.ts +289 -0
package/src/stateful.ts
ADDED
|
@@ -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';
|