@git-stunts/git-warp 10.7.0 → 11.2.1

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.
Files changed (71) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +214 -0
  24. package/bin/presenters/json.js +66 -0
  25. package/bin/presenters/text.js +543 -0
  26. package/bin/warp-graph.js +19 -2824
  27. package/index.d.ts +32 -2
  28. package/index.js +2 -0
  29. package/package.json +9 -7
  30. package/src/domain/WarpGraph.js +106 -3252
  31. package/src/domain/errors/QueryError.js +2 -2
  32. package/src/domain/errors/TrustError.js +29 -0
  33. package/src/domain/errors/index.js +1 -0
  34. package/src/domain/services/AuditMessageCodec.js +137 -0
  35. package/src/domain/services/AuditReceiptService.js +471 -0
  36. package/src/domain/services/AuditVerifierService.js +693 -0
  37. package/src/domain/services/HttpSyncServer.js +36 -22
  38. package/src/domain/services/MessageCodecInternal.js +3 -0
  39. package/src/domain/services/MessageSchemaDetector.js +2 -2
  40. package/src/domain/services/SyncAuthService.js +69 -3
  41. package/src/domain/services/WarpMessageCodec.js +4 -1
  42. package/src/domain/trust/TrustCanonical.js +42 -0
  43. package/src/domain/trust/TrustCrypto.js +111 -0
  44. package/src/domain/trust/TrustEvaluator.js +180 -0
  45. package/src/domain/trust/TrustRecordService.js +274 -0
  46. package/src/domain/trust/TrustStateBuilder.js +209 -0
  47. package/src/domain/trust/canonical.js +68 -0
  48. package/src/domain/trust/reasonCodes.js +64 -0
  49. package/src/domain/trust/schemas.js +160 -0
  50. package/src/domain/trust/verdict.js +42 -0
  51. package/src/domain/types/git-cas.d.ts +20 -0
  52. package/src/domain/utils/RefLayout.js +59 -0
  53. package/src/domain/warp/PatchSession.js +18 -0
  54. package/src/domain/warp/Writer.js +18 -3
  55. package/src/domain/warp/_internal.js +26 -0
  56. package/src/domain/warp/_wire.js +58 -0
  57. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  58. package/src/domain/warp/checkpoint.methods.js +397 -0
  59. package/src/domain/warp/fork.methods.js +323 -0
  60. package/src/domain/warp/materialize.methods.js +188 -0
  61. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  62. package/src/domain/warp/patch.methods.js +529 -0
  63. package/src/domain/warp/provenance.methods.js +284 -0
  64. package/src/domain/warp/query.methods.js +279 -0
  65. package/src/domain/warp/subscribe.methods.js +272 -0
  66. package/src/domain/warp/sync.methods.js +549 -0
  67. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  68. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  69. package/src/ports/CommitPort.js +10 -0
  70. package/src/ports/RefPort.js +17 -0
  71. package/src/hooks/post-merge.sh +0 -60
