@ryanfw/prompt-orchestration-pipeline 1.2.4 → 1.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
5
5
  "type": "module",
6
6
  "main": "src/ui/server/index.ts",
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { runPipeline } from "../task-runner";
7
+ import type { StatusSnapshot } from "../status-writer";
7
8
 
8
9
  const tempRoots: string[] = [];
9
10
 
@@ -57,3 +58,157 @@ describe("runPipeline log tracking", () => {
57
58
  ]);
58
59
  });
59
60
  });
61
+
62
+ describe("task-runner does not write job-level status fields", () => {
63
+ it("does not set snapshot.state, snapshot.current, snapshot.currentStage, or snapshot.progress on success", async () => {
64
+ const root = await makeTempRoot();
65
+ const workDir = path.join(root, "job-1");
66
+ await mkdir(workDir, { recursive: true });
67
+ await writeFile(path.join(workDir, "seed.json"), JSON.stringify({ topic: "x" }));
68
+ await writeFile(
69
+ path.join(workDir, "tasks-status.json"),
70
+ JSON.stringify({ id: "job-1", state: "pending", current: null, currentStage: null, tasks: {} }),
71
+ );
72
+
73
+ const modulePath = path.join(root, "task-module.mjs");
74
+ await writeFile(
75
+ modulePath,
76
+ "export const ingestion = async ({ flags }) => ({ output: { ok: true }, flags });",
77
+ );
78
+
79
+ const result = await runPipeline(modulePath, {
80
+ workDir,
81
+ taskName: "research",
82
+ statusPath: path.join(workDir, "tasks-status.json"),
83
+ jobId: "job-1",
84
+ envLoaded: true,
85
+ seed: { data: { topic: "x" } },
86
+ pipelineTasks: ["research"],
87
+ llm: {} as never,
88
+ });
89
+
90
+ expect(result.ok).toBe(true);
91
+
92
+ const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
93
+
94
+ // Job-level fields must remain untouched by task-runner
95
+ expect(status.state).toBe("pending");
96
+ expect(status.current).toBeNull();
97
+ expect(status.currentStage).toBeNull();
98
+ expect(status.progress).toBeUndefined();
99
+ });
100
+
101
+ it("does not set snapshot.state on task failure", async () => {
102
+ const root = await makeTempRoot();
103
+ const workDir = path.join(root, "job-1");
104
+ await mkdir(workDir, { recursive: true });
105
+ await writeFile(path.join(workDir, "seed.json"), JSON.stringify({ topic: "x" }));
106
+ await writeFile(
107
+ path.join(workDir, "tasks-status.json"),
108
+ JSON.stringify({ id: "job-1", state: "pending", current: null, currentStage: null, tasks: {} }),
109
+ );
110
+
111
+ const modulePath = path.join(root, "task-module.mjs");
112
+ await writeFile(
113
+ modulePath,
114
+ 'export const ingestion = async () => { throw new Error("boom"); };',
115
+ );
116
+
117
+ const result = await runPipeline(modulePath, {
118
+ workDir,
119
+ taskName: "research",
120
+ statusPath: path.join(workDir, "tasks-status.json"),
121
+ jobId: "job-1",
122
+ envLoaded: true,
123
+ seed: { data: { topic: "x" } },
124
+ pipelineTasks: ["research"],
125
+ llm: {} as never,
126
+ });
127
+
128
+ expect(result.ok).toBe(false);
129
+
130
+ const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
131
+
132
+ // Job-level state must remain "pending" -- task-runner does not own it
133
+ expect(status.state).toBe("pending");
134
+ expect(status.current).toBeNull();
135
+ expect(status.currentStage).toBeNull();
136
+ });
137
+ });
138
+
139
+ describe("task-runner writes correct task-level state transitions", () => {
140
+ it("transitions task through running -> done on success", async () => {
141
+ const root = await makeTempRoot();
142
+ const workDir = path.join(root, "job-1");
143
+ await mkdir(workDir, { recursive: true });
144
+ await writeFile(path.join(workDir, "seed.json"), JSON.stringify({ topic: "x" }));
145
+ await writeFile(
146
+ path.join(workDir, "tasks-status.json"),
147
+ JSON.stringify({ id: "job-1", state: "pending", current: null, currentStage: null, tasks: {} }),
148
+ );
149
+
150
+ const modulePath = path.join(root, "task-module.mjs");
151
+ await writeFile(
152
+ modulePath,
153
+ "export const ingestion = async ({ flags }) => ({ output: { ok: true }, flags });",
154
+ );
155
+
156
+ const result = await runPipeline(modulePath, {
157
+ workDir,
158
+ taskName: "research",
159
+ statusPath: path.join(workDir, "tasks-status.json"),
160
+ jobId: "job-1",
161
+ envLoaded: true,
162
+ seed: { data: { topic: "x" } },
163
+ pipelineTasks: ["research"],
164
+ llm: {} as never,
165
+ });
166
+
167
+ expect(result.ok).toBe(true);
168
+
169
+ const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
170
+ const task = status.tasks["research"];
171
+
172
+ expect(task).toBeDefined();
173
+ expect(task!.state).toBe("done");
174
+ expect(task!.currentStage).toBeNull();
175
+ });
176
+
177
+ it("transitions task to failed with error details on stage failure", async () => {
178
+ const root = await makeTempRoot();
179
+ const workDir = path.join(root, "job-1");
180
+ await mkdir(workDir, { recursive: true });
181
+ await writeFile(path.join(workDir, "seed.json"), JSON.stringify({ topic: "x" }));
182
+ await writeFile(
183
+ path.join(workDir, "tasks-status.json"),
184
+ JSON.stringify({ id: "job-1", state: "pending", current: null, currentStage: null, tasks: {} }),
185
+ );
186
+
187
+ const modulePath = path.join(root, "task-module.mjs");
188
+ await writeFile(
189
+ modulePath,
190
+ 'export const ingestion = async () => { throw new Error("stage exploded"); };',
191
+ );
192
+
193
+ const result = await runPipeline(modulePath, {
194
+ workDir,
195
+ taskName: "research",
196
+ statusPath: path.join(workDir, "tasks-status.json"),
197
+ jobId: "job-1",
198
+ envLoaded: true,
199
+ seed: { data: { topic: "x" } },
200
+ pipelineTasks: ["research"],
201
+ llm: {} as never,
202
+ });
203
+
204
+ expect(result.ok).toBe(false);
205
+
206
+ const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
207
+ const task = status.tasks["research"];
208
+
209
+ expect(task).toBeDefined();
210
+ expect(task!.state).toBe("failed");
211
+ expect(task!.failedStage).toBe("ingestion");
212
+ expect(task!.error).toBe("stage exploded");
213
+ });
214
+ });
@@ -102,6 +102,8 @@ import { createTaskFileIO, generateLogName } from "./file-io";
102
102
  import { LogEvent, LogFileExtension } from "../config/log-events";
