@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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/config/__tests__/models.test.ts +31 -1
  3. package/src/config/models.ts +81 -35
  4. package/src/core/__tests__/config.test.ts +79 -0
  5. package/src/core/__tests__/pipeline-runner.test.ts +268 -1
  6. package/src/core/__tests__/task-runner.test.ts +1 -2
  7. package/src/core/config.ts +17 -0
  8. package/src/core/pipeline-runner.ts +39 -4
  9. package/src/core/status-writer.ts +4 -0
  10. package/src/core/task-runner.ts +1 -1
  11. package/src/providers/__tests__/base.test.ts +1 -1
  12. package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
  13. package/src/ui/client/adapters/job-adapter.ts +1 -0
  14. package/src/ui/client/types.ts +1 -0
  15. package/src/ui/components/DAGGrid.tsx +11 -1
  16. package/src/ui/components/JobDetail.tsx +2 -1
  17. package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
  18. package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
  19. package/src/ui/components/types.ts +2 -0
  20. package/src/ui/dist/assets/{index-SKy2shWc.js → index-CNlnQmK4.js} +48 -10
  21. package/src/ui/dist/assets/{index-SKy2shWc.js.map → index-CNlnQmK4.js.map} +1 -1
  22. package/src/ui/dist/assets/style-DNbNL3Yg.css +2 -0
  23. package/src/ui/dist/index.html +2 -2
  24. package/src/ui/embedded-assets.js +6 -6
  25. package/src/ui/server/__tests__/job-control-endpoints.test.ts +474 -2
  26. package/src/ui/server/endpoints/job-control-endpoints.ts +136 -22
  27. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
  28. package/src/ui/state/transformers/status-transformer.ts +1 -0
  29. package/src/ui/state/types.ts +1 -0
  30. package/src/utils/__tests__/dag.test.ts +35 -0
  31. package/src/utils/dag.ts +1 -0
  32. package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
@@ -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
- const result = await runPipeline(relocatedEntryPath, taskExecutionContext);
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 = [];
@@ -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[KNOWN_STAGES.length - 1]!;
798
+ const lastStage = KNOWN_STAGES.at(-1)!;
799
799
  const doneProgress = computeDeterministicProgress(
800
800
  pipelineTasks ?? [taskName],
801
801
  taskName,
@@ -165,7 +165,7 @@ describe("isRetryableError", () => {
165
165
  });
166
166
 
167
167
  describe("DEFAULT_REQUEST_TIMEOUT_MS", () => {
168
- it("is 3 600 000 ms", () => {
168
+ it("is 1 hour (3 600 000 ms)", () => {
169
169
  expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(3_600_000);
170
170
  });
171
171
  });
@@ -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,
@@ -89,6 +89,7 @@ export interface NormalizedTask {
89
89
  startedAt: string | null;
90
90
  endedAt: string | null;
91
91
  attempts?: number;
92
+ restartCount?: number;
92
93
  executionTimeMs?: number;
93
94
  currentStage?: string;
94
95
  failedStage?: string;
@@ -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
- <div className="truncate font-medium">{items[index]?.title ?? formatStepName(items[index]?.id ?? "")}</div>
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.2,
20460
- tokenCostOutPerMillion: 6
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(`Invalid model alias: expected string, got ${typeof alias}`);
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(/[-.]([a-z0-9])/gi, (_, char) => char.toUpperCase());
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({ alias, provider, model, functionName, fullPath })
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
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "truncate font-medium", children: items[index2]?.title ?? formatStepName(items[index2]?.id ?? "") }),
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;