@@ -0,0 +1,592 @@
1
+ import { summarizeOps } from '../../../src/visualization/renderers/ascii/history.js';
2
+ import { diffStates } from '../../../src/domain/services/StateDiff.js';
3
+ import {
4
+ buildCursorActiveRef,
5
+ buildCursorSavedRef,
6
+ buildCursorSavedPrefix,
7
+ } from '../../../src/domain/utils/RefLayout.js';
8
+ import { parseCursorBlob } from '../../../src/domain/utils/parseCursorBlob.js';
9
+ import { stableStringify } from '../../presenters/json.js';
10
+ import { EXIT_CODES, usageError, notFoundError, parseCommandArgs } from '../infrastructure.js';
11
+ import { seekSchema } from '../schemas.js';
12
+ import { openGraph, readActiveCursor, writeActiveCursor, wireSeekCache } from '../shared.js';
13
+
14
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
15
+ /** @typedef {import('../types.js').Persistence} Persistence */
16
+ /** @typedef {import('../types.js').WarpGraphInstance} WarpGraphInstance */
17
+ /** @typedef {import('../types.js').WriterTickInfo} WriterTickInfo */
18
+ /** @typedef {import('../types.js').CursorBlob} CursorBlob */
19
+ /** @typedef {import('../types.js').SeekSpec} SeekSpec */
20
+
21
+ // ============================================================================
22
+ // Cursor I/O Helpers (seek-only)
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Removes the active seek cursor for a graph, returning to present state.
27
+ *
28
+ * @param {Persistence} persistence
29
+ * @param {string} graphName
30
+ * @returns {Promise<void>}
31
+ */
32
+ async function clearActiveCursor(persistence, graphName) {
33
+ const ref = buildCursorActiveRef(graphName);
34
+ const exists = await persistence.readRef(ref);
35
+ if (exists) {
36
+ await persistence.deleteRef(ref);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Reads a named saved cursor from Git ref storage.
42
+ *
43
+ * @param {Persistence} persistence
44
+ * @param {string} graphName
45
+ * @param {string} name
46
+ * @returns {Promise<CursorBlob|null>}
47
+ */
48
+ async function readSavedCursor(persistence, graphName, name) {
49
+ const ref = buildCursorSavedRef(graphName, name);
50
+ const oid = await persistence.readRef(ref);
51
+ if (!oid) {
52
+ return null;
53
+ }
54
+ const buf = await persistence.readBlob(oid);
55
+ return parseCursorBlob(buf, `saved cursor '${name}'`);
56
+ }
57
+
58
+ /**
59
+ * Persists a cursor under a named saved-cursor ref.
60
+ *
61
+ * @param {Persistence} persistence
62
+ * @param {string} graphName
63
+ * @param {string} name
64
+ * @param {CursorBlob} cursor
65
+ * @returns {Promise<void>}
66
+ */
67
+ async function writeSavedCursor(persistence, graphName, name, cursor) {
68
+ const ref = buildCursorSavedRef(graphName, name);
69
+ const json = JSON.stringify(cursor);
70
+ const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
71
+ await persistence.updateRef(ref, oid);
72
+ }
73
+
74
+ /**
75
+ * Deletes a named saved cursor from Git ref storage.
76
+ *
77
+ * @param {Persistence} persistence
78
+ * @param {string} graphName
79
+ * @param {string} name
80
+ * @returns {Promise<void>}
81
+ */
82
+ async function deleteSavedCursor(persistence, graphName, name) {
83
+ const ref = buildCursorSavedRef(graphName, name);
84
+ const exists = await persistence.readRef(ref);
85
+ if (exists) {
86
+ await persistence.deleteRef(ref);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Lists all saved cursors for a graph.
92
+ *
93
+ * @param {Persistence} persistence
94
+ * @param {string} graphName
95
+ * @returns {Promise<Array<{name: string, tick: number, mode?: string}>>}
96
+ */
97
+ async function listSavedCursors(persistence, graphName) {
98
+ const prefix = buildCursorSavedPrefix(graphName);
99
+ const refs = await persistence.listRefs(prefix);
100
+ const cursors = [];
101
+ for (const ref of refs) {
102
+ const name = ref.slice(prefix.length);
103
+ if (name) {
104
+ const oid = await persistence.readRef(ref);
105
+ if (oid) {
106
+ const buf = await persistence.readBlob(oid);
107
+ const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
108
+ cursors.push({ name, ...cursor });
109
+ }
110
+ }
111
+ }
112
+ return cursors;
113
+ }
114
+
115
+ // ============================================================================
116
+ // Seek Arg Parser
117
+ // ============================================================================
118
+
119
+ const SEEK_OPTIONS = {
120
+ tick: { type: 'string' },
121
+ latest: { type: 'boolean', default: false },
122
+ save: { type: 'string' },
123
+ load: { type: 'string' },
124
+ list: { type: 'boolean', default: false },
125
+ drop: { type: 'string' },
126
+ 'clear-cache': { type: 'boolean', default: false },
127
+ 'no-persistent-cache': { type: 'boolean', default: false },
128
+ diff: { type: 'boolean', default: false },
129
+ 'diff-limit': { type: 'string', default: '2000' },
130
+ };
131
+
132
+ /**
133
+ * @param {string[]} args
134
+ * @returns {SeekSpec}
135
+ */
136
+ function parseSeekArgs(args) {
137
+ const { values } = parseCommandArgs(args, SEEK_OPTIONS, seekSchema);
138
+ return /** @type {SeekSpec} */ (values);
139
+ }
140
+
141
+ // ============================================================================
142
+ // Tick Resolution
143
+ // ============================================================================
144
+
145
+ /**
146
+ * @param {string} tickValue
147
+ * @param {number|null} currentTick
148
+ * @param {number[]} ticks
149
+ * @param {number} maxTick
150
+ * @returns {number}
151
+ */
152
+ function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
153
+ if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
154
+ const delta = parseInt(tickValue, 10);
155
+ if (!Number.isInteger(delta)) {
156
+ throw usageError(`Invalid tick delta: ${tickValue}`);
157
+ }
158
+ const base = currentTick ?? 0;
159
+ const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
160
+ const currentIdx = allPoints.indexOf(base);
161
+ const startIdx = currentIdx === -1 ? 0 : currentIdx;
162
+ const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
163
+ return allPoints[targetIdx];
164
+ }
165
+
166
+ const n = parseInt(tickValue, 10);
167
+ if (!Number.isInteger(n) || n < 0) {
168
+ throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
169
+ }
170
+ return Math.min(n, maxTick);
171
+ }
172
+
173
+ // ============================================================================
174
+ // Seek Helpers
175
+ // ============================================================================
176
+
177
+ /**
178
+ * @param {Map<string, WriterTickInfo>} perWriter
179
+ * @returns {Record<string, WriterTickInfo>}
180
+ */
181
+ function serializePerWriter(perWriter) {
182
+ /** @type {Record<string, WriterTickInfo>} */
183
+ const result = {};
184
+ for (const [writerId, info] of perWriter) {
185
+ result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
186
+ }
187
+ return result;
188
+ }
189
+
190
+ /**
191
+ * @param {number} tick
192
+ * @param {Map<string, WriterTickInfo>} perWriter
193
+ * @returns {number}
194
+ */
195
+ function countPatchesAtTick(tick, perWriter) {
196
+ let count = 0;
197
+ for (const [, info] of perWriter) {
198
+ for (const t of info.ticks) {
199
+ if (t <= tick) {
200
+ count++;
201
+ }
202
+ }
203
+ }
204
+ return count;
205
+ }
206
+
207
+ /**
208
+ * @param {Map<string, WriterTickInfo>} perWriter
209
+ * @returns {Promise<string>}
210
+ */
211
+ async function computeFrontierHash(perWriter) {
212
+ /** @type {Record<string, string|null>} */
213
+ const tips = {};
214
+ for (const [writerId, info] of perWriter) {
215
+ tips[writerId] = info?.tipSha || null;
216
+ }
217
+ const data = new TextEncoder().encode(stableStringify(tips));
218
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', data);
219
+ return Array.from(new Uint8Array(digest))
220
+ .map((b) => b.toString(16).padStart(2, '0'))
221
+ .join('');
222
+ }
223
+
224
+ /**
225
+ * @param {CursorBlob|null} cursor
226
+ * @returns {{nodes: number|null, edges: number|null}}
227
+ */
228
+ function readSeekCounts(cursor) {
229
+ if (!cursor || typeof cursor !== 'object') {
230
+ return { nodes: null, edges: null };
231
+ }
232
+ const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
233
+ const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
234
+ return { nodes, edges };
235
+ }
236
+
237
+ /**
238
+ * @param {CursorBlob|null} prevCursor
239
+ * @param {{nodes: number, edges: number}} next
240
+ * @param {string} frontierHash
241
+ * @returns {{nodes: number, edges: number}|null}
242
+ */
243
+ function computeSeekStateDiff(prevCursor, next, frontierHash) {
244
+ const prev = readSeekCounts(prevCursor);
245
+ if (prev.nodes === null || prev.edges === null) {
246
+ return null;
247
+ }
248
+ const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
249
+ if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
250
+ return null;
251
+ }
252
+ return {
253
+ nodes: next.nodes - prev.nodes,
254
+ edges: next.edges - prev.edges,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * @param {{tick: number, perWriter: Map<string, WriterTickInfo>, graph: WarpGraphInstance}} params
260
+ * @returns {Promise<Record<string, {sha: string, opSummary: *}>|null>}
261
+ */
262
+ async function buildTickReceipt({ tick, perWriter, graph }) {
263
+ if (!Number.isInteger(tick) || tick <= 0) {
264
+ return null;
265
+ }
266
+
267
+ /** @type {Record<string, {sha: string, opSummary: *}>} */
268
+ const receipt = {};
269
+
270
+ for (const [writerId, info] of perWriter) {
271
+ const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload
272
+ if (!sha) {
273
+ continue;
274
+ }
275
+
276
+ const patch = await graph.loadPatchBySha(sha);
277
+ const ops = Array.isArray(patch?.ops) ? patch.ops : [];
278
+ receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
279
+ }
280
+
281
+ return Object.keys(receipt).length > 0 ? receipt : null;
282
+ }
283
+
284
+ /**
285
+ * @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
286
+ * @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
287
+ */
288
+ async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
289
+ let beforeState = null;
290
+ let diffBaseline = 'empty';
291
+ let baselineTick = null;
292
+
293
+ if (prevTick !== null && prevTick === currentTick) {
294
+ const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
295
+ return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
296
+ }
297
+
298
+ if (prevTick !== null && prevTick > 0) {
299
+ await graph.materialize({ ceiling: prevTick });
300
+ beforeState = await graph.getStateSnapshot();
301
+ diffBaseline = 'tick';
302
+ baselineTick = prevTick;
303
+ }
304
+
305
+ await graph.materialize({ ceiling: currentTick });
306
+ const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
307
+ const diff = diffStates(beforeState, afterState);
308
+
309
+ return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
310
+ }
311
+
312
+ /**
313
+ * @param {*} diff
314
+ * @param {string} diffBaseline
315
+ * @param {number|null} baselineTick
316
+ * @param {number} diffLimit
317
+ * @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
318
+ */
319
+ function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
320
+ const totalChanges =
321
+ diff.nodes.added.length + diff.nodes.removed.length +
322
+ diff.edges.added.length + diff.edges.removed.length +
323
+ diff.props.set.length + diff.props.removed.length;
324
+
325
+ if (totalChanges <= diffLimit) {
326
+ return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
327
+ }
328
+
329
+ let remaining = diffLimit;
330
+ const cap = (/** @type {any[]} */ arr) => {
331
+ const take = Math.min(arr.length, remaining);
332
+ remaining -= take;
333
+ return arr.slice(0, take);
334
+ };
335
+
336
+ const capped = {
337
+ nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
338
+ edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
339
+ props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
340
+ };
341
+
342
+ const shownChanges = diffLimit - remaining;
343
+ return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
344
+ }
345
+
346
+ // ============================================================================
347
+ // Seek Status Handler
348
+ // ============================================================================
349
+
350
+ /**
351
+ * @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
352
+ * @returns {Promise<{payload: *, exitCode: number}>}
353
+ */
354
+ async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
355
+ if (activeCursor) {
356
+ await graph.materialize({ ceiling: activeCursor.tick });
357
+ const nodes = await graph.getNodes();
358
+ const edges = await graph.getEdges();
359
+ const prevCounts = readSeekCounts(activeCursor);
360
+ const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
361
+ if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
362
+ await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
363
+ }
364
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
365
+ const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
366
+ return {
367
+ payload: {
368
+ graph: graphName,
369
+ action: 'status',
370
+ tick: activeCursor.tick,
371
+ maxTick,
372
+ ticks,
373
+ nodes: nodes.length,
374
+ edges: edges.length,
375
+ perWriter: serializePerWriter(perWriter),
376
+ patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
377
+ diff,
378
+ tickReceipt,
379
+ cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
380
+ },
381
+ exitCode: EXIT_CODES.OK,
382
+ };
383
+ }
384
+ await graph.materialize();
385
+ const nodes = await graph.getNodes();
386
+ const edges = await graph.getEdges();
387
+ const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
388
+ return {
389
+ payload: {
390
+ graph: graphName,
391
+ action: 'status',
392
+ tick: maxTick,
393
+ maxTick,
394
+ ticks,
395
+ nodes: nodes.length,
396
+ edges: edges.length,
397
+ perWriter: serializePerWriter(perWriter),
398
+ patchCount: countPatchesAtTick(maxTick, perWriter),
399
+ diff: null,
400
+ tickReceipt,
401
+ cursor: { active: false },
402
+ },
403
+ exitCode: EXIT_CODES.OK,
404
+ };
405
+ }
406
+
407
+ // ============================================================================
408
+ // Main Seek Handler
409
+ // ============================================================================
410
+
411
+ /**
412
+ * Handles the `git warp seek` command across all sub-actions.
413
+ * @param {{options: CliOptions, args: string[]}} params
414
+ * @returns {Promise<{payload: *, exitCode: number}>}
415
+ */
416
+ export default async function handleSeek({ options, args }) {
417
+ const seekSpec = parseSeekArgs(args);
418
+ const { graph, graphName, persistence } = await openGraph(options);
419
+ void wireSeekCache({ graph, persistence, graphName, seekSpec });
420
+
421
+ // Handle --clear-cache before discovering ticks (no materialization needed)
422
+ if (seekSpec.action === 'clear-cache') {
423
+ if (graph.seekCache) {
424
+ await graph.seekCache.clear();
425
+ }
426
+ return {
427
+ payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' },
428
+ exitCode: EXIT_CODES.OK,
429
+ };
430
+ }
431
+
432
+ const activeCursor = await readActiveCursor(persistence, graphName);
433
+ const { ticks, maxTick, perWriter } = await graph.discoverTicks();
434
+ const frontierHash = await computeFrontierHash(perWriter);
435
+ if (seekSpec.action === 'list') {
436
+ const saved = await listSavedCursors(persistence, graphName);
437
+ return {
438
+ payload: {
439
+ graph: graphName,
440
+ action: 'list',
441
+ cursors: saved,
442
+ activeTick: activeCursor ? activeCursor.tick : null,
443
+ maxTick,
444
+ },
445
+ exitCode: EXIT_CODES.OK,
446
+ };
447
+ }
448
+ if (seekSpec.action === 'drop') {
449
+ const dropName = /** @type {string} */ (seekSpec.name);
450
+ const existing = await readSavedCursor(persistence, graphName, dropName);
451
+ if (!existing) {
452
+ throw notFoundError(`Saved cursor not found: ${dropName}`);
453
+ }
454
+ await deleteSavedCursor(persistence, graphName, dropName);
455
+ return {
456
+ payload: {
457
+ graph: graphName,
458
+ action: 'drop',
459
+ name: seekSpec.name,
460
+ tick: existing.tick,
461
+ },
462
+ exitCode: EXIT_CODES.OK,
463
+ };
464
+ }
465
+ if (seekSpec.action === 'latest') {
466
+ const prevTick = activeCursor ? activeCursor.tick : null;
467
+ let sdResult = null;
468
+ if (seekSpec.diff) {
469
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
470
+ }
471
+ await clearActiveCursor(persistence, graphName);
472
+ // When --diff already materialized at maxTick, skip redundant re-materialize
473
+ if (!sdResult) {
474
+ await graph.materialize({ ceiling: maxTick });
475
+ }
476
+ const nodes = await graph.getNodes();
477
+ const edges = await graph.getEdges();
478
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
479
+ const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
480
+ return {
481
+ payload: {
482
+ graph: graphName,
483
+ action: 'latest',
484
+ tick: maxTick,
485
+ maxTick,
486
+ ticks,
487
+ nodes: nodes.length,
488
+ edges: edges.length,
489
+ perWriter: serializePerWriter(perWriter),
490
+ patchCount: countPatchesAtTick(maxTick, perWriter),
491
+ diff,
492
+ tickReceipt,
493
+ cursor: { active: false },
494
+ ...sdResult,
495
+ },
496
+ exitCode: EXIT_CODES.OK,
497
+ };
498
+ }
499
+ if (seekSpec.action === 'save') {
500
+ if (!activeCursor) {
501
+ throw usageError('No active cursor to save. Use --tick first.');
502
+ }
503
+ await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor);
504
+ return {
505
+ payload: {
506
+ graph: graphName,
507
+ action: 'save',
508
+ name: seekSpec.name,
509
+ tick: activeCursor.tick,
510
+ },
511
+ exitCode: EXIT_CODES.OK,
512
+ };
513
+ }
514
+ if (seekSpec.action === 'load') {
515
+ const loadName = /** @type {string} */ (seekSpec.name);
516
+ const saved = await readSavedCursor(persistence, graphName, loadName);
517
+ if (!saved) {
518
+ throw notFoundError(`Saved cursor not found: ${loadName}`);
519
+ }
520
+ const prevTick = activeCursor ? activeCursor.tick : null;
521
+ let sdResult = null;
522
+ if (seekSpec.diff) {
523
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
524
+ }
525
+ // When --diff already materialized at saved.tick, skip redundant call
526
+ if (!sdResult) {
527
+ await graph.materialize({ ceiling: saved.tick });
528
+ }
529
+ const nodes = await graph.getNodes();
530
+ const edges = await graph.getEdges();
531
+ await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
532
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
533
+ const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
534
+ return {
535
+ payload: {
536
+ graph: graphName,
537
+ action: 'load',
538
+ name: seekSpec.name,
539
+ tick: saved.tick,
540
+ maxTick,
541
+ ticks,
542
+ nodes: nodes.length,
543
+ edges: edges.length,
544
+ perWriter: serializePerWriter(perWriter),
545
+ patchCount: countPatchesAtTick(saved.tick, perWriter),
546
+ diff,
547
+ tickReceipt,
548
+ cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
549
+ ...sdResult,
550
+ },
551
+ exitCode: EXIT_CODES.OK,
552
+ };
553
+ }
554
+ if (seekSpec.action === 'tick') {
555
+ const currentTick = activeCursor ? activeCursor.tick : null;
556
+ const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
557
+ let sdResult = null;
558
+ if (seekSpec.diff) {
559
+ sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
560
+ }
561
+ // When --diff already materialized at resolvedTick, skip redundant call
562
+ if (!sdResult) {
563
+ await graph.materialize({ ceiling: resolvedTick });
564
+ }
565
+ const nodes = await graph.getNodes();
566
+ const edges = await graph.getEdges();
567
+ await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
568
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
569
+ const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
570
+ return {
571
+ payload: {
572
+ graph: graphName,
573
+ action: 'tick',
574
+ tick: resolvedTick,
575
+ maxTick,
576
+ ticks,
577
+ nodes: nodes.length,
578
+ edges: edges.length,
579
+ perWriter: serializePerWriter(perWriter),
580
+ patchCount: countPatchesAtTick(resolvedTick, perWriter),
581
+ diff,
582
+ tickReceipt,
583
+ cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
584
+ ...sdResult,
585
+ },
586
+ exitCode: EXIT_CODES.OK,
587
+ };
588
+ }
589
+
590
+ // status (bare seek)
591
+ return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
592
+ }