103
103
  import { getConfig, getPipelineConfig } from "./config";
104
104
  import { buildReexecArgs } from "../cli/self-reexec";
105
+ import { writeJobStatus } from "./status-writer";
106
+ import { initializeStatusFromArtifacts } from "./status-initializer";
105
107
 
106
108
  /**
107
109
  * Normalize any path that may already include `pipeline-data` (or subdirs
@@ -291,11 +293,10 @@ export async function handleSeedAdd(
291
293
  JSON.stringify(status, null, 2)
292
294
  );
293
295
 
296
+ const normalizedPipelineTasks = pipelineTasks.map((name) => ({ id: name }));
294
297
  try {
295
- const mod = await import("./status-initializer");
296
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
297
- const fn = (mod as unknown as { initializeStatusFromArtifacts: (jobDir: string, status: JobStatusInit) => Promise<void> }).initializeStatusFromArtifacts;
298
- await fn(jobDir, status);
298
+ const applyArtifacts = await initializeStatusFromArtifacts({ jobDir, pipeline: { tasks: normalizedPipelineTasks } });
299
+ await writeJobStatus(jobDir, applyArtifacts);
299
300
  } catch {
300
301
  logger.warn(`status-initializer unavailable or failed for job ${jobId}; proceeding with base status`);
301
302
  }
@@ -346,7 +346,9 @@ export async function runPipelineJob(jobId: string): Promise<void> {
346
346
 
347
347
  // Update status to RUNNING
348
348
  await writeJobStatus(config.workDir, (snapshot) => {
349
+ snapshot.state = "running";
349
350
  snapshot.current = taskName;
351
+ snapshot.currentStage = null;
350
352
  const taskEntry = snapshot.tasks[taskName] ?? {};
351
353
  taskEntry.state = "running";
352
354
  taskEntry.startedAt = new Date().toISOString();
@@ -452,6 +454,8 @@ export async function runPipelineJob(jobId: string): Promise<void> {
452
454
 
453
455
  // Update status to FAILED
454
456
  await writeJobStatus(config.workDir, (snapshot) => {
457
+ snapshot.state = "failed";
458
+ snapshot.current = taskName;
455
459
  const raw = (snapshot.tasks[taskName] ?? {}) as Record<string, unknown>;
456
460
  raw["state"] = TaskState.FAILED;
457
461
  raw["endedAt"] = new Date().toISOString();
@@ -471,6 +475,11 @@ export async function runPipelineJob(jobId: string): Promise<void> {
471
475
 
472
476
  // On full pipeline completion (not single-task mode), finalize the job
473
477
  if (!runSingleTask) {
478
+ await writeJobStatus(config.workDir, (snapshot) => {
479
+ snapshot.state = "done";
480
+ snapshot.current = null;
481
+ snapshot.currentStage = null;
482
+ });
474
483
  const finalStatusText = await Bun.file(config.statusPath).text();
475
484
  const finalStatus = JSON.parse(finalStatusText) as JobStatus;
476
485
  await completeJob(config, finalStatus, pipelineArtifacts);
@@ -4,7 +4,7 @@
4
4
  import { mkdir } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { KNOWN_STAGES, computeDeterministicProgress } from "./progress";
7
+ import { KNOWN_STAGES } from "./progress";
8
8
  import type { StageName } from "./progress";
9
9
  import { createTaskFileIO, generateLogName, trackFile } from "./file-io";
10
10
  import type { TaskFileIO } from "./file-io";
@@ -629,9 +629,6 @@ export async function runPipeline(
629
629
  // Write stage-start status (swallow errors)
630
630
  try {
631
631
  await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
632
- snapshot.state = TaskState.RUNNING;
633
- snapshot.current = taskName;
634
- snapshot.currentStage = stageName;
635
632
  if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
636
633
  snapshot.tasks[taskName]!.state = TaskState.RUNNING;
637
634
  snapshot.tasks[taskName]!.currentStage = stageName;
@@ -705,15 +702,9 @@ export async function runPipeline(
705
702
  context.logs.push(auditEntry);
706
703
  returnedLogs.push(auditEntry);
707
704
 
708
- // Write stage-completion status with progress (swallow errors)
705
+ // Write stage-completion status (swallow errors)
709
706
  try {
710
- const progress = computeDeterministicProgress(
711
- pipelineTasks ?? [taskName],
712
- taskName,
713
- stageName,
714
- );
715
707
  await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
716
- snapshot.progress = progress;
717
708
  if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
718
709
  snapshot.tasks[taskName]!.currentStage = stageName;
719
710
  });
@@ -769,7 +760,6 @@ export async function runPipeline(
769
760
  // Write failure status (swallow errors)
770
761
  try {
771
762
  await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
772
- snapshot.state = TaskState.FAILED;
773
763
  if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
774
764
  snapshot.tasks[taskName]!.state = TaskState.FAILED;
775
765
  snapshot.tasks[taskName]!.failedStage = stageName;
@@ -806,10 +796,6 @@ export async function runPipeline(
806
796
  // Write done status (best-effort)
807
797
  try {
808
798
  await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
809
- snapshot.state = TaskState.DONE;
810
- snapshot.progress = 100;
811
- snapshot.current = null;
812
- snapshot.currentStage = null;
813
799
  if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
814
800
  snapshot.tasks[taskName]!.state = TaskState.DONE;
815
801
  snapshot.tasks[taskName]!.currentStage = null;
@@ -13,7 +13,8 @@ const RETRYABLE_ERROR_CODES = new Set([
13
13
  "ETIMEDOUT",
14
14
  "ECONNREFUSED",
15
15
  ]);
16
- const RETRYABLE_MESSAGE_PATTERN = /network|timeout|connection|socket|protocol|read ECONNRESET|fetch failed/i;
16
+ const RETRYABLE_MESSAGE_PATTERN =
17
+ /network|timeout|connection|socket|protocol|read ECONNRESET|fetch failed/i;
17
18
 
18
19
  /**
19
20
  * Splits a messages array into system, user, and assistant parts.
@@ -19,7 +19,8 @@ export default function UploadSeed({
19
19
  }) {
20
20
  const inputRef = useRef<HTMLInputElement | null>(null);
21
21
  const [error, setError] = useState<string | null>(null);
22
- const [isUploading, setIsUploading] = useState(false);
22
+ const [pendingUploads, setPendingUploads] = useState(0);
23
+ const isUploading = pendingUploads > 0;
23
24
  const [isDragging, setIsDragging] = useState(false);
24
25
 
25
26
  const hint = useMemo(() => (isDragging ? "Drop seed file" : "Upload JSON or ZIP seed"), [isDragging]);
@@ -33,7 +34,7 @@ export default function UploadSeed({
33
34
  const formData = new FormData();
34
35
  formData.append("file", file);
35
36
 
36
- setIsUploading(true);
37
+ setPendingUploads((n) => n + 1);
37
38
  setError(null);
38
39
  try {
39
40
  const response = await fetch("/api/upload/seed", {
@@ -49,7 +50,7 @@ export default function UploadSeed({
49
50
  } catch (uploadError) {
50
51
  setError(normalizeUploadError(uploadError));
51
52
  } finally {
52
- setIsUploading(false);
53
+ setPendingUploads((n) => n - 1);
53
54
  }
54
55
  };
55
56
 
@@ -68,8 +69,8 @@ export default function UploadSeed({
68
69
  onDrop={(event) => {
69
70
  event.preventDefault();
70
71
  setIsDragging(false);
71
- const file = event.dataTransfer.files[0];
72
- if (file) void uploadFile(file);
72
+ const files = Array.from(event.dataTransfer.files);
73
+ for (const file of files) void uploadFile(file);
73
74
  }}
74
75
  >
75
76
  <p>{hint}</p>
@@ -80,10 +81,11 @@ export default function UploadSeed({
80
81
  ref={inputRef}
81
82
  type="file"
82
83
  hidden
84
+ multiple
83
85
  accept=".json,.zip,application/json,application/zip"
84
86
  onChange={(event) => {
85
- const file = event.target.files?.[0];
86
- if (file) void uploadFile(file);
87
+ const files = Array.from(event.target.files ?? []);
88
+ for (const file of files) void uploadFile(file);
87
89
  }}
88
90
  />
89
91
  </div>
@@ -45,6 +45,74 @@ test("UploadSeed posts dropped files and reports success", async () => {
45
45
  expect(onUploadSuccess).toHaveBeenCalledWith({ jobName: "test-job" });
46
46
  });
47
47
 
48
+ test("UploadSeed uploads all dropped files independently", async () => {
49
+ let callCount = 0;
50
+ fetchMock.mockImplementation((_input, _init) => {
51
+ callCount++;
52
+ const jobName = `job-${callCount}`;
53
+ return Promise.resolve(
54
+ new Response(
55
+ JSON.stringify({ ok: true, data: { jobName } }),
56
+ { status: 200, headers: { "Content-Type": "application/json" } },
57
+ ),
58
+ );
59
+ });
60
+
61
+ const onUploadSuccess = mock((_result: { jobName: string }) => {});
62
+ const view = render(<UploadSeed onUploadSuccess={onUploadSuccess} />);
63
+ const fileA = new File(['{"a":1}'], "a.json", { type: "application/json" });
64
+ const fileB = new File(['{"b":2}'], "b.json", { type: "application/json" });
65
+ const fileC = new File([new ArrayBuffer(8)], "c.zip", { type: "application/zip" });
66
+ const dropZone = view.getByText("Upload JSON or ZIP seed").parentElement as Element;
67
+
68
+ await act(async () => {
69
+ fireEvent.drop(dropZone, {
70
+ dataTransfer: { files: [fileA, fileB, fileC] },
71
+ });
72
+ });
73
+
74
+ expect(fetchMock).toHaveBeenCalledTimes(3);
75
+ expect(onUploadSuccess).toHaveBeenCalledTimes(3);
76
+ });
77
+
78
+ test("UploadSeed one failed file does not block other uploads", async () => {
79
+ let callCount = 0;
80
+ fetchMock.mockImplementation((_input, _init) => {
81
+ callCount++;
82
+ if (callCount === 2) {
83
+ return Promise.resolve(
84
+ new Response(
85
+ JSON.stringify({ ok: false, message: "bad file" }),
86
+ { status: 400, headers: { "Content-Type": "application/json" } },
87
+ ),
88
+ );
89
+ }
90
+ return Promise.resolve(
91
+ new Response(
92
+ JSON.stringify({ ok: true, data: { jobName: `job-${callCount}` } }),
93
+ { status: 200, headers: { "Content-Type": "application/json" } },
94
+ ),
95
+ );
96
+ });
97
+
98
+ const onUploadSuccess = mock((_result: { jobName: string }) => {});
99
+ const view = render(<UploadSeed onUploadSuccess={onUploadSuccess} />);
100
+ const fileA = new File(['{"a":1}'], "a.json", { type: "application/json" });
101
+ const fileB = new File(['{"b":2}'], "b.json", { type: "application/json" });
102
+ const fileC = new File(['{"c":3}'], "c.json", { type: "application/json" });
103
+ const dropZone = view.getByText("Upload JSON or ZIP seed").parentElement as Element;
104
+
105
+ await act(async () => {
106
+ fireEvent.drop(dropZone, {
107
+ dataTransfer: { files: [fileA, fileB, fileC] },
108
+ });
109
+ });
110
+
111
+ expect(fetchMock).toHaveBeenCalledTimes(3);
112
+ expect(onUploadSuccess).toHaveBeenCalledTimes(2);
113
+ expect(view.getByText("bad file")).toBeTruthy();
114
+ });
115
+
48
116
  test("UploadSeed shows inline errors", async () => {
49
117
  fetchMock.mockImplementationOnce((_input, _init) =>
50
118
  Promise.resolve(
@@ -20606,7 +20606,8 @@ function UploadSeed({
20606
20606
  }) {
20607
20607
  const inputRef = reactExports.useRef(null);
20608
20608
  const [error, setError] = reactExports.useState(null);
20609
- const [isUploading, setIsUploading] = reactExports.useState(false);
20609
+ const [pendingUploads, setPendingUploads] = reactExports.useState(0);
20610
+ const isUploading = pendingUploads > 0;
20610
20611
  const [isDragging, setIsDragging] = reactExports.useState(false);
20611
20612
  const hint = reactExports.useMemo(() => isDragging ? "Drop seed file" : "Upload JSON or ZIP seed", [isDragging]);
20612
20613
  const uploadFile = async (file) => {
@@ -20616,7 +20617,7 @@ function UploadSeed({
20616
20617
  }
20617
20618
  const formData = new FormData();
20618
20619
  formData.append("file", file);
20619
- setIsUploading(true);
20620
+ setPendingUploads((n) => n + 1);
20620
20621
  setError(null);
20621
20622
  try {
20622
20623
  const response = await fetch("/api/upload/seed", {
@@ -20631,7 +20632,7 @@ function UploadSeed({
20631
20632
  } catch (uploadError) {
20632
20633
  setError(normalizeUploadError(uploadError));
20633
20634
  } finally {
20634
- setIsUploading(false);
20635
+ setPendingUploads((n) => n - 1);
20635
20636
  }
20636
20637
  };
20637
20638
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "space-y-3", children: [
@@ -20650,8 +20651,8 @@ function UploadSeed({
20650
20651
  onDrop: (event) => {
20651
20652
  event.preventDefault();
20652
20653
  setIsDragging(false);
20653
- const file = event.dataTransfer.files[0];
20654
- if (file) void uploadFile(file);
20654
+ const files = Array.from(event.dataTransfer.files);
20655
+ for (const file of files) void uploadFile(file);
20655
20656
  },
20656
20657
  children: [
20657
20658
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { children: hint }),
@@ -20662,10 +20663,11 @@ function UploadSeed({
20662
20663
  ref: inputRef,
20663
20664
  type: "file",
20664
20665
  hidden: true,
20666
+ multiple: true,
20665
20667
  accept: ".json,.zip,application/json,application/zip",
20666
20668
  onChange: (event) => {
20667
- const file = event.target.files?.[0];
20668
- if (file) void uploadFile(file);
20669
+ const files = Array.from(event.target.files ?? []);
20670
+ for (const file of files) void uploadFile(file);
20669
20671
  }
20670
20672
  }
20671
20673
  )
@@ -22243,9 +22245,7 @@ function computeActiveIndex(items) {
22243
22245
  if (running >= 0) return running;
22244
22246
  const failed = items.findIndex((item) => item.status === "failed");
22245
22247
  if (failed >= 0) return failed;
22246
- const pending = items.findIndex((item) => item.status === "pending");
22247
- if (pending >= 0) return pending;
22248
- return Math.max(0, items.length - 1);
22248
+ return -1;
22249
22249
  }
22250
22250
  function formatCurrency4(value) {
22251
22251
  if (!Number.isFinite(value) || value === 0) return "$0";