@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,19 @@
|
|
|
1
|
+
export type SnapshotData = {
|
|
2
|
+
nodes: Array<{
|
|
3
|
+
nodeId: string;
|
|
4
|
+
iteration: number;
|
|
5
|
+
state: string;
|
|
6
|
+
lastAttempt: number | null;
|
|
7
|
+
outputTable: string;
|
|
8
|
+
label: string | null;
|
|
9
|
+
}>;
|
|
10
|
+
outputs: Record<string, unknown>;
|
|
11
|
+
ralph: Array<{
|
|
12
|
+
ralphId: string;
|
|
13
|
+
iteration: number;
|
|
14
|
+
done: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
input: Record<string, unknown>;
|
|
17
|
+
vcsPointer?: string | null;
|
|
18
|
+
workflowHash?: string | null;
|
|
19
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect, Metric } from "effect";
|
|
2
|
+
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
|
|
5
|
+
import { smithersSnapshots } from "../schema.js";
|
|
6
|
+
import { snapshotsCaptured } from "../snapshotsCaptured.js";
|
|
7
|
+
import { snapshotDuration } from "../snapshotDuration.js";
|
|
8
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
9
|
+
/** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
|
|
10
|
+
/** @typedef {import("./Snapshot.ts").Snapshot} Snapshot */
|
|
11
|
+
/** @typedef {import("./SnapshotData.ts").SnapshotData} SnapshotData */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {SnapshotData} data
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function serializeSnapshotContent(data) {
|
|
18
|
+
return JSON.stringify({
|
|
19
|
+
nodes: data.nodes,
|
|
20
|
+
outputs: data.outputs,
|
|
21
|
+
ralph: data.ralph,
|
|
22
|
+
input: data.input,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* @param {SmithersDb} adapter
|
|
27
|
+
* @param {string} runId
|
|
28
|
+
* @param {number} frameNo
|
|
29
|
+
* @param {SnapshotData} data
|
|
30
|
+
* @returns {Effect.Effect<Snapshot, SmithersError>}
|
|
31
|
+
*/
|
|
32
|
+
export function captureSnapshot(adapter, runId, frameNo, data) {
|
|
33
|
+
return Effect.gen(function* () {
|
|
34
|
+
const start = performance.now();
|
|
35
|
+
const nodesJson = JSON.stringify(data.nodes);
|
|
36
|
+
const outputsJson = JSON.stringify(data.outputs);
|
|
37
|
+
const ralphJson = JSON.stringify(data.ralph);
|
|
38
|
+
const inputJson = JSON.stringify(data.input);
|
|
39
|
+
const contentHash = createHash("sha256").update(serializeSnapshotContent(data)).digest("hex");
|
|
40
|
+
const ts = nowMs();
|
|
41
|
+
const row = {
|
|
42
|
+
runId,
|
|
43
|
+
frameNo,
|
|
44
|
+
nodesJson,
|
|
45
|
+
outputsJson,
|
|
46
|
+
ralphJson,
|
|
47
|
+
inputJson,
|
|
48
|
+
vcsPointer: data.vcsPointer ?? null,
|
|
49
|
+
workflowHash: data.workflowHash ?? null,
|
|
50
|
+
contentHash,
|
|
51
|
+
createdAtMs: ts,
|
|
52
|
+
};
|
|
53
|
+
yield* Effect.tryPromise({
|
|
54
|
+
try: () => adapter.db
|
|
55
|
+
.insert(smithersSnapshots)
|
|
56
|
+
.values(row)
|
|
57
|
+
.onConflictDoUpdate({
|
|
58
|
+
target: [smithersSnapshots.runId, smithersSnapshots.frameNo],
|
|
59
|
+
set: row,
|
|
60
|
+
}),
|
|
61
|
+
catch: (cause) => toSmithersError(cause, "insert snapshot", {
|
|
62
|
+
code: "DB_WRITE_FAILED",
|
|
63
|
+
details: { frameNo, runId },
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
yield* Metric.increment(snapshotsCaptured);
|
|
67
|
+
yield* Metric.update(snapshotDuration, performance.now() - start);
|
|
68
|
+
return row;
|
|
69
|
+
}).pipe(Effect.annotateLogs({ runId, frameNo: String(frameNo) }), Effect.withLogSpan("time-travel:capture-snapshot"));
|
|
70
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @smithers-type-exports-begin
|
|
2
|
+
/** @typedef {import("./Snapshot.ts").Snapshot} Snapshot */
|
|
3
|
+
/** @typedef {import("./SnapshotData.ts").SnapshotData} SnapshotData */
|
|
4
|
+
// @smithers-type-exports-end
|
|
5
|
+
|
|
6
|
+
import { Effect } from "effect";
|
|
7
|
+
import { captureSnapshot as captureSnapshotEffect } from "./captureSnapshotEffect.js";
|
|
8
|
+
import { loadLatestSnapshot as loadLatestSnapshotEffect, loadSnapshot as loadSnapshotEffect, } from "./loadSnapshotEffect.js";
|
|
9
|
+
import { listSnapshots as listSnapshotsEffect } from "./listSnapshotsEffect.js";
|
|
10
|
+
export { parseSnapshot } from "./parseSnapshot.js";
|
|
11
|
+
export { captureSnapshotEffect, listSnapshotsEffect, loadLatestSnapshotEffect, loadSnapshotEffect, };
|
|
12
|
+
|
|
13
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Capture a snapshot row for a run at a given frame.
|
|
17
|
+
*
|
|
18
|
+
* @param {SmithersDb} adapter
|
|
19
|
+
* @param {string} runId
|
|
20
|
+
* @param {number} frameNo
|
|
21
|
+
* @param {SnapshotData} data
|
|
22
|
+
* @returns {Promise<Snapshot>}
|
|
23
|
+
*/
|
|
24
|
+
export function captureSnapshot(adapter, runId, frameNo, data) {
|
|
25
|
+
return Effect.runPromise(captureSnapshotEffect(adapter, runId, frameNo, data));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Load a specific snapshot row for a run/frame.
|
|
29
|
+
*
|
|
30
|
+
* @param {SmithersDb} adapter
|
|
31
|
+
* @param {string} runId
|
|
32
|
+
* @param {number} frameNo
|
|
33
|
+
* @returns {Promise<Snapshot | undefined>}
|
|
34
|
+
*/
|
|
35
|
+
export function loadSnapshot(adapter, runId, frameNo) {
|
|
36
|
+
return Effect.runPromise(loadSnapshotEffect(adapter, runId, frameNo));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Load the most recent snapshot row for a run.
|
|
40
|
+
*
|
|
41
|
+
* @param {SmithersDb} adapter
|
|
42
|
+
* @param {string} runId
|
|
43
|
+
* @returns {Promise<Snapshot | undefined>}
|
|
44
|
+
*/
|
|
45
|
+
export function loadLatestSnapshot(adapter, runId) {
|
|
46
|
+
return Effect.runPromise(loadLatestSnapshotEffect(adapter, runId));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* List lightweight snapshot index rows for a run.
|
|
50
|
+
*
|
|
51
|
+
* @param {SmithersDb} adapter
|
|
52
|
+
* @param {string} runId
|
|
53
|
+
* @returns {Promise<Array<Pick<Snapshot, "runId" | "frameNo" | "contentHash" | "createdAtMs" | "vcsPointer">>>}
|
|
54
|
+
*/
|
|
55
|
+
export function listSnapshots(adapter, runId) {
|
|
56
|
+
return Effect.runPromise(listSnapshotsEffect(adapter, runId));
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
4
|
+
import { smithersSnapshots } from "../schema.js";
|
|
5
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
6
|
+
/** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
|
|
7
|
+
/** @typedef {import("./Snapshot.ts").Snapshot} Snapshot */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {SmithersDb} adapter
|
|
11
|
+
* @param {string} runId
|
|
12
|
+
* @returns {Effect.Effect<Array<Pick<Snapshot, "runId" | "frameNo" | "contentHash" | "createdAtMs" | "vcsPointer">>, SmithersError>}
|
|
13
|
+
*/
|
|
14
|
+
export function listSnapshots(adapter, runId) {
|
|
15
|
+
return Effect.tryPromise({
|
|
16
|
+
try: () => adapter.db
|
|
17
|
+
.select({
|
|
18
|
+
runId: smithersSnapshots.runId,
|
|
19
|
+
frameNo: smithersSnapshots.frameNo,
|
|
20
|
+
contentHash: smithersSnapshots.contentHash,
|
|
21
|
+
createdAtMs: smithersSnapshots.createdAtMs,
|
|
22
|
+
vcsPointer: smithersSnapshots.vcsPointer,
|
|
23
|
+
})
|
|
24
|
+
.from(smithersSnapshots)
|
|
25
|
+
.where(eq(smithersSnapshots.runId, runId))
|
|
26
|
+
.orderBy(smithersSnapshots.frameNo),
|
|
27
|
+
catch: (cause) => toSmithersError(cause, "list snapshots", {
|
|
28
|
+
code: "DB_QUERY_FAILED",
|
|
29
|
+
details: { runId },
|
|
30
|
+
}),
|
|
31
|
+
}).pipe(Effect.annotateLogs({ runId }), Effect.withLogSpan("time-travel:list-snapshots"));
|
|
32
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
4
|
+
import { smithersSnapshots } from "../schema.js";
|
|
5
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
6
|
+
/** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
|
|
7
|
+
/** @typedef {import("./Snapshot.ts").Snapshot} Snapshot */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {SmithersDb} adapter
|
|
11
|
+
* @param {string} runId
|
|
12
|
+
* @param {number} frameNo
|
|
13
|
+
* @returns {Effect.Effect<Snapshot | undefined, SmithersError>}
|
|
14
|
+
*/
|
|
15
|
+
export function loadSnapshot(adapter, runId, frameNo) {
|
|
16
|
+
return Effect.tryPromise({
|
|
17
|
+
try: () => adapter.db
|
|
18
|
+
.select()
|
|
19
|
+
.from(smithersSnapshots)
|
|
20
|
+
.where(and(eq(smithersSnapshots.runId, runId), eq(smithersSnapshots.frameNo, frameNo)))
|
|
21
|
+
.limit(1),
|
|
22
|
+
catch: (cause) => toSmithersError(cause, "load snapshot", {
|
|
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-snapshot"));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* @param {SmithersDb} adapter
|
|
30
|
+
* @param {string} runId
|
|
31
|
+
* @returns {Effect.Effect<Snapshot | undefined, SmithersError>}
|
|
32
|
+
*/
|
|
33
|
+
export function loadLatestSnapshot(adapter, runId) {
|
|
34
|
+
return Effect.tryPromise({
|
|
35
|
+
try: () => adapter.db
|
|
36
|
+
.select()
|
|
37
|
+
.from(smithersSnapshots)
|
|
38
|
+
.where(eq(smithersSnapshots.runId, runId))
|
|
39
|
+
.orderBy(desc(smithersSnapshots.frameNo))
|
|
40
|
+
.limit(1),
|
|
41
|
+
catch: (cause) => toSmithersError(cause, "load latest snapshot", {
|
|
42
|
+
code: "DB_QUERY_FAILED",
|
|
43
|
+
details: { runId },
|
|
44
|
+
}),
|
|
45
|
+
}).pipe(Effect.map((rows) => rows[0]), Effect.annotateLogs({ runId }), Effect.withLogSpan("time-travel:load-latest-snapshot"));
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
|
|
2
|
+
/** @typedef {import("../ParsedSnapshot.ts").ParsedSnapshot} ParsedSnapshot */
|
|
3
|
+
/** @typedef {import("./Snapshot.ts").Snapshot} Snapshot */
|
|
4
|
+
/**
|
|
5
|
+
* @param {Snapshot} snapshot
|
|
6
|
+
* @returns {ParsedSnapshot}
|
|
7
|
+
*/
|
|
8
|
+
export function parseSnapshot(snapshot) {
|
|
9
|
+
const nodesArr = JSON.parse(snapshot.nodesJson);
|
|
10
|
+
const nodes = {};
|
|
11
|
+
for (const n of nodesArr) {
|
|
12
|
+
nodes[`${n.nodeId}::${n.iteration}`] = n;
|
|
13
|
+
}
|
|
14
|
+
const ralphArr = JSON.parse(snapshot.ralphJson);
|
|
15
|
+
const ralph = {};
|
|
16
|
+
for (const r of ralphArr) {
|
|
17
|
+
ralph[r.ralphId] = r;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
runId: snapshot.runId,
|
|
21
|
+
frameNo: snapshot.frameNo,
|
|
22
|
+
nodes,
|
|
23
|
+
outputs: JSON.parse(snapshot.outputsJson),
|
|
24
|
+
ralph,
|
|
25
|
+
input: JSON.parse(snapshot.inputJson),
|
|
26
|
+
vcsPointer: snapshot.vcsPointer,
|
|
27
|
+
workflowHash: snapshot.workflowHash,
|
|
28
|
+
contentHash: snapshot.contentHash,
|
|
29
|
+
createdAtMs: snapshot.createdAtMs,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Metric, MetricBoundaries } from "effect";
|
|
2
|
+
const snapshotBuckets = MetricBoundaries.exponential({
|
|
3
|
+
start: 1,
|
|
4
|
+
factor: 2,
|
|
5
|
+
count: 12,
|
|
6
|
+
}); // ~1ms to ~2s
|
|
7
|
+
export const snapshotDuration = Metric.histogram("smithers.snapshot.duration_ms", snapshotBuckets);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { listSnapshots } from "../snapshot/listSnapshotsEffect.js";
|
|
3
|
+
import { listBranches } from "../fork/listBranchesEffect.js";
|
|
4
|
+
import { getBranchInfo } from "../fork/getBranchInfoEffect.js";
|
|
5
|
+
/** @typedef {import("../RunTimeline.ts").RunTimeline} RunTimeline */
|
|
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<RunTimeline, SmithersError>}
|
|
13
|
+
*/
|
|
14
|
+
export function buildTimeline(adapter, runId) {
|
|
15
|
+
return Effect.gen(function* () {
|
|
16
|
+
const snapshots = yield* listSnapshots(adapter, runId);
|
|
17
|
+
const branches = yield* listBranches(adapter, runId);
|
|
18
|
+
const ownBranch = yield* getBranchInfo(adapter, runId);
|
|
19
|
+
// Index branches by parent frame number for fast lookup
|
|
20
|
+
const branchByFrame = new Map();
|
|
21
|
+
for (const b of branches) {
|
|
22
|
+
const existing = branchByFrame.get(b.parentFrameNo) ?? [];
|
|
23
|
+
existing.push(b);
|
|
24
|
+
branchByFrame.set(b.parentFrameNo, existing);
|
|
25
|
+
}
|
|
26
|
+
const frames = snapshots.map((s) => ({
|
|
27
|
+
frameNo: s.frameNo,
|
|
28
|
+
createdAtMs: s.createdAtMs,
|
|
29
|
+
contentHash: s.contentHash,
|
|
30
|
+
forkPoints: branchByFrame.get(s.frameNo) ?? [],
|
|
31
|
+
}));
|
|
32
|
+
return {
|
|
33
|
+
runId,
|
|
34
|
+
frames,
|
|
35
|
+
branch: ownBranch ?? null,
|
|
36
|
+
};
|
|
37
|
+
}).pipe(Effect.annotateLogs({ runId }), Effect.withLogSpan("time-travel:build-timeline"));
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { buildTimeline } from "./buildTimelineEffect.js";
|
|
3
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
4
|
+
/** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
|
|
5
|
+
/** @typedef {import("../TimelineTree.ts").TimelineTree} TimelineTree */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {SmithersDb} adapter
|
|
9
|
+
* @param {string} runId
|
|
10
|
+
* @returns {Effect.Effect<TimelineTree, SmithersError>}
|
|
11
|
+
*/
|
|
12
|
+
export function buildTimelineTree(adapter, runId) {
|
|
13
|
+
return Effect.gen(function* () {
|
|
14
|
+
const timeline = yield* buildTimeline(adapter, runId);
|
|
15
|
+
// Collect all child runs that branch from this run
|
|
16
|
+
const childRunIds = [];
|
|
17
|
+
for (const frame of timeline.frames) {
|
|
18
|
+
for (const fork of frame.forkPoints) {
|
|
19
|
+
childRunIds.push(fork.runId);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Recursively build subtrees
|
|
23
|
+
const children = [];
|
|
24
|
+
for (const childId of childRunIds) {
|
|
25
|
+
const childTree = yield* buildTimelineTree(adapter, childId);
|
|
26
|
+
children.push(childTree);
|
|
27
|
+
}
|
|
28
|
+
return { timeline, children };
|
|
29
|
+
}).pipe(Effect.annotateLogs({ runId }), Effect.withLogSpan("time-travel:build-timeline-tree"));
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
/** @typedef {import("../TimelineTree.ts").TimelineTree} TimelineTree */
|
|
3
|
+
/**
|
|
4
|
+
* @param {TimelineTree} tree
|
|
5
|
+
* @returns {object}
|
|
6
|
+
*/
|
|
7
|
+
export function formatTimelineAsJson(tree) {
|
|
8
|
+
return {
|
|
9
|
+
runId: tree.timeline.runId,
|
|
10
|
+
branch: tree.timeline.branch,
|
|
11
|
+
frames: tree.timeline.frames.map((f) => ({
|
|
12
|
+
frameNo: f.frameNo,
|
|
13
|
+
createdAtMs: f.createdAtMs,
|
|
14
|
+
contentHash: f.contentHash,
|
|
15
|
+
forks: f.forkPoints.map((fp) => ({
|
|
16
|
+
runId: fp.runId,
|
|
17
|
+
branchLabel: fp.branchLabel,
|
|
18
|
+
forkDescription: fp.forkDescription,
|
|
19
|
+
})),
|
|
20
|
+
})),
|
|
21
|
+
children: tree.children.map(formatTimelineAsJson),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { formatTimestamp } from "./_helpers.js";
|
|
3
|
+
/** @typedef {import("../TimelineTree.ts").TimelineTree} TimelineTree */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {TimelineTree} tree
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function formatTimelineForTui(tree, indent = 0) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
const pad = " ".repeat(indent);
|
|
12
|
+
const tl = tree.timeline;
|
|
13
|
+
const labelSuffix = tl.branch
|
|
14
|
+
? ` ${pc.dim(`[${tl.branch.branchLabel ?? "fork"}]`)} ${pc.dim(`(forked from ${tl.branch.parentRunId.slice(0, 8)}:${tl.branch.parentFrameNo})`)}`
|
|
15
|
+
: "";
|
|
16
|
+
lines.push(`${pad}${pc.bold(tl.runId)}${labelSuffix}`);
|
|
17
|
+
for (const frame of tl.frames) {
|
|
18
|
+
const ts = formatTimestamp(frame.createdAtMs);
|
|
19
|
+
const hash = frame.contentHash.slice(0, 8);
|
|
20
|
+
lines.push(`${pad} Frame ${frame.frameNo} ${pc.dim(ts)} ${pc.dim(hash)}`);
|
|
21
|
+
// Show fork points after the frame
|
|
22
|
+
for (const fork of frame.forkPoints) {
|
|
23
|
+
const childTree = tree.children.find((c) => c.timeline.runId === fork.runId);
|
|
24
|
+
if (childTree) {
|
|
25
|
+
lines.push(`${pad} ${pc.yellow("|--")} ${pc.cyan(fork.runId.slice(0, 12))} ${pc.dim(`[${fork.branchLabel ?? "fork"}]`)} ${pc.dim(`(forked at frame ${fork.parentFrameNo})`)}`);
|
|
26
|
+
lines.push(formatTimelineForTui(childTree, indent + 2));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { buildTimeline as buildTimelineEffect } from "./buildTimelineEffect.js";
|
|
3
|
+
import { buildTimelineTree as buildTimelineTreeEffect } from "./buildTimelineTreeEffect.js";
|
|
4
|
+
export { formatTimelineForTui } from "./formatTimelineForTui.js";
|
|
5
|
+
export { formatTimelineAsJson } from "./formatTimelineAsJson.js";
|
|
6
|
+
export { buildTimelineEffect, buildTimelineTreeEffect, };
|
|
7
|
+
|
|
8
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
9
|
+
/** @typedef {import("../RunTimeline.ts").RunTimeline} RunTimeline */
|
|
10
|
+
/** @typedef {import("../TimelineTree.ts").TimelineTree} TimelineTree */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the flat timeline (snapshots + branches) for a run.
|
|
14
|
+
*
|
|
15
|
+
* @param {SmithersDb} adapter
|
|
16
|
+
* @param {string} runId
|
|
17
|
+
* @returns {Promise<RunTimeline>}
|
|
18
|
+
*/
|
|
19
|
+
export function buildTimeline(adapter, runId) {
|
|
20
|
+
return Effect.runPromise(buildTimelineEffect(adapter, runId));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the recursive timeline tree (run + all descendants) for a run.
|
|
24
|
+
*
|
|
25
|
+
* @param {SmithersDb} adapter
|
|
26
|
+
* @param {string} runId
|
|
27
|
+
* @returns {Promise<TimelineTree>}
|
|
28
|
+
*/
|
|
29
|
+
export function buildTimelineTree(adapter, runId) {
|
|
30
|
+
return Effect.runPromise(buildTimelineTreeEffect(adapter, runId));
|
|
31
|
+
}
|