@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 +1 -1
- package/src/core/__tests__/task-runner.test.ts +155 -0
- package/src/core/orchestrator.ts +5 -4
- package/src/core/pipeline-runner.ts +9 -0
- package/src/core/task-runner.ts +2 -16
- package/src/providers/base.ts +2 -1
- package/src/ui/components/UploadSeed.tsx +9 -7
- package/src/ui/components/__tests__/UploadSeed.test.tsx +68 -0
- package/src/ui/dist/assets/{index-CItKJVeE.js → index-Dgzc8e-G.js} +10 -10
- package/src/ui/dist/assets/{index-CItKJVeE.js.map → index-Dgzc8e-G.js.map} +1 -1
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +514 -0
- package/src/ui/server/endpoints/job-control-endpoints.ts +134 -28
- package/src/utils/dag.ts +1 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
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
|
+
});
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -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
|
|
296
|
-
|
|
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);
|
package/src/core/task-runner.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/src/providers/base.ts
CHANGED
|
@@ -13,7 +13,8 @@ const RETRYABLE_ERROR_CODES = new Set([
|
|
|
13
13
|
"ETIMEDOUT",
|
|
14
14
|
"ECONNREFUSED",
|
|
15
15
|
]);
|
|
16
|
-
const RETRYABLE_MESSAGE_PATTERN =
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
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
|
|
86
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
20654
|
-
|
|
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
|
|
20668
|
-
|
|
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
|
-
|
|
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";
|