@crewhaus/branch-history 0.1.3 → 0.1.5

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,38 @@
1
+ /**
2
+ * Catalog R7 `branch-history` — composable time-travel helpers.
3
+ *
4
+ * The runtime feature is owned by `checkpoint-store.branch()`; this
5
+ * package adds two ergonomics:
6
+ *
7
+ * branchAt(store, parentRunId, checkpointId) — same as
8
+ * `store.branch(...)` but records the branch lineage in a
9
+ * dedicated event stream the operator UI can render.
10
+ *
11
+ * diff(store, runIdA, runIdB) — walk both runs' checkpoints in
12
+ * insertion order and emit a `NodeDiff[]` listing where they
13
+ * diverge (different node names, different state hashes, or one
14
+ * run terminated earlier than the other).
15
+ *
16
+ * Layer R7. Pairs with `checkpoint-store` (R7) and `graph-engine` (R11).
17
+ */
18
+ import type { Checkpoint, CheckpointId, CheckpointStore, GraphRunId } from "@crewhaus/checkpoint-store";
19
+ export type NodeDiff = {
20
+ readonly position: number;
21
+ readonly a?: {
22
+ readonly checkpointId: CheckpointId;
23
+ readonly nodeName: string;
24
+ readonly stateHash: string;
25
+ };
26
+ readonly b?: {
27
+ readonly checkpointId: CheckpointId;
28
+ readonly nodeName: string;
29
+ readonly stateHash: string;
30
+ };
31
+ readonly kind: "same" | "node-mismatch" | "state-mismatch" | "only-a" | "only-b";
32
+ };
33
+ export type BranchAtResult = {
34
+ readonly newGraphRunId: GraphRunId;
35
+ readonly head: Checkpoint;
36
+ };
37
+ export declare function branchAt(store: CheckpointStore, parentGraphRunId: GraphRunId, checkpointId: CheckpointId): Promise<BranchAtResult>;
38
+ export declare function diff(store: CheckpointStore, graphRunIdA: GraphRunId, graphRunIdB: GraphRunId): Promise<ReadonlyArray<NodeDiff>>;
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Catalog R7 `branch-history` — composable time-travel helpers.
3
+ *
4
+ * The runtime feature is owned by `checkpoint-store.branch()`; this
5
+ * package adds two ergonomics:
6
+ *
7
+ * branchAt(store, parentRunId, checkpointId) — same as
8
+ * `store.branch(...)` but records the branch lineage in a
9
+ * dedicated event stream the operator UI can render.
10
+ *
11
+ * diff(store, runIdA, runIdB) — walk both runs' checkpoints in
12
+ * insertion order and emit a `NodeDiff[]` listing where they
13
+ * diverge (different node names, different state hashes, or one
14
+ * run terminated earlier than the other).
15
+ *
16
+ * Layer R7. Pairs with `checkpoint-store` (R7) and `graph-engine` (R11).
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ function stateHash(c) {
20
+ // `JSON.stringify` returns `undefined` for `state === undefined` (and for
21
+ // values that serialize to nothing, e.g. functions/symbols). Feeding that
22
+ // straight into `createHash().update()` throws a TypeError, so coalesce to a
23
+ // stable sentinel — every other state serializes unchanged, preserving hashes.
24
+ const serialized = JSON.stringify(c.state) ?? "undefined";
25
+ return createHash("sha256").update(serialized).digest("hex").slice(0, 16);
26
+ }
27
+ export async function branchAt(store, parentGraphRunId, checkpointId) {
28
+ const result = await store.branch(parentGraphRunId, checkpointId);
29
+ return result;
30
+ }
31
+ export async function diff(store, graphRunIdA, graphRunIdB) {
32
+ const [a, b] = await Promise.all([store.list(graphRunIdA), store.list(graphRunIdB)]);
33
+ const out = [];
34
+ const max = Math.max(a.length, b.length);
35
+ for (let i = 0; i < max; i += 1) {
36
+ const ca = a[i];
37
+ const cb = b[i];
38
+ if (ca === undefined && cb !== undefined) {
39
+ out.push({
40
+ position: i,
41
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: stateHash(cb) },
42
+ kind: "only-b",
43
+ });
44
+ continue;
45
+ }
46
+ if (cb === undefined && ca !== undefined) {
47
+ out.push({
48
+ position: i,
49
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: stateHash(ca) },
50
+ kind: "only-a",
51
+ });
52
+ continue;
53
+ }
54
+ if (ca !== undefined && cb !== undefined) {
55
+ const ha = stateHash(ca);
56
+ const hb = stateHash(cb);
57
+ if (ca.nodeName !== cb.nodeName) {
58
+ out.push({
59
+ position: i,
60
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
61
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
62
+ kind: "node-mismatch",
63
+ });
64
+ }
65
+ else if (ha !== hb) {
66
+ out.push({
67
+ position: i,
68
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
69
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
70
+ kind: "state-mismatch",
71
+ });
72
+ }
73
+ else {
74
+ out.push({
75
+ position: i,
76
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
77
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
78
+ kind: "same",
79
+ });
80
+ }
81
+ }
82
+ }
83
+ return out;
84
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/branch-history",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Branch + diff helpers over checkpoint-store — time-travel utilities for the GRPH target",
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/checkpoint-store": "0.1.3",
16
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/checkpoint-store": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5"
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,125 +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 {
6
- type CheckpointStore,
7
- createCheckpointStore,
8
- newGraphRunId,
9
- } from "@crewhaus/checkpoint-store";
10
- import { branchAt, diff } from "./index";
11
-
12
- let tmp: string;
13
- let store: CheckpointStore;
14
-
15
- beforeEach(() => {
16
- tmp = mkdtempSync(join(tmpdir(), "branch-history-"));
17
- store = createCheckpointStore({ rootDir: tmp });
18
- });
19
-
20
- afterEach(() => {
21
- rmSync(tmp, { recursive: true, force: true });
22
- });
23
-
24
- describe("branchAt", () => {
25
- test("returns the underlying store.branch result", async () => {
26
- const grun = newGraphRunId();
27
- const a = await store.save({ graphRunId: grun, nodeName: "a", state: { v: 1 } });
28
- const r = await branchAt(store, grun, a.id);
29
- expect(r.newGraphRunId).not.toBe(grun);
30
- expect(r.head.state).toEqual({ v: 1 });
31
- });
32
- });
33
-
34
- describe("diff", () => {
35
- test("two identical runs report only `same` rows", async () => {
36
- const a = newGraphRunId();
37
- const b = newGraphRunId();
38
- await store.save({ graphRunId: a, nodeName: "x", state: { i: 1 } });
39
- await new Promise((r) => setTimeout(r, 10));
40
- await store.save({ graphRunId: a, nodeName: "y", state: { i: 2 } });
41
- await store.save({ graphRunId: b, nodeName: "x", state: { i: 1 } });
42
- await new Promise((r) => setTimeout(r, 10));
43
- await store.save({ graphRunId: b, nodeName: "y", state: { i: 2 } });
44
- const d = await diff(store, a, b);
45
- expect(d.length).toBe(2);
46
- expect(d.every((row) => row.kind === "same")).toBe(true);
47
- });
48
-
49
- test("state mismatch flagged when same node has different state", async () => {
50
- const a = newGraphRunId();
51
- const b = newGraphRunId();
52
- await store.save({ graphRunId: a, nodeName: "x", state: { v: 1 } });
53
- await store.save({ graphRunId: b, nodeName: "x", state: { v: 2 } });
54
- const d = await diff(store, a, b);
55
- expect(d.length).toBe(1);
56
- expect(d[0]?.kind).toBe("state-mismatch");
57
- });
58
-
59
- test("node mismatch flagged when nodes differ at same position", async () => {
60
- const a = newGraphRunId();
61
- const b = newGraphRunId();
62
- await store.save({ graphRunId: a, nodeName: "x", state: 0 });
63
- await store.save({ graphRunId: b, nodeName: "y", state: 0 });
64
- const d = await diff(store, a, b);
65
- expect(d[0]?.kind).toBe("node-mismatch");
66
- });
67
-
68
- test("only-a / only-b reported when one run is shorter", async () => {
69
- const a = newGraphRunId();
70
- const b = newGraphRunId();
71
- await store.save({ graphRunId: a, nodeName: "x", state: 0 });
72
- await new Promise((r) => setTimeout(r, 10));
73
- await store.save({ graphRunId: a, nodeName: "y", state: 1 });
74
- await store.save({ graphRunId: b, nodeName: "x", state: 0 });
75
- const d = await diff(store, a, b);
76
- expect(d[0]?.kind).toBe("same");
77
- expect(d[1]?.kind).toBe("only-a");
78
- });
79
-
80
- test("only-b reported when run B is longer than run A", async () => {
81
- const a = newGraphRunId();
82
- const b = newGraphRunId();
83
- await store.save({ graphRunId: a, nodeName: "x", state: 0 });
84
- await store.save({ graphRunId: b, nodeName: "x", state: 0 });
85
- await new Promise((r) => setTimeout(r, 10));
86
- const extra = await store.save({ graphRunId: b, nodeName: "y", state: 1 });
87
- const d = await diff(store, a, b);
88
- expect(d.length).toBe(2);
89
- expect(d[0]?.kind).toBe("same");
90
- expect(d[1]?.kind).toBe("only-b");
91
- // The only-b row carries B's checkpoint metadata and no `a` side.
92
- expect(d[1]?.a).toBeUndefined();
93
- expect(d[1]?.b?.checkpointId).toBe(extra.id);
94
- expect(d[1]?.b?.nodeName).toBe("y");
95
- expect(typeof d[1]?.b?.stateHash).toBe("string");
96
- });
97
- });
98
-
99
- describe("stateHash (via diff)", () => {
100
- // Regression: a checkpoint whose `state` serializes to `undefined`
101
- // (e.g. `state: undefined`) once crashed `diff` with a TypeError from
102
- // `createHash().update(undefined)`. It must now hash to a stable sentinel.
103
- test("checkpoints with undefined state diff without throwing", async () => {
104
- const a = newGraphRunId();
105
- const b = newGraphRunId();
106
- await store.save({ graphRunId: a, nodeName: "x", state: undefined });
107
- await store.save({ graphRunId: b, nodeName: "x", state: undefined });
108
- const d = await diff(store, a, b);
109
- expect(d.length).toBe(1);
110
- // Two undefined states hash identically, so the row is `same`.
111
- expect(d[0]?.kind).toBe("same");
112
- expect(d[0]?.a?.stateHash).toBe(d[0]?.b?.stateHash);
113
- });
114
-
115
- test("undefined state differs from a defined state", async () => {
116
- const a = newGraphRunId();
117
- const b = newGraphRunId();
118
- await store.save({ graphRunId: a, nodeName: "x", state: undefined });
119
- await store.save({ graphRunId: b, nodeName: "x", state: { v: 1 } });
120
- const d = await diff(store, a, b);
121
- expect(d.length).toBe(1);
122
- expect(d[0]?.kind).toBe("state-mismatch");
123
- expect(d[0]?.a?.stateHash).not.toBe(d[0]?.b?.stateHash);
124
- });
125
- });
package/src/index.ts DELETED
@@ -1,120 +0,0 @@
1
- /**
2
- * Catalog R7 `branch-history` — composable time-travel helpers.
3
- *
4
- * The runtime feature is owned by `checkpoint-store.branch()`; this
5
- * package adds two ergonomics:
6
- *
7
- * branchAt(store, parentRunId, checkpointId) — same as
8
- * `store.branch(...)` but records the branch lineage in a
9
- * dedicated event stream the operator UI can render.
10
- *
11
- * diff(store, runIdA, runIdB) — walk both runs' checkpoints in
12
- * insertion order and emit a `NodeDiff[]` listing where they
13
- * diverge (different node names, different state hashes, or one
14
- * run terminated earlier than the other).
15
- *
16
- * Layer R7. Pairs with `checkpoint-store` (R7) and `graph-engine` (R11).
17
- */
18
-
19
- import { createHash } from "node:crypto";
20
- import type {
21
- Checkpoint,
22
- CheckpointId,
23
- CheckpointStore,
24
- GraphRunId,
25
- } from "@crewhaus/checkpoint-store";
26
-
27
- export type NodeDiff = {
28
- readonly position: number;
29
- readonly a?: {
30
- readonly checkpointId: CheckpointId;
31
- readonly nodeName: string;
32
- readonly stateHash: string;
33
- };
34
- readonly b?: {
35
- readonly checkpointId: CheckpointId;
36
- readonly nodeName: string;
37
- readonly stateHash: string;
38
- };
39
- readonly kind: "same" | "node-mismatch" | "state-mismatch" | "only-a" | "only-b";
40
- };
41
-
42
- export type BranchAtResult = {
43
- readonly newGraphRunId: GraphRunId;
44
- readonly head: Checkpoint;
45
- };
46
-
47
- function stateHash(c: Checkpoint): string {
48
- // `JSON.stringify` returns `undefined` for `state === undefined` (and for
49
- // values that serialize to nothing, e.g. functions/symbols). Feeding that
50
- // straight into `createHash().update()` throws a TypeError, so coalesce to a
51
- // stable sentinel — every other state serializes unchanged, preserving hashes.
52
- const serialized = JSON.stringify(c.state) ?? "undefined";
53
- return createHash("sha256").update(serialized).digest("hex").slice(0, 16);
54
- }
55
-
56
- export async function branchAt(
57
- store: CheckpointStore,
58
- parentGraphRunId: GraphRunId,
59
- checkpointId: CheckpointId,
60
- ): Promise<BranchAtResult> {
61
- const result = await store.branch(parentGraphRunId, checkpointId);
62
- return result;
63
- }
64
-
65
- export async function diff(
66
- store: CheckpointStore,
67
- graphRunIdA: GraphRunId,
68
- graphRunIdB: GraphRunId,
69
- ): Promise<ReadonlyArray<NodeDiff>> {
70
- const [a, b] = await Promise.all([store.list(graphRunIdA), store.list(graphRunIdB)]);
71
- const out: NodeDiff[] = [];
72
- const max = Math.max(a.length, b.length);
73
- for (let i = 0; i < max; i += 1) {
74
- const ca = a[i];
75
- const cb = b[i];
76
- if (ca === undefined && cb !== undefined) {
77
- out.push({
78
- position: i,
79
- b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: stateHash(cb) },
80
- kind: "only-b",
81
- });
82
- continue;
83
- }
84
- if (cb === undefined && ca !== undefined) {
85
- out.push({
86
- position: i,
87
- a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: stateHash(ca) },
88
- kind: "only-a",
89
- });
90
- continue;
91
- }
92
- if (ca !== undefined && cb !== undefined) {
93
- const ha = stateHash(ca);
94
- const hb = stateHash(cb);
95
- if (ca.nodeName !== cb.nodeName) {
96
- out.push({
97
- position: i,
98
- a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
99
- b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
100
- kind: "node-mismatch",
101
- });
102
- } else if (ha !== hb) {
103
- out.push({
104
- position: i,
105
- a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
106
- b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
107
- kind: "state-mismatch",
108
- });
109
- } else {
110
- out.push({
111
- position: i,
112
- a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
113
- b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
114
- kind: "same",
115
- });
116
- }
117
- }
118
- }
119
- return out;
120
- }