@ryanfw/prompt-orchestration-pipeline 1.2.7 → 1.2.9

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/config/__tests__/models.test.ts +31 -1
  3. package/src/config/models.ts +81 -35
  4. package/src/config/paths.ts +13 -8
  5. package/src/core/__tests__/config.test.ts +121 -0
  6. package/src/core/__tests__/job-concurrency.test.ts +554 -0
  7. package/src/core/__tests__/orchestrator.test.ts +353 -0
  8. package/src/core/__tests__/pipeline-runner.test.ts +430 -2
  9. package/src/core/__tests__/task-runner.test.ts +1 -2
  10. package/src/core/config.ts +48 -1
  11. package/src/core/job-concurrency.ts +462 -0
  12. package/src/core/orchestrator.ts +370 -57
  13. package/src/core/pipeline-runner.ts +79 -15
  14. package/src/core/status-writer.ts +4 -0
  15. package/src/core/task-runner.ts +1 -1
  16. package/src/providers/__tests__/base.test.ts +1 -1
  17. package/src/ui/client/__tests__/api.test.ts +101 -1
  18. package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
  19. package/src/ui/client/__tests__/useConcurrencyStatus.test.ts +126 -0
  20. package/src/ui/client/adapters/job-adapter.ts +1 -0
  21. package/src/ui/client/api.ts +77 -7
  22. package/src/ui/client/hooks/useConcurrencyStatus.ts +102 -0
  23. package/src/ui/client/types.ts +34 -1
  24. package/src/ui/components/DAGGrid.tsx +11 -1
  25. package/src/ui/components/JobDetail.tsx +2 -1
  26. package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
  27. package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
  28. package/src/ui/components/types.ts +2 -0
  29. package/src/ui/dist/assets/{index-SKy2shWc.js → index-BnAqY4_n.js} +336 -52
  30. package/src/ui/dist/assets/index-BnAqY4_n.js.map +1 -0
  31. package/src/ui/dist/assets/style-BKG0bHu-.css +2 -0
  32. package/src/ui/dist/index.html +2 -2
  33. package/src/ui/embedded-assets.js +6 -6
  34. package/src/ui/pages/PromptPipelineDashboard.tsx +186 -4
  35. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +272 -1
  36. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +190 -0
  37. package/src/ui/server/__tests__/index.test.ts +92 -3
  38. package/src/ui/server/__tests__/job-control-endpoints.test.ts +660 -3
  39. package/src/ui/server/endpoints/concurrency-endpoint.ts +72 -0
  40. package/src/ui/server/endpoints/job-control-endpoints.ts +248 -37
  41. package/src/ui/server/index.ts +21 -2
  42. package/src/ui/server/router.ts +2 -0
  43. package/src/ui/state/__tests__/watcher.test.ts +31 -0
  44. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
  45. package/src/ui/state/transformers/status-transformer.ts +1 -0
  46. package/src/ui/state/types.ts +3 -0
  47. package/src/ui/state/watcher.ts +9 -1
  48. package/src/utils/__tests__/dag.test.ts +35 -0
  49. package/src/utils/dag.ts +1 -0
  50. package/src/ui/dist/assets/index-SKy2shWc.js.map +0 -1
  51. package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
