@ryanfw/prompt-orchestration-pipeline 0.9.1 → 0.11.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.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createLogger } from "./logger.js";
4
+ import { ensureTaskSymlinkBridge } from "./symlink-bridge.js";
4
5
 
5
6
  const logger = createLogger("SymlinkUtils");
6
7
 
@@ -49,6 +50,201 @@ export async function ensureSymlink(linkPath, targetPath, type) {
49
50
  }
50
51
  }
51
52
 
53
+ /**
54
+ * Validates that required task symlinks exist and point to accessible targets.
55
+ *
56
+ * @param {string} taskDir - The task directory containing symlinks
57
+ * @param {Object} expectedTargets - Expected symlink targets
58
+ * @param {string} expectedTargets.nodeModules - Expected target for node_modules symlink
59
+ * @param {string} expectedTargets.taskRoot - Expected target for _task_root symlink
60
+ * @returns {Object} Validation result with isValid flag and details
61
+ */
62
+ export async function validateTaskSymlinks(taskDir, expectedTargets) {
63
+ const startTime = Date.now();
64
+ const validationErrors = [];
65
+ const validationDetails = {};
66
+
67
+ const symlinksToValidate = [
68
+ { name: "node_modules", expectedTarget: expectedTargets.nodeModules },
69
+ { name: "_task_root", expectedTarget: expectedTargets.taskRoot },
70
+ ];
71
+
72
+ for (const { name, expectedTarget } of symlinksToValidate) {
73
+ const linkPath = path.join(taskDir, name);
74
+
75
+ try {
76
+ // Check if symlink exists
77
+ const stats = await fs.lstat(linkPath);
78
+
79
+ if (!stats.isSymbolicLink()) {
80
+ validationErrors.push(
81
+ `${name} exists but is not a symlink (type: ${stats.isFile() ? "file" : "directory"})`
82
+ );
83
+ validationDetails[name] = {
84
+ exists: true,
85
+ isSymlink: false,
86
+ targetAccessible: false,
87
+ };
88
+ continue;
89
+ }
90
+
91
+ // Read the symlink target
92
+ const actualTarget = await fs.readlink(linkPath);
93
+
94
+ // Check if target matches expected (normalize paths for comparison)
95
+ const normalizedActual = path.resolve(taskDir, actualTarget);
96
+ const normalizedExpected = path.resolve(expectedTarget);
97
+
98
+ if (normalizedActual !== normalizedExpected) {
99
+ validationErrors.push(
100
+ `${name} points to wrong target: expected ${expectedTarget}, got ${actualTarget}`
101
+ );
102
+ validationDetails[name] = {
103
+ exists: true,
104
+ isSymlink: true,
105
+ targetAccessible: false,
106
+ actualTarget,
107
+ expectedTarget,
108
+ };
109
+ continue;
110
+ }
111
+
112
+ // Check if target is accessible
113
+ const targetStats = await fs.stat(normalizedActual).catch(() => null);
114
+ if (!targetStats) {
115
+ validationErrors.push(
116
+ `${name} target is not accessible: ${actualTarget}`
117
+ );
118
+ validationDetails[name] = {
119
+ exists: true,
120
+ isSymlink: true,
121
+ targetAccessible: false,
122
+ actualTarget,
123
+ };
124
+ continue;
125
+ }
126
+
127
+ if (!targetStats.isDirectory()) {
128
+ validationErrors.push(
129
+ `${name} target is not a directory: ${actualTarget}`
130
+ );
131
+ validationDetails[name] = {
132
+ exists: true,
133
+ isSymlink: true,
134
+ targetAccessible: false,
135
+ actualTarget,
136
+ targetType: "file",
137
+ };
138
+ continue;
139
+ }
140
+
141
+ // Symlink is valid
142
+ validationDetails[name] = {
143
+ exists: true,
144
+ isSymlink: true,
145
+ targetAccessible: true,
146
+ actualTarget,
147
+ };
148
+ } catch (error) {
149
+ if (error.code === "ENOENT") {
150
+ validationErrors.push(`${name} symlink does not exist`);
151
+ validationDetails[name] = {
152
+ exists: false,
153
+ isSymlink: false,
154
+ targetAccessible: false,
155
+ };
156
+ } else {
157
+ validationErrors.push(`${name} validation failed: ${error.message}`);
158
+ validationDetails[name] = {
159
+ exists: false,
160
+ isSymlink: false,
161
+ targetAccessible: false,
162
+ error: error.message,
163
+ };
164
+ }
165
+ }
166
+ }
167
+
168
+ const isValid = validationErrors.length === 0;
169
+ const duration = Date.now() - startTime;
170
+
171
+ logger.debug("Symlink validation completed", {
172
+ taskDir,
173
+ isValid,
174
+ errorsCount: validationErrors.length,
175
+ duration,
176
+ details: validationDetails,
177
+ });
178
+
179
+ return {
180
+ isValid,
181
+ errors: validationErrors,
182
+ details: validationDetails,
183
+ duration,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Repairs task symlinks by recreating them using the existing symlink bridge.
189
+ *
190
+ * @param {string} taskDir - The task directory where symlinks should be created
191
+ * @param {string} poRoot - The repository root directory
192
+ * @param {string} taskModulePath - Absolute path to the original task module
193
+ * @returns {Object} Repair result with success flag and details
194
+ */
195
+ export async function repairTaskSymlinks(taskDir, poRoot, taskModulePath) {
196
+ const startTime = Date.now();
197
+
198
+ try {
199
+ logger.log("Repairing task symlinks", {
200
+ taskDir,
201
+ poRoot,
202
+ taskModulePath,
203
+ });
204
+
205
+ // Use existing ensureTaskSymlinkBridge for repairs
206
+ const relocatedEntry = await ensureTaskSymlinkBridge({
207
+ taskDir,
208
+ poRoot,
209
+ taskModulePath,
210
+ });
211
+
212
+ const duration = Date.now() - startTime;
213
+
214
+ logger.log("Task symlinks repaired successfully", {
215
+ taskDir,
216
+ duration,
217
+ relocatedEntry,
218
+ });
219
+
220
+ return {
221
+ success: true,
222
+ relocatedEntry,
223
+ duration,
224
+ errors: [],
225
+ };
226
+ } catch (error) {
227
+ const duration = Date.now() - startTime;
228
+ const errorMessage = `Failed to repair task symlinks: ${error.message}`;
229
+
230
+ logger.error("Task symlink repair failed", {
231
+ taskDir,
232
+ poRoot,
233
+ taskModulePath,
234
+ duration,
235
+ error: error.message,
236
+ stack: error.stack,
237
+ });
238
+
239
+ return {
240
+ success: false,
241
+ relocatedEntry: null,
242
+ duration,
243
+ errors: [errorMessage],
244
+ };
245
+ }
246
+ }
247
+
52
248
  /**
53
249
  * Removes task symlinks from a completed job directory to avoid dangling links.
54
250
  *
@@ -4,12 +4,13 @@ import fs from "fs";
4
4
  import { createLLM, getLLMEvents } from "../llm/index.js";
5
5
  import { loadFreshModule } from "./module-loader.js";
6
6
  import { loadEnvironment } from "./environment.js";
7
- import { createTaskFileIO } from "./file-io.js";
7
+ import { createTaskFileIO, generateLogName } from "./file-io.js";
8
8
  import { writeJobStatus } from "./status-writer.js";
9
9
  import { computeDeterministicProgress } from "./progress.js";
10
10
  import { TaskState } from "../config/statuses.js";
11
11
  import { validateWithSchema } from "../api/validators/json.js";
12
12
  import { createJobLogger } from "./logger.js";
13
+ import { LogEvent, LogFileExtension } from "../config/log-events.js";
13
14
 
14
15
  /**
15
16
  * Derives model key and token counts from LLM metric event.
@@ -67,13 +68,13 @@ function assertStageResult(stageName, result) {
67
68
  );
68
69
  }
69
70
 
70
- if (!result.hasOwnProperty("output")) {
71
+ if (!Object.prototype.hasOwnProperty.call(result, "output")) {
71
72
  throw new Error(
72
73
  `Stage "${stageName}" result missing required property: output`
73
74
  );
74
75
  }
75
76
 
76
- if (!result.hasOwnProperty("flags")) {
77
+ if (!Object.prototype.hasOwnProperty.call(result, "flags")) {
77
78
  throw new Error(
78
79
  `Stage "${stageName}" result missing required property: flags`
79
80
  );
@@ -507,7 +508,11 @@ export async function runPipeline(modulePath, initialContext = {}) {
507
508
  }
508
509
 
509
510
  // Add console output capture before stage execution using IO
510
- const logName = `stage-${stageName}.log`;
511
+ const logName = generateLogName(
512
+ context.meta.taskName,
513
+ stageName,
514
+ LogEvent.START
515
+ );
511
516
  const logPath = path.join(context.meta.workDir, "files", "logs", logName);
512
517
  console.debug("[task-runner] stage log path resolution via IO", {
513
518
  stage: stageName,
@@ -593,7 +598,12 @@ export async function runPipeline(modulePath, initialContext = {}) {
593
598
  },
594
599
  };
595
600
  await context.io.writeLog(
596
- `stage-${stageName}-context.json`,
601
+ generateLogName(
602
+ context.meta.taskName,
603
+ stageName,
604
+ LogEvent.CONTEXT,
605
+ LogFileExtension.JSON
606
+ ),
597
607
  JSON.stringify(snapshot, null, 2),
598
608
  { mode: "replace" }
599
609
  );
@@ -696,6 +706,18 @@ export async function runPipeline(modulePath, initialContext = {}) {
696
706
  }
697
707
  }
698
708
 
709
+ // Add explicit completion log after stage completion
710
+ const completeLogName = generateLogName(
711
+ context.meta.taskName,
712
+ stageName,
713
+ LogEvent.COMPLETE
714
+ );
715
+ await context.io.writeLog(
716
+ completeLogName,
717
+ `Stage ${stageName} completed at ${new Date().toISOString()}\n`,
718
+ { mode: "replace" }
719
+ );
720
+
699
721
  const ms = +(performance.now() - start).toFixed(2);
700
722
  logger.log("Stage completed successfully", {
701
723
  stage: stageName,
@@ -729,9 +751,17 @@ export async function runPipeline(modulePath, initialContext = {}) {
729
751
  context.meta.workDir,
730
752
  "files",
731
753
  "logs",
732
- `stage-${stageName}.log`
754
+ generateLogName(context.meta.taskName, stageName, LogEvent.START)
755
+ ),
756
+ snapshotPath: path.join(
757
+ logsDir,
758
+ generateLogName(
759
+ context.meta.taskName,
760
+ stageName,
761
+ LogEvent.CONTEXT,
762
+ LogFileExtension.JSON
763
+ )
733
764
  ),
734
- snapshotPath: path.join(logsDir, `stage-${stageName}-context.json`),
735
765
  dataHasSeed: !!context.data?.seed,
736
766
  seedHasData: context.data?.seed?.data !== undefined,
737
767
  flagsKeys: Object.keys(context.flags || {}),
@@ -75,6 +75,8 @@ function normalizeTasks(rawTasks) {
75
75
  : undefined,
76
76
  // Preserve tokenUsage if present
77
77
  ...(t && t.tokenUsage ? { tokenUsage: t.tokenUsage } : {}),
78
+ // Preserve error object if present
79
+ ...(t && t.error ? { error: t.error } : {}),
78
80
  };
79
81
  tasks[name] = taskObj;
80
82
  });
@@ -106,11 +108,28 @@ function normalizeTasks(rawTasks) {
106
108
  ...(typeof t?.failedStage === "string" && t.failedStage.length > 0
107
109
  ? { failedStage: t.failedStage }
108
110
  : {}),
111
+ // Prefer new files.* schema, fallback to legacy artifacts
112
+ files:
113
+ t && t.files
114
+ ? {
115
+ artifacts: Array.isArray(t.files.artifacts)
116
+ ? t.files.artifacts.slice()
117
+ : [],
118
+ logs: Array.isArray(t.files.logs) ? t.files.logs.slice() : [],
119
+ tmp: Array.isArray(t.files.tmp) ? t.files.tmp.slice() : [],
120
+ }
121
+ : {
122
+ artifacts: [],
123
+ logs: [],
124
+ tmp: [],
125
+ },
109
126
  artifacts: Array.isArray(t && t.artifacts)
110
127
  ? t.artifacts.slice()
111
128
  : undefined,
112
129
  // Preserve tokenUsage if present
113
130
  ...(t && t.tokenUsage ? { tokenUsage: t.tokenUsage } : {}),
131
+ // Preserve error object if present
132
+ ...(t && t.error ? { error: t.error } : {}),
114
133
  };
115
134
  });
116
135
  return { tasks, warnings };
@@ -152,7 +171,7 @@ export function adaptJobSummary(apiJob) {
152
171
  // Demo-only: read canonical fields strictly
153
172
  const id = apiJob.jobId;
154
173
  const name = apiJob.title || "";
155
- const rawTasks = apiJob.tasks;
174
+ const rawTasks = apiJob.tasks || apiJob.tasksStatus; // Handle both formats for backward compatibility
156
175
  const location = apiJob.location;
157
176
 
158
177
  // Job-level stage metadata
@@ -221,7 +240,7 @@ export function adaptJobDetail(apiDetail) {
221
240
  // Demo-only: read canonical fields strictly
222
241
  const id = apiDetail.jobId;
223
242
  const name = apiDetail.title || "";
224
- const rawTasks = apiDetail.tasks;
243
+ const rawTasks = apiDetail.tasks || apiDetail.tasksStatus; // Handle both formats for backward compatibility
225
244
  const location = apiDetail.location;
226
245
 
227
246
  // Job-level stage metadata
@@ -347,6 +347,7 @@ export function useJobDetailWithUpdates(jobId) {
347
347
  newEs.addEventListener("job:removed", onJobRemoved);
348
348
  newEs.addEventListener("status:changed", onStatusChanged);
349
349
  newEs.addEventListener("state:change", onStateChange);
350
+ newEs.addEventListener("task:updated", onTaskUpdated);
350
351
  newEs.addEventListener("error", onError);
351
352
 
352
353
  esRef.current = newEs;
@@ -373,6 +374,94 @@ export function useJobDetailWithUpdates(jobId) {
373
374
  return;
374
375
  }
375
376
 
377
+ // Handle task:updated events with task-level merge logic
378
+ if (type === "task:updated") {
379
+ const p = payload || {};
380
+ const { jobId: eventJobId, taskId, task } = p;
381
+
382
+ // Filter by jobId
383
+ if (eventJobId && eventJobId !== jobId) {
384
+ return;
385
+ }
386
+
387
+ // Validate required fields
388
+ if (!taskId || !task) {
389
+ return;
390
+ }
391
+
392
+ startTransition(() => {
393
+ setData((prev) => {
394
+ // If no previous data or tasks, return unchanged
395
+ if (!prev || !prev.tasks) {
396
+ return prev;
397
+ }
398
+
399
+ const prevTask = prev.tasks[taskId];
400
+
401
+ // Compare observable fields to determine if update is needed
402
+ const fieldsToCompare = [
403
+ "state",
404
+ "currentStage",
405
+ "failedStage",
406
+ "startedAt",
407
+ "endedAt",
408
+ "attempts",
409
+ "executionTimeMs",
410
+ "error",
411
+ ];
412
+
413
+ let hasChanged = false;
414
+
415
+ // If task doesn't exist yet, always treat as changed
416
+ if (!prevTask) {
417
+ hasChanged = true;
418
+ } else {
419
+ // Compare observable fields to determine if update is needed
420
+ for (const field of fieldsToCompare) {
421
+ if (prevTask[field] !== task[field]) {
422
+ hasChanged = true;
423
+ break;
424
+ }
425
+ }
426
+
427
+ // Also compare tokenUsage and files arrays by reference
428
+ if (
429
+ prevTask.tokenUsage !== task.tokenUsage ||
430
+ prevTask.files !== task.files
431
+ ) {
432
+ hasChanged = true;
433
+ }
434
+ }
435
+
436
+ if (!hasChanged) {
437
+ return prev; // No change, preserve identity
438
+ }
439
+
440
+ // Create new tasks map with updated task
441
+ const nextTasks = { ...prev.tasks, [taskId]: task };
442
+
443
+ // Recompute job-level summary fields
444
+ const taskCount = Object.keys(nextTasks).length;
445
+ const doneCount = Object.values(nextTasks).filter(
446
+ (t) => t.state === "done"
447
+ ).length;
448
+ const progress =
449
+ taskCount > 0 ? (doneCount / taskCount) * 100 : 0;
450
+
451
+ // Return new job object with updated tasks and summary
452
+ return {
453
+ ...prev,
454
+ tasks: nextTasks,
455
+ doneCount,
456
+ taskCount,
457
+ progress,
458
+ lastUpdated: new Date().toISOString(),
459
+ };
460
+ });
461
+ });
462
+ return; // Skip generic handling for task:updated
463
+ }
464
+
376
465
  // Path-matching state:change → schedule debounced refetch
377
466
  if (type === "state:change") {
378
467
  const d = (payload && (payload.data || payload)) || {};
@@ -415,6 +504,7 @@ export function useJobDetailWithUpdates(jobId) {
415
504
  const onStatusChanged = (evt) =>
416
505
  handleIncomingEvent("status:changed", evt);
417
506
  const onStateChange = (evt) => handleIncomingEvent("state:change", evt);
507
+ const onTaskUpdated = (evt) => handleIncomingEvent("task:updated", evt);
418
508
 
419
509
  es.addEventListener("open", onOpen);
420
510
  es.addEventListener("job:updated", onJobUpdated);
@@ -422,6 +512,7 @@ export function useJobDetailWithUpdates(jobId) {
422
512
  es.addEventListener("job:removed", onJobRemoved);
423
513
  es.addEventListener("status:changed", onStatusChanged);
424
514
  es.addEventListener("state:change", onStateChange);
515
+ es.addEventListener("task:updated", onTaskUpdated);
425
516
  es.addEventListener("error", onError);
426
517
 
427
518
  // Set connection status from readyState when possible
@@ -439,6 +530,7 @@ export function useJobDetailWithUpdates(jobId) {
439
530
  es.removeEventListener("job:removed", onJobRemoved);
440
531
  es.removeEventListener("status:changed", onStatusChanged);
441
532
  es.removeEventListener("state:change", onStateChange);
533
+ es.removeEventListener("task:updated", onTaskUpdated);
442
534
  es.removeEventListener("error", onError);
443
535
  es.close();
444
536
  } catch (err) {