@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

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 (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readdir, readFile, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
+ import { SEED_FILENAME_PATTERN } from "./seed-naming.ts";
4
5
 
5
- const SEED_FILENAME_PATTERN = /^([A-Za-z0-9][A-Za-z0-9-_]*)-seed\.json$/;
6
6
  const TEMP_LEASE_PATTERN = /\.json\.tmp\.[a-f0-9]+$/;
7
7
  const LOCK_POLL_INTERVAL_MS = 25;
8
8
  const DEFAULT_STALE_LEASE_TIMEOUT_MS = 30_000;
@@ -26,7 +26,7 @@ export interface QueuedJobSummary {
26
26
  export interface StaleJobSlot {
27
27
  jobId: string;
28
28
  slotPath: string;
29
- reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json";
29
+ reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json" | "stale_runner";
30
30
  }
31
31
 
32
32
  export interface JobConcurrencyStatus {
@@ -50,6 +50,7 @@ export interface AcquireJobSlotOptions {
50
50
  source: JobSlotLease["source"];
51
51
  pid?: number | null;
52
52
  lockTimeoutMs?: number;
53
+ staleLeaseTimeoutMs?: number;
53
54
  }
54
55
 
55
56
  export interface ConcurrencyRuntimePaths {
@@ -140,11 +141,23 @@ async function removeLeaseFile(slotPath: string): Promise<void> {
140
141
  }
141
142
  }
142
143
 
144
+ async function runnerPidMatches(dataDir: string, jobId: string, expectedPid: number): Promise<boolean> {
145
+ let raw: string;
146
+ try {
147
+ raw = await readFile(join(dataDir, "current", jobId, "runner.pid"), "utf-8");
148
+ } catch (err) {
149
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
150
+ throw err;
151
+ }
152
+ const pid = Number(raw.trim());
153
+ return Number.isInteger(pid) && pid === expectedPid;
154
+ }
155
+
143
156
  async function classifyLease(
144
157
  dataDir: string,
145
158
  slotPath: string,
146
159
  fileName: string,
147
- lockTimeoutMs: number,
160
+ staleLeaseTimeoutMs: number,
148
161
  ): Promise<StaleJobSlot | null> {
149
162
  const fallbackJobId = fileName.replace(/\.json$/, "");
150
163
  let raw: string;
@@ -162,7 +175,7 @@ async function classifyLease(
162
175
  }
163
176
  const jobId = typeof parsed.jobId === "string" ? parsed.jobId : fallbackJobId;
164
177
  const acquiredMs = Date.parse(parsed.acquiredAt);
165
- const leaseAgedOut = !Number.isFinite(acquiredMs) || Date.now() - acquiredMs >= lockTimeoutMs;
178
+ const leaseAgedOut = !Number.isFinite(acquiredMs) || Date.now() - acquiredMs >= staleLeaseTimeoutMs;
166
179
  if (!(await directoryExists(join(dataDir, "current", jobId)))) {
167
180
  // Grace window: drainPendingQueue acquires the slot before creating
168
181
  // current/<jobId>, so a fresh lease without a current dir is in-flight, not stale.
@@ -178,6 +191,9 @@ async function classifyLease(
178
191
  if (!isProcessAlive(parsed.pid)) {
179
192
  return { jobId, slotPath, reason: "dead_pid" };
180
193
  }
194
+ if (leaseAgedOut && !(await runnerPidMatches(dataDir, jobId, parsed.pid))) {
195
+ return { jobId, slotPath, reason: "stale_runner" };
196
+ }
181
197
  return null;
182
198
  }
183
199
 
@@ -191,7 +207,7 @@ async function listRunningJobFileNames(runningJobsDir: string): Promise<string[]
191
207
  }
192
208
  }
193
209
 
194
- async function removeStaleTempLeaseFiles(runningJobsDir: string, names: string[], lockTimeoutMs: number): Promise<void> {
210
+ async function removeStaleTempLeaseFiles(runningJobsDir: string, names: string[], staleLeaseTimeoutMs: number): Promise<void> {
195
211
  await Promise.all(
196
212
  names
197
213
  .filter((name) => TEMP_LEASE_PATTERN.test(name))
@@ -199,7 +215,7 @@ async function removeStaleTempLeaseFiles(runningJobsDir: string, names: string[]
199
215
  const tempPath = join(runningJobsDir, name);
200
216
  try {
201
217
  const stats = await stat(tempPath);
202
- if (Date.now() - stats.mtimeMs >= lockTimeoutMs) await removeLeaseFile(tempPath);
218
+ if (Date.now() - stats.mtimeMs >= staleLeaseTimeoutMs) await removeLeaseFile(tempPath);
203
219
  } catch (err) {
204
220
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
205
221
  }
@@ -209,19 +225,19 @@ async function removeStaleTempLeaseFiles(runningJobsDir: string, names: string[]
209
225
 
210
226
  async function collectLeaseState(
211
227
  dataDir: string,
212
- lockTimeoutMs: number,
228
+ staleLeaseTimeoutMs: number,
213
229
  prune: boolean,
214
230
  ): Promise<{ activeJobs: JobSlotLease[]; staleSlots: StaleJobSlot[] }> {
215
231
  const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
216
232
  const names = await listRunningJobFileNames(runningJobsDir);
217
- if (prune) await removeStaleTempLeaseFiles(runningJobsDir, names, lockTimeoutMs);
233
+ if (prune) await removeStaleTempLeaseFiles(runningJobsDir, names, staleLeaseTimeoutMs);
218
234
 
219
235
  const staleSlots: StaleJobSlot[] = [];
220
236
  const stalePaths = new Set<string>();
221
237
  await Promise.all(names.map(async (name) => {
222
238
  if (!name.endsWith(".json") || TEMP_LEASE_PATTERN.test(name)) return;
223
239
  const slotPath = join(runningJobsDir, name);
224
- const result = await classifyLease(dataDir, slotPath, name, lockTimeoutMs);
240
+ const result = await classifyLease(dataDir, slotPath, name, staleLeaseTimeoutMs);
225
241
  if (result) {
226
242
  staleSlots.push(result);
227
243
  stalePaths.add(slotPath);
@@ -240,11 +256,13 @@ async function collectLeaseState(
240
256
  export async function pruneStaleJobSlots(
241
257
  dataDir: string,
242
258
  lockTimeoutMs: number,
259
+ staleLeaseTimeoutMs?: number,
243
260
  ): Promise<StaleJobSlot[]> {
261
+ const staleAgeMs = staleLeaseTimeoutMs ?? lockTimeoutMs;
244
262
  return withRuntimeLock(dataDir, lockTimeoutMs, async () => {
245
- const { staleSlots } = await collectLeaseState(dataDir, lockTimeoutMs, true);
263
+ const { staleSlots } = await collectLeaseState(dataDir, staleAgeMs, true);
246
264
  return staleSlots;
247
- });
265
+ }, staleAgeMs);
248
266
  }
249
267
 
250
268
  function delay(ms: number): Promise<void> {
@@ -252,11 +270,13 @@ function delay(ms: number): Promise<void> {
252
270
  }
253
271
 
254
272
  // mkdir with recursive:false is atomic — exactly one caller wins the create when racing.
255
- async function withRuntimeLock<T>(
273
+ export async function withRuntimeLock<T>(
256
274
  dataDir: string,
257
275
  lockTimeoutMs: number,
258
276
  fn: () => Promise<T>,
277
+ staleLeaseTimeoutMs?: number,
259
278
  ): Promise<T> {
279
+ const staleAgeMs = staleLeaseTimeoutMs ?? lockTimeoutMs;
260
280
  const { lockDir, runtimeDir } = getConcurrencyRuntimePaths(dataDir);
261
281
  const ownerPath = join(lockDir, "owner.json");
262
282
  await mkdir(runtimeDir, { recursive: true });
@@ -281,7 +301,7 @@ async function withRuntimeLock<T>(
281
301
  const raw = await readFile(ownerPath, "utf-8");
282
302
  const owner = JSON.parse(raw) as { acquiredAt?: unknown; pid?: unknown };
283
303
  const ownerAcquiredMs = typeof owner.acquiredAt === "string" ? Date.parse(owner.acquiredAt) : NaN;
284
- const ownerTimedOut = Number.isFinite(ownerAcquiredMs) && Date.now() - ownerAcquiredMs >= lockTimeoutMs;
304
+ const ownerTimedOut = Number.isFinite(ownerAcquiredMs) && Date.now() - ownerAcquiredMs >= staleAgeMs;
285
305
  if (typeof owner.pid === "number" && !isProcessAlive(owner.pid)) {
286
306
  await rm(lockDir, { recursive: true, force: true });
287
307
  continue;
@@ -293,7 +313,7 @@ async function withRuntimeLock<T>(
293
313
  } catch (ownerErr) {
294
314
  if ((ownerErr as NodeJS.ErrnoException).code === "ENOENT") {
295
315
  const stats = await stat(lockDir);
296
- if (Date.now() - stats.mtimeMs >= lockTimeoutMs) {
316
+ if (Date.now() - stats.mtimeMs >= staleAgeMs) {
297
317
  await rm(lockDir, { recursive: true, force: true });
298
318
  continue;
299
319
  }
@@ -321,10 +341,10 @@ async function withRuntimeLock<T>(
321
341
  async function buildStatus(
322
342
  dataDir: string,
323
343
  maxConcurrentJobs: number,
324
- lockTimeoutMs: number,
344
+ staleLeaseTimeoutMs: number,
325
345
  prune: boolean,
326
346
  ): Promise<JobConcurrencyStatus> {
327
- const { activeJobs, staleSlots } = await collectLeaseState(dataDir, lockTimeoutMs, prune);
347
+ const { activeJobs, staleSlots } = await collectLeaseState(dataDir, staleLeaseTimeoutMs, prune);
328
348
  const queuedJobs = await listQueuedSeeds(dataDir);
329
349
  return {
330
350
  limit: maxConcurrentJobs,
@@ -347,6 +367,16 @@ async function readLease(slotPath: string): Promise<JobSlotLease | null> {
347
367
  }
348
368
  }
349
369
 
370
+ export async function readJobSlotLease(dataDir: string, jobId: string): Promise<JobSlotLease | null> {
371
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
372
+ return readLease(join(runningJobsDir, `${jobId}.json`));
373
+ }
374
+
375
+ export async function listActiveJobLeases(dataDir: string): Promise<JobSlotLease[]> {
376
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
377
+ return readActiveLeases(runningJobsDir);
378
+ }
379
+
350
380
  async function writeLeaseAtomic(slotPath: string, lease: JobSlotLease): Promise<void> {
351
381
  const tmpPath = `${slotPath}.tmp.${randomBytes(8).toString("hex")}`;
352
382
  await writeFile(tmpPath, JSON.stringify(lease));
@@ -380,10 +410,11 @@ export async function tryAcquireJobSlot(
380
410
  ): Promise<AcquireJobSlotResult> {
381
411
  const { dataDir, jobId, maxConcurrentJobs, source } = options;
382
412
  const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_STALE_LEASE_TIMEOUT_MS;
413
+ const staleLeaseTimeoutMs = options.staleLeaseTimeoutMs ?? lockTimeoutMs;
383
414
  const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
384
415
  await mkdir(runningJobsDir, { recursive: true });
385
416
  return withRuntimeLock(dataDir, lockTimeoutMs, async () => {
386
- const status = await buildStatus(dataDir, maxConcurrentJobs, lockTimeoutMs, true);
417
+ const status = await buildStatus(dataDir, maxConcurrentJobs, staleLeaseTimeoutMs, true);
387
418
  const activeJobs = status.activeJobs;
388
419
  const slotPath = join(runningJobsDir, `${jobId}.json`);
389
420
  if (activeJobs.some((l) => l.jobId === jobId)) {
@@ -401,7 +432,7 @@ export async function tryAcquireJobSlot(
401
432
  };
402
433
  await writeLeaseAtomic(slotPath, lease);
403
434
  return { ok: true, lease };
404
- });
435
+ }, staleLeaseTimeoutMs);
405
436
  }
406
437
 
407
438
  export async function updateJobSlotPid(
@@ -438,9 +469,14 @@ export async function getJobConcurrencyStatus(
438
469
  dataDir: string,
439
470
  maxConcurrentJobs: number,
440
471
  lockTimeoutMs: number,
472
+ staleLeaseTimeoutMs?: number,
441
473
  ): Promise<JobConcurrencyStatus> {
442
- return withRuntimeLock(dataDir, lockTimeoutMs, () =>
443
- buildStatus(dataDir, maxConcurrentJobs, lockTimeoutMs, true),
474
+ const staleAgeMs = staleLeaseTimeoutMs ?? lockTimeoutMs;
475
+ return withRuntimeLock(
476
+ dataDir,
477
+ lockTimeoutMs,
478
+ () => buildStatus(dataDir, maxConcurrentJobs, staleAgeMs, true),
479
+ staleAgeMs,
444
480
  );
445
481
  }
446
482
 
@@ -0,0 +1,133 @@
1
+ import path from "node:path";
2
+ import { mkdir, rename } from "node:fs/promises";
3
+ import {
4
+ getPendingSeedPath,
5
+ getPipelineDataDir,
6
+ resolveWorkspaceRoot,
7
+ } from "../config/paths.ts";
8
+ import { resolveWithin } from "../utils/path-containment.ts";
9
+ import { getPipelineConfig } from "./config.ts";
10
+
11
+ export interface SubmitSuccessResult {
12
+ success: true;
13
+ jobId: string;
14
+ jobName: string;
15
+ }
16
+
17
+ export interface SubmitFailureResult {
18
+ success: false;
19
+ message: string;
20
+ }
21
+
22
+ export type SubmitResult = SubmitSuccessResult | SubmitFailureResult;
23
+
24
+ export interface SubmitSeedOptions {
25
+ root: string;
26
+ seedObject: unknown;
27
+ }
28
+
29
+ export interface SubmitUploadedSeedOptions {
30
+ root: string;
31
+ seedObject: unknown;
32
+ artifacts?: Array<{ filename: string; content: Uint8Array }>;
33
+ }
34
+
35
+ interface SubmitInternalOptions {
36
+ root: string;
37
+ seedObject: unknown;
38
+ artifacts?: Array<{ filename: string; content: Uint8Array }>;
39
+ }
40
+
41
+ function validateSeed(seedObject: unknown, rootDir: string): SubmitResult | null {
42
+ if (typeof seedObject !== "object" || seedObject === null || Array.isArray(seedObject)) {
43
+ return { success: false, message: "seed must be a JSON object" };
44
+ }
45
+ const seed = seedObject as Record<string, unknown>;
46
+ if (typeof seed["pipeline"] !== "string" || seed["pipeline"].length === 0) {
47
+ return { success: false, message: "seed.pipeline must be a non-empty string" };
48
+ }
49
+ if (seed["name"] !== undefined) {
50
+ if (typeof seed["name"] !== "string" || seed["name"].length === 0) {
51
+ return { success: false, message: "seed.name must be a non-empty string if provided" };
52
+ }
53
+ }
54
+ try {
55
+ getPipelineConfig(seed["pipeline"] as string, rootDir);
56
+ } catch (err) {
57
+ return { success: false, message: (err as Error).message };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ async function stageArtifacts(
63
+ rootDir: string,
64
+ jobId: string,
65
+ artifacts: Array<{ filename: string; content: Uint8Array }>,
66
+ ): Promise<SubmitFailureResult | null> {
67
+ const stagingDir = path.join(getPipelineDataDir(rootDir), "staging", jobId);
68
+ const resolved: string[] = [];
69
+ for (const artifact of artifacts) {
70
+ const target = resolveWithin(stagingDir, artifact.filename);
71
+ if (target === null) {
72
+ return { success: false, message: `unsafe artifact path: ${artifact.filename}` };
73
+ }
74
+ resolved.push(target);
75
+ }
76
+ for (let i = 0; i < artifacts.length; i++) {
77
+ await mkdir(path.dirname(resolved[i]!), { recursive: true });
78
+ await Bun.write(resolved[i]!, artifacts[i]!.content);
79
+ }
80
+ return null;
81
+ }
82
+
83
+ // Atomic visibility to the watcher, not durability (no fsync).
84
+ // Crash durability is tracked separately as backlog #40.
85
+ async function writePendingSeedAtomic(
86
+ rootDir: string,
87
+ jobId: string,
88
+ seedObject: unknown,
89
+ ): Promise<void> {
90
+ const pendingPath = getPendingSeedPath(rootDir, jobId);
91
+ // Suffix `.tmp` keeps the temp file outside the orchestrator's `pending/*.json` watch glob,
92
+ // so the watcher never observes a partial write as a seed.
93
+ const tmp = `${pendingPath}.${Date.now()}.tmp`;
94
+ await mkdir(path.dirname(pendingPath), { recursive: true });
95
+ await Bun.write(tmp, JSON.stringify(seedObject, null, 2));
96
+ await rename(tmp, pendingPath);
97
+ }
98
+
99
+ async function submit(opts: SubmitInternalOptions): Promise<SubmitResult> {
100
+ const rootDir = resolveWorkspaceRoot(opts.root);
101
+
102
+ const validationFailure = validateSeed(opts.seedObject, rootDir);
103
+ if (validationFailure) return validationFailure;
104
+
105
+ const seed = opts.seedObject as Record<string, unknown>;
106
+ const jobId = crypto.randomUUID();
107
+ const jobName = typeof seed["name"] === "string" ? seed["name"] : jobId;
108
+
109
+ if (opts.artifacts && opts.artifacts.length > 0) {
110
+ const stageFailure = await stageArtifacts(rootDir, jobId, opts.artifacts);
111
+ if (stageFailure) return stageFailure;
112
+ }
113
+
114
+ // Seed is written last so the orchestrator trigger never appears before
115
+ // its artifacts are fully staged (spec §4 step 4; cross-phase invariant 2).
116
+ await writePendingSeedAtomic(rootDir, jobId, opts.seedObject);
117
+
118
+ return { success: true, jobId, jobName };
119
+ }
120
+
121
+ export async function submitSeed(opts: SubmitSeedOptions): Promise<SubmitResult> {
122
+ return submit({ root: opts.root, seedObject: opts.seedObject });
123
+ }
124
+
125
+ export async function submitUploadedSeed(
126
+ opts: SubmitUploadedSeedOptions,
127
+ ): Promise<SubmitResult> {
128
+ return submit({
129
+ root: opts.root,
130
+ seedObject: opts.seedObject,
131
+ artifacts: opts.artifacts,
132
+ });
133
+ }