@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.
- package/LICENSE +21 -0
- package/package.json +68 -0
- package/src/BranchInfo.ts +11 -0
- package/src/ForkParams.ts +11 -0
- package/src/JUMP_MAX_FRAME_NO.js +1 -0
- package/src/JUMP_RUN_ID_PATTERN.js +1 -0
- package/src/JumpResult.ts +9 -0
- package/src/JumpStepName.ts +10 -0
- package/src/JumpToFrameError.js +23 -0
- package/src/JumpToFrameInput.ts +36 -0
- package/src/NodeChange.ts +7 -0
- package/src/NodeSnapshot.ts +8 -0
- package/src/OutputChange.ts +5 -0
- package/src/ParsedSnapshot.ts +18 -0
- package/src/REWIND_RATE_LIMIT_MAX.js +1 -0
- package/src/REWIND_RATE_LIMIT_WINDOW_MS.js +1 -0
- package/src/RalphChange.ts +7 -0
- package/src/RalphSnapshot.ts +5 -0
- package/src/ReplayParams.ts +12 -0
- package/src/ReplayResult.ts +11 -0
- package/src/RetryTaskOptions.ts +10 -0
- package/src/RetryTaskResult.ts +5 -0
- package/src/RevertOptions.ts +9 -0
- package/src/RevertResult.ts +5 -0
- package/src/RewindAuditResult.ts +5 -0
- package/src/RewindLockHandle.ts +4 -0
- package/src/RunTimeline.ts +11 -0
- package/src/SnapshotDiff.ts +18 -0
- package/src/TimeTravelOptions.ts +11 -0
- package/src/TimeTravelResult.ts +7 -0
- package/src/TimelineFrame.ts +11 -0
- package/src/TimelineTree.ts +9 -0
- package/src/acquireRewindLock.js +32 -0
- package/src/countRecentRewindAuditRows.js +27 -0
- package/src/diff.js +189 -0
- package/src/evaluateRewindRateLimit.js +41 -0
- package/src/fork/_helpers.js +28 -0
- package/src/fork/forkRunEffect.js +147 -0
- package/src/fork/getBranchInfoEffect.js +26 -0
- package/src/fork/index.js +41 -0
- package/src/fork/listBranchesEffect.js +25 -0
- package/src/hasRewindLock.js +11 -0
- package/src/index.d.ts +1170 -0
- package/src/index.js +43 -0
- package/src/jumpToFrame.js +1077 -0
- package/src/listRewindAuditRows.js +83 -0
- package/src/metrics.js +4 -0
- package/src/recoverInProgressRewindAudits.js +72 -0
- package/src/replay.js +22 -0
- package/src/replayFromCheckpointEffect.js +59 -0
- package/src/replaysStarted.js +2 -0
- package/src/resetRewindLocksForTests.js +8 -0
- package/src/resolveRewindAuditClient.js +38 -0
- package/src/retry-task.js +215 -0
- package/src/revert.js +68 -0
- package/src/rewindAudit.js +9 -0
- package/src/rewindLock.js +7 -0
- package/src/rewindLockStore.js +8 -0
- package/src/rewindRateLimit.js +3 -0
- package/src/runForksCreated.js +2 -0
- package/src/schema.js +46 -0
- package/src/snapshot/Snapshot.ts +15 -0
- package/src/snapshot/SnapshotData.ts +19 -0
- package/src/snapshot/captureSnapshotEffect.js +70 -0
- package/src/snapshot/index.js +57 -0
- package/src/snapshot/listSnapshotsEffect.js +32 -0
- package/src/snapshot/loadSnapshotEffect.js +46 -0
- package/src/snapshot/parseSnapshot.js +31 -0
- package/src/snapshotDuration.js +7 -0
- package/src/snapshotsCaptured.js +2 -0
- package/src/timeline/_helpers.js +7 -0
- package/src/timeline/buildTimelineEffect.js +38 -0
- package/src/timeline/buildTimelineTreeEffect.js +30 -0
- package/src/timeline/formatTimelineAsJson.js +23 -0
- package/src/timeline/formatTimelineForTui.js +31 -0
- package/src/timeline/index.js +31 -0
- package/src/timetravel.js +247 -0
- package/src/types.ts +15 -0
- package/src/updateRewindAuditRow.js +35 -0
- package/src/validateJumpFrameNo.js +23 -0
- package/src/validateJumpRunId.js +18 -0
- package/src/vcs-version/VcsTag.ts +9 -0
- package/src/vcs-version/index.js +61 -0
- package/src/vcs-version/loadVcsTagEffect.js +27 -0
- package/src/vcs-version/rerunAtRevisionEffect.js +25 -0
- package/src/vcs-version/resolveWorkflowAtRevisionEffect.js +32 -0
- package/src/vcs-version/tagSnapshotVcsEffect.js +56 -0
- 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,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
|
+
}
|