@@ -0,0 +1,462 @@
1
+ import { mkdir, readdir, readFile, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+
5
+ const SEED_FILENAME_PATTERN = /^([A-Za-z0-9][A-Za-z0-9-_]*)-seed\.json$/;
6
+ const TEMP_LEASE_PATTERN = /\.json\.tmp\.[a-f0-9]+$/;
7
+ const LOCK_POLL_INTERVAL_MS = 25;
8
+ const DEFAULT_STALE_LEASE_TIMEOUT_MS = 30_000;
9
+
10
+ export interface JobSlotLease {
11
+ jobId: string;
12
+ pid: number | null;
13
+ acquiredAt: string;
14
+ source: "orchestrator" | "restart" | "task-start";
15
+ slotPath: string;
16
+ }
17
+
18
+ export interface QueuedJobSummary {
19
+ jobId: string;
20
+ seedPath: string;
21
+ queuedAt: string | null;
22
+ name: string | null;
23
+ pipeline: string | null;
24
+ }
25
+
26
+ export interface StaleJobSlot {
27
+ jobId: string;
28
+ slotPath: string;
29
+ reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json";
30
+ }
31
+
32
+ export interface JobConcurrencyStatus {
33
+ limit: number;
34
+ runningCount: number;
35
+ availableSlots: number;
36
+ queuedCount: number;
37
+ activeJobs: JobSlotLease[];
38
+ queuedJobs: QueuedJobSummary[];
39
+ staleSlots: StaleJobSlot[];
40
+ }
41
+
42
+ export type AcquireJobSlotResult =
43
+ | { ok: true; lease: JobSlotLease }
44
+ | { ok: false; reason: "limit_reached" | "already_held"; status: JobConcurrencyStatus };
45
+
46
+ export interface AcquireJobSlotOptions {
47
+ dataDir: string;
48
+ jobId: string;
49
+ maxConcurrentJobs: number;
50
+ source: JobSlotLease["source"];
51
+ pid?: number | null;
52
+ lockTimeoutMs?: number;
53
+ }
54
+
55
+ export interface ConcurrencyRuntimePaths {
56
+ runtimeDir: string;
57
+ lockDir: string;
58
+ runningJobsDir: string;
59
+ }
60
+
61
+ export function getConcurrencyRuntimePaths(dataDir: string): ConcurrencyRuntimePaths {
62
+ const runtimeDir = join(dataDir, "runtime");
63
+ return {
64
+ runtimeDir,
65
+ lockDir: join(runtimeDir, "lock"),
66
+ runningJobsDir: join(runtimeDir, "running-jobs"),
67
+ };
68
+ }
69
+
70
+ interface SeedFileEntry {
71
+ jobId: string;
72
+ seedPath: string;
73
+ mtime: Date;
74
+ }
75
+
76
+ async function readPendingSeedEntries(pendingDir: string): Promise<SeedFileEntry[]> {
77
+ let names: string[];
78
+ try {
79
+ names = await readdir(pendingDir);
80
+ } catch (err) {
81
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
82
+ throw err;
83
+ }
84
+ const entries: SeedFileEntry[] = [];
85
+ for (const name of names) {
86
+ const match = SEED_FILENAME_PATTERN.exec(name);
87
+ if (!match) continue;
88
+ const seedPath = join(pendingDir, name);
89
+ let stats: Awaited<ReturnType<typeof stat>>;
90
+ try {
91
+ stats = await stat(seedPath);
92
+ } catch (err) {
93
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
94
+ throw err;
95
+ }
96
+ entries.push({ jobId: match[1]!, seedPath, mtime: stats.mtime });
97
+ }
98
+ return entries;
99
+ }
100
+
101
+ async function readSeedMetadata(seedPath: string): Promise<{ name: string | null; pipeline: string | null } | null> {
102
+ try {
103
+ const raw = await readFile(seedPath, "utf-8");
104
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
105
+ const name = typeof parsed["name"] === "string" ? (parsed["name"] as string) : null;
106
+ const pipeline = typeof parsed["pipeline"] === "string" ? (parsed["pipeline"] as string) : null;
107
+ return { name, pipeline };
108
+ } catch (err) {
109
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
110
+ return { name: null, pipeline: null };
111
+ }
112
+ }
113
+
114
+ function isProcessAlive(pid: number): boolean {
115
+ if (!Number.isInteger(pid) || pid <= 0) return false;
116
+ try {
117
+ process.kill(pid, 0);
118
+ return true;
119
+ } catch (err) {
120
+ // EPERM means the process exists but is owned by another user — still alive.
121
+ return (err as NodeJS.ErrnoException).code === "EPERM";
122
+ }
123
+ }
124
+
125
+ async function directoryExists(path: string): Promise<boolean> {
126
+ try {
127
+ const stats = await stat(path);
128
+ return stats.isDirectory();
129
+ } catch (err) {
130
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ async function removeLeaseFile(slotPath: string): Promise<void> {
136
+ try {
137
+ await unlink(slotPath);
138
+ } catch (err) {
139
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
140
+ }
141
+ }
142
+
143
+ async function classifyLease(
144
+ dataDir: string,
145
+ slotPath: string,
146
+ fileName: string,
147
+ lockTimeoutMs: number,
148
+ ): Promise<StaleJobSlot | null> {
149
+ const fallbackJobId = fileName.replace(/\.json$/, "");
150
+ let raw: string;
151
+ try {
152
+ raw = await readFile(slotPath, "utf-8");
153
+ } catch (err) {
154
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
155
+ throw err;
156
+ }
157
+ let parsed: JobSlotLease;
158
+ try {
159
+ parsed = JSON.parse(raw) as JobSlotLease;
160
+ } catch {
161
+ return { jobId: fallbackJobId, slotPath, reason: "invalid_json" };
162
+ }
163
+ const jobId = typeof parsed.jobId === "string" ? parsed.jobId : fallbackJobId;
164
+ const acquiredMs = Date.parse(parsed.acquiredAt);
165
+ const leaseAgedOut = !Number.isFinite(acquiredMs) || Date.now() - acquiredMs >= lockTimeoutMs;
166
+ if (!(await directoryExists(join(dataDir, "current", jobId)))) {
167
+ // Grace window: drainPendingQueue acquires the slot before creating
168
+ // current/<jobId>, so a fresh lease without a current dir is in-flight, not stale.
169
+ if (!leaseAgedOut) return null;
170
+ return { jobId, slotPath, reason: "missing_current_job" };
171
+ }
172
+ if (parsed.pid === null || parsed.pid === undefined) {
173
+ if (leaseAgedOut) {
174
+ return { jobId, slotPath, reason: "missing_pid" };
175
+ }
176
+ return null;
177
+ }
178
+ if (!isProcessAlive(parsed.pid)) {
179
+ return { jobId, slotPath, reason: "dead_pid" };
180
+ }
181
+ return null;
182
+ }
183
+
184
+ async function listRunningJobFileNames(runningJobsDir: string): Promise<string[]> {
185
+ try {
186
+ const names = await readdir(runningJobsDir);
187
+ return names.sort();
188
+ } catch (err) {
189
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
190
+ throw err;
191
+ }
192
+ }
193
+
194
+ async function removeStaleTempLeaseFiles(runningJobsDir: string, names: string[], lockTimeoutMs: number): Promise<void> {
195
+ await Promise.all(
196
+ names
197
+ .filter((name) => TEMP_LEASE_PATTERN.test(name))
198
+ .map(async (name) => {
199
+ const tempPath = join(runningJobsDir, name);
200
+ try {
201
+ const stats = await stat(tempPath);
202
+ if (Date.now() - stats.mtimeMs >= lockTimeoutMs) await removeLeaseFile(tempPath);
203
+ } catch (err) {
204
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
205
+ }
206
+ }),
207
+ );
208
+ }
209
+
210
+ async function collectLeaseState(
211
+ dataDir: string,
212
+ lockTimeoutMs: number,
213
+ prune: boolean,
214
+ ): Promise<{ activeJobs: JobSlotLease[]; staleSlots: StaleJobSlot[] }> {
215
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
216
+ const names = await listRunningJobFileNames(runningJobsDir);
217
+ if (prune) await removeStaleTempLeaseFiles(runningJobsDir, names, lockTimeoutMs);
218
+
219
+ const staleSlots: StaleJobSlot[] = [];
220
+ const stalePaths = new Set<string>();
221
+ await Promise.all(names.map(async (name) => {
222
+ if (!name.endsWith(".json") || TEMP_LEASE_PATTERN.test(name)) return;
223
+ const slotPath = join(runningJobsDir, name);
224
+ const result = await classifyLease(dataDir, slotPath, name, lockTimeoutMs);
225
+ if (result) {
226
+ staleSlots.push(result);
227
+ stalePaths.add(slotPath);
228
+ }
229
+ }));
230
+ staleSlots.sort((a, b) => a.jobId.localeCompare(b.jobId));
231
+
232
+ if (prune) {
233
+ await Promise.all(staleSlots.map((slot) => removeLeaseFile(slot.slotPath)));
234
+ }
235
+
236
+ const activeJobs = await readActiveLeases(runningJobsDir, stalePaths);
237
+ return { activeJobs, staleSlots };
238
+ }
239
+
240
+ export async function pruneStaleJobSlots(
241
+ dataDir: string,
242
+ lockTimeoutMs: number,
243
+ ): Promise<StaleJobSlot[]> {
244
+ return withRuntimeLock(dataDir, lockTimeoutMs, async () => {
245
+ const { staleSlots } = await collectLeaseState(dataDir, lockTimeoutMs, true);
246
+ return staleSlots;
247
+ });
248
+ }
249
+
250
+ function delay(ms: number): Promise<void> {
251
+ return new Promise((resolve) => setTimeout(resolve, ms));
252
+ }
253
+
254
+ // mkdir with recursive:false is atomic — exactly one caller wins the create when racing.
255
+ async function withRuntimeLock<T>(
256
+ dataDir: string,
257
+ lockTimeoutMs: number,
258
+ fn: () => Promise<T>,
259
+ ): Promise<T> {
260
+ const { lockDir, runtimeDir } = getConcurrencyRuntimePaths(dataDir);
261
+ const ownerPath = join(lockDir, "owner.json");
262
+ await mkdir(runtimeDir, { recursive: true });
263
+ const start = Date.now();
264
+ while (true) {
265
+ try {
266
+ await mkdir(lockDir, { recursive: false });
267
+ try {
268
+ await writeFile(ownerPath, JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }));
269
+ } catch (ownerErr) {
270
+ await rm(lockDir, { recursive: true, force: true });
271
+ throw ownerErr;
272
+ }
273
+ break;
274
+ } catch (err) {
275
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
276
+ if (Date.now() - start < lockTimeoutMs) {
277
+ await delay(LOCK_POLL_INTERVAL_MS);
278
+ continue;
279
+ }
280
+ try {
281
+ const raw = await readFile(ownerPath, "utf-8");
282
+ const owner = JSON.parse(raw) as { acquiredAt?: unknown; pid?: unknown };
283
+ const ownerAcquiredMs = typeof owner.acquiredAt === "string" ? Date.parse(owner.acquiredAt) : NaN;
284
+ const ownerTimedOut = Number.isFinite(ownerAcquiredMs) && Date.now() - ownerAcquiredMs >= lockTimeoutMs;
285
+ if (typeof owner.pid === "number" && !isProcessAlive(owner.pid)) {
286
+ await rm(lockDir, { recursive: true, force: true });
287
+ continue;
288
+ }
289
+ if (typeof owner.pid !== "number" && ownerTimedOut) {
290
+ await rm(lockDir, { recursive: true, force: true });
291
+ continue;
292
+ }
293
+ } catch (ownerErr) {
294
+ if ((ownerErr as NodeJS.ErrnoException).code === "ENOENT") {
295
+ const stats = await stat(lockDir);
296
+ if (Date.now() - stats.mtimeMs >= lockTimeoutMs) {
297
+ await rm(lockDir, { recursive: true, force: true });
298
+ continue;
299
+ }
300
+ }
301
+ }
302
+ throw new Error(`failed to acquire runtime lock at ${lockDir} within ${lockTimeoutMs}ms`);
303
+ }
304
+ }
305
+ try {
306
+ return await fn();
307
+ } finally {
308
+ try {
309
+ await unlink(ownerPath);
310
+ } catch (err) {
311
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
312
+ }
313
+ try {
314
+ await rmdir(lockDir);
315
+ } catch (err) {
316
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
317
+ }
318
+ }
319
+ }
320
+
321
+ async function buildStatus(
322
+ dataDir: string,
323
+ maxConcurrentJobs: number,
324
+ lockTimeoutMs: number,
325
+ prune: boolean,
326
+ ): Promise<JobConcurrencyStatus> {
327
+ const { activeJobs, staleSlots } = await collectLeaseState(dataDir, lockTimeoutMs, prune);
328
+ const queuedJobs = await listQueuedSeeds(dataDir);
329
+ return {
330
+ limit: maxConcurrentJobs,
331
+ runningCount: activeJobs.length,
332
+ availableSlots: Math.max(0, maxConcurrentJobs - activeJobs.length),
333
+ queuedCount: queuedJobs.length,
334
+ activeJobs,
335
+ queuedJobs,
336
+ staleSlots,
337
+ };
338
+ }
339
+
340
+ async function readLease(slotPath: string): Promise<JobSlotLease | null> {
341
+ try {
342
+ const raw = await readFile(slotPath, "utf-8");
343
+ return JSON.parse(raw) as JobSlotLease;
344
+ } catch (err) {
345
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
346
+ throw err;
347
+ }
348
+ }
349
+
350
+ async function writeLeaseAtomic(slotPath: string, lease: JobSlotLease): Promise<void> {
351
+ const tmpPath = `${slotPath}.tmp.${randomBytes(8).toString("hex")}`;
352
+ await writeFile(tmpPath, JSON.stringify(lease));
353
+ await rename(tmpPath, slotPath);
354
+ }
355
+
356
+ async function readActiveLeases(runningJobsDir: string, excludePaths = new Set<string>()): Promise<JobSlotLease[]> {
357
+ let names: string[];
358
+ try {
359
+ names = await readdir(runningJobsDir);
360
+ } catch (err) {
361
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
362
+ throw err;
363
+ }
364
+ const leases: JobSlotLease[] = [];
365
+ for (const name of names) {
366
+ const slotPath = join(runningJobsDir, name);
367
+ if (!name.endsWith(".json") || TEMP_LEASE_PATTERN.test(name) || excludePaths.has(slotPath)) continue;
368
+ const lease = await readLease(slotPath);
369
+ if (lease) leases.push(lease);
370
+ }
371
+ leases.sort(
372
+ (a, b) =>
373
+ Date.parse(a.acquiredAt) - Date.parse(b.acquiredAt) || a.jobId.localeCompare(b.jobId),
374
+ );
375
+ return leases;
376
+ }
377
+
378
+ export async function tryAcquireJobSlot(
379
+ options: AcquireJobSlotOptions,
380
+ ): Promise<AcquireJobSlotResult> {
381
+ const { dataDir, jobId, maxConcurrentJobs, source } = options;
382
+ const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_STALE_LEASE_TIMEOUT_MS;
383
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
384
+ await mkdir(runningJobsDir, { recursive: true });
385
+ return withRuntimeLock(dataDir, lockTimeoutMs, async () => {
386
+ const status = await buildStatus(dataDir, maxConcurrentJobs, lockTimeoutMs, true);
387
+ const activeJobs = status.activeJobs;
388
+ const slotPath = join(runningJobsDir, `${jobId}.json`);
389
+ if (activeJobs.some((l) => l.jobId === jobId)) {
390
+ return { ok: false, reason: "already_held", status };
391
+ }
392
+ if (activeJobs.length >= maxConcurrentJobs) {
393
+ return { ok: false, reason: "limit_reached", status };
394
+ }
395
+ const lease: JobSlotLease = {
396
+ jobId,
397
+ pid: options.pid ?? null,
398
+ acquiredAt: new Date().toISOString(),
399
+ source,
400
+ slotPath,
401
+ };
402
+ await writeLeaseAtomic(slotPath, lease);
403
+ return { ok: true, lease };
404
+ });
405
+ }
406
+
407
+ export async function updateJobSlotPid(
408
+ dataDir: string,
409
+ jobId: string,
410
+ pid: number,
411
+ lockTimeoutMs = DEFAULT_STALE_LEASE_TIMEOUT_MS,
412
+ ): Promise<void> {
413
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
414
+ await withRuntimeLock(dataDir, lockTimeoutMs, async () => {
415
+ const slotPath = join(runningJobsDir, `${jobId}.json`);
416
+ const lease = await readLease(slotPath);
417
+ if (!lease) throw new Error(`no lease found for job ${jobId}`);
418
+ await writeLeaseAtomic(slotPath, { ...lease, pid });
419
+ });
420
+ }
421
+
422
+ export async function releaseJobSlot(
423
+ dataDir: string,
424
+ jobId: string,
425
+ lockTimeoutMs = DEFAULT_STALE_LEASE_TIMEOUT_MS,
426
+ ): Promise<void> {
427
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
428
+ await withRuntimeLock(dataDir, lockTimeoutMs, async () => {
429
+ try {
430
+ await unlink(join(runningJobsDir, `${jobId}.json`));
431
+ } catch (err) {
432
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
433
+ }
434
+ });
435
+ }
436
+
437
+ export async function getJobConcurrencyStatus(
438
+ dataDir: string,
439
+ maxConcurrentJobs: number,
440
+ lockTimeoutMs: number,
441
+ ): Promise<JobConcurrencyStatus> {
442
+ return withRuntimeLock(dataDir, lockTimeoutMs, () =>
443
+ buildStatus(dataDir, maxConcurrentJobs, lockTimeoutMs, true),
444
+ );
445
+ }
446
+
447
+ export async function listQueuedSeeds(dataDir: string): Promise<QueuedJobSummary[]> {
448
+ const entries = await readPendingSeedEntries(join(dataDir, "pending"));
449
+ entries.sort((a, b) => a.mtime.getTime() - b.mtime.getTime() || a.jobId.localeCompare(b.jobId));
450
+ const summaries = await Promise.all(entries.map(async (entry): Promise<QueuedJobSummary | null> => {
451
+ const metadata = await readSeedMetadata(entry.seedPath);
452
+ if (!metadata) return null;
453
+ return {
454
+ jobId: entry.jobId,
455
+ seedPath: entry.seedPath,
456
+ queuedAt: entry.mtime.toISOString(),
457
+ name: metadata.name,
458
+ pipeline: metadata.pipeline,
459
+ };
460
+ }));
461
+ return summaries.filter((summary): summary is QueuedJobSummary => summary !== null);
462
+ }