@tea-agent/loop-agent 0.35.1-beta.1 → 0.35.1-beta.2
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/CHANGELOG.md +10 -2
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi-executor.js +44 -0
- package/dist/shared/package-metadata.js +10 -0
- package/dist/worker/console/static/assets/{index-fsjzREob.js → index-CvsQgALl.js} +26 -26
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
- package/dist/worker/loop-agent/loop-agent-client.js +4 -3
- package/dist/worker/observability/read-model.js +20 -0
- package/dist/worker/preflight.js +2 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
- package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
- package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
- package/dist/workflows/dag/frontend-recovery-run.js +539 -0
- package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
- package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
- package/dist/workflows/dag/recovery-recommendation.js +58 -0
- package/dist/workflows/dag/runner.js +201 -16
- package/dist/workflows/dag/scheduler.js +159 -0
- package/dist/workflows/dag/types.js +107 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<link rel="stylesheet" href="/inspect/operator-chrome.css" />
|
|
8
8
|
<title>Loop 操作台 · Operator Console</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CvsQgALl.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-hJqCPs_g.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
@@ -22,6 +22,7 @@ export function useRecoveryConsole(params) {
|
|
|
22
22
|
const [recoveryNodesLoading, setRecoveryNodesLoading] = useState(false);
|
|
23
23
|
const [recoveryRunStatus, setRecoveryRunStatus] = useState(null);
|
|
24
24
|
const [recoveryFailureCategory, setRecoveryFailureCategory] = useState(null);
|
|
25
|
+
const [frontendRecovery, setFrontendRecovery] = useState(null);
|
|
25
26
|
const [manualRunId, setManualRunId] = useState(false);
|
|
26
27
|
const [manualNodeId, setManualNodeId] = useState(false);
|
|
27
28
|
const [recoveryReport, setRecoveryReport] = useState(null);
|
|
@@ -124,6 +125,7 @@ export function useRecoveryConsole(params) {
|
|
|
124
125
|
setRecoveryNodes([]);
|
|
125
126
|
setRecoveryRunStatus(null);
|
|
126
127
|
setRecoveryFailureCategory(null);
|
|
128
|
+
setFrontendRecovery(null);
|
|
127
129
|
return;
|
|
128
130
|
}
|
|
129
131
|
setRecoveryNodesLoading(true);
|
|
@@ -167,11 +169,13 @@ export function useRecoveryConsole(params) {
|
|
|
167
169
|
n.failureCategory &&
|
|
168
170
|
n.failureCategory !== "success")?.failureCategory;
|
|
169
171
|
setRecoveryFailureCategory(runFailure ?? nodeFailure ?? null);
|
|
172
|
+
setFrontendRecovery(body.frontendRecovery ?? null);
|
|
170
173
|
}
|
|
171
174
|
catch {
|
|
172
175
|
setRecoveryNodes([]);
|
|
173
176
|
setRecoveryRunStatus(null);
|
|
174
177
|
setRecoveryFailureCategory(null);
|
|
178
|
+
setFrontendRecovery(null);
|
|
175
179
|
}
|
|
176
180
|
finally {
|
|
177
181
|
setRecoveryNodesLoading(false);
|
|
@@ -323,6 +327,7 @@ export function useRecoveryConsole(params) {
|
|
|
323
327
|
recoveryRunsLoading,
|
|
324
328
|
recoveryNodesLoading,
|
|
325
329
|
recoveryRunStatus,
|
|
330
|
+
frontendRecovery,
|
|
326
331
|
manualRunId,
|
|
327
332
|
setManualRunId,
|
|
328
333
|
manualNodeId,
|
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { appendFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { computeBinarySha256, computePackageFingerprint, findPackageRoot, isJavaScriptScript, readPackageName, readPackageVersion, resolveBinPaths, resolveCmdShim, } from "../../shared/package-metadata.js";
|
|
5
|
+
import { computeBinarySha256, computePackageFingerprint, findPackageRoot, isJavaScriptScript, normalizeCliVersionLabel, readPackageName, readPackageVersion, resolveBinPaths, resolveCmdShim, } from "../../shared/package-metadata.js";
|
|
6
6
|
import { parseCommandJson } from "./parse-json.js";
|
|
7
7
|
export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
|
|
8
8
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
@@ -182,10 +182,11 @@ export class LoopAgentClient {
|
|
|
182
182
|
if (!this._identity) {
|
|
183
183
|
throw new Error("controller identity: cannot record a reported version without a pinned identity");
|
|
184
184
|
}
|
|
185
|
-
|
|
185
|
+
const normalized = normalizeCliVersionLabel(reportedVersion);
|
|
186
|
+
if (normalized !== this._identity.packageVersion) {
|
|
186
187
|
throw new Error(`controller identity: reported version ${reportedVersion} does not match package version ${this._identity.packageVersion}`);
|
|
187
188
|
}
|
|
188
|
-
this._identity = { ...this._identity, reportedVersion };
|
|
189
|
+
this._identity = { ...this._identity, reportedVersion: normalized };
|
|
189
190
|
return this._identity;
|
|
190
191
|
}
|
|
191
192
|
async run(args, options) {
|
|
@@ -1491,6 +1491,7 @@ function mergeDagRun(existing, incoming) {
|
|
|
1491
1491
|
failureCategory: incoming.failureCategory ?? existing.failureCategory,
|
|
1492
1492
|
stateConsistent: incoming.stateConsistent ?? existing.stateConsistent,
|
|
1493
1493
|
recoveryEligibility: incoming.recoveryEligibility ?? existing.recoveryEligibility,
|
|
1494
|
+
frontendRecovery: incoming.frontendRecovery ?? existing.frontendRecovery,
|
|
1494
1495
|
title: incoming.title ?? existing.title,
|
|
1495
1496
|
startedAt,
|
|
1496
1497
|
finishedAt,
|
|
@@ -1790,6 +1791,23 @@ async function loadFrontendLintProjection(runDir) {
|
|
|
1790
1791
|
blockingReasons: parsed.data.blockingReasons,
|
|
1791
1792
|
};
|
|
1792
1793
|
}
|
|
1794
|
+
function projectFrontendRecovery(state) {
|
|
1795
|
+
const s = state.frontendRecoveryState;
|
|
1796
|
+
const r = state.frontendRecoveryResult;
|
|
1797
|
+
if (!s && !r)
|
|
1798
|
+
return undefined;
|
|
1799
|
+
return {
|
|
1800
|
+
...(s?.recoveryRootRunId ? { recoveryRootRunId: s.recoveryRootRunId } : {}),
|
|
1801
|
+
...(s?.parentRunId ? { parentRunId: s.parentRunId } : {}),
|
|
1802
|
+
...(s?.childRunId ? { childRunId: s.childRunId } : {}),
|
|
1803
|
+
...(s?.attemptIndex !== undefined ? { attemptIndex: s.attemptIndex } : {}),
|
|
1804
|
+
...(s?.continuationCount !== undefined
|
|
1805
|
+
? { continuationCount: s.continuationCount }
|
|
1806
|
+
: {}),
|
|
1807
|
+
...(s?.phase ? { phase: s.phase } : {}),
|
|
1808
|
+
...(r?.outcome ? { outcome: r.outcome } : {}),
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1793
1811
|
async function parseDagStateFile(statePath, now) {
|
|
1794
1812
|
try {
|
|
1795
1813
|
if (!existsSync(statePath))
|
|
@@ -1860,6 +1878,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1860
1878
|
loadBackendTestProjection(runDir, nodes),
|
|
1861
1879
|
loadFrontendLintProjection(runDir),
|
|
1862
1880
|
]);
|
|
1881
|
+
const frontendRecovery = projectFrontendRecovery(state);
|
|
1863
1882
|
const continuationRecord = state.continuation &&
|
|
1864
1883
|
typeof state.continuation.parentRunId === "string" &&
|
|
1865
1884
|
typeof state.continuation.effectiveFromNodeId === "string" &&
|
|
@@ -1918,6 +1937,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1918
1937
|
...(continuation ? { continuation } : {}),
|
|
1919
1938
|
...(backendTest ? { backendTest } : {}),
|
|
1920
1939
|
...(frontendLint ? { frontendLint } : {}),
|
|
1940
|
+
...(frontendRecovery ? { frontendRecovery } : {}),
|
|
1921
1941
|
};
|
|
1922
1942
|
}
|
|
1923
1943
|
catch {
|
package/dist/worker/preflight.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeCliVersionLabel } from "../shared/package-metadata.js";
|
|
3
4
|
import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from "./loop-agent/loop-agent-client.js";
|
|
4
5
|
export const DEFAULT_CHECK_REPO_TIMEOUT_MS = 300_000;
|
|
5
6
|
export async function preflightTargetRepo(input) {
|
|
@@ -47,7 +48,7 @@ export async function preflightTargetRepo(input) {
|
|
|
47
48
|
controllerIdentity: identity,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
|
-
const reportedVersion = version.stdout
|
|
51
|
+
const reportedVersion = normalizeCliVersionLabel(version.stdout);
|
|
51
52
|
let controllerIdentity = identity
|
|
52
53
|
? { ...identity, reportedVersion }
|
|
53
54
|
: undefined;
|
|
@@ -724,30 +724,40 @@ export function deterministicRewriteScenarioParam(input) {
|
|
|
724
724
|
return { ok: true, source: nextSource, detail: "rewritten" };
|
|
725
725
|
}
|
|
726
726
|
function validatePythonSyntaxWithoutExecution(source) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
727
|
+
// Prefer `python3` (present on modern POSIX images and macOS); fall back to
|
|
728
|
+
// `python` for older or Windows layouts where only the unversioned shim exists.
|
|
729
|
+
// A missing interpreter must not silently roll back an otherwise-valid
|
|
730
|
+
// deterministic rewrite.
|
|
731
|
+
const candidates = ["python3", "python"];
|
|
732
|
+
let lastError;
|
|
733
|
+
for (const interpreter of candidates) {
|
|
734
|
+
const result = spawnSync(interpreter, ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
|
|
735
|
+
input: source,
|
|
736
|
+
encoding: "utf8",
|
|
737
|
+
env: {
|
|
738
|
+
...process.env,
|
|
739
|
+
PYTHONDONTWRITEBYTECODE: "1",
|
|
740
|
+
PYTHONUTF8: "1",
|
|
741
|
+
},
|
|
742
|
+
timeout: 15_000,
|
|
743
|
+
});
|
|
744
|
+
if (result.error) {
|
|
745
|
+
lastError = result.error.message;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (result.status !== 0) {
|
|
749
|
+
const detail = String(result.stderr || result.stdout || "invalid Python syntax")
|
|
750
|
+
.trim()
|
|
751
|
+
.replace(/\s+/g, " ")
|
|
752
|
+
.slice(0, 500);
|
|
753
|
+
return { ok: false, detail: `python syntax validation failed: ${detail}` };
|
|
754
|
+
}
|
|
755
|
+
return { ok: true, detail: "python ast.parse PASS" };
|
|
749
756
|
}
|
|
750
|
-
return {
|
|
757
|
+
return {
|
|
758
|
+
ok: false,
|
|
759
|
+
detail: `python syntax validation unavailable: ${lastError ?? "python/python3 not found"}`,
|
|
760
|
+
};
|
|
751
761
|
}
|
|
752
762
|
export async function applyDeterministicScenarioParamRepairs(input) {
|
|
753
763
|
const repairable = input.facts.entries.filter((entry) => entry.status === "MISMATCH" && entry.repairability === "repairable");
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { computeResetClosure } from "./rerun-plan.js";
|
|
2
|
+
/**
|
|
3
|
+
* Frontend-only candidate-continuation recovery planning (AC-5).
|
|
4
|
+
*
|
|
5
|
+
* Distinct from the generic `dag rerun` reset closure: the failure source is
|
|
6
|
+
* derived from the prewrite-result's `selectedPlanNodeId` rather than an
|
|
7
|
+
* operator-selected node, and the closure starts at the specific frontend
|
|
8
|
+
* candidate producer that failed.
|
|
9
|
+
*/
|
|
10
|
+
/** Idempotency namespace for requestId-keyed recovery intents (D2). */
|
|
11
|
+
export const FRONTEND_RECOVERY_INTENT_REL_DIR = ".runtime/frontend-recovery";
|
|
12
|
+
/** Import manifest path inside the child run dir; hashed into the activation marker. */
|
|
13
|
+
export const FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH = ".runtime/import-manifest.json";
|
|
14
|
+
/** Staging directory prefix under `active/` for in-flight child materialization. */
|
|
15
|
+
export const FRONTEND_RECOVERY_STAGING_PREFIX = ".frontend-recovery-staging-";
|
|
16
|
+
export function deriveFrontendRecoveryFailureSource(result) {
|
|
17
|
+
if (result.selectedPlanNodeId === "frontend-plan-revision-pi") {
|
|
18
|
+
return "frontend-plan-revision-pi";
|
|
19
|
+
}
|
|
20
|
+
if (result.selectedPlanNodeId === "frontend-plan-pi") {
|
|
21
|
+
return "frontend-plan-pi";
|
|
22
|
+
}
|
|
23
|
+
return "frontend-prewrite-gate-shell";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compute the frontend candidate-continuation reset partition:
|
|
27
|
+
* - `frontend-plan-pi` failure → reset plan + design-review + prewrite (+ descendants);
|
|
28
|
+
* - `frontend-plan-revision-pi` failure → reset revision + final-review + prewrite (+ descendants);
|
|
29
|
+
* - prewrite-only materialization → reset prewrite (+ descendants), reuse verified plan/review.
|
|
30
|
+
*
|
|
31
|
+
* Everything upstream of the failure source is imported as verified parent facts.
|
|
32
|
+
*/
|
|
33
|
+
export function computeFrontendRecoveryPlan(input) {
|
|
34
|
+
const failureSource = deriveFrontendRecoveryFailureSource(input.result);
|
|
35
|
+
return computeFrontendRecoveryPlanForSource({
|
|
36
|
+
spec: input.spec,
|
|
37
|
+
parentRunId: input.parentRunId,
|
|
38
|
+
requestId: input.requestId,
|
|
39
|
+
failureSource,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Phase 5: writer transient partial-write recovery plan. The writer itself
|
|
44
|
+
* failed after some bounded writes, so the child re-runs `frontend-implement-pi`
|
|
45
|
+
* (and its descendants) while importing the verified contract/scout/plan/prewrite
|
|
46
|
+
* facts. Everything upstream of the writer is imported; the writer + descendants
|
|
47
|
+
* are reset.
|
|
48
|
+
*/
|
|
49
|
+
export function computeFrontendWriterRecoveryPlan(input) {
|
|
50
|
+
return computeFrontendRecoveryPlanForSource({
|
|
51
|
+
spec: input.spec,
|
|
52
|
+
parentRunId: input.parentRunId,
|
|
53
|
+
requestId: input.requestId,
|
|
54
|
+
failureSource: "frontend-implement-pi",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function computeFrontendRecoveryPlanForSource(input) {
|
|
58
|
+
const resetNodeIds = computeResetClosure(input.spec, input.failureSource);
|
|
59
|
+
const resetSet = new Set(resetNodeIds);
|
|
60
|
+
const importedNodeIds = input.spec.tasks
|
|
61
|
+
.map((task) => task.id)
|
|
62
|
+
.filter((nodeId) => !resetSet.has(nodeId))
|
|
63
|
+
.sort();
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
parentRunId: input.parentRunId,
|
|
67
|
+
requestId: input.requestId,
|
|
68
|
+
failureSource: input.failureSource,
|
|
69
|
+
resetNodeIds,
|
|
70
|
+
importedNodeIds,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export { computeFrontendRecoveryPlanForSource };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
5
|
+
/**
|
|
6
|
+
* Phase 6: frontend recovery root manifest — the single final read model for a
|
|
7
|
+
* frontend recovery chain. A "root" is the first physical run of a
|
|
8
|
+
* frontend-implementation attempt; its parent/child lineage is counted once
|
|
9
|
+
* under `recoveryRootRunId`. report/Console read the manifest for dedup and
|
|
10
|
+
* final-status display instead of scanning parent/child fields (P1-4).
|
|
11
|
+
*/
|
|
12
|
+
export const FRONTEND_RECOVERY_ROOT_MANIFEST_SCHEMA_ID = "frontend-recovery-root-manifest-v1";
|
|
13
|
+
export const frontendRecoveryRootManifestSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
schemaVersion: z.literal(1),
|
|
16
|
+
schemaId: z.literal(FRONTEND_RECOVERY_ROOT_MANIFEST_SCHEMA_ID),
|
|
17
|
+
recoveryRootRunId: z.string().min(1),
|
|
18
|
+
/** Terminal logical status for the whole chain (finished/partial_failed/failed). */
|
|
19
|
+
status: z.enum(["finished", "partial_failed", "failed"]),
|
|
20
|
+
outcome: z.enum([
|
|
21
|
+
"none",
|
|
22
|
+
"recovered",
|
|
23
|
+
"candidate-contract-invalid",
|
|
24
|
+
"prewrite-blocked",
|
|
25
|
+
"repair-exhausted",
|
|
26
|
+
"auto-recovery-blocked",
|
|
27
|
+
]),
|
|
28
|
+
attempts: z.array(z
|
|
29
|
+
.object({
|
|
30
|
+
runId: z.string().min(1),
|
|
31
|
+
role: z.enum(["root", "child"]),
|
|
32
|
+
attemptIndex: z.number().int().min(0),
|
|
33
|
+
outcome: z.enum([
|
|
34
|
+
"none",
|
|
35
|
+
"recovered",
|
|
36
|
+
"candidate-contract-invalid",
|
|
37
|
+
"prewrite-blocked",
|
|
38
|
+
"repair-exhausted",
|
|
39
|
+
"auto-recovery-blocked",
|
|
40
|
+
]),
|
|
41
|
+
status: z.enum(["finished", "partial_failed", "failed"]),
|
|
42
|
+
})
|
|
43
|
+
.strict()),
|
|
44
|
+
evidenceRefs: z.array(z
|
|
45
|
+
.object({
|
|
46
|
+
runId: z.string().min(1),
|
|
47
|
+
relativePath: z.string().min(1),
|
|
48
|
+
sha256: z.string().min(1),
|
|
49
|
+
})
|
|
50
|
+
.strict()),
|
|
51
|
+
createdAt: z.string().min(1),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
/** Relative path of the root manifest inside a run dir. */
|
|
55
|
+
export const FRONTEND_RECOVERY_ROOT_MANIFEST_REL = "contracts/frontend-recovery-root-manifest.json";
|
|
56
|
+
export function frontendRecoveryRootManifestAbsPath(runDir) {
|
|
57
|
+
return path.join(runDir, FRONTEND_RECOVERY_ROOT_MANIFEST_REL);
|
|
58
|
+
}
|
|
59
|
+
export async function writeFrontendRecoveryRootManifest(runDir, manifest) {
|
|
60
|
+
const abs = frontendRecoveryRootManifestAbsPath(runDir);
|
|
61
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
62
|
+
await writeJsonAtomic(abs, manifest);
|
|
63
|
+
return abs;
|
|
64
|
+
}
|
|
65
|
+
export async function readFrontendRecoveryRootManifest(runDir) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = JSON.parse(await readFile(frontendRecoveryRootManifestAbsPath(runDir), "utf8"));
|
|
68
|
+
const parsed = frontendRecoveryRootManifestSchema.safeParse(raw);
|
|
69
|
+
return parsed.success ? parsed.data : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build a root manifest from a converged root run's lineage. The manifest is the
|
|
77
|
+
* single authority for final status/outcome: parent keeps its raw failure status,
|
|
78
|
+
* the chain's logical status is only `finished` when a child finished and the
|
|
79
|
+
* outcome is `recovered`.
|
|
80
|
+
*/
|
|
81
|
+
export function buildFrontendRecoveryRootManifest(input) {
|
|
82
|
+
const status = input.outcome === "recovered" ? "finished" : "failed";
|
|
83
|
+
return {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
schemaId: FRONTEND_RECOVERY_ROOT_MANIFEST_SCHEMA_ID,
|
|
86
|
+
recoveryRootRunId: input.recoveryRootRunId,
|
|
87
|
+
status,
|
|
88
|
+
outcome: input.outcome,
|
|
89
|
+
attempts: input.attempts,
|
|
90
|
+
evidenceRefs: input.evidenceRefs ?? [],
|
|
91
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Dedup a set of DAG runs by recovery root (P1-4): each root contributes exactly
|
|
96
|
+
* one summary; parent/child physical runs are folded into the root's attempts.
|
|
97
|
+
* A run with no `frontendRecoveryState` is its own single-attempt root.
|
|
98
|
+
*/
|
|
99
|
+
export function aggregateFrontendRecoveryRoots(runs) {
|
|
100
|
+
const byRoot = new Map();
|
|
101
|
+
for (const run of runs) {
|
|
102
|
+
const rootId = run.frontendRecoveryState?.recoveryRootRunId ?? run.runId;
|
|
103
|
+
const list = byRoot.get(rootId) ?? [];
|
|
104
|
+
list.push(run);
|
|
105
|
+
byRoot.set(rootId, list);
|
|
106
|
+
}
|
|
107
|
+
const result = [];
|
|
108
|
+
for (const [rootId, list] of byRoot) {
|
|
109
|
+
const sorted = [...list].sort((a, b) => (a.frontendRecoveryState?.attemptIndex ?? 0) -
|
|
110
|
+
(b.frontendRecoveryState?.attemptIndex ?? 0));
|
|
111
|
+
// The root's displayed status is the last attempt's status, unless the
|
|
112
|
+
// root run itself finished (a child recovered only surfaces `finished`
|
|
113
|
+
// via the manifest, not via the raw root run status).
|
|
114
|
+
const last = sorted[sorted.length - 1];
|
|
115
|
+
result.push({
|
|
116
|
+
recoveryRootRunId: rootId,
|
|
117
|
+
status: last.status,
|
|
118
|
+
attemptCount: sorted.length,
|
|
119
|
+
runIds: sorted.map((run) => run.runId),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|