@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,247 @@
1
+ import { Effect } from "effect";
2
+ import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
3
+ import { revertToJjPointer } from "@smithers-orchestrator/vcs/jj";
4
+ import * as BunContext from "@effect/platform-bun/BunContext";
5
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
6
+ /** @typedef {import("./TimeTravelOptions.ts").TimeTravelOptions} TimeTravelOptions */
7
+ /** @typedef {import("./TimeTravelResult.ts").TimeTravelResult} TimeTravelResult */
8
+
9
+ /**
10
+ * @param {string} nodeId
11
+ * @param {number} iteration
12
+ */
13
+ function nodeKey(nodeId, iteration) {
14
+ return `${nodeId}::${iteration}`;
15
+ }
16
+ /**
17
+ * @param {Array<{ nodeId: string; iteration: number }>} nodes
18
+ */
19
+ function uniqueNodeIds(nodes) {
20
+ const seen = new Set();
21
+ const result = [];
22
+ for (const node of nodes) {
23
+ if (seen.has(node.nodeId))
24
+ continue;
25
+ seen.add(node.nodeId);
26
+ result.push(node.nodeId);
27
+ }
28
+ return result;
29
+ }
30
+ /**
31
+ * @param {any[]} attempts
32
+ * @param {number} [requestedAttempt]
33
+ * @returns {AttemptRow | undefined}
34
+ */
35
+ function selectAttempt(attempts, requestedAttempt) {
36
+ if (requestedAttempt == null)
37
+ return attempts[0];
38
+ return attempts.find((attempt) => attempt.attempt === requestedAttempt);
39
+ }
40
+ /**
41
+ * @param {NonNullable<AttemptRow>} targetAttempt
42
+ * @param {any[]} attemptsForRun
43
+ */
44
+ function findTargetAttemptOrder(targetAttempt, attemptsForRun) {
45
+ return attemptsForRun.findIndex((attempt) => attempt.runId === targetAttempt.runId &&
46
+ attempt.nodeId === targetAttempt.nodeId &&
47
+ (attempt.iteration ?? 0) === (targetAttempt.iteration ?? 0) &&
48
+ attempt.attempt === targetAttempt.attempt);
49
+ }
50
+ /**
51
+ * @param {SmithersDb} adapter
52
+ * @param {{ runId: string; targetNode: NonNullable<NodeRow>; targetAttempt: NonNullable<AttemptRow>; attemptsForRun: any[]; resetDependents: boolean; }} opts
53
+ */
54
+ async function resolveResetNodes(adapter, opts) {
55
+ const { runId, targetNode, targetAttempt, attemptsForRun, resetDependents } = opts;
56
+ if (!resetDependents) {
57
+ return [targetNode];
58
+ }
59
+ const nodes = await Effect.runPromise(adapter.listNodes(runId));
60
+ const targetKey = nodeKey(targetNode.nodeId, targetNode.iteration ?? 0);
61
+ const targetAttemptOrder = findTargetAttemptOrder(targetAttempt, attemptsForRun);
62
+ const targetIteration = targetNode.iteration ?? 0;
63
+ const cutoff = targetAttempt.startedAtMs;
64
+ return nodes.filter((node) => {
65
+ const currentKey = nodeKey(node.nodeId, node.iteration ?? 0);
66
+ if (currentKey === targetKey)
67
+ return true;
68
+ if ((node.iteration ?? 0) > targetIteration)
69
+ return true;
70
+ let startedAfterTarget = false;
71
+ let orderedAfterTarget = false;
72
+ for (let index = 0; index < attemptsForRun.length; index += 1) {
73
+ const attempt = attemptsForRun[index];
74
+ if (attempt.nodeId !== node.nodeId ||
75
+ (attempt.iteration ?? 0) !== (node.iteration ?? 0)) {
76
+ continue;
77
+ }
78
+ if ((attempt.startedAtMs ?? 0) >= cutoff) {
79
+ startedAfterTarget = true;
80
+ }
81
+ if (targetAttemptOrder >= 0 && index > targetAttemptOrder) {
82
+ orderedAfterTarget = true;
83
+ }
84
+ }
85
+ return startedAfterTarget || orderedAfterTarget;
86
+ });
87
+ }
88
+ /**
89
+ * @param {NonNullable<NodeRow>} existingNode
90
+ */
91
+ function buildPendingNode(existingNode) {
92
+ return {
93
+ ...existingNode,
94
+ state: "pending",
95
+ updatedAtMs: nowMs(),
96
+ };
97
+ }
98
+ /**
99
+ * @param {SmithersDb} adapter
100
+ * @param {TimeTravelOptions} opts
101
+ * @returns {Promise<TimeTravelResult>}
102
+ */
103
+ export async function timeTravel(adapter, opts) {
104
+ const runId = opts.runId;
105
+ const nodeId = opts.nodeId;
106
+ const iteration = opts.iteration ?? 0;
107
+ const resetDependents = opts.resetDependents ?? true;
108
+ const restoreVcs = opts.restoreVcs ?? true;
109
+ const attempts = await Effect.runPromise(adapter.listAttempts(runId, nodeId, iteration));
110
+ const targetAttempt = selectAttempt(attempts, opts.attempt);
111
+ if (!targetAttempt) {
112
+ return {
113
+ success: false,
114
+ vcsRestored: false,
115
+ resetNodes: [],
116
+ error: `Attempt not found: ${runId}/${nodeId}/${iteration}/${opts.attempt ?? "latest"}`,
117
+ };
118
+ }
119
+ const targetAttemptNo = targetAttempt.attempt;
120
+ const jjPointer = targetAttempt.jjPointer ?? undefined;
121
+ const targetNode = await Effect.runPromise(adapter.getNode(runId, nodeId, iteration));
122
+ if (!targetNode) {
123
+ return {
124
+ success: false,
125
+ vcsRestored: false,
126
+ resetNodes: [],
127
+ error: `Node not found: ${runId}/${nodeId}/${iteration}`,
128
+ };
129
+ }
130
+ opts.onProgress?.({
131
+ type: "TimeTravelStarted",
132
+ runId,
133
+ nodeId,
134
+ iteration,
135
+ attempt: targetAttemptNo,
136
+ jjPointer,
137
+ timestampMs: nowMs(),
138
+ });
139
+ let vcsRestored = false;
140
+ if (restoreVcs && jjPointer) {
141
+ const vcsResult = await Effect.runPromise(revertToJjPointer(jjPointer, targetAttempt.jjCwd ?? undefined).pipe(Effect.provide(BunContext.layer)));
142
+ vcsRestored = vcsResult.success;
143
+ if (!vcsResult.success) {
144
+ const error = vcsResult.error ?? "Failed to restore VCS state";
145
+ opts.onProgress?.({
146
+ type: "TimeTravelFinished",
147
+ runId,
148
+ nodeId,
149
+ iteration,
150
+ attempt: targetAttemptNo,
151
+ jjPointer,
152
+ success: false,
153
+ vcsRestored,
154
+ resetNodes: [],
155
+ error,
156
+ timestampMs: nowMs(),
157
+ });
158
+ return {
159
+ success: false,
160
+ jjPointer,
161
+ vcsRestored,
162
+ resetNodes: [],
163
+ error,
164
+ };
165
+ }
166
+ }
167
+ const attemptsForRun = await Effect.runPromise(adapter.listAttemptsForRun(runId));
168
+ const resetNodes = await resolveResetNodes(adapter, {
169
+ runId,
170
+ targetNode,
171
+ targetAttempt,
172
+ attemptsForRun,
173
+ resetDependents,
174
+ });
175
+ const resetNodeIds = uniqueNodeIds(resetNodes.map((node) => ({
176
+ nodeId: node.nodeId,
177
+ iteration: node.iteration ?? 0,
178
+ })));
179
+ const attemptsByNode = new Map();
180
+ for (const resetNode of resetNodes) {
181
+ attemptsByNode.set(nodeKey(resetNode.nodeId, resetNode.iteration ?? 0), attemptsForRun.filter((attempt) => attempt.nodeId === resetNode.nodeId &&
182
+ (attempt.iteration ?? 0) === (resetNode.iteration ?? 0)));
183
+ }
184
+ await adapter.withTransaction("time-travel", Effect.gen(function* () {
185
+ const frames = yield* adapter.listFrames(runId, 1_000_000);
186
+ const cutoff = targetAttempt.startedAtMs;
187
+ let lastValidFrameNo = -1;
188
+ for (const frame of frames) {
189
+ if (frame.createdAtMs <= cutoff && frame.frameNo > lastValidFrameNo) {
190
+ lastValidFrameNo = frame.frameNo;
191
+ }
192
+ }
193
+ if (lastValidFrameNo >= 0) {
194
+ yield* adapter.deleteFramesAfter(runId, lastValidFrameNo);
195
+ }
196
+ for (const resetNode of resetNodes) {
197
+ const attemptsForNode = attemptsByNode.get(nodeKey(resetNode.nodeId, resetNode.iteration ?? 0)) ??
198
+ [];
199
+ for (const attempt of attemptsForNode) {
200
+ if ((attempt.startedAtMs ?? 0) < cutoff || attempt.state === "cancelled") {
201
+ continue;
202
+ }
203
+ const patch = { state: "cancelled" };
204
+ if (attempt.finishedAtMs == null) {
205
+ patch.finishedAtMs = nowMs();
206
+ }
207
+ yield* adapter.updateAttempt(runId, resetNode.nodeId, resetNode.iteration ?? 0, attempt.attempt, patch);
208
+ }
209
+ if (resetNode.outputTable) {
210
+ yield* adapter.deleteOutputRow(resetNode.outputTable, {
211
+ runId,
212
+ nodeId: resetNode.nodeId,
213
+ iteration: resetNode.iteration ?? 0,
214
+ });
215
+ }
216
+ yield* adapter.insertNode(buildPendingNode(resetNode));
217
+ }
218
+ yield* adapter.updateRun(runId, {
219
+ status: "running",
220
+ finishedAtMs: null,
221
+ heartbeatAtMs: null,
222
+ runtimeOwnerId: null,
223
+ cancelRequestedAtMs: null,
224
+ hijackRequestedAtMs: null,
225
+ hijackTarget: null,
226
+ errorJson: null,
227
+ });
228
+ }));
229
+ opts.onProgress?.({
230
+ type: "TimeTravelFinished",
231
+ runId,
232
+ nodeId,
233
+ iteration,
234
+ attempt: targetAttemptNo,
235
+ jjPointer,
236
+ success: true,
237
+ vcsRestored,
238
+ resetNodes: resetNodeIds,
239
+ timestampMs: nowMs(),
240
+ });
241
+ return {
242
+ success: true,
243
+ jjPointer,
244
+ vcsRestored,
245
+ resetNodes: resetNodeIds,
246
+ };
247
+ }
package/src/types.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Barrel re-export for backward compatibility with @smithers-orchestrator/time-travel/types imports
2
+ export type { Snapshot } from "./snapshot/Snapshot";
3
+ export type { ParsedSnapshot } from "./ParsedSnapshot";
4
+ export type { NodeSnapshot } from "./NodeSnapshot";
5
+ export type { RalphSnapshot } from "./RalphSnapshot";
6
+ export type { SnapshotDiff } from "./SnapshotDiff";
7
+ export type { NodeChange } from "./NodeChange";
8
+ export type { OutputChange } from "./OutputChange";
9
+ export type { RalphChange } from "./RalphChange";
10
+ export type { ForkParams } from "./ForkParams";
11
+ export type { ReplayParams } from "./ReplayParams";
12
+ export type { BranchInfo } from "./BranchInfo";
13
+ export type { TimelineFrame } from "./TimelineFrame";
14
+ export type { RunTimeline } from "./RunTimeline";
15
+ export type { TimelineTree } from "./TimelineTree";
@@ -0,0 +1,35 @@
1
+ import { resolveRewindAuditClient } from "./resolveRewindAuditClient.js";
2
+
3
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
4
+ /** @typedef {import("./RewindAuditResult.ts").RewindAuditResult} RewindAuditResult */
5
+
6
+ /**
7
+ * Update an existing rewind audit row's result and duration.
8
+ * Used to mark an `in_progress` row as `success`, `failed`, or `partial`.
9
+ *
10
+ * @param {SmithersDb} adapter
11
+ * @param {{ id: number; result: RewindAuditResult; durationMs?: number | null; fromFrameNo?: number }} row
12
+ */
13
+ export async function updateRewindAuditRow(adapter, row) {
14
+ const client = resolveRewindAuditClient(adapter);
15
+ if (typeof row.fromFrameNo === "number") {
16
+ client
17
+ .query(
18
+ `UPDATE _smithers_time_travel_audit
19
+ SET result = ?,
20
+ duration_ms = ?,
21
+ from_frame_no = ?
22
+ WHERE id = ?`,
23
+ )
24
+ .run(row.result, row.durationMs ?? null, row.fromFrameNo, row.id);
25
+ return;
26
+ }
27
+ client
28
+ .query(
29
+ `UPDATE _smithers_time_travel_audit
30
+ SET result = ?,
31
+ duration_ms = ?
32
+ WHERE id = ?`,
33
+ )
34
+ .run(row.result, row.durationMs ?? null, row.id);
35
+ }
@@ -0,0 +1,23 @@
1
+ import { JUMP_MAX_FRAME_NO } from "./JUMP_MAX_FRAME_NO.js";
2
+ import { JumpToFrameError } from "./JumpToFrameError.js";
3
+
4
+ /**
5
+ * Validate a jump frame number argument.
6
+ *
7
+ * @param {unknown} frameNo
8
+ * @returns {number}
9
+ */
10
+ export function validateJumpFrameNo(frameNo) {
11
+ if (
12
+ typeof frameNo !== "number" ||
13
+ !Number.isInteger(frameNo) ||
14
+ frameNo < 0 ||
15
+ frameNo > JUMP_MAX_FRAME_NO
16
+ ) {
17
+ throw new JumpToFrameError(
18
+ "InvalidFrameNo",
19
+ "frameNo must be a non-negative i32 integer.",
20
+ );
21
+ }
22
+ return frameNo;
23
+ }
@@ -0,0 +1,18 @@
1
+ import { JUMP_RUN_ID_PATTERN } from "./JUMP_RUN_ID_PATTERN.js";
2
+ import { JumpToFrameError } from "./JumpToFrameError.js";
3
+
4
+ /**
5
+ * Validate a jump run id argument.
6
+ *
7
+ * @param {unknown} runId
8
+ * @returns {string}
9
+ */
10
+ export function validateJumpRunId(runId) {
11
+ if (typeof runId !== "string" || !JUMP_RUN_ID_PATTERN.test(runId)) {
12
+ throw new JumpToFrameError(
13
+ "InvalidRunId",
14
+ "runId must match /^[a-z0-9_-]{1,64}$/.",
15
+ );
16
+ }
17
+ return runId;
18
+ }
@@ -0,0 +1,9 @@
1
+ export type VcsTag = {
2
+ runId: string;
3
+ frameNo: number;
4
+ vcsType: string;
5
+ vcsPointer: string;
6
+ vcsRoot: string | null;
7
+ jjOperationId: string | null;
8
+ createdAtMs: number;
9
+ };
@@ -0,0 +1,61 @@
1
+ // @smithers-type-exports-begin
2
+ /** @typedef {import("./VcsTag.ts").VcsTag} VcsTag */
3
+ // @smithers-type-exports-end
4
+
5
+ import { Effect } from "effect";
6
+ import * as BunContext from "@effect/platform-bun/BunContext";
7
+ import { loadVcsTag as loadVcsTagEffect } from "./loadVcsTagEffect.js";
8
+ import { rerunAtRevision as rerunAtRevisionEffect } from "./rerunAtRevisionEffect.js";
9
+ import { resolveWorkflowAtRevision as resolveWorkflowAtRevisionEffect } from "./resolveWorkflowAtRevisionEffect.js";
10
+ import { tagSnapshotVcs as tagSnapshotVcsEffect } from "./tagSnapshotVcsEffect.js";
11
+ export { loadVcsTagEffect, rerunAtRevisionEffect, resolveWorkflowAtRevisionEffect, tagSnapshotVcsEffect, };
12
+
13
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
14
+
15
+ /**
16
+ * Record the current VCS revision for a run/frame pair.
17
+ *
18
+ * @param {SmithersDb} adapter
19
+ * @param {string} runId
20
+ * @param {number} frameNo
21
+ * @param {{ cwd?: string }} [opts]
22
+ * @returns {Promise<VcsTag | null>}
23
+ */
24
+ export function tagSnapshotVcs(adapter, runId, frameNo, opts = {}) {
25
+ return Effect.runPromise(tagSnapshotVcsEffect(adapter, runId, frameNo, opts).pipe(Effect.provide(BunContext.layer)));
26
+ }
27
+ /**
28
+ * Load the VCS revision tag for a run/frame pair, if any.
29
+ *
30
+ * @param {SmithersDb} adapter
31
+ * @param {string} runId
32
+ * @param {number} frameNo
33
+ * @returns {Promise<VcsTag | undefined>}
34
+ */
35
+ export function loadVcsTag(adapter, runId, frameNo) {
36
+ return Effect.runPromise(loadVcsTagEffect(adapter, runId, frameNo));
37
+ }
38
+ /**
39
+ * Create a jj workspace at the revision recorded for a run/frame pair.
40
+ *
41
+ * @param {SmithersDb} adapter
42
+ * @param {string} runId
43
+ * @param {number} frameNo
44
+ * @param {string} workspacePath
45
+ * @returns {Promise<{ workspacePath: string; vcsPointer: string } | null>}
46
+ */
47
+ export function resolveWorkflowAtRevision(adapter, runId, frameNo, workspacePath) {
48
+ return Effect.runPromise(resolveWorkflowAtRevisionEffect(adapter, runId, frameNo, workspacePath).pipe(Effect.provide(BunContext.layer)));
49
+ }
50
+ /**
51
+ * Revert the working copy to the VCS revision for a run/frame pair.
52
+ *
53
+ * @param {SmithersDb} adapter
54
+ * @param {string} runId
55
+ * @param {number} frameNo
56
+ * @param {{ cwd?: string }} [opts]
57
+ * @returns {Promise<{ restored: boolean; vcsPointer: string | null; error?: string }>}
58
+ */
59
+ export function rerunAtRevision(adapter, runId, frameNo, opts = {}) {
60
+ return Effect.runPromise(rerunAtRevisionEffect(adapter, runId, frameNo, opts).pipe(Effect.provide(BunContext.layer)));
61
+ }
@@ -0,0 +1,27 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { Effect } from "effect";
3
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
4
+ import { smithersVcsTags } from "../schema.js";
5
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
6
+ /** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
7
+ /** @typedef {import("./VcsTag.ts").VcsTag} VcsTag */
8
+
9
+ /**
10
+ * @param {SmithersDb} adapter
11
+ * @param {string} runId
12
+ * @param {number} frameNo
13
+ * @returns {Effect.Effect<VcsTag | undefined, SmithersError>}
14
+ */
15
+ export function loadVcsTag(adapter, runId, frameNo) {
16
+ return Effect.tryPromise({
17
+ try: () => adapter.db
18
+ .select()
19
+ .from(smithersVcsTags)
20
+ .where(and(eq(smithersVcsTags.runId, runId), eq(smithersVcsTags.frameNo, frameNo)))
21
+ .limit(1),
22
+ catch: (cause) => toSmithersError(cause, "load vcs tag", {
23
+ code: "DB_QUERY_FAILED",
24
+ details: { frameNo, runId },
25
+ }),
26
+ }).pipe(Effect.map((rows) => rows[0]), Effect.annotateLogs({ runId, frameNo: String(frameNo) }), Effect.withLogSpan("time-travel:load-vcs-tag"));
27
+ }
@@ -0,0 +1,25 @@
1
+ import { Effect } from "effect";
2
+ import { revertToJjPointer } from "@smithers-orchestrator/vcs/jj";
3
+ import { loadVcsTag } from "./loadVcsTagEffect.js";
4
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
5
+
6
+ /**
7
+ * @param {SmithersDb} adapter
8
+ * @param {string} runId
9
+ * @param {number} frameNo
10
+ * @param {{ cwd?: string }} [opts]
11
+ * @returns {Effect.Effect< { restored: boolean; vcsPointer: string | null; error?: string }, SmithersError, CommandExecutor >}
12
+ */
13
+ export function rerunAtRevision(adapter, runId, frameNo, opts = {}) {
14
+ return Effect.gen(function* () {
15
+ const tag = yield* loadVcsTag(adapter, runId, frameNo);
16
+ if (!tag) {
17
+ return { restored: false, vcsPointer: null };
18
+ }
19
+ const result = yield* revertToJjPointer(tag.vcsPointer, opts.cwd ?? tag.vcsRoot ?? undefined);
20
+ if (!result.success) {
21
+ return { restored: false, vcsPointer: tag.vcsPointer, error: result.error };
22
+ }
23
+ return { restored: true, vcsPointer: tag.vcsPointer };
24
+ }).pipe(Effect.annotateLogs({ runId, frameNo: String(frameNo) }), Effect.withLogSpan("time-travel:rerun-at-revision"));
25
+ }
@@ -0,0 +1,32 @@
1
+ import { Effect } from "effect";
2
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
3
+ import { workspaceAdd } from "@smithers-orchestrator/vcs/jj";
4
+ import { loadVcsTag } from "./loadVcsTagEffect.js";
5
+
6
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
7
+
8
+ /**
9
+ * Create a jj workspace at the revision recorded for a specific snapshot.
10
+ * Returns the workspace path or null if no VCS tag exists.
11
+ *
12
+ * @param {SmithersDb} adapter
13
+ * @param {string} runId
14
+ * @param {number} frameNo
15
+ * @param {string} workspacePath
16
+ */
17
+ export function resolveWorkflowAtRevision(adapter, runId, frameNo, workspacePath) {
18
+ return Effect.gen(function* () {
19
+ const tag = yield* loadVcsTag(adapter, runId, frameNo);
20
+ if (!tag)
21
+ return null;
22
+ const workspaceName = `smithers-replay-${runId.slice(0, 8)}-f${frameNo}`;
23
+ const result = yield* workspaceAdd(workspaceName, workspacePath, {
24
+ cwd: tag.vcsRoot ?? undefined,
25
+ atRev: tag.vcsPointer,
26
+ });
27
+ if (!result.success) {
28
+ return yield* Effect.fail(new SmithersError("VCS_WORKSPACE_CREATE_FAILED", `Failed to create workspace at ${tag.vcsPointer}: ${result.error}`, { frameNo, runId, vcsPointer: tag.vcsPointer, workspacePath }));
29
+ }
30
+ return { workspacePath, vcsPointer: tag.vcsPointer };
31
+ }).pipe(Effect.annotateLogs({ runId, frameNo: String(frameNo) }), Effect.withLogSpan("time-travel:resolve-workflow-at-revision"));
32
+ }
@@ -0,0 +1,56 @@
1
+ import { Effect } from "effect";
2
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
3
+ import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
4
+ import { smithersVcsTags } from "../schema.js";
5
+ import { getJjPointer, runJj, } from "@smithers-orchestrator/vcs/jj";
6
+ /** @typedef {import("@effect/platform/CommandExecutor").CommandExecutor} CommandExecutor */
7
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
8
+ /** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
9
+ /** @typedef {import("./VcsTag.ts").VcsTag} VcsTag */
10
+
11
+ /**
12
+ * @param {SmithersDb} adapter
13
+ * @param {string} runId
14
+ * @param {number} frameNo
15
+ * @param {{ cwd?: string }} [opts]
16
+ * @returns {Effect.Effect<VcsTag | null, SmithersError, CommandExecutor>}
17
+ */
18
+ export function tagSnapshotVcs(adapter, runId, frameNo, opts = {}) {
19
+ return Effect.gen(function* () {
20
+ const pointer = yield* getJjPointer(opts.cwd);
21
+ if (!pointer)
22
+ return null;
23
+ // Get current jj operation ID for precise restore
24
+ const opRes = yield* runJj(["operation", "log", "--no-graph", "--limit", "1", "-T", "self.id()"], { cwd: opts.cwd });
25
+ const jjOperationId = opRes.code === 0 ? opRes.stdout.trim() || null : null;
26
+ const ts = nowMs();
27
+ const tag = {
28
+ runId,
29
+ frameNo,
30
+ vcsType: "jj",
31
+ vcsPointer: pointer,
32
+ vcsRoot: opts.cwd ?? null,
33
+ jjOperationId,
34
+ createdAtMs: ts,
35
+ };
36
+ yield* Effect.tryPromise({
37
+ try: () => adapter.db
38
+ .insert(smithersVcsTags)
39
+ .values(tag)
40
+ .onConflictDoUpdate({
41
+ target: [smithersVcsTags.runId, smithersVcsTags.frameNo],
42
+ set: tag,
43
+ }),
44
+ catch: (cause) => toSmithersError(cause, "insert vcs tag", {
45
+ code: "DB_WRITE_FAILED",
46
+ details: { frameNo, runId },
47
+ }),
48
+ });
49
+ yield* Effect.logDebug("VCS tag recorded").pipe(Effect.annotateLogs({
50
+ runId,
51
+ frameNo: String(frameNo),
52
+ vcsPointer: pointer,
53
+ }));
54
+ return tag;
55
+ }).pipe(Effect.annotateLogs({ runId, frameNo: String(frameNo) }), Effect.withLogSpan("time-travel:tag-snapshot-vcs"));
56
+ }
@@ -0,0 +1,46 @@
1
+ import { resolveRewindAuditClient } from "./resolveRewindAuditClient.js";
2
+
3
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
4
+ /** @typedef {import("./RewindAuditResult.ts").RewindAuditResult} RewindAuditResult */
5
+
6
+ /**
7
+ * Persist one audit row for a jump-to-frame attempt.
8
+ *
9
+ * @param {SmithersDb} adapter
10
+ * @param {{
11
+ * runId: string;
12
+ * fromFrameNo: number;
13
+ * toFrameNo: number;
14
+ * caller: string;
15
+ * timestampMs: number;
16
+ * result: RewindAuditResult;
17
+ * durationMs?: number | null;
18
+ * }} row
19
+ * @returns {Promise<number | null>}
20
+ */
21
+ export async function writeRewindAuditRow(adapter, row) {
22
+ const client = resolveRewindAuditClient(adapter);
23
+ client
24
+ .query(
25
+ `INSERT INTO _smithers_time_travel_audit (
26
+ run_id,
27
+ from_frame_no,
28
+ to_frame_no,
29
+ caller,
30
+ timestamp_ms,
31
+ result,
32
+ duration_ms
33
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
34
+ )
35
+ .run(
36
+ row.runId,
37
+ row.fromFrameNo,
38
+ row.toFrameNo,
39
+ row.caller,
40
+ row.timestampMs,
41
+ row.result,
42
+ row.durationMs ?? null,
43
+ );
44
+ const inserted = client.query("SELECT last_insert_rowid() AS id").get();
45
+ return typeof inserted?.id === "number" ? inserted.id : null;
46
+ }