@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,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
|
+
}
|