@ryanfw/prompt-orchestration-pipeline 1.2.7 → 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config/__tests__/models.test.ts +31 -1
- package/src/config/models.ts +81 -35
- package/src/core/__tests__/config.test.ts +79 -0
- package/src/core/__tests__/pipeline-runner.test.ts +268 -1
- package/src/core/__tests__/task-runner.test.ts +1 -2
- package/src/core/config.ts +17 -0
- package/src/core/pipeline-runner.ts +39 -4
- package/src/core/status-writer.ts +4 -0
- package/src/core/task-runner.ts +1 -1
- package/src/providers/__tests__/base.test.ts +1 -1
- package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
- package/src/ui/client/adapters/job-adapter.ts +1 -0
- package/src/ui/client/types.ts +1 -0
- package/src/ui/components/DAGGrid.tsx +11 -1
- package/src/ui/components/JobDetail.tsx +2 -1
- package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index-SKy2shWc.js → index-CNlnQmK4.js} +48 -10
- package/src/ui/dist/assets/{index-SKy2shWc.js.map → index-CNlnQmK4.js.map} +1 -1
- package/src/ui/dist/assets/style-DNbNL3Yg.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +474 -2
- package/src/ui/server/endpoints/job-control-endpoints.ts +136 -22
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
- package/src/ui/state/transformers/status-transformer.ts +1 -0
- package/src/ui/state/types.ts +1 -0
- package/src/utils/__tests__/dag.test.ts +35 -0
- package/src/utils/dag.ts +1 -0
- package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
package/src/core/config.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface TaskRunnerConfig {
|
|
|
16
16
|
maxRefinementAttempts: number;
|
|
17
17
|
stageTimeout: number;
|
|
18
18
|
llmRequestTimeout: number;
|
|
19
|
+
maxAttempts: number;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface LLMConfig {
|
|
@@ -95,6 +96,7 @@ export const defaultConfig = {
|
|
|
95
96
|
maxRefinementAttempts: 3,
|
|
96
97
|
stageTimeout: 300000,
|
|
97
98
|
llmRequestTimeout: 3600000,
|
|
99
|
+
maxAttempts: 3,
|
|
98
100
|
},
|
|
99
101
|
llm: {
|
|
100
102
|
defaultProvider: "openai",
|
|
@@ -184,6 +186,18 @@ function loadFromEnvironment(config: AppConfig): AppConfig {
|
|
|
184
186
|
overrides["llm"] = { maxConcurrency: parseInt(maxConcurrencyRaw, 10) };
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
const maxAttemptsRaw = process.env["PO_TASK_MAX_ATTEMPTS"];
|
|
190
|
+
if (maxAttemptsRaw) {
|
|
191
|
+
const maxAttempts = Number(maxAttemptsRaw);
|
|
192
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
193
|
+
throw new Error(`PO_TASK_MAX_ATTEMPTS must be an integer >= 1, got ${maxAttemptsRaw}`);
|
|
194
|
+
}
|
|
195
|
+
overrides["taskRunner"] = {
|
|
196
|
+
...((overrides["taskRunner"] as PlainObject | undefined) ?? {}),
|
|
197
|
+
maxAttempts,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
187
201
|
const shutdownTimeoutRaw = process.env["PO_SHUTDOWN_TIMEOUT"];
|
|
188
202
|
if (shutdownTimeoutRaw) {
|
|
189
203
|
overrides["orchestrator"] = { shutdownTimeout: parseInt(shutdownTimeoutRaw, 10) };
|
|
@@ -227,6 +241,9 @@ function validateConfig(config: AppConfig): void {
|
|
|
227
241
|
if (config.taskRunner.maxRefinementAttempts < 1) {
|
|
228
242
|
errors.push(`taskRunner.maxRefinementAttempts must be >= 1, got ${config.taskRunner.maxRefinementAttempts}`);
|
|
229
243
|
}
|
|
244
|
+
if (!Number.isInteger(config.taskRunner.maxAttempts) || config.taskRunner.maxAttempts < 1) {
|
|
245
|
+
errors.push(`taskRunner.maxAttempts must be an integer >= 1, got ${config.taskRunner.maxAttempts}`);
|
|
246
|
+
}
|
|
230
247
|
if (!(VALID_LOG_LEVELS as readonly string[]).includes(config.logging.level)) {
|
|
231
248
|
errors.push(`logging.level must be one of ${VALID_LOG_LEVELS.join(", ")}, got ${config.logging.level}`);
|
|
232
249
|
}
|
|
@@ -2,13 +2,13 @@ import { join, dirname, resolve, basename } from "node:path";
|
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { unlink, mkdir, rename, appendFile } from "node:fs/promises";
|
|
4
4
|
import { unlinkSync } from "node:fs";
|
|
5
|
-
import { getPipelineConfig } from "./config";
|
|
5
|
+
import { getConfig, getPipelineConfig } from "./config";
|
|
6
6
|
import { validatePipelineOrThrow } from "./validation";
|
|
7
7
|
import { loadFreshModule } from "./module-loader";
|
|
8
8
|
import { writeJobStatus } from "./status-writer";
|
|
9
9
|
import { decideTransition } from "./lifecycle-policy";
|
|
10
10
|
import { runPipeline } from "./task-runner";
|
|
11
|
-
import type { AuditLogEntry } from "./task-runner";
|
|
11
|
+
import type { AuditLogEntry, PipelineResult } from "./task-runner";
|
|
12
12
|
import { ensureTaskSymlinkBridge } from "./symlink-bridge";
|
|
13
13
|
import { validateTaskSymlinks, repairTaskSymlinks, cleanupTaskSymlinks } from "./symlink-utils";
|
|
14
14
|
import { createTaskFileIO, generateLogName } from "./file-io";
|
|
@@ -98,6 +98,7 @@ export interface TaskStatus {
|
|
|
98
98
|
attempts?: number;
|
|
99
99
|
executionTimeMs?: number;
|
|
100
100
|
refinementAttempts?: number;
|
|
101
|
+
restartCount?: number;
|
|
101
102
|
error?: NormalizedError;
|
|
102
103
|
failedStage?: string;
|
|
103
104
|
stageLogPath?: string;
|
|
@@ -256,6 +257,10 @@ export async function loadTaskRegistry(registryPath: string): Promise<TaskRegist
|
|
|
256
257
|
|
|
257
258
|
// ─── Pipeline job entry point ─────────────────────────────────────────────────
|
|
258
259
|
|
|
260
|
+
const INITIAL_RETRY_DELAY_MS = 2_000;
|
|
261
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
262
|
+
const RETRY_BACKOFF_MULTIPLIER = 2;
|
|
263
|
+
|
|
259
264
|
/** Runs a pipeline job end-to-end for the given job ID. */
|
|
260
265
|
export async function runPipelineJob(jobId: string): Promise<void> {
|
|
261
266
|
let workDir: string | undefined;
|
|
@@ -414,8 +419,38 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
414
419
|
},
|
|
415
420
|
};
|
|
416
421
|
|
|
417
|
-
// Delegate to task runner
|
|
418
|
-
|
|
422
|
+
// Delegate to task runner with bounded retry loop.
|
|
423
|
+
// Guard against partial test mocks or malformed runtime config.
|
|
424
|
+
const configuredMaxAttempts = getConfig().taskRunner?.maxAttempts;
|
|
425
|
+
const maxAttempts = Number.isInteger(configuredMaxAttempts) ? configuredMaxAttempts : 3;
|
|
426
|
+
const cap = Math.max(1, maxAttempts);
|
|
427
|
+
|
|
428
|
+
let result: PipelineResult | undefined;
|
|
429
|
+
for (let attempt = 1; attempt <= cap; attempt++) {
|
|
430
|
+
result = await runPipeline(relocatedEntryPath, taskExecutionContext);
|
|
431
|
+
if (result.ok) break;
|
|
432
|
+
if (attempt >= cap) break;
|
|
433
|
+
|
|
434
|
+
const delay = Math.min(
|
|
435
|
+
INITIAL_RETRY_DELAY_MS * RETRY_BACKOFF_MULTIPLIER ** (attempt - 1),
|
|
436
|
+
MAX_RETRY_DELAY_MS,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
await writeJobStatus(config.workDir, (snapshot) => {
|
|
440
|
+
const entry = snapshot.tasks[taskName] ?? {};
|
|
441
|
+
const currentAttempts = typeof entry.attempts === "number" ? entry.attempts : attempt;
|
|
442
|
+
entry.state = "running";
|
|
443
|
+
entry.attempts = currentAttempts + 1;
|
|
444
|
+
entry.restartCount = (entry.restartCount ?? 0) + 1;
|
|
445
|
+
delete entry.failedStage;
|
|
446
|
+
delete entry.error;
|
|
447
|
+
snapshot.tasks[taskName] = entry;
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await Bun.sleep(delay);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!result) throw new Error("Retry loop produced no result");
|
|
419
454
|
|
|
420
455
|
if (result.ok) {
|
|
421
456
|
// Compute execution time from logs
|
|
@@ -14,6 +14,7 @@ export interface TaskEntry {
|
|
|
14
14
|
failedStage?: string;
|
|
15
15
|
error?: string;
|
|
16
16
|
attempts?: number;
|
|
17
|
+
restartCount?: number;
|
|
17
18
|
refinementAttempts?: number;
|
|
18
19
|
tokenUsage?: unknown[];
|
|
19
20
|
startedAt?: string;
|
|
@@ -227,6 +228,7 @@ export function resetJobFromTask(jobDir: string, fromTask: string, options?: Res
|
|
|
227
228
|
delete task.failedStage;
|
|
228
229
|
delete task.error;
|
|
229
230
|
task.attempts = 0;
|
|
231
|
+
task.restartCount = 0;
|
|
230
232
|
task.refinementAttempts = 0;
|
|
231
233
|
if (options?.clearTokenUsage !== false) {
|
|
232
234
|
task.tokenUsage = [];
|
|
@@ -253,6 +255,7 @@ export function resetJobToCleanSlate(jobDir: string, options?: ResetOptions): Pr
|
|
|
253
255
|
delete task.failedStage;
|
|
254
256
|
delete task.error;
|
|
255
257
|
task.attempts = 0;
|
|
258
|
+
task.restartCount = 0;
|
|
256
259
|
task.refinementAttempts = 0;
|
|
257
260
|
if (options?.clearTokenUsage !== false) {
|
|
258
261
|
task.tokenUsage = [];
|
|
@@ -280,6 +283,7 @@ export function resetSingleTask(jobDir: string, taskId: string, options?: ResetO
|
|
|
280
283
|
delete task.failedStage;
|
|
281
284
|
delete task.error;
|
|
282
285
|
task.attempts = 0;
|
|
286
|
+
task.restartCount = 0;
|
|
283
287
|
task.refinementAttempts = 0;
|
|
284
288
|
if (options?.clearTokenUsage !== false) {
|
|
285
289
|
task.tokenUsage = [];
|
package/src/core/task-runner.ts
CHANGED
|
@@ -795,7 +795,7 @@ export async function runPipeline(
|
|
|
795
795
|
|
|
796
796
|
// Write done status (best-effort)
|
|
797
797
|
try {
|
|
798
|
-
const lastStage = KNOWN_STAGES
|
|
798
|
+
const lastStage = KNOWN_STAGES.at(-1)!;
|
|
799
799
|
const doneProgress = computeDeterministicProgress(
|
|
800
800
|
pipelineTasks ?? [taskName],
|
|
801
801
|
taskName,
|
|
@@ -30,6 +30,18 @@ describe("job adapter", () => {
|
|
|
30
30
|
expect(normalizeTasks(null)).toEqual({});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
it("maps numeric restartCount onto the normalized task", () => {
|
|
34
|
+
expect(normalizeTasks({ t1: { state: "done", restartCount: 2 } })["t1"]?.restartCount).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("treats null restartCount as undefined", () => {
|
|
38
|
+
expect(normalizeTasks({ t1: { state: "done", restartCount: null } })["t1"]?.restartCount).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves restartCount undefined when absent", () => {
|
|
42
|
+
expect(normalizeTasks({ t1: { state: "done" } })["t1"]?.restartCount).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
33
45
|
it("adapts summary jobs with defaults", () => {
|
|
34
46
|
const job = adaptJobSummary({
|
|
35
47
|
jobId: "job-1",
|
|
@@ -49,6 +49,7 @@ function normalizeTask(name: string, rawTask: unknown): NormalizedTask {
|
|
|
49
49
|
startedAt: toStringOrNull(task["startedAt"]),
|
|
50
50
|
endedAt: toStringOrNull(task["endedAt"]),
|
|
51
51
|
attempts: typeof task["attempts"] === "number" ? task["attempts"] : undefined,
|
|
52
|
+
restartCount: typeof task["restartCount"] === "number" ? task["restartCount"] : undefined,
|
|
52
53
|
executionTimeMs: typeof task["executionTimeMs"] === "number" ? task["executionTimeMs"] : undefined,
|
|
53
54
|
currentStage: typeof task["currentStage"] === "string" ? task["currentStage"] : undefined,
|
|
54
55
|
failedStage: typeof task["failedStage"] === "string" ? task["failedStage"] : undefined,
|
package/src/ui/client/types.ts
CHANGED
|
@@ -276,7 +276,17 @@ export default function DAGGrid({
|
|
|
276
276
|
data-role="card-header"
|
|
277
277
|
className={`flex items-center justify-between gap-3 rounded-t-lg border-b px-4 py-2 ${reducedMotion ? "" : "transition-opacity duration-300 ease-in-out"} ${getHeaderClasses(getItemStatus(items[index], index, activeIndex))}`}
|
|
278
278
|
>
|
|
279
|
-
|
|
279
|
+
{(items[index]?.restartCount ?? 0) > 0 ? (
|
|
280
|
+
<span
|
|
281
|
+
data-role="restart-badge"
|
|
282
|
+
className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-sm border border-red-300 bg-red-50 text-red-700 text-[11px] font-medium tabular-nums"
|
|
283
|
+
title={`Restarted ${items[index]!.restartCount} time${items[index]!.restartCount === 1 ? "" : "s"}`}
|
|
284
|
+
aria-label={`Restarted ${items[index]!.restartCount} time${items[index]!.restartCount === 1 ? "" : "s"}`}
|
|
285
|
+
>
|
|
286
|
+
↻ {items[index]!.restartCount}
|
|
287
|
+
</span>
|
|
288
|
+
) : null}
|
|
289
|
+
<div className="min-w-0 flex-1 truncate text-left font-medium">{items[index]?.title ?? formatStepName(items[index]?.id ?? "")}</div>
|
|
280
290
|
<div className="flex items-center gap-2">
|
|
281
291
|
{getItemStatus(items[index], index, activeIndex) === "running" ? (
|
|
282
292
|
<>
|
|
@@ -43,7 +43,8 @@ export default function JobDetail({
|
|
|
43
43
|
prior.stage === item.stage &&
|
|
44
44
|
prior.title === item.title &&
|
|
45
45
|
prior.subtitle === item.subtitle &&
|
|
46
|
-
prior.body === item.body
|
|
46
|
+
prior.body === item.body &&
|
|
47
|
+
prior.restartCount === item.restartCount
|
|
47
48
|
) {
|
|
48
49
|
return prior;
|
|
49
50
|
}
|
|
@@ -117,3 +117,95 @@ test("DAGGrid opens task details from keyboard interaction and renders connector
|
|
|
117
117
|
expect(view.getByText("Build · done")).toBeTruthy();
|
|
118
118
|
expect(view.container.querySelectorAll('path[marker-end="url(#dag-grid-arrow)"]').length).toBe(1);
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
test("DAGGrid does not render the restart badge when restartCount is absent or zero", () => {
|
|
122
|
+
installMatchMedia();
|
|
123
|
+
|
|
124
|
+
const view = render(
|
|
125
|
+
<DAGGrid
|
|
126
|
+
items={[makeItem({ id: "build", title: "Build", status: "pending" })]}
|
|
127
|
+
jobId="job-1"
|
|
128
|
+
taskById={{ build: makeTaskState({ name: "build", state: "pending" }) }}
|
|
129
|
+
pipelineTasks={["build"]}
|
|
130
|
+
filesByTypeForItem={() => emptyFiles}
|
|
131
|
+
geometryAdapter={geometryAdapter}
|
|
132
|
+
/>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(view.container.querySelector('[data-role="restart-badge"]')).toBeNull();
|
|
136
|
+
|
|
137
|
+
const viewZero = render(
|
|
138
|
+
<DAGGrid
|
|
139
|
+
items={[makeItem({ id: "build", title: "Build", status: "pending", restartCount: 0 })]}
|
|
140
|
+
jobId="job-1"
|
|
141
|
+
taskById={{ build: makeTaskState({ name: "build", state: "pending" }) }}
|
|
142
|
+
pipelineTasks={["build"]}
|
|
143
|
+
filesByTypeForItem={() => emptyFiles}
|
|
144
|
+
geometryAdapter={geometryAdapter}
|
|
145
|
+
/>,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(viewZero.container.querySelector('[data-role="restart-badge"]')).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("DAGGrid renders the restart badge with singular label when restartCount is 1", () => {
|
|
152
|
+
installMatchMedia();
|
|
153
|
+
|
|
154
|
+
const view = render(
|
|
155
|
+
<DAGGrid
|
|
156
|
+
items={[makeItem({ id: "build", title: "Build", status: "pending", restartCount: 1 })]}
|
|
157
|
+
jobId="job-1"
|
|
158
|
+
taskById={{ build: makeTaskState({ name: "build", state: "pending" }) }}
|
|
159
|
+
pipelineTasks={["build"]}
|
|
160
|
+
filesByTypeForItem={() => emptyFiles}
|
|
161
|
+
geometryAdapter={geometryAdapter}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const badge = view.container.querySelector('[data-role="restart-badge"]');
|
|
166
|
+
expect(badge).not.toBeNull();
|
|
167
|
+
expect(badge!.getAttribute("aria-label")).toBe("Restarted 1 time");
|
|
168
|
+
expect(badge!.getAttribute("title")).toBe("Restarted 1 time");
|
|
169
|
+
expect(badge!.textContent).toBe("↻ 1");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("DAGGrid renders the restart badge with plural label when restartCount is greater than 1", () => {
|
|
173
|
+
installMatchMedia();
|
|
174
|
+
|
|
175
|
+
const view = render(
|
|
176
|
+
<DAGGrid
|
|
177
|
+
items={[makeItem({ id: "build", title: "Build", status: "pending", restartCount: 5 })]}
|
|
178
|
+
jobId="job-1"
|
|
179
|
+
taskById={{ build: makeTaskState({ name: "build", state: "pending" }) }}
|
|
180
|
+
pipelineTasks={["build"]}
|
|
181
|
+
filesByTypeForItem={() => emptyFiles}
|
|
182
|
+
geometryAdapter={geometryAdapter}
|
|
183
|
+
/>,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const badge = view.container.querySelector('[data-role="restart-badge"]');
|
|
187
|
+
expect(badge).not.toBeNull();
|
|
188
|
+
expect(badge!.getAttribute("aria-label")).toBe("Restarted 5 times");
|
|
189
|
+
expect(badge!.textContent).toBe("↻ 5");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("DAGGrid renders the restart badge as the first child of the card header", () => {
|
|
193
|
+
installMatchMedia();
|
|
194
|
+
|
|
195
|
+
const view = render(
|
|
196
|
+
<DAGGrid
|
|
197
|
+
items={[makeItem({ id: "build", title: "Build", status: "pending", restartCount: 2 })]}
|
|
198
|
+
jobId="job-1"
|
|
199
|
+
taskById={{ build: makeTaskState({ name: "build", state: "pending" }) }}
|
|
200
|
+
pipelineTasks={["build"]}
|
|
201
|
+
filesByTypeForItem={() => emptyFiles}
|
|
202
|
+
geometryAdapter={geometryAdapter}
|
|
203
|
+
/>,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const header = view.container.querySelector('[data-role="card-header"]');
|
|
207
|
+
expect(header).not.toBeNull();
|
|
208
|
+
const firstChild = header!.firstElementChild;
|
|
209
|
+
expect(firstChild).not.toBeNull();
|
|
210
|
+
expect(firstChild!.getAttribute("data-role")).toBe("restart-badge");
|
|
211
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import "./test-dom";
|
|
2
|
+
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import { afterEach, expect, mock, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import type { DagItem, JobDetail as JobDetailType, PipelineType } from "../types";
|
|
7
|
+
|
|
8
|
+
const capturedItems: DagItem[][] = [];
|
|
9
|
+
|
|
10
|
+
mock.module("../DAGGrid", () => ({
|
|
11
|
+
default: ({ items }: { items: DagItem[] }) => {
|
|
12
|
+
capturedItems.push(items);
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const JobDetail = (await import("../JobDetail")).default;
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
capturedItems.length = 0;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function makeJob(restartCount: number): JobDetailType {
|
|
25
|
+
return {
|
|
26
|
+
id: "job-1",
|
|
27
|
+
name: "Job One",
|
|
28
|
+
status: "running",
|
|
29
|
+
tasks: {
|
|
30
|
+
build: {
|
|
31
|
+
name: "build",
|
|
32
|
+
state: "running",
|
|
33
|
+
startedAt: 1_000,
|
|
34
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
35
|
+
restartCount,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
pipeline: { tasks: ["build"] },
|
|
39
|
+
current: "build",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pipeline: PipelineType = {
|
|
44
|
+
name: "Test",
|
|
45
|
+
slug: "test",
|
|
46
|
+
description: "",
|
|
47
|
+
tasks: [{ name: "build" }],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
test("JobDetail re-emits dag item when only restartCount changes", () => {
|
|
51
|
+
const view = render(<JobDetail job={makeJob(1)} pipeline={pipeline} />);
|
|
52
|
+
expect(capturedItems.length).toBeGreaterThan(0);
|
|
53
|
+
const firstItems = capturedItems[capturedItems.length - 1] as DagItem[];
|
|
54
|
+
const firstBuild = firstItems.find((item) => item.id === "build");
|
|
55
|
+
expect(firstBuild?.restartCount).toBe(1);
|
|
56
|
+
|
|
57
|
+
view.rerender(<JobDetail job={makeJob(2)} pipeline={pipeline} />);
|
|
58
|
+
const lastItems = capturedItems[capturedItems.length - 1] as DagItem[];
|
|
59
|
+
const secondBuild = lastItems.find((item) => item.id === "build");
|
|
60
|
+
expect(secondBuild?.restartCount).toBe(2);
|
|
61
|
+
expect(secondBuild).not.toBe(firstBuild);
|
|
62
|
+
});
|
|
@@ -43,6 +43,7 @@ export interface TaskStateObject {
|
|
|
43
43
|
artifacts?: string[];
|
|
44
44
|
tokenUsage?: Record<string, unknown>;
|
|
45
45
|
attempts?: number;
|
|
46
|
+
restartCount?: number;
|
|
46
47
|
executionTimeMs?: number;
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -105,6 +106,7 @@ export interface DagItem {
|
|
|
105
106
|
body: string | null;
|
|
106
107
|
startedAt: string | number;
|
|
107
108
|
endedAt: string | number | null;
|
|
109
|
+
restartCount?: number;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
export interface PipelineTask {
|
|
@@ -20453,11 +20453,11 @@ const MODEL_CONFIG_RAW = {
|
|
|
20453
20453
|
tokenCostOutPerMillion: 4.5
|
|
20454
20454
|
},
|
|
20455
20455
|
// Alibaba (Qwen via DashScope, international/Singapore deployment, base tier)
|
|
20456
|
-
"alibaba:qwen3-max": {
|
|
20456
|
+
"alibaba:qwen3.6-max-preview": {
|
|
20457
20457
|
provider: "alibaba",
|
|
20458
|
-
model: "qwen3-max",
|
|
20459
|
-
tokenCostInPerMillion: 1.
|
|
20460
|
-
tokenCostOutPerMillion:
|
|
20458
|
+
model: "qwen3.6-max-preview",
|
|
20459
|
+
tokenCostInPerMillion: 1.3,
|
|
20460
|
+
tokenCostOutPerMillion: 7.8
|
|
20461
20461
|
},
|
|
20462
20462
|
"alibaba:qwen3.6-plus": {
|
|
20463
20463
|
provider: "alibaba",
|
|
@@ -20465,6 +20465,18 @@ const MODEL_CONFIG_RAW = {
|
|
|
20465
20465
|
tokenCostInPerMillion: 0.276,
|
|
20466
20466
|
tokenCostOutPerMillion: 1.651
|
|
20467
20467
|
},
|
|
20468
|
+
"alibaba:qwen3.6-flash": {
|
|
20469
|
+
provider: "alibaba",
|
|
20470
|
+
model: "qwen3.6-flash",
|
|
20471
|
+
tokenCostInPerMillion: 0.025,
|
|
20472
|
+
tokenCostOutPerMillion: 1.5
|
|
20473
|
+
},
|
|
20474
|
+
"alibaba:qwen3-max": {
|
|
20475
|
+
provider: "alibaba",
|
|
20476
|
+
model: "qwen3-max",
|
|
20477
|
+
tokenCostInPerMillion: 1.2,
|
|
20478
|
+
tokenCostOutPerMillion: 6
|
|
20479
|
+
},
|
|
20468
20480
|
"alibaba:qwen3.5-plus": {
|
|
20469
20481
|
provider: "alibaba",
|
|
20470
20482
|
model: "qwen3.5-plus",
|
|
@@ -20514,13 +20526,18 @@ const VALID_MODEL_ALIASES = new Set(
|
|
|
20514
20526
|
);
|
|
20515
20527
|
function aliasToFunctionName(alias) {
|
|
20516
20528
|
if (typeof alias !== "string") {
|
|
20517
|
-
throw new Error(
|
|
20529
|
+
throw new Error(
|
|
20530
|
+
`Invalid model alias: expected string, got ${typeof alias}`
|
|
20531
|
+
);
|
|
20518
20532
|
}
|
|
20519
20533
|
if (!alias.includes(":")) {
|
|
20520
20534
|
throw new Error(`Invalid model alias: "${alias}" does not contain a colon`);
|
|
20521
20535
|
}
|
|
20522
20536
|
const model = alias.split(":").slice(1).join(":");
|
|
20523
|
-
return model.replace(
|
|
20537
|
+
return model.replace(
|
|
20538
|
+
/[-.]([a-z0-9])/gi,
|
|
20539
|
+
(_, char) => char.toUpperCase()
|
|
20540
|
+
);
|
|
20524
20541
|
}
|
|
20525
20542
|
Object.freeze(
|
|
20526
20543
|
Object.fromEntries(
|
|
@@ -20542,7 +20559,13 @@ function buildProviderFunctionsIndex() {
|
|
|
20542
20559
|
index2[provider] = [];
|
|
20543
20560
|
}
|
|
20544
20561
|
index2[provider].push(
|
|
20545
|
-
Object.freeze({
|
|
20562
|
+
Object.freeze({
|
|
20563
|
+
alias,
|
|
20564
|
+
provider,
|
|
20565
|
+
model,
|
|
20566
|
+
functionName,
|
|
20567
|
+
fullPath
|
|
20568
|
+
})
|
|
20546
20569
|
);
|
|
20547
20570
|
}
|
|
20548
20571
|
for (const provider of Object.keys(index2)) {
|
|
@@ -21874,6 +21897,7 @@ function normalizeTask(name2, rawTask) {
|
|
|
21874
21897
|
startedAt: toStringOrNull(task["startedAt"]),
|
|
21875
21898
|
endedAt: toStringOrNull(task["endedAt"]),
|
|
21876
21899
|
attempts: typeof task["attempts"] === "number" ? task["attempts"] : void 0,
|
|
21900
|
+
restartCount: typeof task["restartCount"] === "number" ? task["restartCount"] : void 0,
|
|
21877
21901
|
executionTimeMs: typeof task["executionTimeMs"] === "number" ? task["executionTimeMs"] : void 0,
|
|
21878
21902
|
currentStage: typeof task["currentStage"] === "string" ? task["currentStage"] : void 0,
|
|
21879
21903
|
failedStage: typeof task["failedStage"] === "string" ? task["failedStage"] : void 0,
|
|
@@ -22283,7 +22307,8 @@ function computeDagItems(job, pipeline) {
|
|
|
22283
22307
|
subtitle: null,
|
|
22284
22308
|
body: task?.error?.message ?? null,
|
|
22285
22309
|
startedAt: task?.startedAt ?? 0,
|
|
22286
|
-
endedAt: task?.endedAt ?? null
|
|
22310
|
+
endedAt: task?.endedAt ?? null,
|
|
22311
|
+
restartCount: task?.restartCount ?? 0
|
|
22287
22312
|
};
|
|
22288
22313
|
});
|
|
22289
22314
|
}
|
|
@@ -51031,7 +51056,20 @@ function DAGGrid({
|
|
|
51031
51056
|
"data-role": "card-header",
|
|
51032
51057
|
className: `flex items-center justify-between gap-3 rounded-t-lg border-b px-4 py-2 ${reducedMotion ? "" : "transition-opacity duration-300 ease-in-out"} ${getHeaderClasses(getItemStatus(items[index2], index2, activeIndex))}`,
|
|
51033
51058
|
children: [
|
|
51034
|
-
|
|
51059
|
+
(items[index2]?.restartCount ?? 0) > 0 ? /* @__PURE__ */ jsxRuntimeExports.jsxs(
|
|
51060
|
+
"span",
|
|
51061
|
+
{
|
|
51062
|
+
"data-role": "restart-badge",
|
|
51063
|
+
className: "inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-sm border border-red-300 bg-red-50 text-red-700 text-[11px] font-medium tabular-nums",
|
|
51064
|
+
title: `Restarted ${items[index2].restartCount} time${items[index2].restartCount === 1 ? "" : "s"}`,
|
|
51065
|
+
"aria-label": `Restarted ${items[index2].restartCount} time${items[index2].restartCount === 1 ? "" : "s"}`,
|
|
51066
|
+
children: [
|
|
51067
|
+
"↻ ",
|
|
51068
|
+
items[index2].restartCount
|
|
51069
|
+
]
|
|
51070
|
+
}
|
|
51071
|
+
) : null,
|
|
51072
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "min-w-0 flex-1 truncate text-left font-medium", children: items[index2]?.title ?? formatStepName(items[index2]?.id ?? "") }),
|
|
51035
51073
|
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex items-center gap-2", children: getItemStatus(items[index2], index2, activeIndex) === "running" ? /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
|
|
51036
51074
|
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "relative h-4 w-4", "aria-label": "Active", children: [
|
|
51037
51075
|
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "sr-only", children: "Active" }),
|
|
@@ -51185,7 +51223,7 @@ function JobDetail({
|
|
|
51185
51223
|
const previous2 = prevDagItemsRef.current;
|
|
51186
51224
|
const reused = enriched.map((item, index2) => {
|
|
51187
51225
|
const prior = previous2[index2];
|
|
51188
|
-
if (prior && prior.id === item.id && prior.status === item.status && prior.stage === item.stage && prior.title === item.title && prior.subtitle === item.subtitle && prior.body === item.body) {
|
|
51226
|
+
if (prior && prior.id === item.id && prior.status === item.status && prior.stage === item.stage && prior.title === item.title && prior.subtitle === item.subtitle && prior.body === item.body && prior.restartCount === item.restartCount) {
|
|
51189
51227
|
return prior;
|
|
51190
51228
|
}
|
|
51191
51229
|
return item;
|