@smithers-orchestrator/time-travel 0.16.0

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +68 -0
  3. package/src/BranchInfo.ts +11 -0
  4. package/src/ForkParams.ts +11 -0
  5. package/src/JUMP_MAX_FRAME_NO.js +1 -0
  6. package/src/JUMP_RUN_ID_PATTERN.js +1 -0
  7. package/src/JumpResult.ts +9 -0
  8. package/src/JumpStepName.ts +10 -0
  9. package/src/JumpToFrameError.js +23 -0
  10. package/src/JumpToFrameInput.ts +36 -0
  11. package/src/NodeChange.ts +7 -0
  12. package/src/NodeSnapshot.ts +8 -0
  13. package/src/OutputChange.ts +5 -0
  14. package/src/ParsedSnapshot.ts +18 -0
  15. package/src/REWIND_RATE_LIMIT_MAX.js +1 -0
  16. package/src/REWIND_RATE_LIMIT_WINDOW_MS.js +1 -0
  17. package/src/RalphChange.ts +7 -0
  18. package/src/RalphSnapshot.ts +5 -0
  19. package/src/ReplayParams.ts +12 -0
  20. package/src/ReplayResult.ts +11 -0
  21. package/src/RetryTaskOptions.ts +10 -0
  22. package/src/RetryTaskResult.ts +5 -0
  23. package/src/RevertOptions.ts +9 -0
  24. package/src/RevertResult.ts +5 -0
  25. package/src/RewindAuditResult.ts +5 -0
  26. package/src/RewindLockHandle.ts +4 -0
  27. package/src/RunTimeline.ts +11 -0
  28. package/src/SnapshotDiff.ts +18 -0
  29. package/src/TimeTravelOptions.ts +11 -0
  30. package/src/TimeTravelResult.ts +7 -0
  31. package/src/TimelineFrame.ts +11 -0
  32. package/src/TimelineTree.ts +9 -0
  33. package/src/acquireRewindLock.js +32 -0
  34. package/src/countRecentRewindAuditRows.js +27 -0
  35. package/src/diff.js +189 -0
  36. package/src/evaluateRewindRateLimit.js +41 -0
  37. package/src/fork/_helpers.js +28 -0
  38. package/src/fork/forkRunEffect.js +147 -0
  39. package/src/fork/getBranchInfoEffect.js +26 -0
  40. package/src/fork/index.js +41 -0
  41. package/src/fork/listBranchesEffect.js +25 -0
  42. package/src/hasRewindLock.js +11 -0
  43. package/src/index.d.ts +1170 -0
  44. package/src/index.js +43 -0
  45. package/src/jumpToFrame.js +1077 -0
  46. package/src/listRewindAuditRows.js +83 -0
  47. package/src/metrics.js +4 -0
  48. package/src/recoverInProgressRewindAudits.js +72 -0
  49. package/src/replay.js +22 -0
  50. package/src/replayFromCheckpointEffect.js +59 -0
  51. package/src/replaysStarted.js +2 -0
  52. package/src/resetRewindLocksForTests.js +8 -0
  53. package/src/resolveRewindAuditClient.js +38 -0
  54. package/src/retry-task.js +215 -0
  55. package/src/revert.js +68 -0
  56. package/src/rewindAudit.js +9 -0
  57. package/src/rewindLock.js +7 -0
  58. package/src/rewindLockStore.js +8 -0
  59. package/src/rewindRateLimit.js +3 -0
  60. package/src/runForksCreated.js +2 -0
  61. package/src/schema.js +46 -0
  62. package/src/snapshot/Snapshot.ts +15 -0
  63. package/src/snapshot/SnapshotData.ts +19 -0
  64. package/src/snapshot/captureSnapshotEffect.js +70 -0
  65. package/src/snapshot/index.js +57 -0
  66. package/src/snapshot/listSnapshotsEffect.js +32 -0
  67. package/src/snapshot/loadSnapshotEffect.js +46 -0
  68. package/src/snapshot/parseSnapshot.js +31 -0
  69. package/src/snapshotDuration.js +7 -0
  70. package/src/snapshotsCaptured.js +2 -0
  71. package/src/timeline/_helpers.js +7 -0
  72. package/src/timeline/buildTimelineEffect.js +38 -0
  73. package/src/timeline/buildTimelineTreeEffect.js +30 -0
  74. package/src/timeline/formatTimelineAsJson.js +23 -0
  75. package/src/timeline/formatTimelineForTui.js +31 -0
  76. package/src/timeline/index.js +31 -0
  77. package/src/timetravel.js +247 -0
  78. package/src/types.ts +15 -0
  79. package/src/updateRewindAuditRow.js +35 -0
  80. package/src/validateJumpFrameNo.js +23 -0
  81. package/src/validateJumpRunId.js +18 -0
  82. package/src/vcs-version/VcsTag.ts +9 -0
  83. package/src/vcs-version/index.js +61 -0
  84. package/src/vcs-version/loadVcsTagEffect.js +27 -0
  85. package/src/vcs-version/rerunAtRevisionEffect.js +25 -0
  86. package/src/vcs-version/resolveWorkflowAtRevisionEffect.js +32 -0
  87. package/src/vcs-version/tagSnapshotVcsEffect.js +56 -0
  88. package/src/writeRewindAuditRow.js +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 William Cory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@smithers-orchestrator/time-travel",
