@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
@@ -0,0 +1,41 @@
1
+ import { countRecentRewindAuditRows } from "./countRecentRewindAuditRows.js";
2
+ import { REWIND_RATE_LIMIT_MAX } from "./REWIND_RATE_LIMIT_MAX.js";
3
+ import { REWIND_RATE_LIMIT_WINDOW_MS } from "./REWIND_RATE_LIMIT_WINDOW_MS.js";
4
+
5
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
6
+
7
+ /**
8
+ * Evaluate caller-scoped rewind quota for one run.
9
+ *
10
+ * @param {{
11
+ * adapter: SmithersDb;
12
+ * runId: string;
13
+ * caller: string;
14
+ * nowMs?: () => number;
15
+ * maxPerWindow?: number;
16
+ * windowMs?: number;
17
+ * }} input
18
+ */
19
+ export async function evaluateRewindRateLimit(input) {
20
+ const nowMs = input.nowMs ?? (() => Date.now());
21
+ const max = Number.isInteger(input.maxPerWindow)
22
+ ? Math.max(1, Number(input.maxPerWindow))
23
+ : REWIND_RATE_LIMIT_MAX;
24
+ const windowMs = Number.isInteger(input.windowMs)
25
+ ? Math.max(1, Number(input.windowMs))
26
+ : REWIND_RATE_LIMIT_WINDOW_MS;
27
+ const windowStartedAtMs = nowMs() - windowMs;
28
+ const used = await countRecentRewindAuditRows(input.adapter, {
29
+ runId: input.runId,
30
+ caller: input.caller,
31
+ sinceMs: windowStartedAtMs,
32
+ });
33
+ return {
34
+ limited: used >= max,
35
+ used,
36
+ remaining: Math.max(0, max - used),
37
+ max,
38
+ windowMs,
39
+ windowStartedAtMs,
40
+ };
41
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Given a set of node IDs to reset, compute the full transitive set including
3
+ * all downstream dependents. In the absence of an explicit dependency graph,
4
+ * we reset every node whose iteration >= the minimum iteration of the reset
5
+ * set. This is intentionally conservative — it re-runs more rather than less.
6
+ */
7
+ export function expandResetSet(nodes, resetNodeIds) {
8
+ if (resetNodeIds.length === 0)
9
+ return [];
10
+ const resetSet = new Set(resetNodeIds);
11
+ const result = new Set();
12
+ // Collect all unique base nodeIds from the snapshot keyed as "nodeId::iteration"
13
+ for (const key of Object.keys(nodes)) {
14
+ const baseId = key.split("::")[0];
15
+ if (resetSet.has(baseId)) {
16
+ result.add(key);
17
+ }
18
+ }
19
+ // If we found nothing via base nodeId, try exact key match
20
+ if (result.size === 0) {
21
+ for (const id of resetNodeIds) {
22
+ if (nodes[id]) {
23
+ result.add(id);
24
+ }
25
+ }
26
+ }
27
+ return [...result];
28
+ }
@@ -0,0 +1,147 @@
1
+ import { Effect, Metric } from "effect";
2
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
3
+ import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
4
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
5
+ import { smithersBranches, smithersSnapshots } from "../schema.js";
6
+ import { loadSnapshot } from "../snapshot/loadSnapshotEffect.js";
7
+ import { parseSnapshot } from "../snapshot/parseSnapshot.js";
8
+ import { runForksCreated } from "../runForksCreated.js";
9
+ import { expandResetSet } from "./_helpers.js";
10
+ /** @typedef {import("../BranchInfo.ts").BranchInfo} BranchInfo */
11
+ /** @typedef {import("../ForkParams.ts").ForkParams} ForkParams */
12
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
13
+ /** @typedef {import("../snapshot/Snapshot.ts").Snapshot} Snapshot */
14
+
15
+ /**
16
+ * @param {SmithersDb} adapter
17
+ * @param {ForkParams} params
18
+ * @returns {Effect.Effect<{ runId: string; branch: BranchInfo; snapshot: Snapshot }, SmithersError>}
19
+ */
20
+ export function forkRun(adapter, params) {
21
+ return Effect.gen(function* () {
22
+ const { parentRunId, frameNo, inputOverrides, resetNodes, branchLabel, forkDescription } = params;
23
+ // 1. Load source snapshot
24
+ const source = yield* loadSnapshot(adapter, parentRunId, frameNo);
25
+ if (!source) {
26
+ return yield* Effect.fail(new SmithersError("SNAPSHOT_NOT_FOUND", `No snapshot found for run=${parentRunId} frame=${frameNo}`, { frameNo, runId: parentRunId }));
27
+ }
28
+ // 2. Create new run ID
29
+ const childRunId = crypto.randomUUID();
30
+ const ts = nowMs();
31
+ const parentRun = yield* Effect.tryPromise({
32
+ try: () => adapter.getRun(parentRunId),
33
+ catch: (cause) => toSmithersError(cause, "load parent run metadata", {
34
+ code: "DB_QUERY_FAILED",
35
+ details: { runId: parentRunId },
36
+ }),
37
+ });
38
+ // 3. Optionally override input and reset nodes
39
+ let nodesJson = source.nodesJson;
40
+ let inputJson = source.inputJson;
41
+ if (inputOverrides) {
42
+ const existingInput = JSON.parse(source.inputJson);
43
+ inputJson = JSON.stringify({ ...existingInput, ...inputOverrides });
44
+ }
45
+ if (resetNodes && resetNodes.length > 0) {
46
+ const parsed = parseSnapshot(source);
47
+ const keysToReset = expandResetSet(parsed.nodes, resetNodes);
48
+ const nodesArr = JSON.parse(source.nodesJson);
49
+ const updatedNodes = nodesArr.map((n) => {
50
+ const key = `${n.nodeId}::${n.iteration}`;
51
+ if (keysToReset.includes(key) || resetNodes.includes(n.nodeId)) {
52
+ return { ...n, state: "pending", lastAttempt: null };
53
+ }
54
+ return n;
55
+ });
56
+ nodesJson = JSON.stringify(updatedNodes);
57
+ }
58
+ // 4. Insert snapshot for the child run at frame 0
59
+ const childSnapshot = {
60
+ runId: childRunId,
61
+ frameNo: 0,
62
+ nodesJson,
63
+ outputsJson: source.outputsJson,
64
+ ralphJson: source.ralphJson,
65
+ inputJson,
66
+ vcsPointer: source.vcsPointer,
67
+ workflowHash: source.workflowHash,
68
+ contentHash: source.contentHash,
69
+ createdAtMs: ts,
70
+ };
71
+ yield* Effect.tryPromise({
72
+ try: () => adapter.db
73
+ .insert(smithersSnapshots)
74
+ .values(childSnapshot)
75
+ .onConflictDoUpdate({
76
+ target: [smithersSnapshots.runId, smithersSnapshots.frameNo],
77
+ set: childSnapshot,
78
+ }),
79
+ catch: (cause) => toSmithersError(cause, "insert forked snapshot", {
80
+ code: "DB_WRITE_FAILED",
81
+ details: { frameNo: 0, runId: childRunId },
82
+ }),
83
+ });
84
+ if (parentRun) {
85
+ yield* Effect.tryPromise({
86
+ try: () => adapter.insertRun({
87
+ runId: childRunId,
88
+ parentRunId,
89
+ workflowName: parentRun.workflowName,
90
+ workflowPath: parentRun.workflowPath ?? null,
91
+ workflowHash: source.workflowHash ?? parentRun.workflowHash ?? null,
92
+ status: parentRun.status === "running" ? "failed" : parentRun.status,
93
+ createdAtMs: ts,
94
+ startedAtMs: null,
95
+ finishedAtMs: parentRun.finishedAtMs ?? ts,
96
+ heartbeatAtMs: null,
97
+ runtimeOwnerId: null,
98
+ cancelRequestedAtMs: null,
99
+ hijackRequestedAtMs: null,
100
+ hijackTarget: null,
101
+ vcsType: parentRun.vcsType ?? null,
102
+ vcsRoot: parentRun.vcsRoot ?? null,
103
+ vcsRevision: source.vcsPointer ?? parentRun.vcsRevision ?? null,
104
+ errorJson: null,
105
+ configJson: parentRun.configJson ?? null,
106
+ }),
107
+ catch: (cause) => toSmithersError(cause, "insert forked run", {
108
+ code: "DB_WRITE_FAILED",
109
+ details: { runId: childRunId },
110
+ }),
111
+ });
112
+ }
113
+ // 5. Record branch relationship
114
+ const branch = {
115
+ runId: childRunId,
116
+ parentRunId,
117
+ parentFrameNo: frameNo,
118
+ branchLabel: branchLabel ?? null,
119
+ forkDescription: forkDescription ?? null,
120
+ createdAtMs: ts,
121
+ };
122
+ yield* Effect.tryPromise({
123
+ try: () => adapter.db
124
+ .insert(smithersBranches)
125
+ .values(branch)
126
+ .onConflictDoUpdate({
127
+ target: smithersBranches.runId,
128
+ set: branch,
129
+ }),
130
+ catch: (cause) => toSmithersError(cause, "insert branch", {
131
+ code: "DB_WRITE_FAILED",
132
+ details: { runId: childRunId },
133
+ }),
134
+ });
135
+ yield* Metric.increment(runForksCreated);
136
+ yield* Effect.logInfo("Run forked").pipe(Effect.annotateLogs({
137
+ parentRunId,
138
+ parentFrameNo: String(frameNo),
139
+ childRunId,
140
+ branchLabel: branchLabel ?? "",
141
+ }));
142
+ return { runId: childRunId, branch, snapshot: childSnapshot };
143
+ }).pipe(Effect.annotateLogs({
144
+ parentRunId: params.parentRunId,
145
+ parentFrameNo: String(params.frameNo),
146
+ }), Effect.withLogSpan("time-travel:fork-run"));
147
+ }
@@ -0,0 +1,26 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { Effect } from "effect";
3
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
4
+ import { smithersBranches } from "../schema.js";
5
+ /** @typedef {import("../BranchInfo.ts").BranchInfo} BranchInfo */
6
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
7
+ /** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
8
+
9
+ /**
10
+ * @param {SmithersDb} adapter
11
+ * @param {string} runId
12
+ * @returns {Effect.Effect<BranchInfo | undefined, SmithersError>}
13
+ */
14
+ export function getBranchInfo(adapter, runId) {
15
+ return Effect.tryPromise({
16
+ try: () => adapter.db
17
+ .select()
18
+ .from(smithersBranches)
19
+ .where(eq(smithersBranches.runId, runId))
20
+ .limit(1),
21
+ catch: (cause) => toSmithersError(cause, "get branch info", {
22
+ code: "DB_QUERY_FAILED",
23
+ details: { runId },
24
+ }),
25
+ }).pipe(Effect.map((rows) => rows[0]), Effect.annotateLogs({ runId }), Effect.withLogSpan("time-travel:get-branch-info"));
26
+ }
@@ -0,0 +1,41 @@
1
+ import { Effect } from "effect";
2
+ import { forkRun as forkRunEffect } from "./forkRunEffect.js";
3
+ import { getBranchInfo as getBranchInfoEffect } from "./getBranchInfoEffect.js";
4
+ import { listBranches as listBranchesEffect } from "./listBranchesEffect.js";
5
+ export { forkRunEffect, getBranchInfoEffect, listBranchesEffect, };
6
+
7
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
8
+ /** @typedef {import("../BranchInfo.ts").BranchInfo} BranchInfo */
9
+ /** @typedef {import("../ForkParams.ts").ForkParams} ForkParams */
10
+ /** @typedef {import("../snapshot/Snapshot.ts").Snapshot} Snapshot */
11
+
12
+ /**
13
+ * Fork a run at the given frame, returning the child run metadata.
14
+ *
15
+ * @param {SmithersDb} adapter
16
+ * @param {ForkParams} params
17
+ * @returns {Promise<{ runId: string; branch: BranchInfo; snapshot: Snapshot }>}
18
+ */
19
+ export function forkRun(adapter, params) {
20
+ return Effect.runPromise(forkRunEffect(adapter, params));
21
+ }
22
+ /**
23
+ * List branches that were forked from the given parent run.
24
+ *
25
+ * @param {SmithersDb} adapter
26
+ * @param {string} parentRunId
27
+ * @returns {Promise<BranchInfo[]>}
28
+ */
29
+ export function listBranches(adapter, parentRunId) {
30
+ return Effect.runPromise(listBranchesEffect(adapter, parentRunId));
31
+ }
32
+ /**
33
+ * Get the branch record for a run, if any.
34
+ *
35
+ * @param {SmithersDb} adapter
36
+ * @param {string} runId
37
+ * @returns {Promise<BranchInfo | undefined>}
38
+ */
39
+ export function getBranchInfo(adapter, runId) {
40
+ return Effect.runPromise(getBranchInfoEffect(adapter, runId));
41
+ }
@@ -0,0 +1,25 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { Effect } from "effect";
3
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
4
+ import { smithersBranches } from "../schema.js";
5
+ /** @typedef {import("../BranchInfo.ts").BranchInfo} BranchInfo */
6
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
7
+ /** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
8
+
9
+ /**
10
+ * @param {SmithersDb} adapter
11
+ * @param {string} parentRunId
12
+ * @returns {Effect.Effect<BranchInfo[], SmithersError>}
13
+ */
14
+ export function listBranches(adapter, parentRunId) {
15
+ return Effect.tryPromise({
16
+ try: () => adapter.db
17
+ .select()
18
+ .from(smithersBranches)
19
+ .where(eq(smithersBranches.parentRunId, parentRunId)),
20
+ catch: (cause) => toSmithersError(cause, "list branches", {
21
+ code: "DB_QUERY_FAILED",
22
+ details: { parentRunId },
23
+ }),
24
+ }).pipe(Effect.annotateLogs({ parentRunId }), Effect.withLogSpan("time-travel:list-branches"));
25
+ }
@@ -0,0 +1,11 @@
1
+ import { rewindLockStore } from "./rewindLockStore.js";
2
+
3
+ /**
4
+ * Check whether a run currently holds a rewind lock.
5
+ *
6
+ * @param {string} runId
7
+ * @returns {boolean}
8
+ */
9
+ export function hasRewindLock(runId) {
10
+ return rewindLockStore.has(runId);
11
+ }