@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.
- package/docs/http-api.md +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- 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
|
-
|
|
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 >=
|
|
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[],
|
|
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 >=
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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 >=
|
|
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 >=
|
|
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
|
-
|
|
344
|
+
staleLeaseTimeoutMs: number,
|
|
325
345
|
prune: boolean,
|
|
326
346
|
): Promise<JobConcurrencyStatus> {
|
|
327
|
-
const { activeJobs, staleSlots } = await collectLeaseState(dataDir,
|
|
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,
|
|
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
|
-
|
|
443
|
-
|
|
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
|
+
}
|