3
+ "version": "0.16.0",
4
+ "description": "Smithers snapshots, diffs, forks, replay, timelines, and VCS-tagged run history",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "import": "./src/index.js",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./snapshot": {
14
+ "types": "./src/index.d.ts",
15
+ "import": "./src/snapshot/index.js",
16
+ "default": "./src/snapshot/index.js"
17
+ },
18
+ "./fork": {
19
+ "types": "./src/index.d.ts",
20
+ "import": "./src/fork/index.js",
21
+ "default": "./src/fork/index.js"
22
+ },
23
+ "./timeline": {
24
+ "types": "./src/index.d.ts",
25
+ "import": "./src/timeline/index.js",
26
+ "default": "./src/timeline/index.js"
27
+ },
28
+ "./vcs-version": {
29
+ "types": "./src/index.d.ts",
30
+ "import": "./src/vcs-version/index.js",
31
+ "default": "./src/vcs-version/index.js"
32
+ },
33
+ "./metrics": {
34
+ "types": "./src/index.d.ts",
35
+ "import": "./src/metrics.js",
36
+ "default": "./src/metrics.js"
37
+ },
38
+ "./*": {
39
+ "types": "./src/index.d.ts",
40
+ "import": "./src/*.js",
41
+ "default": "./src/*.js"
42
+ }
43
+ },
44
+ "files": [
45
+ "src/"
46
+ ],
47
+ "dependencies": {
48
+ "@effect/platform-bun": "^0.89.0",
49
+ "drizzle-orm": "^0.45.1",
50
+ "picocolors": "^1.1.1",
51
+ "@smithers-orchestrator/driver": "0.16.0",
52
+ "@smithers-orchestrator/db": "0.16.0",
53
+ "@smithers-orchestrator/graph": "0.16.0",
54
+ "@smithers-orchestrator/scheduler": "0.16.0",
55
+ "@smithers-orchestrator/observability": "0.16.0",
56
+ "@smithers-orchestrator/errors": "0.16.0",
57
+ "@smithers-orchestrator/vcs": "0.16.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/bun": "latest",
61
+ "typescript": "~5.9.3"
62
+ },
63
+ "scripts": {
64
+ "build": "tsup --dts-only",
65
+ "test": "bun test tests",
66
+ "typecheck": "tsc -p tsconfig.json --noEmit"
67
+ }
68
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Branch metadata.
3
+ */
4
+ export type BranchInfo = {
5
+ runId: string;
6
+ parentRunId: string;
7
+ parentFrameNo: number;
8
+ branchLabel: string | null;
9
+ forkDescription: string | null;
10
+ createdAtMs: number;
11
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Parameters for forking a run.
3
+ */
4
+ export type ForkParams = {
5
+ parentRunId: string;
6
+ frameNo: number;
7
+ inputOverrides?: Record<string, unknown>;
8
+ resetNodes?: string[];
9
+ branchLabel?: string;
10
+ forkDescription?: string;
11
+ };
@@ -0,0 +1 @@
1
+ export const JUMP_MAX_FRAME_NO = 2_147_483_647;
@@ -0,0 +1 @@
1
+ export const JUMP_RUN_ID_PATTERN = /^[a-z0-9_-]{1,64}$/;
@@ -0,0 +1,9 @@
1
+ export type JumpResult = {
2
+ ok: true;
3
+ newFrameNo: number;
4
+ revertedSandboxes: number;
5
+ deletedFrames: number;
6
+ deletedAttempts: number;
7
+ invalidatedDiffs: number;
8
+ durationMs: number;
9
+ };
@@ -0,0 +1,10 @@
1
+ export type JumpStepName =
2
+ | "snapshot-pre-jump"
3
+ | "pause-event-loop"
4
+ | "revert-sandboxes"
5
+ | "truncate-frames"
6
+ | "truncate-attempts"
7
+ | "truncate-outputs"
8
+ | "invalidate-diffs"
9
+ | "rebuild-reconciler"
10
+ | "resume-event-loop";
@@ -0,0 +1,23 @@
1
+ export class JumpToFrameError extends Error {
2
+ /** @type {string} */
3
+ code;
4
+
5
+ /** @type {string | undefined} */
6
+ hint;
7
+
8
+ /** @type {Record<string, unknown> | undefined} */
9
+ details;
10
+
11
+ /**
12
+ * @param {string} code
13
+ * @param {string} message
14
+ * @param {{ hint?: string; details?: Record<string, unknown> }} [options]
15
+ */
16
+ constructor(code, message, options = {}) {
17
+ super(message);
18
+ this.name = "JumpToFrameError";
19
+ this.code = code;
20
+ this.hint = options.hint;
21
+ this.details = options.details;
22
+ }
23
+ }
@@ -0,0 +1,36 @@
1
+ import type { SmithersDb } from "@smithers-orchestrator/db/adapter";
2
+ import type { SmithersEvent } from "@smithers-orchestrator/observability/SmithersEvent";
3
+ import type { JumpStepName } from "./JumpStepName";
4
+
5
+ export type JumpToFrameInput = {
6
+ adapter: SmithersDb;
7
+ runId: unknown;
8
+ frameNo: unknown;
9
+ confirm?: unknown;
10
+ caller?: string;
11
+ pauseRunLoop?: () => Promise<void> | void;
12
+ resumeRunLoop?: () => Promise<void> | void;
13
+ captureReconcilerState?: () => Promise<unknown> | unknown;
14
+ restoreReconcilerState?: (snapshot: unknown) => Promise<void> | void;
15
+ rebuildReconcilerState?: (xmlJson: string) => Promise<void> | void;
16
+ emitEvent?: (event: SmithersEvent) => Promise<void> | void;
17
+ getCurrentPointerImpl?: (cwd?: string) => Promise<string | null>;
18
+ revertToPointerImpl?: (
19
+ pointer: string,
20
+ cwd?: string,
21
+ ) => Promise<{ success: boolean; error?: string }>;
22
+ nowMs?: () => number;
23
+ rateLimit?: {
24
+ maxPerWindow?: number;
25
+ windowMs?: number;
26
+ };
27
+ hooks?: {
28
+ beforeStep?: (step: JumpStepName) => Promise<void> | void;
29
+ afterStep?: (step: JumpStepName) => Promise<void> | void;
30
+ };
31
+ onLog?: (
32
+ level: "info" | "warn" | "error",
33
+ message: string,
34
+ fields?: Record<string, unknown>,
35
+ ) => Promise<void> | void;
36
+ };
@@ -0,0 +1,7 @@
1
+ import type { NodeSnapshot } from "./NodeSnapshot";
2
+
3
+ export type NodeChange = {
4
+ nodeId: string;
5
+ from: NodeSnapshot;
6
+ to: NodeSnapshot;
7
+ };
@@ -0,0 +1,8 @@
1
+ export type NodeSnapshot = {
2
+ nodeId: string;
3
+ iteration: number;
4
+ state: string;
5
+ lastAttempt: number | null;
6
+ outputTable: string;
7
+ label: string | null;
8
+ };
@@ -0,0 +1,5 @@
1
+ export type OutputChange = {
2
+ key: string;
3
+ from: unknown;
4
+ to: unknown;
5
+ };
@@ -0,0 +1,18 @@
1
+ import type { NodeSnapshot } from "./NodeSnapshot";
2
+ import type { RalphSnapshot } from "./RalphSnapshot";
3
+
4
+ /**
5
+ * Parsed snapshot data for diffing and display.
6
+ */
7
+ export type ParsedSnapshot = {
8
+ runId: string;
9
+ frameNo: number;
10
+ nodes: Record<string, NodeSnapshot>;
11
+ outputs: Record<string, unknown>;
12
+ ralph: Record<string, RalphSnapshot>;
13
+ input: Record<string, unknown>;
14
+ vcsPointer: string | null;
15
+ workflowHash: string | null;
16
+ contentHash: string;
17
+ createdAtMs: number;
18
+ };
@@ -0,0 +1 @@
1
+ export const REWIND_RATE_LIMIT_MAX = 10;
@@ -0,0 +1 @@
1
+ export const REWIND_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
@@ -0,0 +1,7 @@
1
+ import type { RalphSnapshot } from "./RalphSnapshot";
2
+
3
+ export type RalphChange = {
4
+ ralphId: string;
5
+ from: RalphSnapshot;
6
+ to: RalphSnapshot;
7
+ };
@@ -0,0 +1,5 @@
1
+ export type RalphSnapshot = {
2
+ ralphId: string;
3
+ iteration: number;
4
+ done: boolean;
5
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Parameters for replaying from a checkpoint.
3
+ */
4
+ export type ReplayParams = {
5
+ parentRunId: string;
6
+ frameNo: number;
7
+ inputOverrides?: Record<string, unknown>;
8
+ resetNodes?: string[];
9
+ branchLabel?: string;
10
+ restoreVcs?: boolean;
11
+ cwd?: string;
12
+ };
@@ -0,0 +1,11 @@
1
+ import type { BranchInfo } from "./BranchInfo";
2
+ import type { Snapshot } from "./snapshot/Snapshot";
3
+
4
+ export type ReplayResult = {
5
+ runId: string;
6
+ branch: BranchInfo;
7
+ snapshot: Snapshot;
8
+ vcsRestored: boolean;
9
+ vcsPointer: string | null;
10
+ vcsError?: string;
11
+ };
@@ -0,0 +1,10 @@
1
+ import type { SmithersEvent } from "@smithers-orchestrator/observability/SmithersEvent";
2
+
3
+ export type RetryTaskOptions = {
4
+ runId: string;
5
+ nodeId: string;
6
+ iteration?: number;
7
+ resetDependents?: boolean;
8
+ force?: boolean;
9
+ onProgress?: (event: SmithersEvent) => void;
10
+ };
@@ -0,0 +1,5 @@
1
+ export type RetryTaskResult = {
2
+ success: boolean;
3
+ resetNodes: string[];
4
+ error?: string;
5
+ };
@@ -0,0 +1,9 @@
1
+ import type { SmithersEvent } from "@smithers-orchestrator/observability/SmithersEvent";
2
+
3
+ export type RevertOptions = {
4
+ runId: string;
5
+ nodeId: string;
6
+ iteration: number;
7
+ attempt: number;
8
+ onProgress?: (event: SmithersEvent) => void;
9
+ };
@@ -0,0 +1,5 @@
1
+ export type RevertResult = {
2
+ success: boolean;
3
+ error?: string;
4
+ jjPointer?: string;
5
+ };
@@ -0,0 +1,5 @@
1
+ export type RewindAuditResult =
2
+ | "success"
3
+ | "failed"
4
+ | "partial"
5
+ | "in_progress";
@@ -0,0 +1,4 @@
1
+ export type RewindLockHandle = {
2
+ runId: string;
3
+ release: () => boolean;
4
+ };
@@ -0,0 +1,11 @@
1
+ import type { BranchInfo } from "./BranchInfo";
2
+ import type { TimelineFrame } from "./TimelineFrame";
3
+
4
+ /**
5
+ * Timeline for a single run.
6
+ */
7
+ export type RunTimeline = {
8
+ runId: string;
9
+ frames: TimelineFrame[];
10
+ branch: BranchInfo | null;
11
+ };
@@ -0,0 +1,18 @@
1
+ import type { NodeChange } from "./NodeChange";
2
+ import type { OutputChange } from "./OutputChange";
3
+ import type { RalphChange } from "./RalphChange";
4
+
5
+ /**
6
+ * Structured diff between two snapshots.
7
+ */
8
+ export type SnapshotDiff = {
9
+ nodesAdded: string[];
10
+ nodesRemoved: string[];
11
+ nodesChanged: NodeChange[];
12
+ outputsAdded: string[];
13
+ outputsRemoved: string[];
14
+ outputsChanged: OutputChange[];
15
+ ralphChanged: RalphChange[];
16
+ inputChanged: boolean;
17
+ vcsPointerChanged: boolean;
18
+ };
@@ -0,0 +1,11 @@
1
+ import type { SmithersEvent } from "@smithers-orchestrator/observability/SmithersEvent";
2
+
3
+ export type TimeTravelOptions = {
4
+ runId: string;
5
+ nodeId: string;
6
+ iteration?: number;
7
+ attempt?: number;
8
+ resetDependents?: boolean;
9
+ restoreVcs?: boolean;
10
+ onProgress?: (event: SmithersEvent) => void;
11
+ };
@@ -0,0 +1,7 @@
1
+ export type TimeTravelResult = {
2
+ success: boolean;
3
+ jjPointer?: string;
4
+ vcsRestored: boolean;
5
+ resetNodes: string[];
6
+ error?: string;
7
+ };
@@ -0,0 +1,11 @@
1
+ import type { BranchInfo } from "./BranchInfo";
2
+
3
+ /**
4
+ * Timeline entry for a single frame in a run.
5
+ */
6
+ export type TimelineFrame = {
7
+ frameNo: number;
8
+ createdAtMs: number;
9
+ contentHash: string;
10
+ forkPoints: BranchInfo[];
11
+ };
@@ -0,0 +1,9 @@
1
+ import type { RunTimeline } from "./RunTimeline";
2
+
3
+ /**
4
+ * Recursive timeline tree including forks.
5
+ */
6
+ export type TimelineTree = {
7
+ timeline: RunTimeline;
8
+ children: TimelineTree[];
9
+ };
@@ -0,0 +1,32 @@
1
+ import { rewindLockStore } from "./rewindLockStore.js";
2
+
3
+ /** @typedef {import("./RewindLockHandle.ts").RewindLockHandle} RewindLockHandle */
4
+
5
+ /**
6
+ * Acquire a single-flight lock for one run.
7
+ * Returns null when another rewind for this run is already in progress.
8
+ *
9
+ * @param {string} runId
10
+ * @returns {RewindLockHandle | null}
11
+ */
12
+ export function acquireRewindLock(runId) {
13
+ if (rewindLockStore.has(runId)) {
14
+ return null;
15
+ }
16
+ const token = Symbol(runId);
17
+ rewindLockStore.set(runId, token);
18
+ let released = false;
19
+ return {
20
+ runId,
21
+ release() {
22
+ if (released) {
23
+ return false;
24
+ }
25
+ released = true;
26
+ if (rewindLockStore.get(runId) === token) {
27
+ rewindLockStore.delete(runId);
28
+ }
29
+ return true;
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,27 @@
1
+ import { resolveRewindAuditClient } from "./resolveRewindAuditClient.js";
2
+
3
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
4
+
5
+ /**
6
+ * Count audit rows for one caller and run in a time window.
7
+ * Only counts terminal (non-in_progress) rows so that a live attempt
8
+ * does not itself blow the rate-limit quota.
9
+ *
10
+ * @param {SmithersDb} adapter
11
+ * @param {{ runId: string; caller: string; sinceMs: number; }} input
12
+ * @returns {Promise<number>}
13
+ */
14
+ export async function countRecentRewindAuditRows(adapter, input) {
15
+ const client = resolveRewindAuditClient(adapter);
16
+ const row = client
17
+ .query(
18
+ `SELECT COUNT(*) AS count
19
+ FROM _smithers_time_travel_audit
20
+ WHERE run_id = ?
21
+ AND caller = ?
22
+ AND timestamp_ms >= ?
23
+ AND result <> 'in_progress'`,
24
+ )
25
+ .get(input.runId, input.caller, input.sinceMs);
26
+ return Number(row?.count ?? 0);
27
+ }
package/src/diff.js ADDED
@@ -0,0 +1,189 @@
1
+ import pc from "picocolors";
2
+ import { parseSnapshot } from "./snapshot/parseSnapshot.js";
3
+
4
+ /** @typedef {import("./ParsedSnapshot.ts").ParsedSnapshot} ParsedSnapshot */
5
+ /** @typedef {import("./snapshot/Snapshot.ts").Snapshot} Snapshot */
6
+ /** @typedef {import("./SnapshotDiff.ts").SnapshotDiff} SnapshotDiff */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Diffing — pure function, no DB access
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Compute a structured diff between two parsed snapshots.
13
+ *
14
+ * @param {ParsedSnapshot} a
15
+ * @param {ParsedSnapshot} b
16
+ * @returns {SnapshotDiff}
17
+ */
18
+ export function diffSnapshots(a, b) {
19
+ // Nodes
20
+ const aNodeKeys = new Set(Object.keys(a.nodes));
21
+ const bNodeKeys = new Set(Object.keys(b.nodes));
22
+ const nodesAdded = [];
23
+ const nodesRemoved = [];
24
+ const nodesChanged = [];
25
+ for (const key of bNodeKeys) {
26
+ if (!aNodeKeys.has(key)) {
27
+ nodesAdded.push(key);
28
+ }
29
+ }
30
+ for (const key of aNodeKeys) {
31
+ if (!bNodeKeys.has(key)) {
32
+ nodesRemoved.push(key);
33
+ }
34
+ }
35
+ for (const key of aNodeKeys) {
36
+ if (bNodeKeys.has(key)) {
37
+ const aNode = a.nodes[key];
38
+ const bNode = b.nodes[key];
39
+ if (aNode.state !== bNode.state ||
40
+ aNode.lastAttempt !== bNode.lastAttempt ||
41
+ aNode.label !== bNode.label) {
42
+ nodesChanged.push({ nodeId: key, from: aNode, to: bNode });
43
+ }
44
+ }
45
+ }
46
+ // Outputs
47
+ const aOutputKeys = new Set(Object.keys(a.outputs));
48
+ const bOutputKeys = new Set(Object.keys(b.outputs));
49
+ const outputsAdded = [];
50
+ const outputsRemoved = [];
51
+ const outputsChanged = [];
52
+ for (const key of bOutputKeys) {
53
+ if (!aOutputKeys.has(key)) {
54
+ outputsAdded.push(key);
55
+ }
56
+ }
57
+ for (const key of aOutputKeys) {
58
+ if (!bOutputKeys.has(key)) {
59
+ outputsRemoved.push(key);
60
+ }
61
+ }
62
+ for (const key of aOutputKeys) {
63
+ if (bOutputKeys.has(key)) {
64
+ const aVal = JSON.stringify(a.outputs[key]);
65
+ const bVal = JSON.stringify(b.outputs[key]);
66
+ if (aVal !== bVal) {
67
+ outputsChanged.push({ key, from: a.outputs[key], to: b.outputs[key] });
68
+ }
69
+ }
70
+ }
71
+ // Ralph
72
+ const ralphChanged = [];
73
+ const allRalphKeys = new Set([
74
+ ...Object.keys(a.ralph),
75
+ ...Object.keys(b.ralph),
76
+ ]);
77
+ for (const key of allRalphKeys) {
78
+ const aR = a.ralph[key];
79
+ const bR = b.ralph[key];
80
+ if (!aR || !bR) {
81
+ // One side missing — treat as changed if both exist
82
+ if (aR && bR) {
83
+ ralphChanged.push({ ralphId: key, from: aR, to: bR });
84
+ }
85
+ continue;
86
+ }
87
+ if (aR.iteration !== bR.iteration || aR.done !== bR.done) {
88
+ ralphChanged.push({ ralphId: key, from: aR, to: bR });
89
+ }
90
+ }
91
+ // Input
92
+ const inputChanged = JSON.stringify(a.input) !== JSON.stringify(b.input);
93
+ // VCS
94
+ const vcsPointerChanged = a.vcsPointer !== b.vcsPointer;
95
+ return {
96
+ nodesAdded,
97
+ nodesRemoved,
98
+ nodesChanged,
99
+ outputsAdded,
100
+ outputsRemoved,
101
+ outputsChanged,
102
+ ralphChanged,
103
+ inputChanged,
104
+ vcsPointerChanged,
105
+ };
106
+ }
107
+ /**
108
+ * Convenience: diff two raw Snapshot rows.
109
+ *
110
+ * @param {Snapshot} a
111
+ * @param {Snapshot} b
112
+ * @returns {SnapshotDiff}
113
+ */
114
+ export function diffRawSnapshots(a, b) {
115
+ return diffSnapshots(parseSnapshot(a), parseSnapshot(b));
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Formatting
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Colorized terminal output for a snapshot diff.
122
+ *
123
+ * @param {SnapshotDiff} diff
124
+ * @returns {string}
125
+ */
126
+ export function formatDiffForTui(diff) {
127
+ const lines = [];
128
+ if (diff.nodesAdded.length > 0) {
129
+ lines.push(pc.bold("Nodes added:"));
130
+ for (const n of diff.nodesAdded) {
131
+ lines.push(pc.green(` + ${n}`));
132
+ }
133
+ }
134
+ if (diff.nodesRemoved.length > 0) {
135
+ lines.push(pc.bold("Nodes removed:"));
136
+ for (const n of diff.nodesRemoved) {
137
+ lines.push(pc.red(` - ${n}`));
138
+ }
139
+ }
140
+ if (diff.nodesChanged.length > 0) {
141
+ lines.push(pc.bold("Nodes changed:"));
142
+ for (const c of diff.nodesChanged) {
143
+ lines.push(pc.yellow(` ~ ${c.nodeId}: ${c.from.state} -> ${c.to.state}`));
144
+ }
145
+ }
146
+ if (diff.outputsAdded.length > 0) {
147
+ lines.push(pc.bold("Outputs added:"));
148
+ for (const o of diff.outputsAdded) {
149
+ lines.push(pc.green(` + ${o}`));
150
+ }
151
+ }
152
+ if (diff.outputsRemoved.length > 0) {
153
+ lines.push(pc.bold("Outputs removed:"));
154
+ for (const o of diff.outputsRemoved) {
155
+ lines.push(pc.red(` - ${o}`));
156
+ }
157
+ }
158
+ if (diff.outputsChanged.length > 0) {
159
+ lines.push(pc.bold("Outputs changed:"));
160
+ for (const o of diff.outputsChanged) {
161
+ lines.push(pc.yellow(` ~ ${o.key}`));
162
+ }
163
+ }
164
+ if (diff.ralphChanged.length > 0) {
165
+ lines.push(pc.bold("Ralph (loops) changed:"));
166
+ for (const r of diff.ralphChanged) {
167
+ lines.push(pc.yellow(` ~ ${r.ralphId}: iter ${r.from.iteration}->${r.to.iteration} done ${r.from.done}->${r.to.done}`));
168
+ }
169
+ }
170
+ if (diff.inputChanged) {
171
+ lines.push(pc.bold(pc.yellow("Input changed")));
172
+ }
173
+ if (diff.vcsPointerChanged) {
174
+ lines.push(pc.bold(pc.yellow("VCS pointer changed")));
175
+ }
176
+ if (lines.length === 0) {
177
+ lines.push(pc.dim("No differences"));
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+ /**
182
+ * Structured JSON output for a snapshot diff.
183
+ *
184
+ * @param {SnapshotDiff} diff
185
+ * @returns {SnapshotDiff}
186
+ */
187
+ export function formatDiffAsJson(diff) {
188
+ return { ...diff };
189
+ }