@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.3.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.
Files changed (76) hide show
  1. package/README.md +415 -24
  2. package/package.json +45 -8
  3. package/src/api/files.js +48 -0
  4. package/src/api/index.js +149 -53
  5. package/src/api/validators/seed.js +141 -0
  6. package/src/cli/index.js +456 -29
  7. package/src/cli/run-orchestrator.js +39 -0
  8. package/src/cli/update-pipeline-json.js +47 -0
  9. package/src/components/DAGGrid.jsx +649 -0
  10. package/src/components/JobCard.jsx +96 -0
  11. package/src/components/JobDetail.jsx +159 -0
  12. package/src/components/JobTable.jsx +202 -0
  13. package/src/components/Layout.jsx +134 -0
  14. package/src/components/TaskFilePane.jsx +570 -0
  15. package/src/components/UploadSeed.jsx +239 -0
  16. package/src/components/ui/badge.jsx +20 -0
  17. package/src/components/ui/button.jsx +43 -0
  18. package/src/components/ui/card.jsx +20 -0
  19. package/src/components/ui/focus-styles.css +60 -0
  20. package/src/components/ui/progress.jsx +26 -0
  21. package/src/components/ui/select.jsx +27 -0
  22. package/src/components/ui/separator.jsx +6 -0
  23. package/src/config/paths.js +99 -0
  24. package/src/core/config.js +270 -9
  25. package/src/core/file-io.js +202 -0
  26. package/src/core/module-loader.js +157 -0
  27. package/src/core/orchestrator.js +275 -294
  28. package/src/core/pipeline-runner.js +95 -41
  29. package/src/core/progress.js +66 -0
  30. package/src/core/status-writer.js +331 -0
  31. package/src/core/task-runner.js +719 -73
  32. package/src/core/validation.js +120 -1
  33. package/src/lib/utils.js +6 -0
  34. package/src/llm/README.md +139 -30
  35. package/src/llm/index.js +222 -72
  36. package/src/pages/PipelineDetail.jsx +111 -0
  37. package/src/pages/PromptPipelineDashboard.jsx +223 -0
  38. package/src/providers/deepseek.js +3 -15
  39. package/src/ui/client/adapters/job-adapter.js +258 -0
  40. package/src/ui/client/bootstrap.js +120 -0
  41. package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
  42. package/src/ui/client/hooks/useJobList.js +50 -0
  43. package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
  44. package/src/ui/client/hooks/useTicker.js +26 -0
  45. package/src/ui/client/index.css +31 -0
  46. package/src/ui/client/index.html +18 -0
  47. package/src/ui/client/main.jsx +38 -0
  48. package/src/ui/config-bridge.browser.js +149 -0
  49. package/src/ui/config-bridge.js +149 -0
  50. package/src/ui/config-bridge.node.js +310 -0
  51. package/src/ui/dist/assets/index-BDABnI-4.js +33399 -0
  52. package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -0
  53. package/src/ui/dist/index.html +19 -0
  54. package/src/ui/endpoints/job-endpoints.js +300 -0
  55. package/src/ui/file-reader.js +216 -0
  56. package/src/ui/job-change-detector.js +83 -0
  57. package/src/ui/job-index.js +231 -0
  58. package/src/ui/job-reader.js +274 -0
  59. package/src/ui/job-scanner.js +188 -0
  60. package/src/ui/public/app.js +3 -1
  61. package/src/ui/server.js +1636 -59
  62. package/src/ui/sse-enhancer.js +149 -0
  63. package/src/ui/sse.js +204 -0
  64. package/src/ui/state-snapshot.js +252 -0
  65. package/src/ui/transformers/list-transformer.js +347 -0
  66. package/src/ui/transformers/status-transformer.js +307 -0
  67. package/src/ui/watcher.js +61 -7
  68. package/src/utils/dag.js +101 -0
  69. package/src/utils/duration.js +126 -0
  70. package/src/utils/id-generator.js +30 -0
  71. package/src/utils/jobs.js +7 -0
  72. package/src/utils/pipelines.js +44 -0
  73. package/src/utils/task-files.js +271 -0
  74. package/src/utils/ui.jsx +76 -0
  75. package/src/ui/public/index.html +0 -53
  76. package/src/ui/public/style.css +0 -341
@@ -1,26 +1,358 @@
1
1
  import path from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
+ import fs from "fs";
3
4
  import { createLLM, getLLMEvents } from "../llm/index.js";
5
+ import { loadFreshModule } from "./module-loader.js";
4
6
  import { loadEnvironment } from "./environment.js";
5
7
  import { getConfig } from "./config.js";
8
+ import { createTaskFileIO } from "./file-io.js";
9
+ import { writeJobStatus } from "./status-writer.js";
10
+ import { computeDeterministicProgress } from "./progress.js";
6
11
 
