@crewhaus/checkpoint-store 0.1.4 → 0.1.6

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.
@@ -0,0 +1,81 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ export declare const DEFAULT_ROOT_DIR = ".crewhaus/graphs";
3
+ export type GraphRunId = string;
4
+ export type CheckpointId = string;
5
+ export type Checkpoint = {
6
+ readonly version: 1;
7
+ readonly id: CheckpointId;
8
+ readonly graphRunId: GraphRunId;
9
+ readonly nodeName: string;
10
+ readonly state: unknown;
11
+ /** Parent within the same graph run — `undefined` for the entry node. */
12
+ readonly parentCheckpointId?: CheckpointId;
13
+ readonly createdAt: string;
14
+ };
15
+ export type BranchInfo = {
16
+ readonly graphRunId: GraphRunId;
17
+ readonly checkpointId: CheckpointId;
18
+ };
19
+ export type GraphRunMeta = {
20
+ readonly version: 1;
21
+ readonly graphRunId: GraphRunId;
22
+ /** Most recently saved checkpoint id; absent on a freshly-branched run with no new commits. */
23
+ head?: CheckpointId;
24
+ readonly createdAt: string;
25
+ /** Set when this run was branched from a sibling. */
26
+ readonly branchedFrom?: BranchInfo;
27
+ };
28
+ export type ListOptions = {
29
+ /** Cap the number of returned checkpoints (insertion order, oldest first). */
30
+ readonly limit?: number;
31
+ /** Skip checkpoints created before this ISO timestamp. */
32
+ readonly since?: string;
33
+ };
34
+ export interface CheckpointStoreAdapter {
35
+ save(c: Checkpoint): Promise<void>;
36
+ load(graphRunId: GraphRunId, checkpointId: CheckpointId): Promise<Checkpoint | undefined>;
37
+ list(graphRunId: GraphRunId, opts: ListOptions): Promise<ReadonlyArray<Checkpoint>>;
38
+ loadMeta(graphRunId: GraphRunId): Promise<GraphRunMeta | undefined>;
39
+ saveMeta(meta: GraphRunMeta): Promise<void>;
40
+ /** Delete the entire graph run (best-effort; idempotent). */
41
+ drop(graphRunId: GraphRunId): Promise<void>;
42
+ }
43
+ export interface CheckpointStore {
44
+ /** Persist a new checkpoint and update the graph run's head. */
45
+ save(opts: {
46
+ graphRunId: GraphRunId;
47
+ nodeName: string;
48
+ state: unknown;
49
+ parentCheckpointId?: CheckpointId;
50
+ }): Promise<Checkpoint>;
51
+ /** Load a specific checkpoint, or the head when `checkpointId` is omitted. */
52
+ load(graphRunId: GraphRunId, checkpointId?: CheckpointId): Promise<Checkpoint | undefined>;
53
+ /** Walk the run's checkpoints in insertion order. */
54
+ list(graphRunId: GraphRunId, opts?: ListOptions): Promise<ReadonlyArray<Checkpoint>>;
55
+ /**
56
+ * Materialise a NEW graph run that starts from `checkpointId` of
57
+ * `graphRunId`. The head of the new run is a fresh copy of the source
58
+ * checkpoint and the `_meta.json` records `branchedFrom: { graphRunId,
59
+ * checkpointId }` for time-travel auditing.
60
+ */
61
+ branch(graphRunId: GraphRunId, checkpointId: CheckpointId): Promise<{
62
+ newGraphRunId: GraphRunId;
63
+ head: Checkpoint;
64
+ }>;
65
+ /** Read the `_meta.json` for `graphRunId`. */
66
+ meta(graphRunId: GraphRunId): Promise<GraphRunMeta | undefined>;
67
+ /** Delete a graph run's directory tree. Idempotent. */
68
+ drop(graphRunId: GraphRunId): Promise<void>;
69
+ }
70
+ export declare class CheckpointStoreError extends CrewhausError {
71
+ readonly name = "CheckpointStoreError";
72
+ constructor(message: string, cause?: unknown);
73
+ }
74
+ export declare function newGraphRunId(): GraphRunId;
75
+ export declare function newCheckpointId(): CheckpointId;
76
+ export type CreateCheckpointStoreOptions = {
77
+ readonly rootDir?: string;
78
+ readonly adapter?: CheckpointStoreAdapter;
79
+ readonly now?: () => Date;
80
+ };
81
+ export declare function createCheckpointStore(opts?: CreateCheckpointStoreOptions): CheckpointStore;
package/dist/index.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Catalog R7 `checkpoint-store` — durable, branchable graph-run state.
3
+ *
4
+ * One subdirectory per graph run at `<rootDir>/<graphRunId>/`, with one
5
+ * JSON file per checkpoint (`<checkpointId>.json`) plus a `_meta.json`
6
+ * pointer that names the current head and the parent run+checkpoint
7
+ * (when branched). The format mirrors `event-log`'s "schema version
8
+ * stamped on every record" convention so future migrations can fan out
9
+ * on `version`.
10
+ *
11
+ * Branching: `branch(parentRunId, checkpointId)` creates a NEW
12
+ * `graphRunId` whose `_meta.json` points to `parentRunId` +
13
+ * `checkpointId` and whose head is a fresh COPY of the requested
14
+ * checkpoint. Reads on the new run still work even if the parent run is
15
+ * later deleted — checkpoint files are duplicated, not aliased — so
16
+ * long-lived branches are independent.
17
+ *
18
+ * Path-traversal defense: every public method validates ids against
19
+ * stable regexes (`grun_<16hex>`, `ckpt_<16hex>`); anything else throws
20
+ * `RuntimeError` before the filesystem is touched, mirroring
21
+ * `session-store`'s pattern.
22
+ *
23
+ * Pluggable adapter: callers can substitute a different
24
+ * `CheckpointStoreAdapter` (SQLite, Postgres, S3) and the
25
+ * `createCheckpointStore({ adapter })` factory wires it through. The
26
+ * default adapter is the file-backed implementation in this module.
27
+ *
28
+ * Layer R7. Pairs with `graph-engine` (R11) and `branch-history` (R7).
29
+ */
30
+ import { randomBytes } from "node:crypto";
31
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
32
+ import { join, resolve } from "node:path";
33
+ import { CrewhausError, RuntimeError } from "@crewhaus/errors";
34
+ import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
35
+ export const DEFAULT_ROOT_DIR = ".crewhaus/graphs";
36
+ const GRAPH_RUN_ID_RE = /^grun_[0-9a-f]{16}$/;
37
+ const CHECKPOINT_ID_RE = /^ckpt_[0-9a-f]{16}$/;
38
+ export class CheckpointStoreError extends CrewhausError {
39
+ name = "CheckpointStoreError";
40
+ constructor(message, cause) {
41
+ super("config", message, cause);
42
+ }
43
+ }
44
+ export function newGraphRunId() {
45
+ return `grun_${randomBytes(8).toString("hex")}`;
46
+ }
47
+ export function newCheckpointId() {
48
+ return `ckpt_${randomBytes(8).toString("hex")}`;
49
+ }
50
+ function validateGraphRunId(id) {
51
+ if (!GRAPH_RUN_ID_RE.test(id)) {
52
+ throw new RuntimeError(`checkpoint-store: invalid graphRunId "${id}" — expected grun_<16 hex>`);
53
+ }
54
+ }
55
+ function validateCheckpointId(id) {
56
+ if (!CHECKPOINT_ID_RE.test(id)) {
57
+ throw new RuntimeError(`checkpoint-store: invalid checkpointId "${id}" — expected ckpt_<16 hex>`);
58
+ }
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // File-backed adapter — default implementation.
62
+ // ---------------------------------------------------------------------------
63
+ class FileSystemAdapter {
64
+ rootDir;
65
+ constructor(rootDir) {
66
+ this.rootDir = rootDir;
67
+ mkdirSync(this.rootDir, { recursive: true });
68
+ }
69
+ dir(graphRunId) {
70
+ validateGraphRunId(graphRunId);
71
+ const dir = join(this.rootDir, graphRunId);
72
+ // When a tenant context is active, fail closed on a resolved path that
73
+ // escapes the tenant's sessionRoot (CWE-1230). Every checkpoint/meta path
74
+ // is built under this directory, so fencing here covers every read/write.
75
+ // Outside a tenant scope (the common CLI case) this is a no-op so
76
+ // non-tenant behaviour is unchanged.
77
+ if (currentTenantContext() !== undefined) {
78
+ assertSamePath(resolve(dir), requireTenant().sessionRoot);
79
+ }
80
+ return dir;
81
+ }
82
+ metaPath(graphRunId) {
83
+ return join(this.dir(graphRunId), "_meta.json");
84
+ }
85
+ checkpointPath(graphRunId, checkpointId) {
86
+ validateCheckpointId(checkpointId);
87
+ return join(this.dir(graphRunId), `${checkpointId}.json`);
88
+ }
89
+ async save(c) {
90
+ const dir = this.dir(c.graphRunId);
91
+ mkdirSync(dir, { recursive: true });
92
+ const tmp = `${this.checkpointPath(c.graphRunId, c.id)}.tmp.${randomBytes(4).toString("hex")}`;
93
+ writeFileSync(tmp, JSON.stringify(c), { mode: 0o600 });
94
+ // Atomic rename so concurrent reads never see a half-written file.
95
+ const final = this.checkpointPath(c.graphRunId, c.id);
96
+ renameSync(tmp, final);
97
+ }
98
+ async load(graphRunId, checkpointId) {
99
+ const path = this.checkpointPath(graphRunId, checkpointId);
100
+ if (!existsSync(path))
101
+ return undefined;
102
+ const raw = readFileSync(path, "utf8");
103
+ return JSON.parse(raw);
104
+ }
105
+ async list(graphRunId, opts) {
106
+ const dir = this.dir(graphRunId);
107
+ if (!existsSync(dir))
108
+ return [];
109
+ const files = readdirSync(dir).filter((f) => f.startsWith("ckpt_") && f.endsWith(".json"));
110
+ // Order by mtime ascending — file-backed adapter has no other notion of insertion order.
111
+ const withStat = files.map((f) => {
112
+ const full = join(dir, f);
113
+ return { name: f, mtimeMs: statSync(full).mtimeMs, full };
114
+ });
115
+ withStat.sort((a, b) => a.mtimeMs - b.mtimeMs);
116
+ const out = [];
117
+ const sinceTs = opts.since !== undefined ? Date.parse(opts.since) : Number.NEGATIVE_INFINITY;
118
+ for (const { full } of withStat) {
119
+ const c = JSON.parse(readFileSync(full, "utf8"));
120
+ if (Date.parse(c.createdAt) < sinceTs)
121
+ continue;
122
+ out.push(c);
123
+ if (opts.limit !== undefined && out.length >= opts.limit)
124
+ break;
125
+ }
126
+ return out;
127
+ }
128
+ async loadMeta(graphRunId) {
129
+ const path = this.metaPath(graphRunId);
130
+ if (!existsSync(path))
131
+ return undefined;
132
+ return JSON.parse(readFileSync(path, "utf8"));
133
+ }
134
+ async saveMeta(meta) {
135
+ const dir = this.dir(meta.graphRunId);
136
+ mkdirSync(dir, { recursive: true });
137
+ const tmp = `${this.metaPath(meta.graphRunId)}.tmp.${randomBytes(4).toString("hex")}`;
138
+ writeFileSync(tmp, JSON.stringify(meta), { mode: 0o600 });
139
+ renameSync(tmp, this.metaPath(meta.graphRunId));
140
+ }
141
+ async drop(graphRunId) {
142
+ const dir = this.dir(graphRunId);
143
+ if (!existsSync(dir))
144
+ return;
145
+ rmSync(dir, { recursive: true, force: true });
146
+ }
147
+ }
148
+ export function createCheckpointStore(opts = {}) {
149
+ const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
150
+ const adapter = opts.adapter ?? new FileSystemAdapter(rootDir);
151
+ const now = opts.now ?? (() => new Date());
152
+ async function ensureMeta(graphRunId) {
153
+ const existing = await adapter.loadMeta(graphRunId);
154
+ if (existing !== undefined)
155
+ return existing;
156
+ const meta = {
157
+ version: 1,
158
+ graphRunId,
159
+ createdAt: now().toISOString(),
160
+ };
161
+ await adapter.saveMeta(meta);
162
+ return meta;
163
+ }
164
+ return {
165
+ async save(req) {
166
+ validateGraphRunId(req.graphRunId);
167
+ if (req.parentCheckpointId !== undefined)
168
+ validateCheckpointId(req.parentCheckpointId);
169
+ if (typeof req.nodeName !== "string" || req.nodeName.length === 0) {
170
+ throw new CheckpointStoreError("nodeName must be a non-empty string");
171
+ }
172
+ const id = newCheckpointId();
173
+ const cp = {
174
+ version: 1,
175
+ id,
176
+ graphRunId: req.graphRunId,
177
+ nodeName: req.nodeName,
178
+ state: req.state,
179
+ ...(req.parentCheckpointId !== undefined
180
+ ? { parentCheckpointId: req.parentCheckpointId }
181
+ : {}),
182
+ createdAt: now().toISOString(),
183
+ };
184
+ await adapter.save(cp);
185
+ const prevMeta = await ensureMeta(req.graphRunId);
186
+ const meta = { ...prevMeta, head: id };
187
+ await adapter.saveMeta(meta);
188
+ return cp;
189
+ },
190
+ async load(graphRunId, checkpointId) {
191
+ validateGraphRunId(graphRunId);
192
+ let id = checkpointId;
193
+ if (id === undefined) {
194
+ const meta = await adapter.loadMeta(graphRunId);
195
+ if (meta?.head === undefined)
196
+ return undefined;
197
+ id = meta.head;
198
+ }
199
+ validateCheckpointId(id);
200
+ return adapter.load(graphRunId, id);
201
+ },
202
+ async list(graphRunId, listOpts = {}) {
203
+ validateGraphRunId(graphRunId);
204
+ return adapter.list(graphRunId, listOpts);
205
+ },
206
+ async branch(graphRunId, checkpointId) {
207
+ validateGraphRunId(graphRunId);
208
+ validateCheckpointId(checkpointId);
209
+ const source = await adapter.load(graphRunId, checkpointId);
210
+ if (source === undefined) {
211
+ throw new CheckpointStoreError(`checkpoint ${checkpointId} not found in graph run ${graphRunId}`);
212
+ }
213
+ const branchedRunId = newGraphRunId();
214
+ const newHead = {
215
+ version: 1,
216
+ id: newCheckpointId(),
217
+ graphRunId: branchedRunId,
218
+ nodeName: source.nodeName,
219
+ state: source.state,
220
+ createdAt: now().toISOString(),
221
+ };
222
+ await adapter.save(newHead);
223
+ const meta = {
224
+ version: 1,
225
+ graphRunId: branchedRunId,
226
+ head: newHead.id,
227
+ createdAt: newHead.createdAt,
228
+ branchedFrom: { graphRunId, checkpointId },
229
+ };
230
+ await adapter.saveMeta(meta);
231
+ return { newGraphRunId: branchedRunId, head: newHead };
232
+ },
233
+ async meta(graphRunId) {
234
+ validateGraphRunId(graphRunId);
235
+ return adapter.loadMeta(graphRunId);
236
+ },
237
+ async drop(graphRunId) {
238
+ validateGraphRunId(graphRunId);
239
+ await adapter.drop(graphRunId);
240
+ },
241
+ };
242
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/checkpoint-store",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "File-backed checkpoint store for graph-engine — save/load/list/branch over per-graph-run JSON files",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4",
16
- "@crewhaus/tenancy": "0.1.4"
18
+ "@crewhaus/errors": "0.1.6",
19
+ "@crewhaus/tenancy": "0.1.6"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,401 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { RuntimeError } from "@crewhaus/errors";
6
- import { TenancyError, buildTenant, withTenant } from "@crewhaus/tenancy";
7
- import {
8
- type Checkpoint,
9
- type CheckpointStore,
10
- type CheckpointStoreAdapter,
11
- CheckpointStoreError,
12
- type GraphRunId,
13
- type GraphRunMeta,
14
- type ListOptions,
15
- createCheckpointStore,
16
- newCheckpointId,
17
- newGraphRunId,
18
- } from "./index";
19
-
20
- let tmp: string;
21
- let store: CheckpointStore;
22
-
23
- beforeEach(() => {
24
- tmp = mkdtempSync(join(tmpdir(), "checkpoint-store-"));
25
- store = createCheckpointStore({ rootDir: tmp });
26
- });
27
-
28
- afterEach(() => {
29
- rmSync(tmp, { recursive: true, force: true });
30
- });
31
-
32
- describe("id generators", () => {
33
- test("newGraphRunId matches the regex", () => {
34
- expect(newGraphRunId()).toMatch(/^grun_[0-9a-f]{16}$/);
35
- });
36
- test("newCheckpointId matches the regex", () => {
37
- expect(newCheckpointId()).toMatch(/^ckpt_[0-9a-f]{16}$/);
38
- });
39
- });
40
-
41
- describe("save + load round-trip", () => {
42
- test("save returns a Checkpoint with the requested fields", async () => {
43
- const grun = newGraphRunId();
44
- const cp = await store.save({ graphRunId: grun, nodeName: "plan", state: { foo: 1 } });
45
- expect(cp.graphRunId).toBe(grun);
46
- expect(cp.nodeName).toBe("plan");
47
- expect(cp.state).toEqual({ foo: 1 });
48
- expect(cp.id).toMatch(/^ckpt_[0-9a-f]{16}$/);
49
- expect(cp.parentCheckpointId).toBeUndefined();
50
- });
51
-
52
- test("load returns the saved checkpoint", async () => {
53
- const grun = newGraphRunId();
54
- const cp = await store.save({ graphRunId: grun, nodeName: "plan", state: { x: 1 } });
55
- const loaded = await store.load(grun, cp.id);
56
- expect(loaded?.id).toBe(cp.id);
57
- expect(loaded?.state).toEqual({ x: 1 });
58
- });
59
-
60
- test("load without checkpointId returns the head", async () => {
61
- const grun = newGraphRunId();
62
- const a = await store.save({ graphRunId: grun, nodeName: "a", state: 1 });
63
- const b = await store.save({
64
- graphRunId: grun,
65
- nodeName: "b",
66
- state: 2,
67
- parentCheckpointId: a.id,
68
- });
69
- const head = await store.load(grun);
70
- expect(head?.id).toBe(b.id);
71
- expect(head?.state).toBe(2);
72
- });
73
-
74
- test("load returns undefined for an unknown checkpoint", async () => {
75
- const grun = newGraphRunId();
76
- const ck = newCheckpointId();
77
- expect(await store.load(grun, ck)).toBeUndefined();
78
- });
79
-
80
- test("save updates _meta.json head pointer", async () => {
81
- const grun = newGraphRunId();
82
- const cp = await store.save({ graphRunId: grun, nodeName: "x", state: 1 });
83
- const meta = await store.meta(grun);
84
- expect(meta?.head).toBe(cp.id);
85
- });
86
- });
87
-
88
- describe("list", () => {
89
- test("returns checkpoints in insertion order", async () => {
90
- const grun = newGraphRunId();
91
- const a = await store.save({ graphRunId: grun, nodeName: "a", state: 1 });
92
- // small delay so mtime ordering is deterministic
93
- await new Promise((r) => setTimeout(r, 10));
94
- const b = await store.save({ graphRunId: grun, nodeName: "b", state: 2 });
95
- const list = await store.list(grun);
96
- expect(list.map((c) => c.id)).toEqual([a.id, b.id]);
97
- });
98
-
99
- test("limit caps the list", async () => {
100
- const grun = newGraphRunId();
101
- await store.save({ graphRunId: grun, nodeName: "a", state: 1 });
102
- await new Promise((r) => setTimeout(r, 10));
103
- await store.save({ graphRunId: grun, nodeName: "b", state: 2 });
104
- const list = await store.list(grun, { limit: 1 });
105
- expect(list.length).toBe(1);
106
- });
107
-
108
- test("empty list for an unknown graph run", async () => {
109
- const grun = newGraphRunId();
110
- expect(await store.list(grun)).toEqual([]);
111
- });
112
- });
113
-
114
- describe("branch", () => {
115
- test("creates a new run with a fresh head copy", async () => {
116
- const parentRun = newGraphRunId();
117
- const a = await store.save({ graphRunId: parentRun, nodeName: "plan", state: { plan: "foo" } });
118
- const b = await store.save({
119
- graphRunId: parentRun,
120
- nodeName: "execute",
121
- state: { result: "bar" },
122
- parentCheckpointId: a.id,
123
- });
124
-
125
- const { newGraphRunId: child, head } = await store.branch(parentRun, a.id);
126
- expect(child).not.toBe(parentRun);
127
- expect(child).toMatch(/^grun_[0-9a-f]{16}$/);
128
- expect(head.nodeName).toBe("plan");
129
- expect(head.state).toEqual({ plan: "foo" });
130
- expect(head.id).not.toBe(a.id);
131
- expect(head.graphRunId).toBe(child);
132
-
133
- const meta = await store.meta(child);
134
- expect(meta?.branchedFrom).toEqual({ graphRunId: parentRun, checkpointId: a.id });
135
- expect(meta?.head).toBe(head.id);
136
-
137
- // Parent's head is unchanged.
138
- const parentMeta = await store.meta(parentRun);
139
- expect(parentMeta?.head).toBe(b.id);
140
- });
141
-
142
- test("throws when branching from an unknown checkpoint", async () => {
143
- const grun = newGraphRunId();
144
- const ck = newCheckpointId();
145
- await expect(store.branch(grun, ck)).rejects.toBeInstanceOf(CheckpointStoreError);
146
- });
147
- });
148
-
149
- describe("path-traversal defense (T8)", () => {
150
- test("rejects malformed graphRunId on save", async () => {
151
- await expect(
152
- store.save({ graphRunId: "../etc/passwd", nodeName: "a", state: 1 }),
153
- ).rejects.toBeInstanceOf(RuntimeError);
154
- });
155
- test("rejects malformed graphRunId on load", async () => {
156
- await expect(store.load("not-a-grun")).rejects.toBeInstanceOf(RuntimeError);
157
- });
158
- test("rejects malformed graphRunId on list", async () => {
159
- await expect(store.list("../etc")).rejects.toBeInstanceOf(RuntimeError);
160
- });
161
- test("rejects malformed graphRunId on branch (parent)", async () => {
162
- await expect(store.branch("../etc", newCheckpointId())).rejects.toBeInstanceOf(RuntimeError);
163
- });
164
- test("rejects malformed checkpointId on branch", async () => {
165
- const grun = newGraphRunId();
166
- await store.save({ graphRunId: grun, nodeName: "a", state: 1 });
167
- await expect(store.branch(grun, "../../bad")).rejects.toBeInstanceOf(RuntimeError);
168
- });
169
- test("rejects malformed nodeName on save", async () => {
170
- const grun = newGraphRunId();
171
- await expect(store.save({ graphRunId: grun, nodeName: "", state: 1 })).rejects.toBeInstanceOf(
172
- CheckpointStoreError,
173
- );
174
- });
175
- });
176
-
177
- describe("drop", () => {
178
- test("removes the graph run directory", async () => {
179
- const grun = newGraphRunId();
180
- await store.save({ graphRunId: grun, nodeName: "a", state: 1 });
181
- await store.drop(grun);
182
- expect(await store.list(grun)).toEqual([]);
183
- expect(await store.meta(grun)).toBeUndefined();
184
- });
185
-
186
- test("idempotent on a missing run", async () => {
187
- const grun = newGraphRunId();
188
- await store.drop(grun);
189
- await store.drop(grun);
190
- });
191
- });
192
-
193
- describe("stress (T7-lite)", () => {
194
- // 500 sequential async fs writes is tight against bun:test's 5 s default
195
- // on shared CI runners — observed 5.3 s with 5 s budget. The 15 s budget
196
- // gives ~3× headroom without sacrificing the stress shape (still 500
197
- // round-trips against real disk).
198
- test("saving 500 checkpoints round-trips correctly", async () => {
199
- const grun = newGraphRunId();
200
- let parent: string | undefined;
201
- for (let i = 0; i < 500; i += 1) {
202
- const cp = await store.save({
203
- graphRunId: grun,
204
- nodeName: `n${i}`,
205
- state: { i },
206
- ...(parent !== undefined ? { parentCheckpointId: parent } : {}),
207
- });
208
- parent = cp.id;
209
- }
210
- const list = await store.list(grun);
211
- expect(list.length).toBe(500);
212
- expect(list[499]?.state).toEqual({ i: 499 });
213
- }, 15_000);
214
- });
215
-
216
- describe("cross-tenant fencing (CWE-1230)", () => {
217
- test("inside tenantA, a store rooted under tenantB fails closed", async () => {
218
- const tenantsRoot = mkdtempSync(join(tmpdir(), "checkpoint-tenants-"));
219
- try {
220
- const tenantA = buildTenant("tenant-a", { tenantsRoot });
221
- const tenantB = buildTenant("tenant-b", { tenantsRoot });
222
- // Store rooted under tenantB while tenantA is active — every resolved
223
- // graph-run directory escapes tenantA's sessionRoot, so it fails closed.
224
- const fenced = createCheckpointStore({ rootDir: tenantB.sessionRoot });
225
- await withTenant(tenantA, async () => {
226
- const grun = newGraphRunId();
227
- await expect(fenced.save({ graphRunId: grun, nodeName: "a", state: 1 })).rejects.toThrow(
228
- TenancyError,
229
- );
230
- await expect(fenced.load(grun)).rejects.toThrow(/cross-tenant access denied/);
231
- });
232
- } finally {
233
- rmSync(tenantsRoot, { recursive: true, force: true });
234
- }
235
- });
236
-
237
- test("inside tenantA, a store rooted under tenantA round-trips", async () => {
238
- const tenantsRoot = mkdtempSync(join(tmpdir(), "checkpoint-tenants-"));
239
- try {
240
- const tenantA = buildTenant("tenant-a", { tenantsRoot });
241
- const ok = createCheckpointStore({ rootDir: tenantA.sessionRoot });
242
- await withTenant(tenantA, async () => {
243
- const grun = newGraphRunId();
244
- const cp = await ok.save({ graphRunId: grun, nodeName: "a", state: { x: 1 } });
245
- const loaded = await ok.load(grun, cp.id);
246
- expect(loaded?.id).toBe(cp.id);
247
- });
248
- } finally {
249
- rmSync(tenantsRoot, { recursive: true, force: true });
250
- }
251
- });
252
-
253
- test("no active tenant — behaviour is unchanged (no fencing)", async () => {
254
- const grun = newGraphRunId();
255
- const cp = await store.save({ graphRunId: grun, nodeName: "a", state: { x: 1 } });
256
- const loaded = await store.load(grun, cp.id);
257
- expect(loaded?.id).toBe(cp.id);
258
- });
259
- });
260
-
261
- describe("list: since filter", () => {
262
- // A controllable clock keeps checkpoint timestamps deterministic so the
263
- // `since` boundary is exercised without depending on the real wall clock.
264
- test("skips checkpoints created strictly before `since`", async () => {
265
- const clock = { ms: Date.parse("2026-01-01T00:00:00.000Z") };
266
- const clocked = createCheckpointStore({
267
- rootDir: tmp,
268
- now: () => new Date(clock.ms),
269
- });
270
- const grun = newGraphRunId();
271
-
272
- const older = await clocked.save({ graphRunId: grun, nodeName: "old", state: 1 });
273
- clock.ms += 60_000; // advance one minute
274
- const newer = await clocked.save({ graphRunId: grun, nodeName: "new", state: 2 });
275
-
276
- // `since` lands between the two checkpoints: the older one is filtered out
277
- // (the `continue` branch), the newer one is kept.
278
- const cutoff = new Date(clock.ms - 30_000).toISOString();
279
- const list = await clocked.list(grun, { since: cutoff });
280
- expect(list.map((c) => c.id)).toEqual([newer.id]);
281
- expect(list.map((c) => c.id)).not.toContain(older.id);
282
- });
283
-
284
- test("`since` in the future drops everything", async () => {
285
- const grun = newGraphRunId();
286
- await store.save({ graphRunId: grun, nodeName: "a", state: 1 });
287
- const list = await store.list(grun, { since: "2999-01-01T00:00:00.000Z" });
288
- expect(list).toEqual([]);
289
- });
290
- });
291
-
292
- describe("save: parentCheckpointId validation", () => {
293
- test("rejects a malformed parentCheckpointId before any write", async () => {
294
- const grun = newGraphRunId();
295
- await expect(
296
- store.save({ graphRunId: grun, nodeName: "a", state: 1, parentCheckpointId: "../../bad" }),
297
- ).rejects.toBeInstanceOf(RuntimeError);
298
- // Nothing was persisted for this run.
299
- expect(await store.meta(grun)).toBeUndefined();
300
- });
301
- });
302
-
303
- describe("pluggable adapter", () => {
304
- // A minimal in-memory adapter proves `createCheckpointStore({ adapter })`
305
- // wires a non-filesystem backend through every public method, and lets us
306
- // reach states the file-backed adapter never produces on its own.
307
- function makeMemoryAdapter(): {
308
- adapter: CheckpointStoreAdapter;
309
- checkpoints: Map<string, Checkpoint>;
310
- metas: Map<GraphRunId, GraphRunMeta>;
311
- } {
312
- const checkpoints = new Map<string, Checkpoint>();
313
- const metas = new Map<GraphRunId, GraphRunMeta>();
314
- const key = (g: GraphRunId, c: string): string => `${g}/${c}`;
315
- const adapter: CheckpointStoreAdapter = {
316
- async save(c: Checkpoint): Promise<void> {
317
- checkpoints.set(key(c.graphRunId, c.id), c);
318
- },
319
- async load(g: GraphRunId, c: string): Promise<Checkpoint | undefined> {
320
- return checkpoints.get(key(g, c));
321
- },
322
- async list(g: GraphRunId, _opts: ListOptions): Promise<ReadonlyArray<Checkpoint>> {
323
- return [...checkpoints.values()].filter((c) => c.graphRunId === g);
324
- },
325
- async loadMeta(g: GraphRunId): Promise<GraphRunMeta | undefined> {
326
- return metas.get(g);
327
- },
328
- async saveMeta(m: GraphRunMeta): Promise<void> {
329
- metas.set(m.graphRunId, m);
330
- },
331
- async drop(g: GraphRunId): Promise<void> {
332
- metas.delete(g);
333
- for (const k of [...checkpoints.keys()]) {
334
- if (k.startsWith(`${g}/`)) checkpoints.delete(k);
335
- }
336
- },
337
- };
338
- return { adapter, checkpoints, metas };
339
- }
340
-
341
- test("save → load → branch → drop all route through the custom adapter", async () => {
342
- const { adapter, checkpoints, metas } = makeMemoryAdapter();
343
- const custom = createCheckpointStore({ adapter });
344
-
345
- const grun = newGraphRunId();
346
- const cp = await custom.save({ graphRunId: grun, nodeName: "plan", state: { a: 1 } });
347
- expect(checkpoints.get(`${grun}/${cp.id}`)?.nodeName).toBe("plan");
348
- expect(metas.get(grun)?.head).toBe(cp.id);
349
-
350
- expect((await custom.load(grun, cp.id))?.id).toBe(cp.id);
351
- expect((await custom.load(grun))?.id).toBe(cp.id);
352
-
353
- const { newGraphRunId: child, head } = await custom.branch(grun, cp.id);
354
- expect(metas.get(child)?.branchedFrom).toEqual({ graphRunId: grun, checkpointId: cp.id });
355
- expect(head.state).toEqual({ a: 1 });
356
-
357
- await custom.drop(grun);
358
- expect(metas.has(grun)).toBe(false);
359
- expect(await custom.load(grun, cp.id)).toBeUndefined();
360
- });
361
-
362
- test("load without checkpointId returns undefined when meta has no head", async () => {
363
- const { adapter, metas } = makeMemoryAdapter();
364
- const custom = createCheckpointStore({ adapter });
365
- const grun = newGraphRunId();
366
- // Seed a meta with no head — a state a freshly-created run can hold before
367
- // its first commit. `load(grun)` must short-circuit to undefined.
368
- metas.set(grun, { version: 1, graphRunId: grun, createdAt: new Date(0).toISOString() });
369
- expect(await custom.load(grun)).toBeUndefined();
370
- });
371
-
372
- test("save preserves an existing meta's branchedFrom when advancing head", async () => {
373
- const { adapter, metas } = makeMemoryAdapter();
374
- const custom = createCheckpointStore({ adapter });
375
- const grun = newGraphRunId();
376
- const branchedFrom = { graphRunId: newGraphRunId(), checkpointId: newCheckpointId() };
377
- // Pre-seed a branched-run meta (head absent), then commit: ensureMeta must
378
- // reuse the existing meta so branchedFrom survives the head update.
379
- metas.set(grun, {
380
- version: 1,
381
- graphRunId: grun,
382
- createdAt: new Date(0).toISOString(),
383
- branchedFrom,
384
- });
385
- const cp = await custom.save({ graphRunId: grun, nodeName: "n", state: 1 });
386
- expect(metas.get(grun)?.head).toBe(cp.id);
387
- expect(metas.get(grun)?.branchedFrom).toEqual(branchedFrom);
388
- });
389
- });
390
-
391
- describe("CheckpointStoreError", () => {
392
- test("carries the config code and serializes its cause chain", () => {
393
- const root = new Error("disk gone");
394
- const err = new CheckpointStoreError("save failed", root);
395
- expect(err).toBeInstanceOf(CheckpointStoreError);
396
- expect(err.code).toBe("config");
397
- expect(err.name).toBe("CheckpointStoreError");
398
- const json = err.toJSON();
399
- expect(json.cause).toEqual({ name: "Error", message: "disk gone" });
400
- });
401
- });
package/src/index.ts DELETED
@@ -1,351 +0,0 @@
1
- /**
2
- * Catalog R7 `checkpoint-store` — durable, branchable graph-run state.
3
- *
4
- * One subdirectory per graph run at `<rootDir>/<graphRunId>/`, with one
5
- * JSON file per checkpoint (`<checkpointId>.json`) plus a `_meta.json`
6
- * pointer that names the current head and the parent run+checkpoint
7
- * (when branched). The format mirrors `event-log`'s "schema version
8
- * stamped on every record" convention so future migrations can fan out
9
- * on `version`.
10
- *
11
- * Branching: `branch(parentRunId, checkpointId)` creates a NEW
12
- * `graphRunId` whose `_meta.json` points to `parentRunId` +
13
- * `checkpointId` and whose head is a fresh COPY of the requested
14
- * checkpoint. Reads on the new run still work even if the parent run is
15
- * later deleted — checkpoint files are duplicated, not aliased — so
16
- * long-lived branches are independent.
17
- *
18
- * Path-traversal defense: every public method validates ids against
19
- * stable regexes (`grun_<16hex>`, `ckpt_<16hex>`); anything else throws
20
- * `RuntimeError` before the filesystem is touched, mirroring
21
- * `session-store`'s pattern.
22
- *
23
- * Pluggable adapter: callers can substitute a different
24
- * `CheckpointStoreAdapter` (SQLite, Postgres, S3) and the
25
- * `createCheckpointStore({ adapter })` factory wires it through. The
26
- * default adapter is the file-backed implementation in this module.
27
- *
28
- * Layer R7. Pairs with `graph-engine` (R11) and `branch-history` (R7).
29
- */
30
- import { randomBytes } from "node:crypto";
31
- import {
32
- existsSync,
33
- mkdirSync,
34
- readFileSync,
35
- readdirSync,
36
- renameSync,
37
- rmSync,
38
- statSync,
39
- writeFileSync,
40
- } from "node:fs";
41
- import { join, resolve } from "node:path";
42
- import { CrewhausError, RuntimeError } from "@crewhaus/errors";
43
- import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
44
-
45
- export const DEFAULT_ROOT_DIR = ".crewhaus/graphs";
46
-
47
- const GRAPH_RUN_ID_RE = /^grun_[0-9a-f]{16}$/;
48
- const CHECKPOINT_ID_RE = /^ckpt_[0-9a-f]{16}$/;
49
-
50
- export type GraphRunId = string;
51
- export type CheckpointId = string;
52
-
53
- export type Checkpoint = {
54
- readonly version: 1;
55
- readonly id: CheckpointId;
56
- readonly graphRunId: GraphRunId;
57
- readonly nodeName: string;
58
- readonly state: unknown;
59
- /** Parent within the same graph run — `undefined` for the entry node. */
60
- readonly parentCheckpointId?: CheckpointId;
61
- readonly createdAt: string;
62
- };
63
-
64
- export type BranchInfo = {
65
- readonly graphRunId: GraphRunId;
66
- readonly checkpointId: CheckpointId;
67
- };
68
-
69
- export type GraphRunMeta = {
70
- readonly version: 1;
71
- readonly graphRunId: GraphRunId;
72
- /** Most recently saved checkpoint id; absent on a freshly-branched run with no new commits. */
73
- head?: CheckpointId;
74
- readonly createdAt: string;
75
- /** Set when this run was branched from a sibling. */
76
- readonly branchedFrom?: BranchInfo;
77
- };
78
-
79
- export type ListOptions = {
80
- /** Cap the number of returned checkpoints (insertion order, oldest first). */
81
- readonly limit?: number;
82
- /** Skip checkpoints created before this ISO timestamp. */
83
- readonly since?: string;
84
- };
85
-
86
- export interface CheckpointStoreAdapter {
87
- save(c: Checkpoint): Promise<void>;
88
- load(graphRunId: GraphRunId, checkpointId: CheckpointId): Promise<Checkpoint | undefined>;
89
- list(graphRunId: GraphRunId, opts: ListOptions): Promise<ReadonlyArray<Checkpoint>>;
90
- loadMeta(graphRunId: GraphRunId): Promise<GraphRunMeta | undefined>;
91
- saveMeta(meta: GraphRunMeta): Promise<void>;
92
- /** Delete the entire graph run (best-effort; idempotent). */
93
- drop(graphRunId: GraphRunId): Promise<void>;
94
- }
95
-
96
- export interface CheckpointStore {
97
- /** Persist a new checkpoint and update the graph run's head. */
98
- save(opts: {
99
- graphRunId: GraphRunId;
100
- nodeName: string;
101
- state: unknown;
102
- parentCheckpointId?: CheckpointId;
103
- }): Promise<Checkpoint>;
104
- /** Load a specific checkpoint, or the head when `checkpointId` is omitted. */
105
- load(graphRunId: GraphRunId, checkpointId?: CheckpointId): Promise<Checkpoint | undefined>;
106
- /** Walk the run's checkpoints in insertion order. */
107
- list(graphRunId: GraphRunId, opts?: ListOptions): Promise<ReadonlyArray<Checkpoint>>;
108
- /**
109
- * Materialise a NEW graph run that starts from `checkpointId` of
110
- * `graphRunId`. The head of the new run is a fresh copy of the source
111
- * checkpoint and the `_meta.json` records `branchedFrom: { graphRunId,
112
- * checkpointId }` for time-travel auditing.
113
- */
114
- branch(
115
- graphRunId: GraphRunId,
116
- checkpointId: CheckpointId,
117
- ): Promise<{ newGraphRunId: GraphRunId; head: Checkpoint }>;
118
- /** Read the `_meta.json` for `graphRunId`. */
119
- meta(graphRunId: GraphRunId): Promise<GraphRunMeta | undefined>;
120
- /** Delete a graph run's directory tree. Idempotent. */
121
- drop(graphRunId: GraphRunId): Promise<void>;
122
- }
123
-
124
- export class CheckpointStoreError extends CrewhausError {
125
- override readonly name = "CheckpointStoreError";
126
- constructor(message: string, cause?: unknown) {
127
- super("config", message, cause);
128
- }
129
- }
130
-
131
- export function newGraphRunId(): GraphRunId {
132
- return `grun_${randomBytes(8).toString("hex")}`;
133
- }
134
-
135
- export function newCheckpointId(): CheckpointId {
136
- return `ckpt_${randomBytes(8).toString("hex")}`;
137
- }
138
-
139
- function validateGraphRunId(id: string): void {
140
- if (!GRAPH_RUN_ID_RE.test(id)) {
141
- throw new RuntimeError(`checkpoint-store: invalid graphRunId "${id}" — expected grun_<16 hex>`);
142
- }
143
- }
144
-
145
- function validateCheckpointId(id: string): void {
146
- if (!CHECKPOINT_ID_RE.test(id)) {
147
- throw new RuntimeError(
148
- `checkpoint-store: invalid checkpointId "${id}" — expected ckpt_<16 hex>`,
149
- );
150
- }
151
- }
152
-
153
- // ---------------------------------------------------------------------------
154
- // File-backed adapter — default implementation.
155
- // ---------------------------------------------------------------------------
156
-
157
- class FileSystemAdapter implements CheckpointStoreAdapter {
158
- constructor(private readonly rootDir: string) {
159
- mkdirSync(this.rootDir, { recursive: true });
160
- }
161
-
162
- private dir(graphRunId: GraphRunId): string {
163
- validateGraphRunId(graphRunId);
164
- const dir = join(this.rootDir, graphRunId);
165
- // When a tenant context is active, fail closed on a resolved path that
166
- // escapes the tenant's sessionRoot (CWE-1230). Every checkpoint/meta path
167
- // is built under this directory, so fencing here covers every read/write.
168
- // Outside a tenant scope (the common CLI case) this is a no-op so
169
- // non-tenant behaviour is unchanged.
170
- if (currentTenantContext() !== undefined) {
171
- assertSamePath(resolve(dir), requireTenant().sessionRoot);
172
- }
173
- return dir;
174
- }
175
-
176
- private metaPath(graphRunId: GraphRunId): string {
177
- return join(this.dir(graphRunId), "_meta.json");
178
- }
179
-
180
- private checkpointPath(graphRunId: GraphRunId, checkpointId: CheckpointId): string {
181
- validateCheckpointId(checkpointId);
182
- return join(this.dir(graphRunId), `${checkpointId}.json`);
183
- }
184
-
185
- async save(c: Checkpoint): Promise<void> {
186
- const dir = this.dir(c.graphRunId);
187
- mkdirSync(dir, { recursive: true });
188
- const tmp = `${this.checkpointPath(c.graphRunId, c.id)}.tmp.${randomBytes(4).toString("hex")}`;
189
- writeFileSync(tmp, JSON.stringify(c), { mode: 0o600 });
190
- // Atomic rename so concurrent reads never see a half-written file.
191
- const final = this.checkpointPath(c.graphRunId, c.id);
192
- renameSync(tmp, final);
193
- }
194
-
195
- async load(graphRunId: GraphRunId, checkpointId: CheckpointId): Promise<Checkpoint | undefined> {
196
- const path = this.checkpointPath(graphRunId, checkpointId);
197
- if (!existsSync(path)) return undefined;
198
- const raw = readFileSync(path, "utf8");
199
- return JSON.parse(raw) as Checkpoint;
200
- }
201
-
202
- async list(graphRunId: GraphRunId, opts: ListOptions): Promise<ReadonlyArray<Checkpoint>> {
203
- const dir = this.dir(graphRunId);
204
- if (!existsSync(dir)) return [];
205
- const files = readdirSync(dir).filter((f) => f.startsWith("ckpt_") && f.endsWith(".json"));
206
- // Order by mtime ascending — file-backed adapter has no other notion of insertion order.
207
- const withStat = files.map((f) => {
208
- const full = join(dir, f);
209
- return { name: f, mtimeMs: statSync(full).mtimeMs, full };
210
- });
211
- withStat.sort((a, b) => a.mtimeMs - b.mtimeMs);
212
- const out: Checkpoint[] = [];
213
- const sinceTs = opts.since !== undefined ? Date.parse(opts.since) : Number.NEGATIVE_INFINITY;
214
- for (const { full } of withStat) {
215
- const c = JSON.parse(readFileSync(full, "utf8")) as Checkpoint;
216
- if (Date.parse(c.createdAt) < sinceTs) continue;
217
- out.push(c);
218
- if (opts.limit !== undefined && out.length >= opts.limit) break;
219
- }
220
- return out;
221
- }
222
-
223
- async loadMeta(graphRunId: GraphRunId): Promise<GraphRunMeta | undefined> {
224
- const path = this.metaPath(graphRunId);
225
- if (!existsSync(path)) return undefined;
226
- return JSON.parse(readFileSync(path, "utf8")) as GraphRunMeta;
227
- }
228
-
229
- async saveMeta(meta: GraphRunMeta): Promise<void> {
230
- const dir = this.dir(meta.graphRunId);
231
- mkdirSync(dir, { recursive: true });
232
- const tmp = `${this.metaPath(meta.graphRunId)}.tmp.${randomBytes(4).toString("hex")}`;
233
- writeFileSync(tmp, JSON.stringify(meta), { mode: 0o600 });
234
- renameSync(tmp, this.metaPath(meta.graphRunId));
235
- }
236
-
237
- async drop(graphRunId: GraphRunId): Promise<void> {
238
- const dir = this.dir(graphRunId);
239
- if (!existsSync(dir)) return;
240
- rmSync(dir, { recursive: true, force: true });
241
- }
242
- }
243
-
244
- export type CreateCheckpointStoreOptions = {
245
- readonly rootDir?: string;
246
- readonly adapter?: CheckpointStoreAdapter;
247
- readonly now?: () => Date;
248
- };
249
-
250
- export function createCheckpointStore(opts: CreateCheckpointStoreOptions = {}): CheckpointStore {
251
- const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
252
- const adapter = opts.adapter ?? new FileSystemAdapter(rootDir);
253
- const now = opts.now ?? ((): Date => new Date());
254
-
255
- async function ensureMeta(graphRunId: GraphRunId): Promise<GraphRunMeta> {
256
- const existing = await adapter.loadMeta(graphRunId);
257
- if (existing !== undefined) return existing;
258
- const meta: GraphRunMeta = {
259
- version: 1,
260
- graphRunId,
261
- createdAt: now().toISOString(),
262
- };
263
- await adapter.saveMeta(meta);
264
- return meta;
265
- }
266
-
267
- return {
268
- async save(req): Promise<Checkpoint> {
269
- validateGraphRunId(req.graphRunId);
270
- if (req.parentCheckpointId !== undefined) validateCheckpointId(req.parentCheckpointId);
271
- if (typeof req.nodeName !== "string" || req.nodeName.length === 0) {
272
- throw new CheckpointStoreError("nodeName must be a non-empty string");
273
- }
274
- const id = newCheckpointId();
275
- const cp: Checkpoint = {
276
- version: 1,
277
- id,
278
- graphRunId: req.graphRunId,
279
- nodeName: req.nodeName,
280
- state: req.state,
281
- ...(req.parentCheckpointId !== undefined
282
- ? { parentCheckpointId: req.parentCheckpointId }
283
- : {}),
284
- createdAt: now().toISOString(),
285
- };
286
- await adapter.save(cp);
287
- const prevMeta = await ensureMeta(req.graphRunId);
288
- const meta: GraphRunMeta = { ...prevMeta, head: id };
289
- await adapter.saveMeta(meta);
290
- return cp;
291
- },
292
- async load(graphRunId, checkpointId): Promise<Checkpoint | undefined> {
293
- validateGraphRunId(graphRunId);
294
- let id = checkpointId;
295
- if (id === undefined) {
296
- const meta = await adapter.loadMeta(graphRunId);
297
- if (meta?.head === undefined) return undefined;
298
- id = meta.head;
299
- }
300
- validateCheckpointId(id);
301
- return adapter.load(graphRunId, id);
302
- },
303
- async list(graphRunId, listOpts = {}): Promise<ReadonlyArray<Checkpoint>> {
304
- validateGraphRunId(graphRunId);
305
- return adapter.list(graphRunId, listOpts);
306
- },
307
- async branch(
308
- graphRunId,
309
- checkpointId,
310
- ): Promise<{
311
- newGraphRunId: GraphRunId;
312
- head: Checkpoint;
313
- }> {
314
- validateGraphRunId(graphRunId);
315
- validateCheckpointId(checkpointId);
316
- const source = await adapter.load(graphRunId, checkpointId);
317
- if (source === undefined) {
318
- throw new CheckpointStoreError(
319
- `checkpoint ${checkpointId} not found in graph run ${graphRunId}`,
320
- );
321
- }
322
- const branchedRunId = newGraphRunId();
323
- const newHead: Checkpoint = {
324
- version: 1,
325
- id: newCheckpointId(),
326
- graphRunId: branchedRunId,
327
- nodeName: source.nodeName,
328
- state: source.state,
329
- createdAt: now().toISOString(),
330
- };
331
- await adapter.save(newHead);
332
- const meta: GraphRunMeta = {
333
- version: 1,
334
- graphRunId: branchedRunId,
335
- head: newHead.id,
336
- createdAt: newHead.createdAt,
337
- branchedFrom: { graphRunId, checkpointId },
338
- };
339
- await adapter.saveMeta(meta);
340
- return { newGraphRunId: branchedRunId, head: newHead };
341
- },
342
- async meta(graphRunId): Promise<GraphRunMeta | undefined> {
343
- validateGraphRunId(graphRunId);
344
- return adapter.loadMeta(graphRunId);
345
- },
346
- async drop(graphRunId): Promise<void> {
347
- validateGraphRunId(graphRunId);
348
- await adapter.drop(graphRunId);
349
- },
350
- };
351
- }