@ryanfw/prompt-orchestration-pipeline 1.3.1 → 1.3.3
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 +66 -0
- package/docs/pop-task-guide.md +1 -0
- package/package.json +1 -2
- package/src/api/__tests__/index.test.ts +311 -3
- package/src/api/index.ts +94 -45
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +322 -3
- package/src/core/__tests__/config.test.ts +31 -1
- package/src/core/__tests__/job-concurrency.test.ts +60 -0
- package/src/core/__tests__/job-view.test.ts +859 -0
- package/src/core/__tests__/orchestrator.test.ts +242 -0
- package/src/core/__tests__/runner-liveness.test.ts +865 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +57 -1
- package/src/core/agent-step.ts +231 -14
- package/src/core/agent-types.ts +3 -0
- package/src/core/config.ts +19 -0
- package/src/core/file-io.ts +8 -74
- package/src/core/job-concurrency.ts +6 -1
- package/src/core/job-view.ts +329 -0
- package/src/core/orchestrator.ts +78 -6
- package/src/core/pipeline-runner.ts +54 -3
- package/src/core/runner-liveness.ts +276 -0
- package/src/core/status-writer.ts +68 -31
- package/src/core/task-runner.ts +27 -3
- package/src/harness/__tests__/discovery.test.ts +60 -8
- package/src/harness/discovery.ts +16 -6
- package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
- package/src/ui/client/adapters/job-adapter.ts +10 -0
- package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
- package/src/ui/client/types.ts +2 -0
- package/src/ui/components/JobTable.tsx +29 -12
- package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index-CbS3OsW7.js → index-L6cvsCAx.js} +39 -13
- package/src/ui/dist/assets/{index-CbS3OsW7.js.map → index-L6cvsCAx.js.map} +1 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +245 -1
- package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
- package/src/ui/server/__tests__/path-containment.test.ts +54 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
- package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
- package/src/ui/server/config-bridge.ts +4 -4
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +19 -7
- package/src/ui/server/endpoints/job-control-endpoints.ts +43 -70
- package/src/ui/server/endpoints/job-endpoints.ts +6 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
- package/src/ui/server/utils/http-utils.ts +6 -1
- package/src/ui/server/utils/path-containment.ts +18 -0
- package/src/ui/server/utils/status-corruption.ts +27 -0
- package/src/ui/state/__tests__/snapshot.test.ts +51 -0
- package/src/ui/state/__tests__/types.test.ts +98 -5
- package/src/ui/state/snapshot.ts +1 -1
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
- package/src/ui/state/transformers/list-transformer.ts +7 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +9 -47
- package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
package/src/core/file-io.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { mkdir, rename, appendFile } from "node:fs/promises";
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync, renameSync } from "node:fs";
|
|
3
3
|
import { dirname, join, basename } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
5
|
import { LogFileExtension, isValidLogEvent, isValidLogFileExtension } from "../config/log-events";
|
|
6
|
-
import { writeJobStatus } from "./status-writer";
|
|
6
|
+
import { writeJobStatus, flushJobStatus } from "./status-writer";
|
|
7
|
+
import { createJobLogger } from "./logger";
|
|
7
8
|
import { executeBatch, validateBatchOptions } from "./batch-runner";
|
|
8
9
|
import type { BatchOptions, BatchResult } from "./batch-runner";
|
|
9
10
|
|
|
@@ -169,34 +170,6 @@ export async function trackFile(
|
|
|
169
170
|
});
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
/**
|
|
173
|
-
* @internal Synchronous status writer for writeLogSync and getDB.
|
|
174
|
-
* Reads tasks-status.json, applies updater, writes back as pretty-printed JSON.
|
|
175
|
-
* Falls back to a minimal default snapshot on missing or invalid JSON.
|
|
176
|
-
* No temp-file-rename, no async queue participation.
|
|
177
|
-
*/
|
|
178
|
-
export function writeJobStatusSync(
|
|
179
|
-
jobDir: string,
|
|
180
|
-
updater: (snapshot: Record<string, unknown>) => void,
|
|
181
|
-
): void {
|
|
182
|
-
const statusPath = join(jobDir, "tasks-status.json");
|
|
183
|
-
let snapshot: Record<string, unknown>;
|
|
184
|
-
try {
|
|
185
|
-
const raw = readFileSync(statusPath, "utf-8");
|
|
186
|
-
snapshot = JSON.parse(raw) as Record<string, unknown>;
|
|
187
|
-
} catch {
|
|
188
|
-
snapshot = {
|
|
189
|
-
id: basename(jobDir),
|
|
190
|
-
state: "pending",
|
|
191
|
-
tasks: {},
|
|
192
|
-
files: { artifacts: [], logs: [], tmp: [] },
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
updater(snapshot);
|
|
196
|
-
mkdirSync(jobDir, { recursive: true });
|
|
197
|
-
writeFileSync(statusPath, JSON.stringify(snapshot, null, 2));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
173
|
export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
201
174
|
const { workDir, taskName, statusPath, getStage } = config;
|
|
202
175
|
const trackTaskFilesEnabled = config.trackTaskFiles ?? true;
|
|
@@ -234,28 +207,8 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
|
234
207
|
const db = new Database(dbPath);
|
|
235
208
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
236
209
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
snapshot["files"] = files;
|
|
240
|
-
const artifactsList = (files.artifacts ?? []) as string[];
|
|
241
|
-
files.artifacts = artifactsList;
|
|
242
|
-
if (!artifactsList.includes("run.db")) {
|
|
243
|
-
artifactsList.push("run.db");
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (trackTaskFilesEnabled) {
|
|
247
|
-
const tasks = (snapshot["tasks"] ?? {}) as Record<string, TaskRecord>;
|
|
248
|
-
snapshot["tasks"] = tasks;
|
|
249
|
-
const task = (tasks[taskName] ?? {}) as TaskRecord;
|
|
250
|
-
tasks[taskName] = task;
|
|
251
|
-
const taskFiles = (task.files ?? {}) as FilesRecord;
|
|
252
|
-
task.files = taskFiles;
|
|
253
|
-
const taskArtifactsList = (taskFiles.artifacts ?? []) as string[];
|
|
254
|
-
taskFiles.artifacts = taskArtifactsList;
|
|
255
|
-
if (!taskArtifactsList.includes("run.db")) {
|
|
256
|
-
taskArtifactsList.push("run.db");
|
|
257
|
-
}
|
|
258
|
-
}
|
|
210
|
+
void trackFile(jobDir, "artifacts", "run.db", taskName, trackTaskFilesEnabled).catch((err) => {
|
|
211
|
+
createJobLogger("file-io", basename(jobDir)).warn("getDB: manifest tracking failed", err);
|
|
259
212
|
});
|
|
260
213
|
|
|
261
214
|
return db;
|
|
@@ -314,28 +267,8 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
|
314
267
|
renameSync(tmpPath, filePath);
|
|
315
268
|
}
|
|
316
269
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
snapshot["files"] = files;
|
|
320
|
-
const logsList = (files.logs ?? []) as string[];
|
|
321
|
-
files.logs = logsList;
|
|
322
|
-
if (!logsList.includes(name)) {
|
|
323
|
-
logsList.push(name);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (trackTaskFilesEnabled) {
|
|
327
|
-
const tasks = (snapshot["tasks"] ?? {}) as Record<string, TaskRecord>;
|
|
328
|
-
snapshot["tasks"] = tasks;
|
|
329
|
-
const task = (tasks[taskName] ?? {}) as TaskRecord;
|
|
330
|
-
tasks[taskName] = task;
|
|
331
|
-
const taskFiles = (task.files ?? {}) as FilesRecord;
|
|
332
|
-
task.files = taskFiles;
|
|
333
|
-
const taskLogsList = (taskFiles.logs ?? []) as string[];
|
|
334
|
-
taskFiles.logs = taskLogsList;
|
|
335
|
-
if (!taskLogsList.includes(name)) {
|
|
336
|
-
taskLogsList.push(name);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
270
|
+
void trackFile(jobDir, "logs", name, taskName, trackTaskFilesEnabled).catch((err) => {
|
|
271
|
+
createJobLogger("file-io", basename(jobDir)).warn("writeLogSync: manifest tracking failed", err);
|
|
339
272
|
});
|
|
340
273
|
},
|
|
341
274
|
|
|
@@ -348,6 +281,7 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
|
348
281
|
return await executeBatch(db, options);
|
|
349
282
|
} finally {
|
|
350
283
|
db.close();
|
|
284
|
+
await flushJobStatus(jobDir);
|
|
351
285
|
}
|
|
352
286
|
},
|
|
353
287
|
};
|
|
@@ -252,7 +252,7 @@ function delay(ms: number): Promise<void> {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
// mkdir with recursive:false is atomic — exactly one caller wins the create when racing.
|
|
255
|
-
async function withRuntimeLock<T>(
|
|
255
|
+
export async function withRuntimeLock<T>(
|
|
256
256
|
dataDir: string,
|
|
257
257
|
lockTimeoutMs: number,
|
|
258
258
|
fn: () => Promise<T>,
|
|
@@ -347,6 +347,11 @@ async function readLease(slotPath: string): Promise<JobSlotLease | null> {
|
|
|
347
347
|
}
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
export async function readJobSlotLease(dataDir: string, jobId: string): Promise<JobSlotLease | null> {
|
|
351
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
|
|
352
|
+
return readLease(join(runningJobsDir, `${jobId}.json`));
|
|
353
|
+
}
|
|
354
|
+
|
|
350
355
|
async function writeLeaseAtomic(slotPath: string, lease: JobSlotLease): Promise<void> {
|
|
351
356
|
const tmpPath = `${slotPath}.tmp.${randomBytes(8).toString("hex")}`;
|
|
352
357
|
await writeFile(tmpPath, JSON.stringify(lease));
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JOB_STATUS_SYNONYMS,
|
|
3
|
+
VALID_JOB_STATUSES,
|
|
4
|
+
deriveJobStatusFromTasks,
|
|
5
|
+
jobStateToStatus,
|
|
6
|
+
normalizeJobStatus,
|
|
7
|
+
normalizeTaskState,
|
|
8
|
+
} from "../config/statuses.ts";
|
|
9
|
+
import type { JobStatusValue, TaskStateValue } from "../config/statuses.ts";
|
|
10
|
+
import type { GateInfo } from "./status-writer.ts";
|
|
11
|
+
|
|
12
|
+
export type RetryError = { message: string; name?: string; stack?: string } | string;
|
|
13
|
+
|
|
14
|
+
export interface JobViewError {
|
|
15
|
+
code: string;
|
|
16
|
+
message: string;
|
|
17
|
+
path?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
stack?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface JobViewTask {
|
|
23
|
+
state: TaskStateValue;
|
|
24
|
+
name: string;
|
|
25
|
+
files: { artifacts: string[]; logs: string[]; tmp: string[] };
|
|
26
|
+
startedAt?: string | null;
|
|
27
|
+
endedAt?: string | null;
|
|
28
|
+
attempts?: number;
|
|
29
|
+
restartCount?: number;
|
|
30
|
+
retrying?: boolean;
|
|
31
|
+
nextRetryAt?: string;
|
|
32
|
+
lastRetryError?: RetryError | null;
|
|
33
|
+
executionTimeMs?: number;
|
|
34
|
+
refinementAttempts?: number;
|
|
35
|
+
stageLogPath?: string;
|
|
36
|
+
errorContext?: unknown;
|
|
37
|
+
currentStage?: string;
|
|
38
|
+
failedStage?: string;
|
|
39
|
+
artifacts?: unknown;
|
|
40
|
+
tokenUsage?: unknown[];
|
|
41
|
+
error?: { message: string; [key: string]: unknown } | null;
|
|
42
|
+
skipReason?: string;
|
|
43
|
+
skippedBy?: string;
|
|
44
|
+
controlApplied?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface JobView {
|
|
48
|
+
id: string;
|
|
49
|
+
jobId: string;
|
|
50
|
+
name: string;
|
|
51
|
+
title: string;
|
|
52
|
+
status: JobStatusValue;
|
|
53
|
+
progress: number;
|
|
54
|
+
createdAt: string | null;
|
|
55
|
+
updatedAt: string | null;
|
|
56
|
+
lastUpdated?: string;
|
|
57
|
+
location: string | null;
|
|
58
|
+
tasks: Record<string, JobViewTask>;
|
|
59
|
+
files: Record<string, unknown>;
|
|
60
|
+
costs: Record<string, unknown>;
|
|
61
|
+
gate?: GateInfo | null;
|
|
62
|
+
pipeline?: string;
|
|
63
|
+
pipelineLabel?: string;
|
|
64
|
+
pipelineConfig?: Record<string, unknown>;
|
|
65
|
+
current?: unknown;
|
|
66
|
+
currentStage?: unknown;
|
|
67
|
+
warnings?: string[];
|
|
68
|
+
readable?: boolean;
|
|
69
|
+
error?: JobViewError;
|
|
70
|
+
errorContext?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TaskFilesView {
|
|
74
|
+
artifacts: string[];
|
|
75
|
+
logs: string[];
|
|
76
|
+
tmp: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const EMPTY_TASK_FILES: TaskFilesView = { artifacts: [], logs: [], tmp: [] };
|
|
80
|
+
const DEFAULT_JOB_ERROR_CODE = "JOB_ERROR";
|
|
81
|
+
|
|
82
|
+
const warnedFallbackKeys = new Set<string>();
|
|
83
|
+
|
|
84
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isRecognizedStatusValue(value: unknown): value is string {
|
|
89
|
+
if (typeof value !== "string") return false;
|
|
90
|
+
const normalized = value.toLowerCase().trim();
|
|
91
|
+
if (Object.prototype.hasOwnProperty.call(JOB_STATUS_SYNONYMS, normalized)) return true;
|
|
92
|
+
return VALID_JOB_STATUSES.has(normalized);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toStringOrNull(value: unknown): string | null {
|
|
96
|
+
return typeof value === "string" ? value : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toStringArray(value: unknown): string[] {
|
|
100
|
+
if (!Array.isArray(value)) return [];
|
|
101
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toFiniteNumber(value: unknown): number | undefined {
|
|
105
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toTaskFiles(value: unknown): TaskFilesView {
|
|
109
|
+
if (!isRecord(value)) return { ...EMPTY_TASK_FILES };
|
|
110
|
+
return {
|
|
111
|
+
artifacts: toStringArray(value["artifacts"]),
|
|
112
|
+
logs: toStringArray(value["logs"]),
|
|
113
|
+
tmp: toStringArray(value["tmp"]),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toTaskError(value: unknown): JobViewTask["error"] {
|
|
118
|
+
if (value === null) return null;
|
|
119
|
+
if (typeof value === "string") return { message: value };
|
|
120
|
+
if (isRecord(value)) return value as { message: string; [key: string]: unknown };
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toGateInfo(value: unknown): GateInfo | null | undefined {
|
|
125
|
+
if (value === null) return null;
|
|
126
|
+
if (!isRecord(value)) return undefined;
|
|
127
|
+
const rawArtifacts = value["artifacts"];
|
|
128
|
+
const artifacts = Array.isArray(rawArtifacts)
|
|
129
|
+
? rawArtifacts.filter((entry): entry is string => typeof entry === "string")
|
|
130
|
+
: undefined;
|
|
131
|
+
return {
|
|
132
|
+
afterTask: typeof value["afterTask"] === "string" ? value["afterTask"] : "",
|
|
133
|
+
message: typeof value["message"] === "string" ? value["message"] : "",
|
|
134
|
+
requestedAt: typeof value["requestedAt"] === "string" ? value["requestedAt"] : "",
|
|
135
|
+
...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toJobViewTask(name: string, value: unknown): JobViewTask {
|
|
140
|
+
const task = isRecord(value) ? value : {};
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
state: normalizeTaskState(task["state"]),
|
|
144
|
+
files: toTaskFiles(task["files"]),
|
|
145
|
+
startedAt: toStringOrNull(task["startedAt"]),
|
|
146
|
+
endedAt: toStringOrNull(task["endedAt"]),
|
|
147
|
+
attempts: typeof task["attempts"] === "number" ? task["attempts"] : undefined,
|
|
148
|
+
restartCount: typeof task["restartCount"] === "number" ? task["restartCount"] : undefined,
|
|
149
|
+
retrying: typeof task["retrying"] === "boolean" ? task["retrying"] : undefined,
|
|
150
|
+
nextRetryAt: typeof task["nextRetryAt"] === "string" ? task["nextRetryAt"] : undefined,
|
|
151
|
+
lastRetryError: Object.hasOwn(task, "lastRetryError")
|
|
152
|
+
? (task["lastRetryError"] as RetryError | null)
|
|
153
|
+
: undefined,
|
|
154
|
+
executionTimeMs: typeof task["executionTimeMs"] === "number"
|
|
155
|
+
? task["executionTimeMs"]
|
|
156
|
+
: undefined,
|
|
157
|
+
refinementAttempts: typeof task["refinementAttempts"] === "number"
|
|
158
|
+
? task["refinementAttempts"]
|
|
159
|
+
: undefined,
|
|
160
|
+
stageLogPath: typeof task["stageLogPath"] === "string" ? task["stageLogPath"] : undefined,
|
|
161
|
+
errorContext: task["errorContext"],
|
|
162
|
+
currentStage: typeof task["currentStage"] === "string" ? task["currentStage"] : undefined,
|
|
163
|
+
failedStage: typeof task["failedStage"] === "string" ? task["failedStage"] : undefined,
|
|
164
|
+
artifacts: task["artifacts"],
|
|
165
|
+
tokenUsage: Array.isArray(task["tokenUsage"]) ? task["tokenUsage"] : undefined,
|
|
166
|
+
error: toTaskError(task["error"]),
|
|
167
|
+
skipReason: typeof task["skipReason"] === "string" ? task["skipReason"] : undefined,
|
|
168
|
+
skippedBy: typeof task["skippedBy"] === "string" ? task["skippedBy"] : undefined,
|
|
169
|
+
controlApplied: typeof task["controlApplied"] === "boolean" ? task["controlApplied"] : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toJobViewTasks(value: unknown): Record<string, JobViewTask> {
|
|
174
|
+
if (Array.isArray(value)) {
|
|
175
|
+
return Object.fromEntries(
|
|
176
|
+
value.map((entry, index) => {
|
|
177
|
+
const record = isRecord(entry) ? entry : {};
|
|
178
|
+
const explicitName = record["name"];
|
|
179
|
+
const name = typeof explicitName === "string" && explicitName.length > 0
|
|
180
|
+
? explicitName
|
|
181
|
+
: `task-${index}`;
|
|
182
|
+
return [name, toJobViewTask(name, record)] as const;
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (isRecord(value)) {
|
|
187
|
+
return Object.fromEntries(
|
|
188
|
+
Object.entries(value).map(([name, entry]) => [name, toJobViewTask(name, entry)] as const),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getProgress(rawProgress: unknown, tasks: JobViewTask[]): number {
|
|
195
|
+
const explicitProgress = toFiniteNumber(rawProgress);
|
|
196
|
+
if (explicitProgress !== undefined) return explicitProgress;
|
|
197
|
+
if (tasks.length === 0) return 0;
|
|
198
|
+
|
|
199
|
+
const completed = tasks.filter((task) => task.state === "done" || task.state === "skipped").length;
|
|
200
|
+
return Math.floor((completed / tasks.length) * 100);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getRawTaskEntries(value: unknown): unknown[] {
|
|
204
|
+
if (Array.isArray(value)) return value;
|
|
205
|
+
if (isRecord(value)) return Object.values(value);
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getCosts(record: Record<string, unknown>): Record<string, unknown> {
|
|
210
|
+
const explicitCosts = record["costs"];
|
|
211
|
+
if (isRecord(explicitCosts)) return explicitCosts;
|
|
212
|
+
|
|
213
|
+
let totalCost = 0;
|
|
214
|
+
let totalInputTokens = 0;
|
|
215
|
+
let totalOutputTokens = 0;
|
|
216
|
+
|
|
217
|
+
for (const value of getRawTaskEntries(record["tasks"])) {
|
|
218
|
+
if (!isRecord(value)) continue;
|
|
219
|
+
const usage = value["tokenUsage"];
|
|
220
|
+
if (!Array.isArray(usage)) continue;
|
|
221
|
+
|
|
222
|
+
for (const entry of usage) {
|
|
223
|
+
if (!Array.isArray(entry)) continue;
|
|
224
|
+
totalInputTokens += toFiniteNumber(entry[1]) ?? 0;
|
|
225
|
+
totalOutputTokens += toFiniteNumber(entry[2]) ?? 0;
|
|
226
|
+
totalCost += toFiniteNumber(entry[3]) ?? 0;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const totalTokens = totalInputTokens + totalOutputTokens;
|
|
231
|
+
if (totalTokens === 0 && totalCost === 0) return {};
|
|
232
|
+
return { totalCost, totalTokens, totalInputTokens, totalOutputTokens };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveStateStatus(
|
|
236
|
+
fromState: JobStatusValue,
|
|
237
|
+
tasks: JobViewTask[],
|
|
238
|
+
location: string,
|
|
239
|
+
): JobStatusValue {
|
|
240
|
+
if (fromState !== "complete" || location === "complete" || tasks.length === 0) {
|
|
241
|
+
return fromState;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const taskStatus = deriveJobStatusFromTasks(tasks);
|
|
245
|
+
return taskStatus === "complete" ? fromState : taskStatus;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function toTitle(record: Record<string, unknown>, fallback: string): string {
|
|
249
|
+
const candidate = record["title"] ?? record["name"];
|
|
250
|
+
return typeof candidate === "string" && candidate.length > 0 ? candidate : fallback;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function toErrorView(value: unknown): JobViewError | undefined {
|
|
254
|
+
if (!isRecord(value)) return undefined;
|
|
255
|
+
if (typeof value["message"] !== "string") return undefined;
|
|
256
|
+
const code = typeof value["code"] === "string"
|
|
257
|
+
? value["code"]
|
|
258
|
+
: typeof value["name"] === "string"
|
|
259
|
+
? value["name"]
|
|
260
|
+
: DEFAULT_JOB_ERROR_CODE;
|
|
261
|
+
const result: JobViewError = { code, message: value["message"] };
|
|
262
|
+
if (typeof value["path"] === "string") result.path = value["path"];
|
|
263
|
+
if (typeof value["name"] === "string") result.name = value["name"];
|
|
264
|
+
if (typeof value["stack"] === "string") result.stack = value["stack"];
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function normalizeJobView(raw: unknown, jobId: string, location: string): JobView {
|
|
269
|
+
const record = isRecord(raw) ? raw : {};
|
|
270
|
+
|
|
271
|
+
const tasks = toJobViewTasks(record["tasks"]);
|
|
272
|
+
const taskList = Object.values(tasks);
|
|
273
|
+
|
|
274
|
+
const fromState = jobStateToStatus(record["state"]);
|
|
275
|
+
let status: JobStatusValue;
|
|
276
|
+
if (fromState !== null) {
|
|
277
|
+
status = resolveStateStatus(fromState, taskList, location);
|
|
278
|
+
} else if (isRecognizedStatusValue(record["status"])) {
|
|
279
|
+
status = normalizeJobStatus(record["status"]);
|
|
280
|
+
} else {
|
|
281
|
+
const warnKey = `${location}:${jobId}`;
|
|
282
|
+
if (!warnedFallbackKeys.has(warnKey)) {
|
|
283
|
+
warnedFallbackKeys.add(warnKey);
|
|
284
|
+
console.warn(
|
|
285
|
+
`normalizeJobView: no valid state or status for ${warnKey}; deriving job status from tasks.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
status = deriveJobStatusFromTasks(taskList);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const title = toTitle(record, jobId);
|
|
292
|
+
const lastUpdated = toStringOrNull(record["lastUpdated"]);
|
|
293
|
+
|
|
294
|
+
const result: JobView = {
|
|
295
|
+
id: jobId,
|
|
296
|
+
jobId,
|
|
297
|
+
name: title,
|
|
298
|
+
title,
|
|
299
|
+
status,
|
|
300
|
+
progress: getProgress(record["progress"], taskList),
|
|
301
|
+
createdAt: toStringOrNull(record["createdAt"]),
|
|
302
|
+
updatedAt: toStringOrNull(record["updatedAt"]) ?? lastUpdated,
|
|
303
|
+
location,
|
|
304
|
+
tasks,
|
|
305
|
+
files: isRecord(record["files"]) ? record["files"] : {},
|
|
306
|
+
costs: getCosts(record),
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (lastUpdated !== null) result.lastUpdated = lastUpdated;
|
|
310
|
+
if ("gate" in record) result.gate = toGateInfo(record["gate"]);
|
|
311
|
+
if (typeof record["pipeline"] === "string") result.pipeline = record["pipeline"];
|
|
312
|
+
if (typeof record["pipelineLabel"] === "string") result.pipelineLabel = record["pipelineLabel"];
|
|
313
|
+
if (isRecord(record["pipelineConfig"])) result.pipelineConfig = record["pipelineConfig"];
|
|
314
|
+
if ("current" in record) result.current = record["current"];
|
|
315
|
+
if ("currentStage" in record) result.currentStage = record["currentStage"];
|
|
316
|
+
if (Array.isArray(record["warnings"])) {
|
|
317
|
+
result.warnings = record["warnings"].filter((entry): entry is string => typeof entry === "string");
|
|
318
|
+
}
|
|
319
|
+
if (typeof record["readable"] === "boolean") result.readable = record["readable"];
|
|
320
|
+
const errorView = toErrorView(record["error"]);
|
|
321
|
+
if (errorView) result.error = errorView;
|
|
322
|
+
if ("errorContext" in record) result.errorContext = record["errorContext"];
|
|
323
|
+
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function __resetWarnedFallbackKeys(): void {
|
|
328
|
+
warnedFallbackKeys.clear();
|
|
329
|
+
}
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -98,6 +98,20 @@ interface SpawnedRunner {
|
|
|
98
98
|
kill?: () => void;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function describeCleanupError(err: unknown): unknown {
|
|
102
|
+
if (!(err instanceof AggregateError)) return err;
|
|
103
|
+
return {
|
|
104
|
+
name: err.name,
|
|
105
|
+
message: err.message,
|
|
106
|
+
errors: err.errors.map((cause) => {
|
|
107
|
+
if (cause instanceof Error) {
|
|
108
|
+
return { name: cause.name, message: cause.message, stack: cause.stack };
|
|
109
|
+
}
|
|
110
|
+
return { message: String(cause) };
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
/** Seed filename regex. Captures jobId from {jobId}-seed.json. */
|
|
102
116
|
export const SEED_PATTERN = /^([A-Za-z0-9-_]+)-seed\.json$/;
|
|
103
117
|
|
|
@@ -120,6 +134,7 @@ import {
|
|
|
120
134
|
tryAcquireJobSlot,
|
|
121
135
|
updateJobSlotPid,
|
|
122
136
|
} from "./job-concurrency";
|
|
137
|
+
import { reconcileInterruptedJob, reconcileOrphanedCurrentJobs } from "./runner-liveness";
|
|
123
138
|
|
|
124
139
|
/**
|
|
125
140
|
* Normalize any path that may already include `pipeline-data` (or subdirs
|
|
@@ -184,7 +199,7 @@ export async function spawnRunner(
|
|
|
184
199
|
running: Map<string, ChildHandle>,
|
|
185
200
|
logger: ReturnType<typeof createLogger>,
|
|
186
201
|
spawnFn: SpawnFn,
|
|
187
|
-
onExit?: (jobId: string) => void | Promise<void>,
|
|
202
|
+
onExit?: (jobId: string, result: ChildExitResult) => void | Promise<void>,
|
|
188
203
|
): Promise<void> {
|
|
189
204
|
if (!seed.pipeline) {
|
|
190
205
|
throw new Error(`seed.pipeline is required for job ${jobId}`);
|
|
@@ -236,9 +251,9 @@ export async function spawnRunner(
|
|
|
236
251
|
});
|
|
237
252
|
if (!onExit) return;
|
|
238
253
|
try {
|
|
239
|
-
await onExit(jobId);
|
|
254
|
+
await onExit(jobId, result);
|
|
240
255
|
} catch (err) {
|
|
241
|
-
logger.error(`child exit cleanup failed for job ${jobId}`, err);
|
|
256
|
+
logger.error(`child exit cleanup failed for job ${jobId}`, describeCleanupError(err));
|
|
242
257
|
}
|
|
243
258
|
})
|
|
244
259
|
.catch((err) => {
|
|
@@ -251,11 +266,46 @@ export interface HandleChildExitOptions {
|
|
|
251
266
|
jobId: string;
|
|
252
267
|
triggerDrain: () => void;
|
|
253
268
|
lockTimeoutMs?: number;
|
|
269
|
+
code?: number | null;
|
|
270
|
+
signal?: string | null;
|
|
271
|
+
releaseSlot?: (dataDir: string, jobId: string, lockTimeoutMs?: number) => Promise<void>;
|
|
272
|
+
reconcileJob?: typeof reconcileInterruptedJob;
|
|
254
273
|
}
|
|
255
274
|
|
|
256
275
|
export async function handleChildExit(opts: HandleChildExitOptions): Promise<void> {
|
|
276
|
+
const lockTimeoutMs = opts.lockTimeoutMs ?? getOrchestratorConfig().lockFileTimeout;
|
|
277
|
+
const releaseSlot = opts.releaseSlot ?? releaseJobSlot;
|
|
278
|
+
const reconcileJob = opts.reconcileJob ?? reconcileInterruptedJob;
|
|
257
279
|
try {
|
|
258
|
-
|
|
280
|
+
let releaseError: unknown;
|
|
281
|
+
let reconcileError: unknown;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await releaseSlot(opts.dataDir, opts.jobId, lockTimeoutMs);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
releaseError = err;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const jobDir = join(opts.dataDir, "current", opts.jobId);
|
|
290
|
+
try {
|
|
291
|
+
await reconcileJob({
|
|
292
|
+
dataDir: opts.dataDir,
|
|
293
|
+
jobId: opts.jobId,
|
|
294
|
+
jobDir,
|
|
295
|
+
source: "child-exit",
|
|
296
|
+
code: opts.code,
|
|
297
|
+
signal: opts.signal,
|
|
298
|
+
lockTimeoutMs,
|
|
299
|
+
});
|
|
300
|
+
} catch (err) {
|
|
301
|
+
reconcileError = err;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (releaseError && reconcileError) {
|
|
305
|
+
throw new AggregateError([releaseError, reconcileError], "child exit cleanup failed");
|
|
306
|
+
}
|
|
307
|
+
if (releaseError) throw releaseError;
|
|
308
|
+
if (reconcileError) throw reconcileError;
|
|
259
309
|
} finally {
|
|
260
310
|
opts.triggerDrain();
|
|
261
311
|
}
|
|
@@ -685,6 +735,21 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
|
|
|
685
735
|
.then(() => mkdir(dirs.rejected, { recursive: true }))
|
|
686
736
|
.then(() => mkdir(dirs.staging, { recursive: true }))
|
|
687
737
|
.then(() => harnessPreflight(logger))
|
|
738
|
+
.then(async () => {
|
|
739
|
+
try {
|
|
740
|
+
const summary = await reconcileOrphanedCurrentJobs({
|
|
741
|
+
dataDir: dirs.dataDir,
|
|
742
|
+
currentDir: dirs.current,
|
|
743
|
+
lockTimeoutMs,
|
|
744
|
+
staleAfterMs: getOrchestratorConfig().staleRunningGraceMs,
|
|
745
|
+
});
|
|
746
|
+
logger.log("boot reconciliation complete", summary);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
logger.warn(
|
|
749
|
+
`boot reconciliation failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
})
|
|
688
753
|
.then(() => new Promise<OrchestratorHandle>((resolve, reject) => {
|
|
689
754
|
const running = new Map<string, ChildHandle>();
|
|
690
755
|
const spawnFn = opts.spawn ?? createDefaultSpawn();
|
|
@@ -721,9 +786,16 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
|
|
|
721
786
|
})();
|
|
722
787
|
};
|
|
723
788
|
|
|
724
|
-
const onChildExit = (jobId: string): Promise<void> => {
|
|
789
|
+
const onChildExit = (jobId: string, result: ChildExitResult): Promise<void> => {
|
|
725
790
|
if (stopped) return Promise.resolve();
|
|
726
|
-
return handleChildExit({
|
|
791
|
+
return handleChildExit({
|
|
792
|
+
dataDir: dirs.dataDir,
|
|
793
|
+
jobId,
|
|
794
|
+
triggerDrain,
|
|
795
|
+
lockTimeoutMs,
|
|
796
|
+
code: result.code,
|
|
797
|
+
signal: result.signal,
|
|
798
|
+
});
|
|
727
799
|
};
|
|
728
800
|
|
|
729
801
|
const spawnRunnerForJob = async (jobId: string, seed: SeedData): Promise<SpawnedRunner> => {
|
|
@@ -5,7 +5,7 @@ import { unlinkSync } from "node:fs";
|
|
|
5
5
|
import { getConfig, getPipelineConfig } from "./config";
|
|
6
6
|
import { validatePipelineOrThrow } from "./validation";
|
|
7
7
|
import { loadFreshModule } from "./module-loader";
|
|
8
|
-
import { atomicWrite, writeJobStatus, type GateInfo } from "./status-writer";
|
|
8
|
+
import { atomicWrite, writeJobStatus, flushJobStatus, StatusCorruptError, type GateInfo } from "./status-writer";
|
|
9
9
|
import { decideTransition } from "./lifecycle-policy";
|
|
10
10
|
import { runPipeline } from "./task-runner";
|
|
11
11
|
import type { AuditLogEntry, PipelineResult } from "./task-runner";
|
|
@@ -672,8 +672,14 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
672
672
|
while (true) {
|
|
673
673
|
const pipeline = await loadPipeline(config.pipelineJsonPath);
|
|
674
674
|
const pipelineTasks = normalizePipelineTasks(pipeline);
|
|
675
|
-
|
|
676
|
-
|
|
675
|
+
let status: { tasks: Record<string, { state?: string }> };
|
|
676
|
+
try {
|
|
677
|
+
const statusText = await Bun.file(config.statusPath).text();
|
|
678
|
+
status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
|
|
679
|
+
} catch (parseErr: unknown) {
|
|
680
|
+
if (parseErr instanceof SyntaxError) throw new StatusCorruptError(config.statusPath, { cause: parseErr });
|
|
681
|
+
throw parseErr;
|
|
682
|
+
}
|
|
677
683
|
|
|
678
684
|
let selectedEntry: PipelineTaskEntry | null = null;
|
|
679
685
|
let selectedTaskState: TaskStateValue = TaskState.PENDING;
|
|
@@ -1092,6 +1098,49 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
1092
1098
|
}
|
|
1093
1099
|
await releaseJobSlotBestEffort(dataDir, jobId);
|
|
1094
1100
|
} catch (err) {
|
|
1101
|
+
if (err instanceof StatusCorruptError) {
|
|
1102
|
+
console.error(`Status corruption detected for job "${jobId}": ${err.statusPath} — refusing to overwrite`);
|
|
1103
|
+
if (workDir !== undefined) {
|
|
1104
|
+
try {
|
|
1105
|
+
const failureIO = createTaskFileIO({
|
|
1106
|
+
workDir,
|
|
1107
|
+
taskName: "orchestrator",
|
|
1108
|
+
trackTaskFiles: false,
|
|
1109
|
+
getStage: () => "runPipelineJob",
|
|
1110
|
+
statusPath: join(workDir, "tasks-status.json"),
|
|
1111
|
+
});
|
|
1112
|
+
const failureLogName = generateLogName(
|
|
1113
|
+
"orchestrator",
|
|
1114
|
+
"runPipelineJob",
|
|
1115
|
+
LogEvent.FAILURE_DETAILS,
|
|
1116
|
+
LogFileExtension.JSON,
|
|
1117
|
+
);
|
|
1118
|
+
await failureIO.writeLog(
|
|
1119
|
+
failureLogName,
|
|
1120
|
+
JSON.stringify({
|
|
1121
|
+
type: "status_corrupt",
|
|
1122
|
+
jobId,
|
|
1123
|
+
statusPath: err.statusPath,
|
|
1124
|
+
message: err.message,
|
|
1125
|
+
}, null, 2),
|
|
1126
|
+
);
|
|
1127
|
+
} catch {
|
|
1128
|
+
// Do not mask the original error if log-write fails
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
await cleanupPidFile(workDir);
|
|
1132
|
+
} catch {
|
|
1133
|
+
// Do not mask the original failure if PID cleanup fails
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (dataDir !== undefined) {
|
|
1137
|
+
await releaseJobSlotBestEffort(dataDir, jobId);
|
|
1138
|
+
}
|
|
1139
|
+
process.exitCode = 1;
|
|
1140
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1095
1144
|
const normalized = normalizeError(err);
|
|
1096
1145
|
console.error(normalized.message);
|
|
1097
1146
|
if (workDir !== undefined) {
|
|
@@ -1171,6 +1220,8 @@ export async function completeJob(
|
|
|
1171
1220
|
// Create complete/ directory if needed
|
|
1172
1221
|
await mkdir(config.completeDir, { recursive: true });
|
|
1173
1222
|
|
|
1223
|
+
await flushJobStatus(config.workDir);
|
|
1224
|
+
|
|
1174
1225
|
// Move job directory from current/ to complete/
|
|
1175
1226
|
await rename(config.workDir, destDir);
|
|
1176
1227
|
|