7
- /** Canonical order using the field terms we discussed */
8
- const ORDER = [
9
- "ingestion",
10
- "preProcessing",
11
- "promptTemplating",
12
- "inference",
13
- "parsing",
14
- "validateStructure",
15
- "validateQuality",
16
- "critique",
17
- "refine",
18
- "finalValidation",
19
- "integration",
12
+ /**
13
+ * Validates that a value is a plain object (not array, null, or class instance).
14
+ * @param {*} value - The value to check
15
+ * @returns {boolean} True if the value is a plain object, false otherwise
16
+ */
17
+ function isPlainObject(value) {
18
+ if (typeof value !== "object") {
19
+ return false;
20
+ }
21
+ if (value === null) {
22
+ return false;
23
+ }
24
+ if (Array.isArray(value)) {
25
+ return false;
26
+ }
27
+ if (Object.getPrototypeOf(value) === Object.prototype) {
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * Validates stage handler return values conform to { output, flags } contract.
35
+ * @param {string} stageName - The name of the stage for error reporting
36
+ * @param {*} result - The result returned by the stage handler
37
+ * @throws {Error} If the result doesn't conform to the expected contract
38
+ */
39
+ function assertStageResult(stageName, result) {
40
+ if (result === null || result === undefined) {
41
+ throw new Error(`Stage "${stageName}" returned null or undefined`);
42
+ }
43
+
44
+ if (typeof result !== "object") {
45
+ throw new Error(
46
+ `Stage "${stageName}" must return an object, got ${typeof result}`
47
+ );
48
+ }
49
+
50
+ if (!result.hasOwnProperty("output")) {
51
+ throw new Error(
52
+ `Stage "${stageName}" result missing required property: output`
53
+ );
54
+ }
55
+
56
+ if (!result.hasOwnProperty("flags")) {
57
+ throw new Error(
58
+ `Stage "${stageName}" result missing required property: flags`
59
+ );
60
+ }
61
+
62
+ if (!isPlainObject(result.flags)) {
63
+ throw new Error(
64
+ `Stage "${stageName}" flags must be a plain object, got ${typeof result.flags}`
65
+ );
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Validates flag values match declared types in schema.
71
+ * @param {string} stageName - The name of the stage for error reporting
72
+ * @param {object} flags - The flags object to validate
73
+ * @param {object} schema - The schema defining expected types for each flag
74
+ * @throws {Error} If flag types don't match the schema
75
+ */
76
+ function validateFlagTypes(stageName, flags, schema) {
77
+ if (schema === undefined || schema === null) {
78
+ return;
79
+ }
80
+
81
+ for (const key in schema) {
82
+ const expectedTypes = schema[key];
83
+ const actualType = typeof flags[key];
84
+
85
+ // Allow undefined flags (they may be optional)
86
+ if (flags[key] === undefined) {
87
+ continue;
88
+ }
89
+
90
+ if (typeof expectedTypes === "string") {
91
+ // Single expected type
92
+ if (actualType !== expectedTypes) {
93
+ throw new Error(
94
+ `Stage "${stageName}" flag "${key}" has type ${actualType}, expected ${expectedTypes}`
95
+ );
96
+ }
97
+ } else if (Array.isArray(expectedTypes)) {
98
+ // Multiple allowed types
99
+ if (!expectedTypes.includes(actualType)) {
100
+ throw new Error(
101
+ `Stage "${stageName}" flag "${key}" has type ${actualType}, expected one of: ${expectedTypes.join(", ")}`
102
+ );
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Detects type conflicts when merging new flags into existing flags.
110
+ * @param {object} currentFlags - The existing flags object
111
+ * @param {object} newFlags - The new flags to merge
112
+ * @param {string} stageName - The name of the stage for error reporting
113
+ * @throws {Error} If any flag would change type when merged
114
+ */
115
+ function checkFlagTypeConflicts(currentFlags, newFlags, stageName) {
116
+ for (const key of Object.keys(newFlags)) {
117
+ if (key in currentFlags) {
118
+ const currentType = typeof currentFlags[key];
119
+ const newType = typeof newFlags[key];
120
+ if (currentType !== newType) {
121
+ throw new Error(
122
+ `Stage "${stageName}" attempted to change flag "${key}" type from ${currentType} to ${newType}`
123
+ );
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Ensures log directory exists before creating log files.
131
+ * @param {string} workDir - The working directory path
132
+ * @param {string} jobId - The job ID
133
+ * @returns {string} The full path to the logs directory
134
+ */
135
+ function ensureLogDirectory(workDir, jobId) {
136
+ const logsPath = path.join(workDir, "files", "logs");
137
+ fs.mkdirSync(logsPath, { recursive: true });
138
+ return logsPath;
139
+ }
140
+
141
+ /**
142
+ * Writes a compact pre-execution snapshot for debugging stage inputs.
143
+ * Safe: does not throw on write failure; logs warnings instead.
144
+ * @param {string} stageName - Name of the stage
145
+ * @param {object} snapshot - Summary data to persist
146
+ * @param {string} logsDir - Directory to write the snapshot into
147
+ */
148
+ function writePreExecutionSnapshot(stageName, snapshot, logsDir) {
149
+ const snapshotPath = path.join(logsDir, `stage-${stageName}-context.json`);
150
+ try {
151
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
152
+ } catch (error) {
153
+ console.warn(
154
+ `[task-runner] Failed to write pre-execution snapshot for ${stageName}: ${error.message}`
155
+ );
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Redirects console output to a log file for a stage.
161
+ * @param {string} logPath - The path to the log file
162
+ * @returns {() => void} A function that restores console output and closes the log stream
163
+ */
164
+ function captureConsoleOutput(logPath) {
165
+ // Ensure the directory for the log file exists
166
+ const logDir = path.dirname(logPath);
167
+ fs.mkdirSync(logDir, { recursive: true });
168
+
169
+ const logStream = fs.createWriteStream(logPath, { flags: "w" });
170
+
171
+ // Store original console methods
172
+ const originalLog = console.log;
173
+ const originalError = console.error;
174
+ const originalWarn = console.warn;
175
+ const originalInfo = console.info;
176
+ const originalDebug = console.debug;
177
+
178
+ // Override console methods to write to stream
179
+ console.log = (...args) => logStream.write(args.join(" ") + "\n");
180
+ console.error = (...args) =>
181
+ logStream.write("[ERROR] " + args.join(" ") + "\n");
182
+ console.warn = (...args) =>
183
+ logStream.write("[WARN] " + args.join(" ") + "\n");
184
+ console.info = (...args) =>
185
+ logStream.write("[INFO] " + args.join(" ") + "\n");
186
+ console.debug = (...args) =>
187
+ logStream.write("[DEBUG] " + args.join(" ") + "\n");
188
+
189
+ // Return restoration function
190
+ return () => {
191
+ logStream.end();
192
+ console.log = originalLog;
193
+ console.error = originalError;
194
+ console.warn = originalWarn;
195
+ console.info = originalInfo;
196
+ console.debug = originalDebug;
197
+ };
198
+ }
199
+
200
+ function readStatusSnapshot(statusPath) {
201
+ try {
202
+ if (!statusPath || !fs.existsSync(statusPath)) {
203
+ return null;
204
+ }
205
+ const raw = fs.readFileSync(statusPath, "utf8");
206
+ if (!raw) {
207
+ return null;
208
+ }
209
+ return JSON.parse(raw);
210
+ } catch (error) {
211
+ console.warn(
212
+ `[task-runner] Failed to read existing status file at ${statusPath}: ${error.message}`
213
+ );
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function mergeStatusSnapshot(existing, updates) {
219
+ const base =
220
+ existing && typeof existing === "object" && !Array.isArray(existing)
221
+ ? { ...existing }
222
+ : {};
223
+
224
+ if (updates?.data) {
225
+ base.data = { ...(existing?.data || {}), ...updates.data };
226
+ }
227
+ if (updates?.flags) {
228
+ base.flags = { ...(existing?.flags || {}), ...updates.flags };
229
+ }
230
+ if (Object.prototype.hasOwnProperty.call(updates || {}, "logs")) {
231
+ base.logs = updates.logs;
232
+ }
233
+
234
+ for (const [key, value] of Object.entries(updates || {})) {
235
+ if (key === "data" || key === "flags" || key === "logs") continue;
236
+ base[key] = value;
237
+ }
238
+
239
+ return base;
240
+ }
241
+
242
+ function persistStatusSnapshot(statusPath, updates) {
243
+ if (!statusPath || !updates) {
244
+ return;
245
+ }
246
+ const existing = readStatusSnapshot(statusPath);
247
+ const merged = mergeStatusSnapshot(existing, updates);
248
+ fs.writeFileSync(statusPath, JSON.stringify(merged, null, 2));
249
+ }
250
+
251
+ /**
252
+ * Flag schemas for each pipeline stage.
253
+ * Defines required flags (prerequisites) and produced flags (outputs) with their types.
254
+ */
255
+ const FLAG_SCHEMAS = {
256
+ validateStructure: {
257
+ requires: {},
258
+ produces: {
259
+ validationFailed: "boolean",
260
+ lastValidationError: ["string", "object", "undefined"],
261
+ },
262
+ },
263
+ critique: {
264
+ requires: {},
265
+ produces: {
266
+ critiqueComplete: "boolean",
267
+ },
268
+ },
269
+ refine: {
270
+ requires: {
271
+ validationFailed: "boolean",
272
+ },
273
+ produces: {
274
+ refined: "boolean",
275
+ },
276
+ },
277
+ };
278
+
279
+ /**
280
+ * Canonical pipeline stage execution order for the modern pipeline.
281
+ * Each stage defines its handler, skip predicate, and iteration limits.
282
+ * Stages with missing handlers are automatically skipped during execution.
283
+ * This is the single, unified pipeline with no legacy execution paths.
284
+ */
285
+ const PIPELINE_STAGES = [
286
+ {
287
+ name: "ingestion",
288
+ handler: null, // Will be populated from dynamic module import
289
+ skipIf: null,
290
+ maxIterations: null,
291
+ },
292
+ {
293
+ name: "preProcessing",
294
+ handler: null, // Will be populated from dynamic module import
295
+ skipIf: null,
296
+ maxIterations: null,
297
+ },
298
+ {
299
+ name: "promptTemplating",
300
+ handler: null, // Will be populated from dynamic module import
301
+ skipIf: null,
302
+ maxIterations: null,
303
+ },
304
+ {
305
+ name: "inference",
306
+ handler: null, // Will be populated from dynamic module import
307
+ skipIf: null,
308
+ maxIterations: null,
309
+ },
310
+ {
311
+ name: "parsing",
312
+ handler: null, // Will be populated from dynamic module import
313
+ skipIf: null,
314
+ maxIterations: null,
315
+ },
316
+ {
317
+ name: "validateStructure",
318
+ handler: null, // Will be populated from dynamic module import
319
+ skipIf: null,
320
+ maxIterations: null,
321
+ },
322
+ {
323
+ name: "validateQuality",
324
+ handler: null, // Will be populated from dynamic module import
325
+ skipIf: null,
326
+ maxIterations: null,
327
+ },
328
+ {
329
+ name: "critique",
330
+ handler: null, // Will be populated from dynamic module import
331
+ skipIf: (flags) => flags.validationFailed === false,
332
+ maxIterations: null,
333
+ },
334
+ {
335
+ name: "refine",
336
+ handler: null, // Will be populated from dynamic module import
337
+ skipIf: (flags) => flags.validationFailed === false,
338
+ maxIterations: (seed) => seed.maxRefinements || 1,
339
+ },
340
+ {
341
+ name: "finalValidation",
342
+ handler: null, // Will be populated from dynamic module import
343
+ skipIf: null,
344
+ maxIterations: null,
345
+ },
346
+ {
347
+ name: "integration",
348
+ handler: null, // Will be populated from dynamic module import
349
+ skipIf: null,
350
+ maxIterations: null,
351
+ },
20
352
  ];
21
353
 
22
354
  /**
23
- * Runs a pipeline by loading a module that exports functions keyed by ORDER.
355
+ * Runs a pipeline by loading a module that exports functions keyed by stage name.
24
356
  */
25
357
  export async function runPipeline(modulePath, initialContext = {}) {
26
358
  if (!initialContext.envLoaded) {
@@ -28,20 +360,15 @@ export async function runPipeline(modulePath, initialContext = {}) {
28
360
  initialContext.envLoaded = true;
29
361
  }
30
362
 
31
- if (!initialContext.llm) {
32
- initialContext.llm = createLLM({
33
- defaultProvider: initialContext.modelConfig?.defaultProvider || "openai",
34
- });
35
- }
363
+ if (!initialContext.llm) initialContext.llm = createLLM();
36
364
 
37
- const config = getConfig();
38
365
  const llmMetrics = [];
39
366
  const llmEvents = getLLMEvents();
40
367
 
41
368
  const onLLMComplete = (metric) => {
42
369
  llmMetrics.push({
43
370
  ...metric,
44
- task: context.taskName,
371
+ task: context.meta.taskName,
45
372
  stage: context.currentStage,
46
373
  });
47
374
  };
@@ -52,35 +379,111 @@ export async function runPipeline(modulePath, initialContext = {}) {
52
379
  );
53
380
 
54
381
  const abs = toAbsFileURL(modulePath);
55
- // Add cache busting to force module reload
56
- const modUrl = `${abs.href}?t=${Date.now()}`;
57
- const mod = await import(modUrl);
382
+ const mod = await loadFreshModule(abs);
58
383
  const tasks = mod.default ?? mod;
59
384
 
60
- const context = { ...initialContext, currentStage: null };
385
+ // Populate PIPELINE_STAGES handlers from dynamically loaded tasks or test override
386
+ const handlersSource = initialContext.tasksOverride || tasks;
387
+ PIPELINE_STAGES.forEach((stageConfig) => {
388
+ if (
389
+ handlersSource[stageConfig.name] &&
390
+ typeof handlersSource[stageConfig.name] === "function"
391
+ ) {
392
+ stageConfig.handler = handlersSource[stageConfig.name];
393
+ } else {
394
+ // Set handler to null when not available - will be skipped
395
+ stageConfig.handler = null;
396
+ }
397
+ });
398
+
399
+ // Create fileIO singleton if we have the required context
400
+ let fileIO = null;
401
+ if (
402
+ initialContext.workDir &&
403
+ initialContext.taskName &&
404
+ initialContext.statusPath
405
+ ) {
406
+ fileIO = createTaskFileIO({
407
+ workDir: initialContext.workDir,
408
+ taskName: initialContext.taskName,
409
+ getStage: () => context.currentStage,
410
+ statusPath: initialContext.statusPath,
411
+ });
412
+ }
413
+
414
+ // Extract seed and maxRefinements for new context structure
415
+ const seed = initialContext.seed || initialContext;
416
+ const maxRefinements = seed.maxRefinements ?? 1; // Default to 1 unless explicitly set
417
+
418
+ // Create new context structure with io, llm, meta, data, flags, logs, currentStage
419
+ const context = {
420
+ io: fileIO,
421
+ llm: initialContext.llm,
422
+ meta: {
423
+ taskName: initialContext.taskName,
424
+ workDir: initialContext.workDir,
425
+ statusPath: initialContext.statusPath,
426
+ jobId: initialContext.jobId,
427
+ envLoaded: initialContext.envLoaded,
428
+ modelConfig: initialContext.modelConfig,
429
+ pipelineTasks:
430
+ initialContext.meta?.pipelineTasks ||
431
+ initialContext.pipelineTasks ||
432
+ [],
433
+ },
434
+ data: {
435
+ seed: seed,
436
+ },
437
+ flags: {},
438
+ logs: [],
439
+ currentStage: null,
440
+ };
61
441
  const logs = [];
62
442
  let needsRefinement = false;
63
443
  let refinementCount = 0;
64
- const maxRefinements = config.taskRunner.maxRefinementAttempts;
444
+ let lastStageOutput = context.data.seed;
445
+ let lastStageName = "seed";
446
+ let lastExecutedStageName = "seed";
447
+
448
+ // Ensure log directory exists before stage execution
449
+ const logsDir = ensureLogDirectory(context.meta.workDir, context.meta.jobId);
65
450
 
66
451
  do {
67
452
  needsRefinement = false;
68
453
  let preRefinedThisCycle = false;
69
454
 
70
- for (const stage of ORDER) {
71
- context.currentStage = stage;
72
- const fn = tasks[stage];
73
- if (typeof fn !== "function") {
74
- logs.push({ stage, skipped: true, refinementCycle: refinementCount });
455
+ for (const stageConfig of PIPELINE_STAGES) {
456
+ const stageName = stageConfig.name;
457
+ const stageHandler = stageConfig.handler;
458
+
459
+ // Skip stages when skipIf predicate returns true
460
+ if (stageConfig.skipIf && stageConfig.skipIf(context.flags)) {
461
+ context.logs.push({
462
+ stage: stageName,
463
+ action: "skipped",
464
+ reason: "skipIf predicate returned true",
465
+ timestamp: new Date().toISOString(),
466
+ });
75
467
  continue;
76
468
  }
77
469
 
470
+ // Skip if handler is not available (not implemented)
471
+ if (typeof stageHandler !== "function") {
472
+ logs.push({
473
+ stage: stageName,
474
+ skipped: true,
475
+ refinementCycle: refinementCount,
476
+ });
477
+ continue;
478
+ }
479
+
480
+ // Skip ingestion and preProcessing during refinement cycles
78
481
  if (
79
482
  refinementCount > 0 &&
80
- ["ingestion", "preProcessing"].includes(stage)
483
+ ["ingestion", "preProcessing"].includes(stageName)
81
484
  ) {
82
485
  logs.push({
83
- stage,
486
+ stage: stageName,
84
487
  skipped: true,
85
488
  reason: "refinement-cycle",
86
489
  refinementCycle: refinementCount,
@@ -88,15 +491,17 @@ export async function runPipeline(modulePath, initialContext = {}) {
88
491
  continue;
89
492
  }
90
493
 
494
+ // Handle pre-refinement logic for validation stages
91
495
  if (
92
496
  refinementCount > 0 &&
93
497
  !preRefinedThisCycle &&
94
- !context.refined &&
95
- (stage === "validateStructure" || stage === "validateQuality")
498
+ !context.flags.refined &&
499
+ (stageName === "validateStructure" || stageName === "validateQuality")
96
500
  ) {
97
501
  for (const s of ["critique", "refine"]) {
98
- const f = tasks[s];
99
- if (typeof f !== "function") {
502
+ const sConfig = PIPELINE_STAGES.find((config) => config.name === s);
503
+ const sHandler = sConfig?.handler;
504
+ if (typeof sHandler !== "function") {
100
505
  logs.push({
101
506
  stage: s,
102
507
  skipped: true,
@@ -107,8 +512,7 @@ export async function runPipeline(modulePath, initialContext = {}) {
107
512
  }
108
513
  const sStart = performance.now();
109
514
  try {
110
- const r = await f(context);
111
- if (r && typeof r === "object") Object.assign(context, r);
515
+ const r = await sHandler(context);
112
516
  const sMs = +(performance.now() - sStart).toFixed(2);
113
517
  logs.push({
114
518
  stage: s,
@@ -140,9 +544,13 @@ export async function runPipeline(modulePath, initialContext = {}) {
140
544
  preRefinedThisCycle = true;
141
545
  }
142
546
 
143
- if (preRefinedThisCycle && (stage === "critique" || stage === "refine")) {
547
+ // Skip critique and refine if already pre-refined
548
+ if (
549
+ preRefinedThisCycle &&
550
+ (stageName === "critique" || stageName === "refine")
551
+ ) {
144
552
  logs.push({
145
- stage,
553
+ stage: stageName,
146
554
  skipped: true,
147
555
  reason: "already-pre-refined",
148
556
  refinementCycle: refinementCount,
@@ -150,52 +558,286 @@ export async function runPipeline(modulePath, initialContext = {}) {
150
558
  continue;
151
559
  }
152
560
 
561
+ // Add console output capture before stage execution
562
+ const logPath = path.join(
563
+ context.meta.workDir,
564
+ "files",
565
+ "logs",
566
+ `stage-${stageName}.log`
567
+ );
568
+ console.debug("[task-runner] stage log path resolution", {
569
+ stage: stageName,
570
+ workDir: context.meta.workDir,
571
+ jobId: context.meta.jobId,
572
+ logPath,
573
+ });
574
+ const restoreConsole = captureConsoleOutput(logPath);
575
+
576
+ // Set current stage before execution
577
+ context.currentStage = stageName;
578
+
579
+ // Write stage start status using writeJobStatus
580
+ if (context.meta.workDir && context.meta.taskName) {
581
+ try {
582
+ await writeJobStatus(context.meta.workDir, (snapshot) => {
583
+ snapshot.current = context.meta.taskName;
584
+ snapshot.currentStage = stageName;
585
+ snapshot.lastUpdated = new Date().toISOString();
586
+
587
+ // Ensure task exists and update task-specific fields
588
+ if (!snapshot.tasks[context.meta.taskName]) {
589
+ snapshot.tasks[context.meta.taskName] = {};
590
+ }
591
+ snapshot.tasks[context.meta.taskName].currentStage = stageName;
592
+ snapshot.tasks[context.meta.taskName].state = "running";
593
+ });
594
+ } catch (error) {
595
+ // Don't fail the pipeline if status write fails
596
+ console.warn(`Failed to write stage start status: ${error.message}`);
597
+ }
598
+ }
599
+
600
+ // Clone data and flags before stage execution
601
+ const stageData = JSON.parse(JSON.stringify(context.data));
602
+ const stageFlags = JSON.parse(JSON.stringify(context.flags));
603
+ const stageContext = {
604
+ io: context.io,
605
+ llm: context.llm,
606
+ meta: context.meta,
607
+ data: stageData,
608
+ flags: stageFlags,
609
+ currentStage: stageName,
610
+ output: JSON.parse(
611
+ JSON.stringify(
612
+ lastStageOutput !== undefined
613
+ ? lastStageOutput
614
+ : (context.data.seed ?? null)
615
+ )
616
+ ),
617
+ previousStage: lastExecutedStageName,
618
+ };
619
+
620
+ // Write pre-execution snapshot for debugging inputs
621
+ const snapshot = {
622
+ meta: { taskName: context.meta.taskName, jobId: context.meta.jobId },
623
+ previousStage: lastExecutedStageName,
624
+ refinementCycle: refinementCount,
625
+ dataSummary: {
626
+ keys: Object.keys(context.data),
627
+ hasSeed: !!context.data?.seed,
628
+ seedKeys: Object.keys(context.data?.seed || {}),
629
+ seedHasData: context.data?.seed?.data !== undefined,
630
+ },
631
+ flagsSummary: {
632
+ keys: Object.keys(context.flags),
633
+ },
634
+ outputSummary: {
635
+ type: typeof stageContext.output,
636
+ keys:
637
+ stageContext.output && typeof stageContext.output === "object"
638
+ ? Object.keys(stageContext.output).slice(0, 20)
639
+ : [],
640
+ },
641
+ };
642
+ writePreExecutionSnapshot(stageName, snapshot, logsDir);
643
+
644
+ // Validate prerequisite flags before stage execution
645
+ const requiredFlags = FLAG_SCHEMAS[stageName]?.requires;
646
+ if (requiredFlags && Object.keys(requiredFlags).length > 0) {
647
+ validateFlagTypes(stageName, context.flags, requiredFlags);
648
+ }
649
+
650
+ // Execute the stage
153
651
  const start = performance.now();
652
+ let stageResult;
154
653
  try {
155
- const result = await fn(context);
156
- if (result && typeof result === "object")
157
- Object.assign(context, result);
654
+ context.logs.push({
655
+ stage: stageName,
656
+ action: "debugging",
657
+ data: stageContext,
658
+ });
659
+
660
+ console.log("STAGE CONTEXT", JSON.stringify(stageContext, null, 2));
661
+ stageResult = await stageHandler(stageContext);
662
+
663
+ // Validate stage result shape after execution
664
+ assertStageResult(stageName, stageResult);
665
+
666
+ // Validate produced flags against schema
667
+ const producedFlagsSchema = FLAG_SCHEMAS[stageName]?.produces;
668
+ if (producedFlagsSchema) {
669
+ validateFlagTypes(stageName, stageResult.flags, producedFlagsSchema);
670
+ }
671
+
672
+ // Check for flag type conflicts before merging
673
+ checkFlagTypeConflicts(context.flags, stageResult.flags, stageName);
674
+
675
+ // Store stage output in context.data
676
+ context.data[stageName] = stageResult.output;
677
+ lastStageName = stageName;
678
+
679
+ // Only update lastStageOutput and lastExecutedStageName for non-validation stages
680
+ // This ensures previousStage and context.output skip validation stages
681
+ const validationStages = [
682
+ "validateStructure",
683
+ "validateQuality",
684
+ "validateFinal",
685
+ "finalValidation",
686
+ ];
687
+ if (!validationStages.includes(stageName)) {
688
+ lastStageOutput = stageResult.output;
689
+ lastExecutedStageName = stageName;
690
+ }
691
+
692
+ // Merge stage flags into context.flags
693
+ context.flags = { ...context.flags, ...stageResult.flags };
694
+
695
+ // Add audit log entry after stage completes
696
+ context.logs.push({
697
+ stage: stageName,
698
+ action: "completed",
699
+ outputType: typeof stageResult.output,
700
+ flagKeys: Object.keys(stageResult.flags),
701
+ timestamp: new Date().toISOString(),
702
+ });
703
+
704
+ // Write stage completion status
705
+ if (context.meta.workDir && context.meta.taskName) {
706
+ try {
707
+ await writeJobStatus(context.meta.workDir, (snapshot) => {
708
+ // Keep current task and stage as-is since we're still within the same task
709
+ snapshot.current = context.meta.taskName;
710
+ snapshot.currentStage = stageName;
711
+ snapshot.lastUpdated = new Date().toISOString();
712
+
713
+ // Compute deterministic progress after stage completion
714
+ const pct = computeDeterministicProgress(
715
+ context.meta.pipelineTasks || [],
716
+ context.meta.taskName,
717
+ stageName
718
+ );
719
+ snapshot.progress = pct;
720
+
721
+ // Debug log for progress computation
722
+ console.debug("[task-runner] stage completion progress", {
723
+ task: context.meta.taskName,
724
+ stage: stageName,
725
+ progress: pct,
726
+ });
727
+
728
+ // Ensure task exists and update task-specific fields
729
+ if (!snapshot.tasks[context.meta.taskName]) {
730
+ snapshot.tasks[context.meta.taskName] = {};
731
+ }
732
+ snapshot.tasks[context.meta.taskName].currentStage = stageName;
733
+ snapshot.tasks[context.meta.taskName].state = "running";
734
+ });
735
+ } catch (error) {
736
+ // Don't fail the pipeline if status write fails
737
+ console.warn(
738
+ `Failed to write stage completion status: ${error.message}`
739
+ );
740
+ }
741
+ }
158
742
 
159
743
  const ms = +(performance.now() - start).toFixed(2);
160
- logs.push({ stage, ok: true, ms, refinementCycle: refinementCount });
744
+ logs.push({
745
+ stage: stageName,
746
+ ok: true,
747
+ ms,
748
+ refinementCycle: refinementCount,
749
+ });
161
750
 
162
751
  if (
163
- (stage === "validateStructure" || stage === "validateQuality") &&
164
- context.validationFailed &&
752
+ (stageName === "validateStructure" ||
753
+ stageName === "validateQuality") &&
754
+ context.flags.validationFailed &&
165
755
  refinementCount < maxRefinements
166
756
  ) {
167
757
  needsRefinement = true;
168
- context.validationFailed = false;
758
+ // Don't reset validationFailed here - let the refinement cycle handle it
169
759
  break;
170
760
  }
171
761
  } catch (error) {
762
+ console.error(`Stage ${stageName} failed:`, error);
172
763
  const ms = +(performance.now() - start).toFixed(2);
173
764
  const errInfo = normalizeError(error);
765
+
766
+ // Attach debug metadata to the error envelope for richer diagnostics
767
+ errInfo.debug = {
768
+ stage: stageName,
769
+ previousStage: lastExecutedStageName,
770
+ refinementCycle: refinementCount,
771
+ logPath: path.join(
772
+ context.meta.workDir,
773
+ "files",
774
+ "logs",
775
+ `stage-${stageName}.log`
776
+ ),
777
+ snapshotPath: path.join(logsDir, `stage-${stageName}-context.json`),
778
+ dataHasSeed: !!context.data?.seed,
779
+ seedHasData: context.data?.seed?.data !== undefined,
780
+ flagsKeys: Object.keys(context.flags || {}),
781
+ };
782
+
174
783
  logs.push({
175
- stage,
784
+ stage: stageName,
176
785
  ok: false,
177
786
  ms,
178
787
  error: errInfo,
179
788
  refinementCycle: refinementCount,
180
789
  });
181
790
 
791
+ // For validation stages, trigger refinement if we haven't exceeded max refinements AND maxRefinements > 0
182
792
  if (
183
- (stage === "validateStructure" || stage === "validateQuality") &&
793
+ (stageName === "validateStructure" ||
794
+ stageName === "validateQuality") &&
795
+ maxRefinements > 0 &&
184
796
  refinementCount < maxRefinements
185
797
  ) {
186
- context.lastValidationError = errInfo;
798
+ context.flags.lastValidationError = errInfo;
799
+ context.flags.validationFailed = true; // Set the flag to trigger refinement
187
800
  needsRefinement = true;
188
801
  break;
189
802
  }
190
803
 
804
+ // Write failure status using writeJobStatus
805
+ if (context.meta.workDir && context.meta.taskName) {
806
+ try {
807
+ await writeJobStatus(context.meta.workDir, (snapshot) => {
808
+ snapshot.current = context.meta.taskName;
809
+ snapshot.currentStage = stageName;
810
+ snapshot.state = "failed";
811
+ snapshot.lastUpdated = new Date().toISOString();
812
+
813
+ // Ensure task exists and update task-specific fields
814
+ if (!snapshot.tasks[context.meta.taskName]) {
815
+ snapshot.tasks[context.meta.taskName] = {};
816
+ }
817
+ snapshot.tasks[context.meta.taskName].state = "failed";
818
+ snapshot.tasks[context.meta.taskName].failedStage = stageName;
819
+ snapshot.tasks[context.meta.taskName].currentStage = stageName;
820
+ });
821
+ } catch (error) {
822
+ // Don't fail the pipeline if status write fails
823
+ console.warn(`Failed to write failure status: ${error.message}`);
824
+ }
825
+ }
826
+
827
+ // For non-validation stages or when refinements are exhausted, fail immediately
191
828
  return {
192
829
  ok: false,
193
- failedStage: stage,
830
+ failedStage: stageName,
194
831
  error: errInfo,
195
832
  logs,
196
833
  context,
197
834
  refinementAttempts: refinementCount,
198
835
  };
836
+ } finally {
837
+ // Add console output restoration after stage execution
838
+ if (restoreConsole) {
839
+ restoreConsole();
840
+ }
199
841
  }
200
842
  }
201
843
 
@@ -204,7 +846,7 @@ export async function runPipeline(modulePath, initialContext = {}) {
204
846
  logs.push({
205
847
  stage: "refinement-trigger",
206
848
  refinementCycle: refinementCount,
207
- reason: context.lastValidationError
849
+ reason: context.flags.lastValidationError
208
850
  ? "validation-error"
209
851
  : "validation-failed-flag",
210
852
  });
@@ -216,7 +858,7 @@ export async function runPipeline(modulePath, initialContext = {}) {
216
858
  typeof tasks.validateStructure === "function" ||
217
859
  typeof tasks.validateQuality === "function";
218
860
 
219
- if (context.validationFailed && hasValidation) {
861
+ if (context.flags.validationFailed && hasValidation) {
220
862
  return {
221
863
  ok: false,
222
864
  failedStage: "final-validation",
@@ -229,6 +871,29 @@ export async function runPipeline(modulePath, initialContext = {}) {
229
871
 
230
872
  llmEvents.off("llm:request:complete", onLLMComplete);
231
873
 
874
+ // Write final status with currentStage: null to indicate completion
875
+ if (context.meta.workDir && context.meta.taskName) {
876
+ try {
877
+ await writeJobStatus(context.meta.workDir, (snapshot) => {
878
+ snapshot.current = null;
879
+ snapshot.currentStage = null;
880
+ snapshot.state = "done";
881
+ snapshot.progress = 100;
882
+ snapshot.lastUpdated = new Date().toISOString();
883
+
884
+ // Update task state to done
885
+ if (!snapshot.tasks[context.meta.taskName]) {
886
+ snapshot.tasks[context.meta.taskName] = {};
887
+ }
888
+ snapshot.tasks[context.meta.taskName].state = "done";
889
+ snapshot.tasks[context.meta.taskName].currentStage = null;
890
+ });
891
+ } catch (error) {
892
+ // Don't fail the pipeline if final status write fails
893
+ console.warn(`Failed to write final status: ${error.message}`);
894
+ }
895
+ }
896
+
232
897
  return {
233
898
  ok: true,
234
899
  logs,
@@ -252,25 +917,6 @@ export async function runPipelineWithModelRouting(
252
917
  return runPipeline(modulePath, context);
253
918
  }
254
919
 
255
- export function selectModel(taskType, complexity, speed = "normal") {
256
- const modelMap = {
257
- "simple-fast": "gpt-3.5-turbo",
258
- "simple-accurate": "gpt-4",
259
- "complex-fast": "gpt-4",
260
- "complex-accurate": "gpt-4-turbo",
261
- specialized: "claude-3-opus",
262
- };
263
- const key =
264
- complexity === "high"
265
- ? speed === "fast"
266
- ? "complex-fast"
267
- : "complex-accurate"
268
- : speed === "fast"
269
- ? "simple-fast"
270
- : "simple-accurate";
271
- return modelMap[key] || "gpt-4";
272
- }
273
-
274
920
  function toAbsFileURL(p) {
275
921
  if (!path.isAbsolute(p)) {
276
922
  throw new Error(