@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
package/bin/warp-graph.js CHANGED
@@ -1,2824 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import crypto from 'node:crypto';
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
3
  import process from 'node:process';
7
- import readline from 'node:readline';
8
- import { execFileSync } from 'node:child_process';
9
- // @ts-expect-error no type declarations for @git-stunts/plumbing
10
- import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing';
11
- import WarpGraph from '../src/domain/WarpGraph.js';
12
- import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
13
- import HealthCheckService from '../src/domain/services/HealthCheckService.js';
14
- import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
15
- import NodeCryptoAdapter from '../src/infrastructure/adapters/NodeCryptoAdapter.js';
16
- import {
17
- REF_PREFIX,
18
- buildCheckpointRef,
19
- buildCoverageRef,
20
- buildWritersPrefix,
21
- parseWriterIdFromRef,
22
- buildCursorActiveRef,
23
- buildCursorSavedRef,
24
- buildCursorSavedPrefix,
25
- } from '../src/domain/utils/RefLayout.js';
26
- import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
27
- import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
28
- import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
29
- import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
30
- import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
31
- import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
32
- import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
33
- import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
34
- import { diffStates } from '../src/domain/services/StateDiff.js';
35
- import { renderSeekView, formatStructuralDiff } from '../src/visualization/renderers/ascii/seek.js';
36
- import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
37
- import { renderSvg } from '../src/visualization/renderers/svg/index.js';
38
- import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
4
+ import { EXIT_CODES, HELP_TEXT, CliError, parseArgs, usageError } from './cli/infrastructure.js';
5
+ import { present } from './presenters/index.js';
6
+ import { stableStringify, compactStringify } from './presenters/json.js';
7
+ import { renderError } from './presenters/text.js';
8
+ import { COMMANDS } from './cli/commands/registry.js';
39
9
 
40
- /**
41
- * @typedef {Object} Persistence
42
- * @property {(prefix: string) => Promise<string[]>} listRefs
43
- * @property {(ref: string) => Promise<string|null>} readRef
44
- * @property {(ref: string, oid: string) => Promise<void>} updateRef
45
- * @property {(ref: string) => Promise<void>} deleteRef
46
- * @property {(oid: string) => Promise<Buffer>} readBlob
47
- * @property {(buf: Buffer) => Promise<string>} writeBlob
48
- * @property {(sha: string) => Promise<{date?: string|null}>} getNodeInfo
49
- * @property {(sha: string, coverageSha: string) => Promise<boolean>} isAncestor
50
- * @property {() => Promise<{ok: boolean}>} ping
51
- * @property {*} plumbing
52
- */
53
-
54
- /**
55
- * @typedef {Object} WarpGraphInstance
56
- * @property {(opts?: {ceiling?: number}) => Promise<void>} materialize
57
- * @property {() => Promise<Array<{id: string}>>} getNodes
58
- * @property {() => Promise<Array<{from: string, to: string, label?: string}>>} getEdges
59
- * @property {() => Promise<string|null>} createCheckpoint
60
- * @property {() => *} query
61
- * @property {{ shortestPath: Function }} traverse
62
- * @property {(writerId: string) => Promise<Array<{patch: any, sha: string}>>} getWriterPatches
63
- * @property {() => Promise<{frontier: Record<string, any>}>} status
64
- * @property {() => Promise<Map<string, any>>} getFrontier
65
- * @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
66
- * @property {() => Promise<number>} getPropertyCount
67
- * @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
68
- * @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
69
- * @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
70
- * @property {(cache: any) => void} setSeekCache
71
- * @property {*} seekCache
72
- * @property {number} [_seekCeiling]
73
- * @property {boolean} [_provenanceDegraded]
74
- */
75
-
76
- /**
77
- * @typedef {Object} WriterTickInfo
78
- * @property {number[]} ticks
79
- * @property {string|null} tipSha
80
- * @property {Record<number, string>} [tickShas]
81
- */
82
-
83
- /**
84
- * @typedef {Object} CursorBlob
85
- * @property {number} tick
86
- * @property {string} [mode]
87
- * @property {number} [nodes]
88
- * @property {number} [edges]
89
- * @property {string} [frontierHash]
90
- */
91
-
92
- /**
93
- * @typedef {Object} CliOptions
94
- * @property {string} repo
95
- * @property {boolean} json
96
- * @property {string|null} view
97
- * @property {string|null} graph
98
- * @property {string} writer
99
- * @property {boolean} help
100
- */
101
-
102
- /**
103
- * @typedef {Object} GraphInfoResult
104
- * @property {string} name
105
- * @property {{count: number, ids?: string[]}} writers
106
- * @property {{ref: string, sha: string|null, date?: string|null}} [checkpoint]
107
- * @property {{ref: string, sha: string|null}} [coverage]
108
- * @property {Record<string, number>} [writerPatches]
109
- * @property {{active: boolean, tick?: number, mode?: string}} [cursor]
110
- */
111
-
112
- /**
113
- * @typedef {Object} SeekSpec
114
- * @property {string} action
115
- * @property {string|null} tickValue
116
- * @property {string|null} name
117
- * @property {boolean} noPersistentCache
118
- * @property {boolean} diff
119
- * @property {number} diffLimit
120
- */
121
-
122
- const EXIT_CODES = {
123
- OK: 0,
124
- USAGE: 1,
125
- NOT_FOUND: 2,
126
- INTERNAL: 3,
127
- };
128
-
129
- const HELP_TEXT = `warp-graph <command> [options]
130
- (or: git warp <command> [options])
131
-
132
- Commands:
133
- info Summarize graphs in the repo
134
- query Run a logical graph query
135
- path Find a logical path between two nodes
136
- history Show writer history
137
- check Report graph health/GC status
138
- materialize Materialize and checkpoint all graphs
139
- seek Time-travel: step through graph history by Lamport tick
140
- view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
141
- install-hooks Install post-merge git hook
142
-
143
- Options:
144
- --repo <path> Path to git repo (default: cwd)
145
- --json Emit JSON output
146
- --view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
147
- --graph <name> Graph name (required if repo has multiple graphs)
148
- --writer <id> Writer id (default: cli)
149
- -h, --help Show this help
150
-
151
- Install-hooks options:
152
- --force Replace existing hook (backs up original)
153
-
154
- Query options:
155
- --match <glob> Match node ids (default: *)
156
- --outgoing [label] Traverse outgoing edge (repeatable)
157
- --incoming [label] Traverse incoming edge (repeatable)
158
- --where-prop k=v Filter nodes by prop equality (repeatable)
159
- --select <fields> Fields to select (id, props)
160
-
161
- Path options:
162
- --from <id> Start node id
163
- --to <id> End node id
164
- --dir <out|in|both> Traversal direction (default: out)
165
- --label <label> Filter by edge label (repeatable, comma-separated)
166
- --max-depth <n> Maximum depth
167
-
168
- History options:
169
- --node <id> Filter patches touching node id
170
-
171
- Seek options:
172
- --tick <N|+N|-N> Jump to tick N, or step forward/backward
173
- --latest Clear cursor, return to present
174
- --save <name> Save current position as named cursor
175
- --load <name> Restore a saved cursor
176
- --list List all saved cursors
177
- --drop <name> Delete a saved cursor
178
- --diff Show structural diff (added/removed nodes, edges, props)
179
- --diff-limit <N> Max diff entries (default 2000)
180
- `;
181
-
182
- /**
183
- * Structured CLI error with exit code and error code.
184
- */
185
- class CliError extends Error {
186
- /**
187
- * @param {string} message - Human-readable error message
188
- * @param {Object} [options]
189
- * @param {string} [options.code='E_CLI'] - Machine-readable error code
190
- * @param {number} [options.exitCode=3] - Process exit code
191
- * @param {Error} [options.cause] - Underlying cause
192
- */
193
- constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
194
- super(message);
195
- this.code = code;
196
- this.exitCode = exitCode;
197
- this.cause = cause;
198
- }
199
- }
200
-
201
- /** @param {string} message */
202
- function usageError(message) {
203
- return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
204
- }
205
-
206
- /** @param {string} message */
207
- function notFoundError(message) {
208
- return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
209
- }
210
-
211
- /** @param {*} value */
212
- function stableStringify(value) {
213
- /** @param {*} input @returns {*} */
214
- const normalize = (input) => {
215
- if (Array.isArray(input)) {
216
- return input.map(normalize);
217
- }
218
- if (input && typeof input === 'object') {
219
- /** @type {Record<string, *>} */
220
- const sorted = {};
221
- for (const key of Object.keys(input).sort()) {
222
- sorted[key] = normalize(input[key]);
223
- }
224
- return sorted;
225
- }
226
- return input;
227
- };
228
-
229
- return JSON.stringify(normalize(value), null, 2);
230
- }
231
-
232
- /** @param {string[]} argv */
233
- function parseArgs(argv) {
234
- const options = createDefaultOptions();
235
- /** @type {string[]} */
236
- const positionals = [];
237
- const optionDefs = [
238
- { flag: '--repo', shortFlag: '-r', key: 'repo' },
239
- { flag: '--graph', key: 'graph' },
240
- { flag: '--writer', key: 'writer' },
241
- ];
242
-
243
- for (let i = 0; i < argv.length; i += 1) {
244
- const result = consumeBaseArg({ argv, index: i, options, optionDefs, positionals });
245
- if (result.done) {
246
- break;
247
- }
248
- i += result.consumed;
249
- }
250
-
251
- options.repo = path.resolve(options.repo);
252
- return { options, positionals };
253
- }
254
-
255
- function createDefaultOptions() {
256
- return {
257
- repo: process.cwd(),
258
- json: false,
259
- view: null,
260
- graph: null,
261
- writer: 'cli',
262
- help: false,
263
- };
264
- }
265
-
266
- /**
267
- * @param {Object} params
268
- * @param {string[]} params.argv
269
- * @param {number} params.index
270
- * @param {Record<string, *>} params.options
271
- * @param {Array<{flag: string, shortFlag?: string, key: string}>} params.optionDefs
272
- * @param {string[]} params.positionals
273
- */
274
- function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
275
- const arg = argv[index];
276
-
277
- if (arg === '--') {
278
- positionals.push(...argv.slice(index + 1));
279
- return { consumed: argv.length - index - 1, done: true };
280
- }
281
-
282
- if (arg === '--json') {
283
- options.json = true;
284
- return { consumed: 0 };
285
- }
286
-
287
- if (arg === '--view') {
288
- // Valid view modes: ascii, browser, svg:FILE, html:FILE
289
- // Don't consume known commands as modes
290
- const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks'];
291
- const nextArg = argv[index + 1];
292
- const isViewMode = nextArg &&
293
- !nextArg.startsWith('-') &&
294
- !KNOWN_COMMANDS.includes(nextArg);
295
- if (isViewMode) {
296
- // Validate the view mode value
297
- const validModes = ['ascii', 'browser'];
298
- const validPrefixes = ['svg:', 'html:'];
299
- const isValid = validModes.includes(nextArg) ||
300
- validPrefixes.some((prefix) => nextArg.startsWith(prefix));
301
- if (!isValid) {
302
- throw usageError(`Invalid view mode: ${nextArg}. Valid modes: ascii, browser, svg:FILE, html:FILE`);
303
- }
304
- options.view = nextArg;
305
- return { consumed: 1 };
306
- }
307
- options.view = 'ascii'; // default mode
308
- return { consumed: 0 };
309
- }
310
-
311
- if (arg === '-h' || arg === '--help') {
312
- options.help = true;
313
- return { consumed: 0 };
314
- }
315
-
316
- const matched = matchOptionDef(arg, optionDefs);
317
- if (matched) {
318
- const result = readOptionValue({
319
- args: argv,
320
- index,
321
- flag: matched.flag,
322
- shortFlag: matched.shortFlag,
323
- allowEmpty: false,
324
- });
325
- if (result) {
326
- options[matched.key] = result.value;
327
- return { consumed: result.consumed };
328
- }
329
- }
330
-
331
- if (arg.startsWith('-')) {
332
- throw usageError(`Unknown option: ${arg}`);
333
- }
334
-
335
- positionals.push(arg, ...argv.slice(index + 1));
336
- return { consumed: argv.length - index - 1, done: true };
337
- }
338
-
339
- /**
340
- * @param {string} arg
341
- * @param {Array<{flag: string, shortFlag?: string, key: string}>} optionDefs
342
- */
343
- function matchOptionDef(arg, optionDefs) {
344
- return optionDefs.find((def) =>
345
- arg === def.flag ||
346
- arg === def.shortFlag ||
347
- arg.startsWith(`${def.flag}=`)
348
- );
349
- }
350
-
351
- /** @param {string} repoPath @returns {Promise<{persistence: Persistence}>} */
352
- async function createPersistence(repoPath) {
353
- const runner = ShellRunnerFactory.create();
354
- const plumbing = new GitPlumbing({ cwd: repoPath, runner });
355
- const persistence = new GitGraphAdapter({ plumbing });
356
- const ping = await persistence.ping();
357
- if (!ping.ok) {
358
- throw usageError(`Repository not accessible: ${repoPath}`);
359
- }
360
- return { persistence };
361
- }
362
-
363
- /** @param {Persistence} persistence @returns {Promise<string[]>} */
364
- async function listGraphNames(persistence) {
365
- if (typeof persistence.listRefs !== 'function') {
366
- return [];
367
- }
368
- const refs = await persistence.listRefs(REF_PREFIX);
369
- const prefix = `${REF_PREFIX}/`;
370
- const names = new Set();
371
-
372
- for (const ref of refs) {
373
- if (!ref.startsWith(prefix)) {
374
- continue;
375
- }
376
- const rest = ref.slice(prefix.length);
377
- const [graphName] = rest.split('/');
378
- if (graphName) {
379
- names.add(graphName);
380
- }
381
- }
382
-
383
- return [...names].sort();
384
- }
385
-
386
- /**
387
- * @param {Persistence} persistence
388
- * @param {string|null} explicitGraph
389
- * @returns {Promise<string>}
390
- */
391
- async function resolveGraphName(persistence, explicitGraph) {
392
- if (explicitGraph) {
393
- return explicitGraph;
394
- }
395
- const graphNames = await listGraphNames(persistence);
396
- if (graphNames.length === 1) {
397
- return graphNames[0];
398
- }
399
- if (graphNames.length === 0) {
400
- throw notFoundError('No graphs found in repo; specify --graph');
401
- }
402
- throw usageError('Multiple graphs found; specify --graph');
403
- }
404
-
405
- /**
406
- * Collects metadata about a single graph (writer count, refs, patches, checkpoint).
407
- * @param {Persistence} persistence - GraphPersistencePort adapter
408
- * @param {string} graphName - Name of the graph to inspect
409
- * @param {Object} [options]
410
- * @param {boolean} [options.includeWriterIds=false] - Include writer ID list
411
- * @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
412
- * @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
413
- * @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
414
- * @returns {Promise<GraphInfoResult>} Graph info object
415
- */
416
- async function getGraphInfo(persistence, graphName, {
417
- includeWriterIds = false,
418
- includeRefs = false,
419
- includeWriterPatches = false,
420
- includeCheckpointDate = false,
421
- } = {}) {
422
- const writersPrefix = buildWritersPrefix(graphName);
423
- const writerRefs = typeof persistence.listRefs === 'function'
424
- ? await persistence.listRefs(writersPrefix)
425
- : [];
426
- const writerIds = /** @type {string[]} */ (writerRefs
427
- .map((ref) => parseWriterIdFromRef(ref))
428
- .filter(Boolean)
429
- .sort());
430
-
431
- /** @type {GraphInfoResult} */
432
- const info = {
433
- name: graphName,
434
- writers: {
435
- count: writerIds.length,
436
- },
437
- };
438
-
439
- if (includeWriterIds) {
440
- info.writers.ids = writerIds;
441
- }
442
-
443
- if (includeRefs || includeCheckpointDate) {
444
- const checkpointRef = buildCheckpointRef(graphName);
445
- const checkpointSha = await persistence.readRef(checkpointRef);
446
-
447
- /** @type {{ref: string, sha: string|null, date?: string|null}} */
448
- const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
449
-
450
- if (includeCheckpointDate && checkpointSha) {
451
- const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
452
- checkpoint.date = checkpointDate;
453
- }
454
-
455
- info.checkpoint = checkpoint;
456
-
457
- if (includeRefs) {
458
- const coverageRef = buildCoverageRef(graphName);
459
- const coverageSha = await persistence.readRef(coverageRef);
460
- info.coverage = { ref: coverageRef, sha: coverageSha || null };
461
- }
462
- }
463
-
464
- if (includeWriterPatches && writerIds.length > 0) {
465
- const graph = await WarpGraph.open({
466
- persistence,
467
- graphName,
468
- writerId: 'cli',
469
- crypto: new NodeCryptoAdapter(),
470
- });
471
- /** @type {Record<string, number>} */
472
- const writerPatches = {};
473
- for (const writerId of writerIds) {
474
- const patches = await graph.getWriterPatches(writerId);
475
- writerPatches[/** @type {string} */ (writerId)] = patches.length;
476
- }
477
- info.writerPatches = writerPatches;
478
- }
479
-
480
- return info;
481
- }
482
-
483
- /**
484
- * Opens a WarpGraph for the given CLI options.
485
- * @param {CliOptions} options - Parsed CLI options
486
- * @returns {Promise<{graph: WarpGraphInstance, graphName: string, persistence: Persistence}>}
487
- * @throws {CliError} If the specified graph is not found
488
- */
489
- async function openGraph(options) {
490
- const { persistence } = await createPersistence(options.repo);
491
- const graphName = await resolveGraphName(persistence, options.graph);
492
- if (options.graph) {
493
- const graphNames = await listGraphNames(persistence);
494
- if (!graphNames.includes(options.graph)) {
495
- throw notFoundError(`Graph not found: ${options.graph}`);
496
- }
497
- }
498
- const graph = /** @type {WarpGraphInstance} */ (/** @type {*} */ (await WarpGraph.open({ // TODO(ts-cleanup): narrow port type
499
- persistence,
500
- graphName,
501
- writerId: options.writer,
502
- crypto: new NodeCryptoAdapter(),
503
- })));
504
- return { graph, graphName, persistence };
505
- }
506
-
507
- /** @param {string[]} args */
508
- function parseQueryArgs(args) {
509
- const spec = {
510
- match: null,
511
- select: null,
512
- steps: [],
513
- };
514
-
515
- for (let i = 0; i < args.length; i += 1) {
516
- const result = consumeQueryArg(args, i, spec);
517
- if (!result) {
518
- throw usageError(`Unknown query option: ${args[i]}`);
519
- }
520
- i += result.consumed;
521
- }
522
-
523
- return spec;
524
- }
525
-
526
- /**
527
- * @param {string[]} args
528
- * @param {number} index
529
- * @param {{match: string|null, select: string[]|null, steps: Array<{type: string, label?: string, key?: string, value?: string}>}} spec
530
- */
531
- function consumeQueryArg(args, index, spec) {
532
- const stepResult = readTraversalStep(args, index);
533
- if (stepResult) {
534
- spec.steps.push(stepResult.step);
535
- return stepResult;
536
- }
537
-
538
- const matchResult = readOptionValue({
539
- args,
540
- index,
541
- flag: '--match',
542
- allowEmpty: true,
543
- });
544
- if (matchResult) {
545
- spec.match = matchResult.value;
546
- return matchResult;
547
- }
548
-
549
- const whereResult = readOptionValue({
550
- args,
551
- index,
552
- flag: '--where-prop',
553
- allowEmpty: false,
554
- });
555
- if (whereResult) {
556
- spec.steps.push(parseWhereProp(whereResult.value));
557
- return whereResult;
558
- }
559
-
560
- const selectResult = readOptionValue({
561
- args,
562
- index,
563
- flag: '--select',
564
- allowEmpty: true,
565
- });
566
- if (selectResult) {
567
- spec.select = parseSelectFields(selectResult.value);
568
- return selectResult;
569
- }
570
-
571
- return null;
572
- }
573
-
574
- /** @param {string} value */
575
- function parseWhereProp(value) {
576
- const [key, ...rest] = value.split('=');
577
- if (!key || rest.length === 0) {
578
- throw usageError('Expected --where-prop key=value');
579
- }
580
- return { type: 'where-prop', key, value: rest.join('=') };
581
- }
582
-
583
- /** @param {string} value */
584
- function parseSelectFields(value) {
585
- if (value === '') {
586
- return [];
587
- }
588
- return value.split(',').map((field) => field.trim()).filter(Boolean);
589
- }
590
-
591
- /**
592
- * @param {string[]} args
593
- * @param {number} index
594
- */
595
- function readTraversalStep(args, index) {
596
- const arg = args[index];
597
- if (arg !== '--outgoing' && arg !== '--incoming') {
598
- return null;
599
- }
600
- const next = args[index + 1];
601
- const label = next && !next.startsWith('-') ? next : undefined;
602
- const consumed = label ? 1 : 0;
603
- return { step: { type: arg.slice(2), label }, consumed };
604
- }
605
-
606
- /**
607
- * @param {{args: string[], index: number, flag: string, shortFlag?: string, allowEmpty?: boolean}} params
608
- */
609
- function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
610
- const arg = args[index];
611
- if (matchesOptionFlag(arg, flag, shortFlag)) {
612
- return readNextOptionValue({ args, index, flag, allowEmpty });
613
- }
614
-
615
- if (arg.startsWith(`${flag}=`)) {
616
- return readInlineOptionValue({ arg, flag, allowEmpty });
617
- }
618
-
619
- return null;
620
- }
621
-
622
- /**
623
- * @param {string} arg
624
- * @param {string} flag
625
- * @param {string} [shortFlag]
626
- */
627
- function matchesOptionFlag(arg, flag, shortFlag) {
628
- return arg === flag || (shortFlag && arg === shortFlag);
629
- }
630
-
631
- /** @param {{args: string[], index: number, flag: string, allowEmpty?: boolean}} params */
632
- function readNextOptionValue({ args, index, flag, allowEmpty }) {
633
- const value = args[index + 1];
634
- if (value === undefined || (!allowEmpty && value === '')) {
635
- throw usageError(`Missing value for ${flag}`);
636
- }
637
- return { value, consumed: 1 };
638
- }
639
-
640
- /** @param {{arg: string, flag: string, allowEmpty?: boolean}} params */
641
- function readInlineOptionValue({ arg, flag, allowEmpty }) {
642
- const value = arg.slice(flag.length + 1);
643
- if (!allowEmpty && value === '') {
644
- throw usageError(`Missing value for ${flag}`);
645
- }
646
- return { value, consumed: 0 };
647
- }
648
-
649
- /** @param {string[]} args */
650
- function parsePathArgs(args) {
651
- const options = createPathOptions();
652
- /** @type {string[]} */
653
- const labels = [];
654
- /** @type {string[]} */
655
- const positionals = [];
656
-
657
- for (let i = 0; i < args.length; i += 1) {
658
- const result = consumePathArg({ args, index: i, options, labels, positionals });
659
- i += result.consumed;
660
- }
661
-
662
- finalizePathOptions(options, labels, positionals);
663
- return options;
664
- }
665
-
666
- /** @returns {{from: string|null, to: string|null, dir: string|undefined, labelFilter: string|string[]|undefined, maxDepth: number|undefined}} */
667
- function createPathOptions() {
668
- return {
669
- from: null,
670
- to: null,
671
- dir: undefined,
672
- labelFilter: undefined,
673
- maxDepth: undefined,
674
- };
675
- }
676
-
677
- /**
678
- * @param {{args: string[], index: number, options: ReturnType<typeof createPathOptions>, labels: string[], positionals: string[]}} params
679
- */
680
- function consumePathArg({ args, index, options, labels, positionals }) {
681
- const arg = args[index];
682
- /** @type {Array<{flag: string, apply: (value: string) => void}>} */
683
- const handlers = [
684
- { flag: '--from', apply: (value) => { options.from = value; } },
685
- { flag: '--to', apply: (value) => { options.to = value; } },
686
- { flag: '--dir', apply: (value) => { options.dir = value; } },
687
- { flag: '--label', apply: (value) => { labels.push(...parseLabels(value)); } },
688
- { flag: '--max-depth', apply: (value) => { options.maxDepth = parseMaxDepth(value); } },
689
- ];
690
-
691
- for (const handler of handlers) {
692
- const result = readOptionValue({ args, index, flag: handler.flag });
693
- if (result) {
694
- handler.apply(result.value);
695
- return result;
696
- }
697
- }
698
-
699
- if (arg.startsWith('-')) {
700
- throw usageError(`Unknown path option: ${arg}`);
701
- }
702
-
703
- positionals.push(arg);
704
- return { consumed: 0 };
705
- }
706
-
707
- /**
708
- * @param {ReturnType<typeof createPathOptions>} options
709
- * @param {string[]} labels
710
- * @param {string[]} positionals
711
- */
712
- function finalizePathOptions(options, labels, positionals) {
713
- if (!options.from) {
714
- options.from = positionals[0] || null;
715
- }
716
-
717
- if (!options.to) {
718
- options.to = positionals[1] || null;
719
- }
720
-
721
- if (!options.from || !options.to) {
722
- throw usageError('Path requires --from and --to (or two positional ids)');
723
- }
724
-
725
- if (labels.length === 1) {
726
- options.labelFilter = labels[0];
727
- } else if (labels.length > 1) {
728
- options.labelFilter = labels;
729
- }
730
- }
731
-
732
- /** @param {string} value */
733
- function parseLabels(value) {
734
- return value.split(',').map((label) => label.trim()).filter(Boolean);
735
- }
736
-
737
- /** @param {string} value */
738
- function parseMaxDepth(value) {
739
- const parsed = Number.parseInt(value, 10);
740
- if (Number.isNaN(parsed)) {
741
- throw usageError('Invalid value for --max-depth');
742
- }
743
- return parsed;
744
- }
745
-
746
- /** @param {string[]} args */
747
- function parseHistoryArgs(args) {
748
- /** @type {{node: string|null}} */
749
- const options = { node: null };
750
-
751
- for (let i = 0; i < args.length; i += 1) {
752
- const arg = args[i];
753
-
754
- if (arg === '--node') {
755
- const value = args[i + 1];
756
- if (!value) {
757
- throw usageError('Missing value for --node');
758
- }
759
- options.node = value;
760
- i += 1;
761
- continue;
762
- }
763
-
764
- if (arg.startsWith('--node=')) {
765
- options.node = arg.slice('--node='.length);
766
- continue;
767
- }
768
-
769
- if (arg.startsWith('-')) {
770
- throw usageError(`Unknown history option: ${arg}`);
771
- }
772
-
773
- throw usageError(`Unexpected history argument: ${arg}`);
774
- }
775
-
776
- return options;
777
- }
778
-
779
- /**
780
- * @param {*} patch
781
- * @param {string} nodeId
782
- */
783
- function patchTouchesNode(patch, nodeId) {
784
- const ops = Array.isArray(patch?.ops) ? patch.ops : [];
785
- for (const op of ops) {
786
- if (op.node === nodeId) {
787
- return true;
788
- }
789
- if (op.from === nodeId || op.to === nodeId) {
790
- return true;
791
- }
792
- }
793
- return false;
794
- }
795
-
796
- /** @param {*} payload */
797
- function renderInfo(payload) {
798
- const lines = [`Repo: ${payload.repo}`];
799
- lines.push(`Graphs: ${payload.graphs.length}`);
800
- for (const graph of payload.graphs) {
801
- const writers = graph.writers ? ` writers=${graph.writers.count}` : '';
802
- lines.push(`- ${graph.name}${writers}`);
803
- if (graph.checkpoint?.sha) {
804
- lines.push(` checkpoint: ${graph.checkpoint.sha}`);
805
- }
806
- if (graph.coverage?.sha) {
807
- lines.push(` coverage: ${graph.coverage.sha}`);
808
- }
809
- if (graph.cursor?.active) {
810
- lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
811
- }
812
- }
813
- return `${lines.join('\n')}\n`;
814
- }
815
-
816
- /** @param {*} payload */
817
- function renderQuery(payload) {
818
- const lines = [
819
- `Graph: ${payload.graph}`,
820
- `State: ${payload.stateHash}`,
821
- `Nodes: ${payload.nodes.length}`,
822
- ];
823
-
824
- for (const node of payload.nodes) {
825
- const id = node.id ?? '(unknown)';
826
- lines.push(`- ${id}`);
827
- if (node.props && Object.keys(node.props).length > 0) {
828
- lines.push(` props: ${JSON.stringify(node.props)}`);
829
- }
830
- }
831
-
832
- return `${lines.join('\n')}\n`;
833
- }
834
-
835
- /** @param {*} payload */
836
- function renderPath(payload) {
837
- const lines = [
838
- `Graph: ${payload.graph}`,
839
- `From: ${payload.from}`,
840
- `To: ${payload.to}`,
841
- `Found: ${payload.found ? 'yes' : 'no'}`,
842
- `Length: ${payload.length}`,
843
- ];
844
-
845
- if (payload.path && payload.path.length > 0) {
846
- lines.push(`Path: ${payload.path.join(' -> ')}`);
847
- }
848
-
849
- return `${lines.join('\n')}\n`;
850
- }
851
-
852
- const ANSI_GREEN = '\x1b[32m';
853
- const ANSI_YELLOW = '\x1b[33m';
854
- const ANSI_RED = '\x1b[31m';
855
- const ANSI_DIM = '\x1b[2m';
856
- const ANSI_RESET = '\x1b[0m';
857
-
858
- /** @param {string} state */
859
- function colorCachedState(state) {
860
- if (state === 'fresh') {
861
- return `${ANSI_GREEN}${state}${ANSI_RESET}`;
862
- }
863
- if (state === 'stale') {
864
- return `${ANSI_YELLOW}${state}${ANSI_RESET}`;
865
- }
866
- return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
867
- }
868
-
869
- /** @param {*} payload */
870
- function renderCheck(payload) {
871
- const lines = [
872
- `Graph: ${payload.graph}`,
873
- `Health: ${payload.health.status}`,
874
- ];
875
-
876
- if (payload.status) {
877
- lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`);
878
- lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`);
879
- lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`);
880
- lines.push(`Writers: ${payload.status.writers}`);
881
- }
882
-
883
- if (payload.checkpoint?.sha) {
884
- lines.push(`Checkpoint: ${payload.checkpoint.sha}`);
885
- if (payload.checkpoint.ageSeconds !== null) {
886
- lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`);
887
- }
888
- } else {
889
- lines.push('Checkpoint: none');
890
- }
891
-
892
- if (!payload.status) {
893
- lines.push(`Writers: ${payload.writers.count}`);
894
- }
895
- for (const head of payload.writers.heads) {
896
- lines.push(`- ${head.writerId}: ${head.sha}`);
897
- }
898
-
899
- if (payload.coverage?.sha) {
900
- lines.push(`Coverage: ${payload.coverage.sha}`);
901
- lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`);
902
- } else {
903
- lines.push('Coverage: none');
904
- }
905
-
906
- if (payload.gc) {
907
- lines.push(`Tombstones: ${payload.gc.totalTombstones}`);
908
- if (!payload.status) {
909
- lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`);
910
- }
911
- }
912
-
913
- if (payload.hook) {
914
- lines.push(formatHookStatusLine(payload.hook));
915
- }
916
-
917
- return `${lines.join('\n')}\n`;
918
- }
919
-
920
- /** @param {*} hook */
921
- function formatHookStatusLine(hook) {
922
- if (!hook.installed && hook.foreign) {
923
- return "Hook: foreign hook present — run 'git warp install-hooks'";
924
- }
925
- if (!hook.installed) {
926
- return "Hook: not installed — run 'git warp install-hooks'";
927
- }
928
- if (hook.current) {
929
- return `Hook: installed (v${hook.version}) — up to date`;
930
- }
931
- return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
932
- }
933
-
934
- /** @param {*} payload */
935
- function renderHistory(payload) {
936
- const lines = [
937
- `Graph: ${payload.graph}`,
938
- `Writer: ${payload.writer}`,
939
- `Entries: ${payload.entries.length}`,
940
- ];
941
-
942
- if (payload.nodeFilter) {
943
- lines.push(`Node Filter: ${payload.nodeFilter}`);
944
- }
945
-
946
- for (const entry of payload.entries) {
947
- lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`);
948
- }
949
-
950
- return `${lines.join('\n')}\n`;
951
- }
952
-
953
- /** @param {*} payload */
954
- function renderError(payload) {
955
- return `Error: ${payload.error.message}\n`;
956
- }
957
-
958
- /**
959
- * Wraps SVG content in a minimal HTML document and writes it to disk.
960
- * @param {string} filePath - Destination file path
961
- * @param {string} svgContent - SVG markup to embed
962
- */
963
- function writeHtmlExport(filePath, svgContent) {
964
- const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
965
- fs.writeFileSync(filePath, html);
966
- }
967
-
968
- /**
969
- * Writes a command result to stdout/stderr in the appropriate format.
970
- * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
971
- * based on the combination of flags.
972
- * @param {*} payload - Command result payload
973
- * @param {{json: boolean, command: string, view: string|null}} options
974
- */
975
- function emit(payload, { json, command, view }) {
976
- if (json) {
977
- process.stdout.write(`${stableStringify(payload)}\n`);
978
- return;
979
- }
980
-
981
- if (command === 'info') {
982
- if (view) {
983
- process.stdout.write(renderInfoView(payload));
984
- } else {
985
- process.stdout.write(renderInfo(payload));
986
- }
987
- return;
988
- }
989
-
990
- if (command === 'query') {
991
- if (view && typeof view === 'string' && view.startsWith('svg:')) {
992
- const svgPath = view.slice(4);
993
- if (!payload._renderedSvg) {
994
- process.stderr.write('No graph data — skipping SVG export.\n');
995
- } else {
996
- fs.writeFileSync(svgPath, payload._renderedSvg);
997
- process.stderr.write(`SVG written to ${svgPath}\n`);
998
- }
999
- } else if (view && typeof view === 'string' && view.startsWith('html:')) {
1000
- const htmlPath = view.slice(5);
1001
- if (!payload._renderedSvg) {
1002
- process.stderr.write('No graph data — skipping HTML export.\n');
1003
- } else {
1004
- writeHtmlExport(htmlPath, payload._renderedSvg);
1005
- process.stderr.write(`HTML written to ${htmlPath}\n`);
1006
- }
1007
- } else if (view) {
1008
- process.stdout.write(`${payload._renderedAscii}\n`);
1009
- } else {
1010
- process.stdout.write(renderQuery(payload));
1011
- }
1012
- return;
1013
- }
1014
-
1015
- if (command === 'path') {
1016
- if (view && typeof view === 'string' && view.startsWith('svg:')) {
1017
- const svgPath = view.slice(4);
1018
- if (!payload._renderedSvg) {
1019
- process.stderr.write('No path found — skipping SVG export.\n');
1020
- } else {
1021
- fs.writeFileSync(svgPath, payload._renderedSvg);
1022
- process.stderr.write(`SVG written to ${svgPath}\n`);
1023
- }
1024
- } else if (view && typeof view === 'string' && view.startsWith('html:')) {
1025
- const htmlPath = view.slice(5);
1026
- if (!payload._renderedSvg) {
1027
- process.stderr.write('No path found — skipping HTML export.\n');
1028
- } else {
1029
- writeHtmlExport(htmlPath, payload._renderedSvg);
1030
- process.stderr.write(`HTML written to ${htmlPath}\n`);
1031
- }
1032
- } else if (view) {
1033
- process.stdout.write(renderPathView(payload));
1034
- } else {
1035
- process.stdout.write(renderPath(payload));
1036
- }
1037
- return;
1038
- }
1039
-
1040
- if (command === 'check') {
1041
- if (view) {
1042
- process.stdout.write(renderCheckView(payload));
1043
- } else {
1044
- process.stdout.write(renderCheck(payload));
1045
- }
1046
- return;
1047
- }
1048
-
1049
- if (command === 'history') {
1050
- if (view) {
1051
- process.stdout.write(renderHistoryView(payload));
1052
- } else {
1053
- process.stdout.write(renderHistory(payload));
1054
- }
1055
- return;
1056
- }
1057
-
1058
- if (command === 'materialize') {
1059
- if (view) {
1060
- process.stdout.write(renderMaterializeView(payload));
1061
- } else {
1062
- process.stdout.write(renderMaterialize(payload));
1063
- }
1064
- return;
1065
- }
1066
-
1067
- if (command === 'seek') {
1068
- if (view) {
1069
- process.stdout.write(renderSeekView(payload));
1070
- } else {
1071
- process.stdout.write(renderSeek(payload));
1072
- }
1073
- return;
1074
- }
1075
-
1076
- if (command === 'install-hooks') {
1077
- process.stdout.write(renderInstallHooks(payload));
1078
- return;
1079
- }
1080
-
1081
- if (payload?.error) {
1082
- process.stderr.write(renderError(payload));
1083
- return;
1084
- }
1085
-
1086
- process.stdout.write(`${stableStringify(payload)}\n`);
1087
- }
1088
-
1089
- /**
1090
- * Handles the `info` command: summarizes graphs in the repository.
1091
- * @param {{options: CliOptions}} params
1092
- * @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>} Info payload
1093
- * @throws {CliError} If the specified graph is not found
1094
- */
1095
- async function handleInfo({ options }) {
1096
- const { persistence } = await createPersistence(options.repo);
1097
- const graphNames = await listGraphNames(persistence);
1098
-
1099
- if (options.graph && !graphNames.includes(options.graph)) {
1100
- throw notFoundError(`Graph not found: ${options.graph}`);
1101
- }
1102
-
1103
- const detailGraphs = new Set();
1104
- if (options.graph) {
1105
- detailGraphs.add(options.graph);
1106
- } else if (graphNames.length === 1) {
1107
- detailGraphs.add(graphNames[0]);
1108
- }
1109
-
1110
- // In view mode, include extra data for visualization
1111
- const isViewMode = Boolean(options.view);
1112
-
1113
- const graphs = [];
1114
- for (const name of graphNames) {
1115
- const includeDetails = detailGraphs.has(name);
1116
- const info = await getGraphInfo(persistence, name, {
1117
- includeWriterIds: includeDetails || isViewMode,
1118
- includeRefs: includeDetails || isViewMode,
1119
- includeWriterPatches: isViewMode,
1120
- includeCheckpointDate: isViewMode,
1121
- });
1122
- const activeCursor = await readActiveCursor(persistence, name);
1123
- if (activeCursor) {
1124
- info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
1125
- } else {
1126
- info.cursor = { active: false };
1127
- }
1128
- graphs.push(info);
1129
- }
1130
-
1131
- return {
1132
- repo: options.repo,
1133
- graphs,
1134
- };
1135
- }
1136
-
1137
- /**
1138
- * Handles the `query` command: runs a logical graph query.
1139
- * @param {{options: CliOptions, args: string[]}} params
1140
- * @returns {Promise<{payload: *, exitCode: number}>} Query result payload
1141
- * @throws {CliError} On invalid query options or query execution errors
1142
- */
1143
- async function handleQuery({ options, args }) {
1144
- const querySpec = parseQueryArgs(args);
1145
- const { graph, graphName, persistence } = await openGraph(options);
1146
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1147
- emitCursorWarning(cursorInfo, null);
1148
- let builder = graph.query();
1149
-
1150
- if (querySpec.match !== null) {
1151
- builder = builder.match(querySpec.match);
1152
- }
1153
-
1154
- builder = applyQuerySteps(builder, querySpec.steps);
1155
-
1156
- if (querySpec.select !== null) {
1157
- builder = builder.select(querySpec.select);
1158
- }
1159
-
1160
- try {
1161
- const result = await builder.run();
1162
- const payload = buildQueryPayload(graphName, result);
1163
-
1164
- if (options.view) {
1165
- const edges = await graph.getEdges();
1166
- const graphData = queryResultToGraphData(payload, edges);
1167
- const positioned = await layoutGraph(graphData, { type: 'query' });
1168
- if (typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
1169
- payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
1170
- } else {
1171
- payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
1172
- }
1173
- }
1174
-
1175
- return {
1176
- payload,
1177
- exitCode: EXIT_CODES.OK,
1178
- };
1179
- } catch (error) {
1180
- throw mapQueryError(error);
1181
- }
1182
- }
1183
-
1184
- /**
1185
- * @param {*} builder
1186
- * @param {Array<{type: string, label?: string, key?: string, value?: string}>} steps
1187
- */
1188
- function applyQuerySteps(builder, steps) {
1189
- let current = builder;
1190
- for (const step of steps) {
1191
- current = applyQueryStep(current, step);
1192
- }
1193
- return current;
1194
- }
1195
-
1196
- /**
1197
- * @param {*} builder
1198
- * @param {{type: string, label?: string, key?: string, value?: string}} step
1199
- */
1200
- function applyQueryStep(builder, step) {
1201
- if (step.type === 'outgoing') {
1202
- return builder.outgoing(step.label);
1203
- }
1204
- if (step.type === 'incoming') {
1205
- return builder.incoming(step.label);
1206
- }
1207
- if (step.type === 'where-prop') {
1208
- return builder.where((/** @type {*} */ node) => matchesPropFilter(node, /** @type {string} */ (step.key), /** @type {string} */ (step.value))); // TODO(ts-cleanup): type CLI payload
1209
- }
1210
- return builder;
1211
- }
1212
-
1213
- /**
1214
- * @param {*} node
1215
- * @param {string} key
1216
- * @param {string} value
1217
- */
1218
- function matchesPropFilter(node, key, value) {
1219
- const props = node.props || {};
1220
- if (!Object.prototype.hasOwnProperty.call(props, key)) {
1221
- return false;
1222
- }
1223
- return String(props[key]) === value;
1224
- }
1225
-
1226
- /**
1227
- * @param {string} graphName
1228
- * @param {*} result
1229
- * @returns {{graph: string, stateHash: *, nodes: *, _renderedSvg?: string, _renderedAscii?: string}}
1230
- */
1231
- function buildQueryPayload(graphName, result) {
1232
- return {
1233
- graph: graphName,
1234
- stateHash: result.stateHash,
1235
- nodes: result.nodes,
1236
- };
1237
- }
1238
-
1239
- /** @param {*} error */
1240
- function mapQueryError(error) {
1241
- if (error && error.code && String(error.code).startsWith('E_QUERY')) {
1242
- throw usageError(error.message);
1243
- }
1244
- throw error;
1245
- }
1246
-
1247
- /**
1248
- * Handles the `path` command: finds a shortest path between two nodes.
1249
- * @param {{options: CliOptions, args: string[]}} params
1250
- * @returns {Promise<{payload: *, exitCode: number}>} Path result payload
1251
- * @throws {CliError} If --from/--to are missing or a node is not found
1252
- */
1253
- async function handlePath({ options, args }) {
1254
- const pathOptions = parsePathArgs(args);
1255
- const { graph, graphName, persistence } = await openGraph(options);
1256
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1257
- emitCursorWarning(cursorInfo, null);
1258
-
1259
- try {
1260
- const result = await graph.traverse.shortestPath(
1261
- pathOptions.from,
1262
- pathOptions.to,
1263
- {
1264
- dir: pathOptions.dir,
1265
- labelFilter: pathOptions.labelFilter,
1266
- maxDepth: pathOptions.maxDepth,
1267
- }
1268
- );
1269
-
1270
- const payload = {
1271
- graph: graphName,
1272
- from: pathOptions.from,
1273
- to: pathOptions.to,
1274
- ...result,
1275
- };
1276
-
1277
- if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
1278
- const graphData = pathResultToGraphData(payload);
1279
- const positioned = await layoutGraph(graphData, { type: 'path' });
1280
- payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
1281
- }
1282
-
1283
- return {
1284
- payload,
1285
- exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
1286
- };
1287
- } catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error
1288
- if (error && error.code === 'NODE_NOT_FOUND') {
1289
- throw notFoundError(error.message);
1290
- }
1291
- throw error;
1292
- }
1293
- }
1294
-
1295
- /**
1296
- * Handles the `check` command: reports graph health, GC, and hook status.
1297
- * @param {{options: CliOptions}} params
1298
- * @returns {Promise<{payload: *, exitCode: number}>} Health check payload
1299
- */
1300
- async function handleCheck({ options }) {
1301
- const { graph, graphName, persistence } = await openGraph(options);
1302
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1303
- emitCursorWarning(cursorInfo, null);
1304
- const health = await getHealth(persistence);
1305
- const gcMetrics = await getGcMetrics(graph);
1306
- const status = await graph.status();
1307
- const writerHeads = await collectWriterHeads(graph);
1308
- const checkpoint = await loadCheckpointInfo(persistence, graphName);
1309
- const coverage = await loadCoverageInfo(persistence, graphName, writerHeads);
1310
- const hook = getHookStatusForCheck(options.repo);
1311
-
1312
- return {
1313
- payload: buildCheckPayload({
1314
- repo: options.repo,
1315
- graphName,
1316
- health,
1317
- checkpoint,
1318
- writerHeads,
1319
- coverage,
1320
- gcMetrics,
1321
- hook,
1322
- status,
1323
- }),
1324
- exitCode: EXIT_CODES.OK,
1325
- };
1326
- }
1327
-
1328
- /** @param {Persistence} persistence */
1329
- async function getHealth(persistence) {
1330
- const clock = ClockAdapter.node();
1331
- const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type
1332
- return await healthService.getHealth();
1333
- }
1334
-
1335
- /** @param {WarpGraphInstance} graph */
1336
- async function getGcMetrics(graph) {
1337
- await graph.materialize();
1338
- return graph.getGCMetrics();
1339
- }
1340
-
1341
- /** @param {WarpGraphInstance} graph */
1342
- async function collectWriterHeads(graph) {
1343
- const frontier = await graph.getFrontier();
1344
- return [...frontier.entries()]
1345
- .sort(([a], [b]) => a.localeCompare(b))
1346
- .map(([writerId, sha]) => ({ writerId, sha }));
1347
- }
1348
-
1349
- /**
1350
- * @param {Persistence} persistence
1351
- * @param {string} graphName
1352
- */
1353
- async function loadCheckpointInfo(persistence, graphName) {
1354
- const checkpointRef = buildCheckpointRef(graphName);
1355
- const checkpointSha = await persistence.readRef(checkpointRef);
1356
- const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
1357
- const checkpointAgeSeconds = computeAgeSeconds(checkpointDate);
1358
-
1359
- return {
1360
- ref: checkpointRef,
1361
- sha: checkpointSha || null,
1362
- date: checkpointDate,
1363
- ageSeconds: checkpointAgeSeconds,
1364
- };
1365
- }
1366
-
1367
- /**
1368
- * @param {Persistence} persistence
1369
- * @param {string|null} checkpointSha
1370
- */
1371
- async function readCheckpointDate(persistence, checkpointSha) {
1372
- if (!checkpointSha) {
1373
- return null;
1374
- }
1375
- const info = await persistence.getNodeInfo(checkpointSha);
1376
- return info.date || null;
1377
- }
1378
-
1379
- /** @param {string|null} checkpointDate */
1380
- function computeAgeSeconds(checkpointDate) {
1381
- if (!checkpointDate) {
1382
- return null;
1383
- }
1384
- const parsed = Date.parse(checkpointDate);
1385
- if (Number.isNaN(parsed)) {
1386
- return null;
1387
- }
1388
- return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
1389
- }
1390
-
1391
- /**
1392
- * @param {Persistence} persistence
1393
- * @param {string} graphName
1394
- * @param {Array<{writerId: string, sha: string}>} writerHeads
1395
- */
1396
- async function loadCoverageInfo(persistence, graphName, writerHeads) {
1397
- const coverageRef = buildCoverageRef(graphName);
1398
- const coverageSha = await persistence.readRef(coverageRef);
1399
- const missingWriters = coverageSha
1400
- ? await findMissingWriters(persistence, writerHeads, coverageSha)
1401
- : [];
1402
-
1403
- return {
1404
- ref: coverageRef,
1405
- sha: coverageSha || null,
1406
- missingWriters: missingWriters.sort(),
1407
- };
1408
- }
1409
-
1410
- /**
1411
- * @param {Persistence} persistence
1412
- * @param {Array<{writerId: string, sha: string}>} writerHeads
1413
- * @param {string} coverageSha
1414
- */
1415
- async function findMissingWriters(persistence, writerHeads, coverageSha) {
1416
- const missing = [];
1417
- for (const head of writerHeads) {
1418
- const reachable = await persistence.isAncestor(head.sha, coverageSha);
1419
- if (!reachable) {
1420
- missing.push(head.writerId);
1421
- }
1422
- }
1423
- return missing;
1424
- }
1425
-
1426
- /**
1427
- * @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params
1428
- */
1429
- function buildCheckPayload({
1430
- repo,
1431
- graphName,
1432
- health,
1433
- checkpoint,
1434
- writerHeads,
1435
- coverage,
1436
- gcMetrics,
1437
- hook,
1438
- status,
1439
- }) {
1440
- return {
1441
- repo,
1442
- graph: graphName,
1443
- health,
1444
- checkpoint,
1445
- writers: {
1446
- count: writerHeads.length,
1447
- heads: writerHeads,
1448
- },
1449
- coverage,
1450
- gc: gcMetrics,
1451
- hook: hook || null,
1452
- status: status || null,
1453
- };
1454
- }
1455
-
1456
- /**
1457
- * Handles the `history` command: shows patch history for a writer.
1458
- * @param {{options: CliOptions, args: string[]}} params
1459
- * @returns {Promise<{payload: *, exitCode: number}>} History payload
1460
- * @throws {CliError} If no patches are found for the writer
1461
- */
1462
- async function handleHistory({ options, args }) {
1463
- const historyOptions = parseHistoryArgs(args);
1464
- const { graph, graphName, persistence } = await openGraph(options);
1465
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1466
- emitCursorWarning(cursorInfo, null);
1467
-
1468
- const writerId = options.writer;
1469
- let patches = await graph.getWriterPatches(writerId);
1470
- if (cursorInfo.active) {
1471
- patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
1472
- }
1473
- if (patches.length === 0) {
1474
- throw notFoundError(`No patches found for writer: ${writerId}`);
1475
- }
1476
-
1477
- const entries = patches
1478
- .filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
1479
- .map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
1480
- sha,
1481
- schema: patch.schema,
1482
- lamport: patch.lamport,
1483
- opCount: Array.isArray(patch.ops) ? patch.ops.length : 0,
1484
- opSummary: Array.isArray(patch.ops) ? summarizeOps(patch.ops) : undefined,
1485
- }));
1486
-
1487
- const payload = {
1488
- graph: graphName,
1489
- writer: writerId,
1490
- nodeFilter: historyOptions.node,
1491
- entries,
1492
- };
1493
-
1494
- return { payload, exitCode: EXIT_CODES.OK };
1495
- }
1496
-
1497
- /**
1498
- * Materializes a single graph, creates a checkpoint, and returns summary stats.
1499
- * When a ceiling tick is provided (seek cursor active), the checkpoint step is
1500
- * skipped because the user is exploring historical state, not persisting it.
1501
- * @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params
1502
- * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record<string, number>, patchCount: number}>}
1503
- */
1504
- async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
1505
- const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
1506
- await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
1507
- const nodes = await graph.getNodes();
1508
- const edges = await graph.getEdges();
1509
- const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
1510
- const status = await graph.status();
1511
-
1512
- // Build per-writer patch counts for the view renderer
1513
- /** @type {Record<string, number>} */
1514
- const writers = {};
1515
- let totalPatchCount = 0;
1516
- for (const wId of Object.keys(status.frontier)) {
1517
- const patches = await graph.getWriterPatches(wId);
1518
- writers[wId] = patches.length;
1519
- totalPatchCount += patches.length;
1520
- }
1521
-
1522
- const properties = await graph.getPropertyCount();
1523
-
1524
- return {
1525
- graph: graphName,
1526
- nodes: nodes.length,
1527
- edges: edges.length,
1528
- properties,
1529
- checkpoint,
1530
- writers,
1531
- patchCount: totalPatchCount,
1532
- };
1533
- }
1534
-
1535
- /**
1536
- * Handles the `materialize` command: materializes and checkpoints all graphs.
1537
- * @param {{options: CliOptions}} params
1538
- * @returns {Promise<{payload: *, exitCode: number}>} Materialize result payload
1539
- * @throws {CliError} If the specified graph is not found
1540
- */
1541
- async function handleMaterialize({ options }) {
1542
- const { persistence } = await createPersistence(options.repo);
1543
- const graphNames = await listGraphNames(persistence);
1544
-
1545
- if (graphNames.length === 0) {
1546
- return {
1547
- payload: { graphs: [] },
1548
- exitCode: EXIT_CODES.OK,
1549
- };
1550
- }
1551
-
1552
- const targets = options.graph
1553
- ? [options.graph]
1554
- : graphNames;
1555
-
1556
- if (options.graph && !graphNames.includes(options.graph)) {
1557
- throw notFoundError(`Graph not found: ${options.graph}`);
1558
- }
1559
-
1560
- const results = [];
1561
- let cursorWarningEmitted = false;
1562
- for (const name of targets) {
1563
- try {
1564
- const cursor = await readActiveCursor(persistence, name);
1565
- const ceiling = cursor ? cursor.tick : undefined;
1566
- if (cursor && !cursorWarningEmitted) {
1567
- emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
1568
- cursorWarningEmitted = true;
1569
- }
1570
- const result = await materializeOneGraph({
1571
- persistence,
1572
- graphName: name,
1573
- writerId: options.writer,
1574
- ceiling,
1575
- });
1576
- results.push(result);
1577
- } catch (error) {
1578
- results.push({
1579
- graph: name,
1580
- error: error instanceof Error ? error.message : String(error),
1581
- });
1582
- }
1583
- }
1584
-
1585
- const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload
1586
- return {
1587
- payload: { graphs: results },
1588
- exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
1589
- };
1590
- }
1591
-
1592
- /** @param {*} payload */
1593
- function renderMaterialize(payload) {
1594
- if (payload.graphs.length === 0) {
1595
- return 'No graphs found in repo.\n';
1596
- }
1597
-
1598
- const lines = [];
1599
- for (const entry of payload.graphs) {
1600
- if (entry.error) {
1601
- lines.push(`${entry.graph}: error — ${entry.error}`);
1602
- } else {
1603
- lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`);
1604
- }
1605
- }
1606
- return `${lines.join('\n')}\n`;
1607
- }
1608
-
1609
- /** @param {*} payload */
1610
- function renderInstallHooks(payload) {
1611
- if (payload.action === 'up-to-date') {
1612
- return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
1613
- }
1614
- if (payload.action === 'skipped') {
1615
- return 'Hook: installation skipped\n';
1616
- }
1617
- const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`];
1618
- if (payload.backupPath) {
1619
- lines.push(`Backup: ${payload.backupPath}`);
1620
- }
1621
- return `${lines.join('\n')}\n`;
1622
- }
1623
-
1624
- function createHookInstaller() {
1625
- const __filename = new URL(import.meta.url).pathname;
1626
- const __dirname = path.dirname(__filename);
1627
- const templateDir = path.resolve(__dirname, '..', 'hooks');
1628
- const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
1629
- return new HookInstaller({
1630
- fs: /** @type {*} */ (fs), // TODO(ts-cleanup): narrow port type
1631
- execGitConfig: execGitConfigValue,
1632
- version,
1633
- templateDir,
1634
- path,
1635
- });
1636
- }
1637
-
1638
- /**
1639
- * @param {string} repoPath
1640
- * @param {string} key
1641
- * @returns {string|null}
1642
- */
1643
- function execGitConfigValue(repoPath, key) {
1644
- try {
1645
- if (key === '--git-dir') {
1646
- return execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-dir'], {
1647
- encoding: 'utf8',
1648
- }).trim();
1649
- }
1650
- return execFileSync('git', ['-C', repoPath, 'config', key], {
1651
- encoding: 'utf8',
1652
- }).trim();
1653
- } catch {
1654
- return null;
1655
- }
1656
- }
1657
-
1658
- function isInteractive() {
1659
- return Boolean(process.stderr.isTTY);
1660
- }
1661
-
1662
- /** @param {string} question @returns {Promise<string>} */
1663
- function promptUser(question) {
1664
- const rl = readline.createInterface({
1665
- input: process.stdin,
1666
- output: process.stderr,
1667
- });
1668
- return new Promise((resolve) => {
1669
- rl.question(question, (answer) => {
1670
- rl.close();
1671
- resolve(answer.trim());
1672
- });
1673
- });
1674
- }
1675
-
1676
- /** @param {string[]} args */
1677
- function parseInstallHooksArgs(args) {
1678
- const options = { force: false };
1679
- for (const arg of args) {
1680
- if (arg === '--force') {
1681
- options.force = true;
1682
- } else if (arg.startsWith('-')) {
1683
- throw usageError(`Unknown install-hooks option: ${arg}`);
1684
- }
1685
- }
1686
- return options;
1687
- }
1688
-
1689
- /**
1690
- * @param {*} classification
1691
- * @param {{force: boolean}} hookOptions
1692
- */
1693
- async function resolveStrategy(classification, hookOptions) {
1694
- if (hookOptions.force) {
1695
- return 'replace';
1696
- }
1697
-
1698
- if (classification.kind === 'none') {
1699
- return 'install';
1700
- }
1701
-
1702
- if (classification.kind === 'ours') {
1703
- return await promptForOursStrategy(classification);
1704
- }
1705
-
1706
- return await promptForForeignStrategy();
1707
- }
1708
-
1709
- /** @param {*} classification */
1710
- async function promptForOursStrategy(classification) {
1711
- const installer = createHookInstaller();
1712
- if (classification.version === installer._version) {
1713
- return 'up-to-date';
1714
- }
1715
-
1716
- if (!isInteractive()) {
1717
- throw usageError('Existing hook found. Use --force or run interactively.');
1718
- }
1719
-
1720
- const answer = await promptUser(
1721
- `Upgrade hook from v${classification.version} to v${installer._version}? [Y/n] `,
1722
- );
1723
- if (answer === '' || answer.toLowerCase() === 'y') {
1724
- return 'upgrade';
1725
- }
1726
- return 'skip';
1727
- }
1728
-
1729
- async function promptForForeignStrategy() {
1730
- if (!isInteractive()) {
1731
- throw usageError('Existing hook found. Use --force or run interactively.');
1732
- }
1733
-
1734
- process.stderr.write('Existing post-merge hook found.\n');
1735
- process.stderr.write(' 1) Append (keep existing hook, add warp section)\n');
1736
- process.stderr.write(' 2) Replace (back up existing, install fresh)\n');
1737
- process.stderr.write(' 3) Skip\n');
1738
- const answer = await promptUser('Choose [1-3]: ');
1739
-
1740
- if (answer === '1') {
1741
- return 'append';
1742
- }
1743
- if (answer === '2') {
1744
- return 'replace';
1745
- }
1746
- return 'skip';
1747
- }
1748
-
1749
- /**
1750
- * Handles the `install-hooks` command: installs or upgrades the post-merge git hook.
1751
- * @param {{options: CliOptions, args: string[]}} params
1752
- * @returns {Promise<{payload: *, exitCode: number}>} Install result payload
1753
- * @throws {CliError} If an existing hook is found and the session is not interactive
1754
- */
1755
- async function handleInstallHooks({ options, args }) {
1756
- const hookOptions = parseInstallHooksArgs(args);
1757
- const installer = createHookInstaller();
1758
- const status = installer.getHookStatus(options.repo);
1759
- const content = readHookContent(status.hookPath);
1760
- const classification = classifyExistingHook(content);
1761
- const strategy = await resolveStrategy(classification, hookOptions);
1762
-
1763
- if (strategy === 'up-to-date') {
1764
- return {
1765
- payload: {
1766
- action: 'up-to-date',
1767
- hookPath: status.hookPath,
1768
- version: installer._version,
1769
- },
1770
- exitCode: EXIT_CODES.OK,
1771
- };
1772
- }
1773
-
1774
- if (strategy === 'skip') {
1775
- return {
1776
- payload: { action: 'skipped' },
1777
- exitCode: EXIT_CODES.OK,
1778
- };
1779
- }
1780
-
1781
- const result = installer.install(options.repo, { strategy });
1782
- return {
1783
- payload: result,
1784
- exitCode: EXIT_CODES.OK,
1785
- };
1786
- }
1787
-
1788
- /** @param {string} hookPath */
1789
- function readHookContent(hookPath) {
1790
- try {
1791
- return fs.readFileSync(hookPath, 'utf8');
1792
- } catch {
1793
- return null;
1794
- }
1795
- }
1796
-
1797
- /** @param {string} repoPath */
1798
- function getHookStatusForCheck(repoPath) {
1799
- try {
1800
- const installer = createHookInstaller();
1801
- return installer.getHookStatus(repoPath);
1802
- } catch {
1803
- return null;
1804
- }
1805
- }
1806
-
1807
- // ============================================================================
1808
- // Cursor I/O Helpers
1809
- // ============================================================================
1810
-
1811
- /**
1812
- * Reads the active seek cursor for a graph from Git ref storage.
1813
- *
1814
- * @param {Persistence} persistence - GraphPersistencePort adapter
1815
- * @param {string} graphName - Name of the WARP graph
1816
- * @returns {Promise<CursorBlob|null>} Cursor object, or null if no active cursor
1817
- * @throws {Error} If the stored blob is corrupted or not valid JSON
1818
- */
1819
- async function readActiveCursor(persistence, graphName) {
1820
- const ref = buildCursorActiveRef(graphName);
1821
- const oid = await persistence.readRef(ref);
1822
- if (!oid) {
1823
- return null;
1824
- }
1825
- const buf = await persistence.readBlob(oid);
1826
- return parseCursorBlob(buf, 'active cursor');
1827
- }
1828
-
1829
- /**
1830
- * Writes (creates or overwrites) the active seek cursor for a graph.
1831
- *
1832
- * Serializes the cursor as JSON, stores it as a Git blob, and points
1833
- * the active cursor ref at that blob.
1834
- *
1835
- * @param {Persistence} persistence - GraphPersistencePort adapter
1836
- * @param {string} graphName - Name of the WARP graph
1837
- * @param {CursorBlob} cursor - Cursor state to persist
1838
- * @returns {Promise<void>}
1839
- */
1840
- async function writeActiveCursor(persistence, graphName, cursor) {
1841
- const ref = buildCursorActiveRef(graphName);
1842
- const json = JSON.stringify(cursor);
1843
- const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1844
- await persistence.updateRef(ref, oid);
1845
- }
1846
-
1847
- /**
1848
- * Removes the active seek cursor for a graph, returning to present state.
1849
- *
1850
- * No-op if no active cursor exists.
1851
- *
1852
- * @param {Persistence} persistence - GraphPersistencePort adapter
1853
- * @param {string} graphName - Name of the WARP graph
1854
- * @returns {Promise<void>}
1855
- */
1856
- async function clearActiveCursor(persistence, graphName) {
1857
- const ref = buildCursorActiveRef(graphName);
1858
- const exists = await persistence.readRef(ref);
1859
- if (exists) {
1860
- await persistence.deleteRef(ref);
1861
- }
1862
- }
1863
-
1864
- /**
1865
- * Reads a named saved cursor from Git ref storage.
1866
- *
1867
- * @param {Persistence} persistence - GraphPersistencePort adapter
1868
- * @param {string} graphName - Name of the WARP graph
1869
- * @param {string} name - Saved cursor name
1870
- * @returns {Promise<CursorBlob|null>} Cursor object, or null if not found
1871
- * @throws {Error} If the stored blob is corrupted or not valid JSON
1872
- */
1873
- async function readSavedCursor(persistence, graphName, name) {
1874
- const ref = buildCursorSavedRef(graphName, name);
1875
- const oid = await persistence.readRef(ref);
1876
- if (!oid) {
1877
- return null;
1878
- }
1879
- const buf = await persistence.readBlob(oid);
1880
- return parseCursorBlob(buf, `saved cursor '${name}'`);
1881
- }
1882
-
1883
- /**
1884
- * Persists a cursor under a named saved-cursor ref.
1885
- *
1886
- * Serializes the cursor as JSON, stores it as a Git blob, and points
1887
- * the named saved-cursor ref at that blob.
1888
- *
1889
- * @param {Persistence} persistence - GraphPersistencePort adapter
1890
- * @param {string} graphName - Name of the WARP graph
1891
- * @param {string} name - Saved cursor name
1892
- * @param {CursorBlob} cursor - Cursor state to persist
1893
- * @returns {Promise<void>}
1894
- */
1895
- async function writeSavedCursor(persistence, graphName, name, cursor) {
1896
- const ref = buildCursorSavedRef(graphName, name);
1897
- const json = JSON.stringify(cursor);
1898
- const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1899
- await persistence.updateRef(ref, oid);
1900
- }
1901
-
1902
- /**
1903
- * Deletes a named saved cursor from Git ref storage.
1904
- *
1905
- * No-op if the named cursor does not exist.
1906
- *
1907
- * @param {Persistence} persistence - GraphPersistencePort adapter
1908
- * @param {string} graphName - Name of the WARP graph
1909
- * @param {string} name - Saved cursor name to delete
1910
- * @returns {Promise<void>}
1911
- */
1912
- async function deleteSavedCursor(persistence, graphName, name) {
1913
- const ref = buildCursorSavedRef(graphName, name);
1914
- const exists = await persistence.readRef(ref);
1915
- if (exists) {
1916
- await persistence.deleteRef(ref);
1917
- }
1918
- }
1919
-
1920
- /**
1921
- * Lists all saved cursors for a graph, reading each blob to include full cursor state.
1922
- *
1923
- * @param {Persistence} persistence - GraphPersistencePort adapter
1924
- * @param {string} graphName - Name of the WARP graph
1925
- * @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
1926
- * @throws {Error} If any stored blob is corrupted or not valid JSON
1927
- */
1928
- async function listSavedCursors(persistence, graphName) {
1929
- const prefix = buildCursorSavedPrefix(graphName);
1930
- const refs = await persistence.listRefs(prefix);
1931
- const cursors = [];
1932
- for (const ref of refs) {
1933
- const name = ref.slice(prefix.length);
1934
- if (name) {
1935
- const oid = await persistence.readRef(ref);
1936
- if (oid) {
1937
- const buf = await persistence.readBlob(oid);
1938
- const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
1939
- cursors.push({ name, ...cursor });
1940
- }
1941
- }
1942
- }
1943
- return cursors;
1944
- }
1945
-
1946
- // ============================================================================
1947
- // Seek Arg Parser
1948
- // ============================================================================
1949
-
1950
- /**
1951
- * @param {string} arg
1952
- * @param {SeekSpec} spec
1953
- */
1954
- function handleSeekBooleanFlag(arg, spec) {
1955
- if (arg === '--clear-cache') {
1956
- if (spec.action !== 'status') {
1957
- throw usageError('--clear-cache cannot be combined with other seek flags');
1958
- }
1959
- spec.action = 'clear-cache';
1960
- } else if (arg === '--no-persistent-cache') {
1961
- spec.noPersistentCache = true;
1962
- } else if (arg === '--diff') {
1963
- spec.diff = true;
1964
- }
1965
- }
1966
-
1967
- /**
1968
- * Parses --diff-limit / --diff-limit=N into the seek spec.
1969
- * @param {string} arg
1970
- * @param {string[]} args
1971
- * @param {number} i
1972
- * @param {SeekSpec} spec
1973
- */
1974
- function handleDiffLimitFlag(arg, args, i, spec) {
1975
- let raw;
1976
- if (arg.startsWith('--diff-limit=')) {
1977
- raw = arg.slice('--diff-limit='.length);
1978
- } else {
1979
- raw = args[i + 1];
1980
- if (raw === undefined) {
1981
- throw usageError('Missing value for --diff-limit');
1982
- }
1983
- }
1984
- const n = Number(raw);
1985
- if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
1986
- throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
1987
- }
1988
- spec.diffLimit = n;
1989
- }
1990
-
1991
- /**
1992
- * Parses a named action flag (--save, --load, --drop) with its value.
1993
- * @param {string} flagName - e.g. 'save'
1994
- * @param {string} arg - Current arg token
1995
- * @param {string[]} args - All args
1996
- * @param {number} i - Current index
1997
- * @param {SeekSpec} spec
1998
- * @returns {number} Number of extra args consumed (0 or 1)
1999
- */
2000
- function parseSeekNamedAction(flagName, arg, args, i, spec) {
2001
- if (spec.action !== 'status') {
2002
- throw usageError(`--${flagName} cannot be combined with other seek flags`);
2003
- }
2004
- spec.action = flagName;
2005
- if (arg === `--${flagName}`) {
2006
- const val = args[i + 1];
2007
- if (val === undefined || val.startsWith('-')) {
2008
- throw usageError(`Missing name for --${flagName}`);
2009
- }
2010
- spec.name = val;
2011
- return 1;
2012
- }
2013
- spec.name = arg.slice(`--${flagName}=`.length);
2014
- if (!spec.name) {
2015
- throw usageError(`Missing name for --${flagName}`);
2016
- }
2017
- return 0;
2018
- }
2019
-
2020
- /**
2021
- * Parses CLI arguments for the `seek` command into a structured spec.
2022
- * @param {string[]} args - Raw CLI arguments following the `seek` subcommand
2023
- * @returns {SeekSpec} Parsed spec
2024
- */
2025
- function parseSeekArgs(args) {
2026
- /** @type {SeekSpec} */
2027
- const spec = {
2028
- action: 'status', // status, tick, latest, save, load, list, drop, clear-cache
2029
- tickValue: null,
2030
- name: null,
2031
- noPersistentCache: false,
2032
- diff: false,
2033
- diffLimit: 2000,
2034
- };
2035
- let diffLimitProvided = false;
2036
-
2037
- for (let i = 0; i < args.length; i++) {
2038
- const arg = args[i];
2039
-
2040
- if (arg === '--tick') {
2041
- if (spec.action !== 'status') {
2042
- throw usageError('--tick cannot be combined with other seek flags');
2043
- }
2044
- spec.action = 'tick';
2045
- const val = args[i + 1];
2046
- if (val === undefined) {
2047
- throw usageError('Missing value for --tick');
2048
- }
2049
- spec.tickValue = val;
2050
- i += 1;
2051
- } else if (arg.startsWith('--tick=')) {
2052
- if (spec.action !== 'status') {
2053
- throw usageError('--tick cannot be combined with other seek flags');
2054
- }
2055
- spec.action = 'tick';
2056
- spec.tickValue = arg.slice('--tick='.length);
2057
- } else if (arg === '--latest') {
2058
- if (spec.action !== 'status') {
2059
- throw usageError('--latest cannot be combined with other seek flags');
2060
- }
2061
- spec.action = 'latest';
2062
- } else if (arg === '--save' || arg.startsWith('--save=')) {
2063
- i += parseSeekNamedAction('save', arg, args, i, spec);
2064
- } else if (arg === '--load' || arg.startsWith('--load=')) {
2065
- i += parseSeekNamedAction('load', arg, args, i, spec);
2066
- } else if (arg === '--list') {
2067
- if (spec.action !== 'status') {
2068
- throw usageError('--list cannot be combined with other seek flags');
2069
- }
2070
- spec.action = 'list';
2071
- } else if (arg === '--drop' || arg.startsWith('--drop=')) {
2072
- i += parseSeekNamedAction('drop', arg, args, i, spec);
2073
- } else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
2074
- handleSeekBooleanFlag(arg, spec);
2075
- } else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
2076
- handleDiffLimitFlag(arg, args, i, spec);
2077
- diffLimitProvided = true;
2078
- if (arg === '--diff-limit') {
2079
- i += 1;
2080
- }
2081
- } else if (arg.startsWith('-')) {
2082
- throw usageError(`Unknown seek option: ${arg}`);
2083
- }
2084
- }
2085
-
2086
- // --diff is only meaningful for actions that navigate to a tick
2087
- const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
2088
- if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
2089
- throw usageError(`--diff cannot be used with --${spec.action}`);
2090
- }
2091
- if (diffLimitProvided && !spec.diff) {
2092
- throw usageError('--diff-limit requires --diff');
2093
- }
2094
-
2095
- return spec;
2096
- }
2097
-
2098
- /**
2099
- * Resolves a tick value (absolute or relative +N/-N) against available ticks.
2100
- *
2101
- * For relative values, steps through the sorted tick array (with 0 prepended
2102
- * as a virtual "empty state" position) by the given delta from the current
2103
- * position. For absolute values, clamps to maxTick.
2104
- *
2105
- * @private
2106
- * @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2")
2107
- * @param {number|null} currentTick - Current cursor tick, or null if no active cursor
2108
- * @param {number[]} ticks - Sorted ascending array of available Lamport ticks
2109
- * @param {number} maxTick - Maximum tick across all writers
2110
- * @returns {number} Resolved tick value (clamped to valid range)
2111
- * @throws {CliError} If tickValue is not a valid integer or relative delta
2112
- */
2113
- function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
2114
- // Relative: +N or -N
2115
- if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
2116
- const delta = parseInt(tickValue, 10);
2117
- if (!Number.isInteger(delta)) {
2118
- throw usageError(`Invalid tick delta: ${tickValue}`);
2119
- }
2120
- const base = currentTick ?? 0;
2121
-
2122
- // Find the current position in sorted ticks, then step by delta
2123
- // Include tick 0 as a virtual "empty state" position (avoid duplicating if already present)
2124
- const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
2125
- const currentIdx = allPoints.indexOf(base);
2126
- const startIdx = currentIdx === -1 ? 0 : currentIdx;
2127
- const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
2128
- return allPoints[targetIdx];
2129
- }
2130
-
2131
- // Absolute
2132
- const n = parseInt(tickValue, 10);
2133
- if (!Number.isInteger(n) || n < 0) {
2134
- throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
2135
- }
2136
-
2137
- // Clamp to maxTick
2138
- return Math.min(n, maxTick);
2139
- }
2140
-
2141
- // ============================================================================
2142
- // Seek Handler
2143
- // ============================================================================
2144
-
2145
- /**
2146
- * @param {WarpGraphInstance} graph
2147
- * @param {Persistence} persistence
2148
- * @param {string} graphName
2149
- * @param {SeekSpec} seekSpec
2150
- */
2151
- function wireSeekCache(graph, persistence, graphName, seekSpec) {
2152
- if (seekSpec.noPersistentCache) {
2153
- return;
2154
- }
2155
- graph.setSeekCache(new CasSeekCacheAdapter({
2156
- persistence,
2157
- plumbing: persistence.plumbing,
2158
- graphName,
2159
- }));
2160
- }
2161
-
2162
- /**
2163
- * Handles the `git warp seek` command across all sub-actions.
2164
- * @param {{options: CliOptions, args: string[]}} params
2165
- * @returns {Promise<{payload: *, exitCode: number}>}
2166
- */
2167
- async function handleSeek({ options, args }) {
2168
- const seekSpec = parseSeekArgs(args);
2169
- const { graph, graphName, persistence } = await openGraph(options);
2170
- void wireSeekCache(graph, persistence, graphName, seekSpec);
2171
-
2172
- // Handle --clear-cache before discovering ticks (no materialization needed)
2173
- if (seekSpec.action === 'clear-cache') {
2174
- if (graph.seekCache) {
2175
- await graph.seekCache.clear();
2176
- }
2177
- return {
2178
- payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' },
2179
- exitCode: EXIT_CODES.OK,
2180
- };
2181
- }
2182
-
2183
- const activeCursor = await readActiveCursor(persistence, graphName);
2184
- const { ticks, maxTick, perWriter } = await graph.discoverTicks();
2185
- const frontierHash = computeFrontierHash(perWriter);
2186
- if (seekSpec.action === 'list') {
2187
- const saved = await listSavedCursors(persistence, graphName);
2188
- return {
2189
- payload: {
2190
- graph: graphName,
2191
- action: 'list',
2192
- cursors: saved,
2193
- activeTick: activeCursor ? activeCursor.tick : null,
2194
- maxTick,
2195
- },
2196
- exitCode: EXIT_CODES.OK,
2197
- };
2198
- }
2199
- if (seekSpec.action === 'drop') {
2200
- const dropName = /** @type {string} */ (seekSpec.name);
2201
- const existing = await readSavedCursor(persistence, graphName, dropName);
2202
- if (!existing) {
2203
- throw notFoundError(`Saved cursor not found: ${dropName}`);
2204
- }
2205
- await deleteSavedCursor(persistence, graphName, dropName);
2206
- return {
2207
- payload: {
2208
- graph: graphName,
2209
- action: 'drop',
2210
- name: seekSpec.name,
2211
- tick: existing.tick,
2212
- },
2213
- exitCode: EXIT_CODES.OK,
2214
- };
2215
- }
2216
- if (seekSpec.action === 'latest') {
2217
- const prevTick = activeCursor ? activeCursor.tick : null;
2218
- let sdResult = null;
2219
- if (seekSpec.diff) {
2220
- sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
2221
- }
2222
- await clearActiveCursor(persistence, graphName);
2223
- // When --diff already materialized at maxTick, skip redundant re-materialize
2224
- if (!sdResult) {
2225
- await graph.materialize({ ceiling: maxTick });
2226
- }
2227
- const nodes = await graph.getNodes();
2228
- const edges = await graph.getEdges();
2229
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2230
- const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
2231
- return {
2232
- payload: {
2233
- graph: graphName,
2234
- action: 'latest',
2235
- tick: maxTick,
2236
- maxTick,
2237
- ticks,
2238
- nodes: nodes.length,
2239
- edges: edges.length,
2240
- perWriter: serializePerWriter(perWriter),
2241
- patchCount: countPatchesAtTick(maxTick, perWriter),
2242
- diff,
2243
- tickReceipt,
2244
- cursor: { active: false },
2245
- ...sdResult,
2246
- },
2247
- exitCode: EXIT_CODES.OK,
2248
- };
2249
- }
2250
- if (seekSpec.action === 'save') {
2251
- if (!activeCursor) {
2252
- throw usageError('No active cursor to save. Use --tick first.');
2253
- }
2254
- await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor);
2255
- return {
2256
- payload: {
2257
- graph: graphName,
2258
- action: 'save',
2259
- name: seekSpec.name,
2260
- tick: activeCursor.tick,
2261
- },
2262
- exitCode: EXIT_CODES.OK,
2263
- };
2264
- }
2265
- if (seekSpec.action === 'load') {
2266
- const loadName = /** @type {string} */ (seekSpec.name);
2267
- const saved = await readSavedCursor(persistence, graphName, loadName);
2268
- if (!saved) {
2269
- throw notFoundError(`Saved cursor not found: ${loadName}`);
2270
- }
2271
- const prevTick = activeCursor ? activeCursor.tick : null;
2272
- let sdResult = null;
2273
- if (seekSpec.diff) {
2274
- sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
2275
- }
2276
- // When --diff already materialized at saved.tick, skip redundant call
2277
- if (!sdResult) {
2278
- await graph.materialize({ ceiling: saved.tick });
2279
- }
2280
- const nodes = await graph.getNodes();
2281
- const edges = await graph.getEdges();
2282
- await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2283
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2284
- const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
2285
- return {
2286
- payload: {
2287
- graph: graphName,
2288
- action: 'load',
2289
- name: seekSpec.name,
2290
- tick: saved.tick,
2291
- maxTick,
2292
- ticks,
2293
- nodes: nodes.length,
2294
- edges: edges.length,
2295
- perWriter: serializePerWriter(perWriter),
2296
- patchCount: countPatchesAtTick(saved.tick, perWriter),
2297
- diff,
2298
- tickReceipt,
2299
- cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
2300
- ...sdResult,
2301
- },
2302
- exitCode: EXIT_CODES.OK,
2303
- };
2304
- }
2305
- if (seekSpec.action === 'tick') {
2306
- const currentTick = activeCursor ? activeCursor.tick : null;
2307
- const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
2308
- let sdResult = null;
2309
- if (seekSpec.diff) {
2310
- sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
2311
- }
2312
- // When --diff already materialized at resolvedTick, skip redundant call
2313
- if (!sdResult) {
2314
- await graph.materialize({ ceiling: resolvedTick });
2315
- }
2316
- const nodes = await graph.getNodes();
2317
- const edges = await graph.getEdges();
2318
- await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2319
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2320
- const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
2321
- return {
2322
- payload: {
2323
- graph: graphName,
2324
- action: 'tick',
2325
- tick: resolvedTick,
2326
- maxTick,
2327
- ticks,
2328
- nodes: nodes.length,
2329
- edges: edges.length,
2330
- perWriter: serializePerWriter(perWriter),
2331
- patchCount: countPatchesAtTick(resolvedTick, perWriter),
2332
- diff,
2333
- tickReceipt,
2334
- cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
2335
- ...sdResult,
2336
- },
2337
- exitCode: EXIT_CODES.OK,
2338
- };
2339
- }
2340
-
2341
- // status (bare seek)
2342
- return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
2343
- }
2344
-
2345
- /**
2346
- * Handles the `status` sub-action of `seek` (bare seek with no action flag).
2347
- * @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
2348
- * @returns {Promise<{payload: *, exitCode: number}>}
2349
- */
2350
- async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
2351
- if (activeCursor) {
2352
- await graph.materialize({ ceiling: activeCursor.tick });
2353
- const nodes = await graph.getNodes();
2354
- const edges = await graph.getEdges();
2355
- const prevCounts = readSeekCounts(activeCursor);
2356
- const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
2357
- if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
2358
- await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2359
- }
2360
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2361
- const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
2362
- return {
2363
- payload: {
2364
- graph: graphName,
2365
- action: 'status',
2366
- tick: activeCursor.tick,
2367
- maxTick,
2368
- ticks,
2369
- nodes: nodes.length,
2370
- edges: edges.length,
2371
- perWriter: serializePerWriter(perWriter),
2372
- patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
2373
- diff,
2374
- tickReceipt,
2375
- cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
2376
- },
2377
- exitCode: EXIT_CODES.OK,
2378
- };
2379
- }
2380
- await graph.materialize();
2381
- const nodes = await graph.getNodes();
2382
- const edges = await graph.getEdges();
2383
- const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
2384
- return {
2385
- payload: {
2386
- graph: graphName,
2387
- action: 'status',
2388
- tick: maxTick,
2389
- maxTick,
2390
- ticks,
2391
- nodes: nodes.length,
2392
- edges: edges.length,
2393
- perWriter: serializePerWriter(perWriter),
2394
- patchCount: countPatchesAtTick(maxTick, perWriter),
2395
- diff: null,
2396
- tickReceipt,
2397
- cursor: { active: false },
2398
- },
2399
- exitCode: EXIT_CODES.OK,
2400
- };
2401
- }
2402
-
2403
- /**
2404
- * Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
2405
- *
2406
- * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2407
- * @returns {Record<string, WriterTickInfo>} Plain object keyed by writer ID
2408
- */
2409
- function serializePerWriter(perWriter) {
2410
- /** @type {Record<string, WriterTickInfo>} */
2411
- const result = {};
2412
- for (const [writerId, info] of perWriter) {
2413
- result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
2414
- }
2415
- return result;
2416
- }
2417
-
2418
- /**
2419
- * Counts the total number of patches across all writers at or before the given tick.
2420
- *
2421
- * @param {number} tick - Lamport tick ceiling (inclusive)
2422
- * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2423
- * @returns {number} Total patch count at or before the given tick
2424
- */
2425
- function countPatchesAtTick(tick, perWriter) {
2426
- let count = 0;
2427
- for (const [, info] of perWriter) {
2428
- for (const t of info.ticks) {
2429
- if (t <= tick) {
2430
- count++;
2431
- }
2432
- }
2433
- }
2434
- return count;
2435
- }
2436
-
2437
- /**
2438
- * Computes a stable fingerprint of the current graph frontier (writer tips).
2439
- *
2440
- * Used to suppress seek diffs when graph history may have changed since the
2441
- * previous cursor snapshot (e.g. new writers/patches, rewritten refs).
2442
- *
2443
- * @param {Map<string, WriterTickInfo>} perWriter - Per-writer metadata from discoverTicks()
2444
- * @returns {string} Hex digest of the frontier fingerprint
2445
- */
2446
- function computeFrontierHash(perWriter) {
2447
- /** @type {Record<string, string|null>} */
2448
- const tips = {};
2449
- for (const [writerId, info] of perWriter) {
2450
- tips[writerId] = info?.tipSha || null;
2451
- }
2452
- return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex');
2453
- }
2454
-
2455
- /**
2456
- * Reads cached seek state counts from a cursor blob.
2457
- *
2458
- * Counts may be missing for older cursors (pre-diff support). In that case
2459
- * callers should treat the counts as unknown and suppress diffs.
2460
- *
2461
- * @param {CursorBlob|null} cursor - Parsed cursor blob object
2462
- * @returns {{nodes: number|null, edges: number|null}} Parsed counts
2463
- */
2464
- function readSeekCounts(cursor) {
2465
- if (!cursor || typeof cursor !== 'object') {
2466
- return { nodes: null, edges: null };
2467
- }
2468
-
2469
- const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
2470
- const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
2471
- return { nodes, edges };
2472
- }
2473
-
2474
- /**
2475
- * Computes node/edge deltas between the current seek position and the previous cursor.
2476
- *
2477
- * Returns null if the previous cursor is missing cached counts.
2478
- *
2479
- * @param {CursorBlob|null} prevCursor - Cursor object read before updating the position
2480
- * @param {{nodes: number, edges: number}} next - Current materialized counts
2481
- * @param {string} frontierHash - Frontier fingerprint of the current graph
2482
- * @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
2483
- */
2484
- function computeSeekStateDiff(prevCursor, next, frontierHash) {
2485
- const prev = readSeekCounts(prevCursor);
2486
- if (prev.nodes === null || prev.edges === null) {
2487
- return null;
2488
- }
2489
- const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
2490
- if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
2491
- return null;
2492
- }
2493
- return {
2494
- nodes: next.nodes - prev.nodes,
2495
- edges: next.edges - prev.edges,
2496
- };
2497
- }
2498
-
2499
- /**
2500
- * Builds a per-writer operation summary for patches at an exact tick.
2501
- *
2502
- * Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and
2503
- * summarizes patch ops. Typically only a handful of writers have a patch at any
2504
- * single Lamport tick.
2505
- *
2506
- * @param {{tick: number, perWriter: Map<string, WriterTickInfo>, graph: WarpGraphInstance}} params
2507
- * @returns {Promise<Record<string, {sha: string, opSummary: *}>|null>} Map of writerId to { sha, opSummary }, or null if empty
2508
- */
2509
- async function buildTickReceipt({ tick, perWriter, graph }) {
2510
- if (!Number.isInteger(tick) || tick <= 0) {
2511
- return null;
2512
- }
2513
-
2514
- /** @type {Record<string, {sha: string, opSummary: *}>} */
2515
- const receipt = {};
2516
-
2517
- for (const [writerId, info] of perWriter) {
2518
- const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload
2519
- if (!sha) {
2520
- continue;
2521
- }
2522
-
2523
- const patch = await graph.loadPatchBySha(sha);
2524
- const ops = Array.isArray(patch?.ops) ? patch.ops : [];
2525
- receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
2526
- }
2527
-
2528
- return Object.keys(receipt).length > 0 ? receipt : null;
2529
- }
2530
-
2531
- /**
2532
- * Computes a structural diff between the state at a previous tick and
2533
- * the state at the current tick.
2534
- *
2535
- * Materializes the baseline tick first, snapshots the state, then
2536
- * materializes the target tick and calls diffStates() between the two.
2537
- * Applies diffLimit truncation when the total change count exceeds the cap.
2538
- *
2539
- * @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
2540
- * @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
2541
- */
2542
- async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
2543
- let beforeState = null;
2544
- let diffBaseline = 'empty';
2545
- let baselineTick = null;
2546
-
2547
- // Short-circuit: same tick produces an empty diff
2548
- if (prevTick !== null && prevTick === currentTick) {
2549
- const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
2550
- return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
2551
- }
2552
-
2553
- if (prevTick !== null && prevTick > 0) {
2554
- await graph.materialize({ ceiling: prevTick });
2555
- beforeState = await graph.getStateSnapshot();
2556
- diffBaseline = 'tick';
2557
- baselineTick = prevTick;
2558
- }
2559
-
2560
- await graph.materialize({ ceiling: currentTick });
2561
- const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
2562
- const diff = diffStates(beforeState, afterState);
2563
-
2564
- return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
2565
- }
2566
-
2567
- /**
2568
- * Applies truncation limits to a structural diff result.
2569
- *
2570
- * @param {*} diff
2571
- * @param {string} diffBaseline
2572
- * @param {number|null} baselineTick
2573
- * @param {number} diffLimit
2574
- * @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
2575
- */
2576
- function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
2577
- const totalChanges =
2578
- diff.nodes.added.length + diff.nodes.removed.length +
2579
- diff.edges.added.length + diff.edges.removed.length +
2580
- diff.props.set.length + diff.props.removed.length;
2581
-
2582
- if (totalChanges <= diffLimit) {
2583
- return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
2584
- }
2585
-
2586
- // Truncate sequentially (nodes → edges → props), keeping sort order within each category
2587
- let remaining = diffLimit;
2588
- const cap = (/** @type {any[]} */ arr) => {
2589
- const take = Math.min(arr.length, remaining);
2590
- remaining -= take;
2591
- return arr.slice(0, take);
2592
- };
2593
-
2594
- const capped = {
2595
- nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
2596
- edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
2597
- props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
2598
- };
2599
-
2600
- const shownChanges = diffLimit - remaining;
2601
- return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
2602
- }
2603
-
2604
- /**
2605
- * Renders a seek command payload as a human-readable string for terminal output.
2606
- *
2607
- * Handles all seek actions: list, drop, save, latest, load, tick, and status.
2608
- *
2609
- * @param {*} payload - Seek result payload from handleSeek
2610
- * @returns {string} Formatted output string (includes trailing newline)
2611
- */
2612
- function renderSeek(payload) {
2613
- const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload
2614
- if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
2615
- return '';
2616
- }
2617
- const sign = n > 0 ? '+' : '';
2618
- return ` (${sign}${n})`;
2619
- };
2620
-
2621
- const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
2622
- const order = [
2623
- ['NodeAdd', '+', 'node'],
2624
- ['EdgeAdd', '+', 'edge'],
2625
- ['PropSet', '~', 'prop'],
2626
- ['NodeTombstone', '-', 'node'],
2627
- ['EdgeTombstone', '-', 'edge'],
2628
- ['BlobValue', '+', 'blob'],
2629
- ];
2630
-
2631
- const parts = [];
2632
- for (const [opType, symbol, label] of order) {
2633
- const n = summary?.[opType];
2634
- if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
2635
- parts.push(`${symbol}${n}${label}`);
2636
- }
2637
- }
2638
- return parts.length > 0 ? parts.join(' ') : '(empty)';
2639
- };
2640
-
2641
- const appendReceiptSummary = (/** @type {string} */ baseLine) => {
2642
- const tickReceipt = payload?.tickReceipt;
2643
- if (!tickReceipt || typeof tickReceipt !== 'object') {
2644
- return `${baseLine}\n`;
2645
- }
2646
-
2647
- const entries = Object.entries(tickReceipt)
2648
- .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
2649
- .sort(([a], [b]) => a.localeCompare(b));
2650
-
2651
- if (entries.length === 0) {
2652
- return `${baseLine}\n`;
2653
- }
2654
-
2655
- const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
2656
- const receiptLines = [` Tick ${payload.tick}:`];
2657
- for (const [writerId, entry] of entries) {
2658
- const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
2659
- const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
2660
- receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
2661
- }
2662
-
2663
- return `${baseLine}\n${receiptLines.join('\n')}\n`;
2664
- };
2665
-
2666
- const buildStateStrings = () => {
2667
- const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
2668
- const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
2669
- const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
2670
- return {
2671
- nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
2672
- edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
2673
- patchesStr: `${payload.patchCount} ${patchLabel}`,
2674
- };
2675
- };
2676
-
2677
- if (payload.action === 'clear-cache') {
2678
- return `${payload.message}\n`;
2679
- }
2680
-
2681
- if (payload.action === 'list') {
2682
- if (payload.cursors.length === 0) {
2683
- return 'No saved cursors.\n';
2684
- }
2685
- const lines = [];
2686
- for (const c of payload.cursors) {
2687
- const active = c.tick === payload.activeTick ? ' (active)' : '';
2688
- lines.push(` ${c.name}: tick ${c.tick}${active}`);
2689
- }
2690
- return `${lines.join('\n')}\n`;
2691
- }
2692
-
2693
- if (payload.action === 'drop') {
2694
- return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
2695
- }
2696
-
2697
- if (payload.action === 'save') {
2698
- return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
2699
- }
2700
-
2701
- if (payload.action === 'latest') {
2702
- const { nodesStr, edgesStr } = buildStateStrings();
2703
- const base = appendReceiptSummary(
2704
- `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
2705
- );
2706
- return base + formatStructuralDiff(payload);
2707
- }
2708
-
2709
- if (payload.action === 'load') {
2710
- const { nodesStr, edgesStr } = buildStateStrings();
2711
- const base = appendReceiptSummary(
2712
- `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
2713
- );
2714
- return base + formatStructuralDiff(payload);
2715
- }
2716
-
2717
- if (payload.action === 'tick') {
2718
- const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2719
- const base = appendReceiptSummary(
2720
- `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2721
- );
2722
- return base + formatStructuralDiff(payload);
2723
- }
2724
-
2725
- // status (structuralDiff is never populated here; no formatStructuralDiff call)
2726
- if (payload.cursor && payload.cursor.active) {
2727
- const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2728
- return appendReceiptSummary(
2729
- `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2730
- );
2731
- }
2732
-
2733
- return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
2734
- }
2735
-
2736
- /**
2737
- * Reads the active cursor and sets `_seekCeiling` on the graph instance
2738
- * so that subsequent materialize calls respect the time-travel boundary.
2739
- *
2740
- * Called by non-seek commands (query, path, check, etc.) that should
2741
- * honour an active seek cursor.
2742
- *
2743
- * @param {WarpGraphInstance} graph - WarpGraph instance
2744
- * @param {Persistence} persistence - GraphPersistencePort adapter
2745
- * @param {string} graphName - Name of the WARP graph
2746
- * @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance
2747
- */
2748
- async function applyCursorCeiling(graph, persistence, graphName) {
2749
- const cursor = await readActiveCursor(persistence, graphName);
2750
- if (cursor) {
2751
- graph._seekCeiling = cursor.tick;
2752
- return { active: true, tick: cursor.tick, maxTick: null };
2753
- }
2754
- return { active: false, tick: null, maxTick: null };
2755
- }
2756
-
2757
- /**
2758
- * Prints a seek cursor warning banner to stderr when a cursor is active.
2759
- *
2760
- * No-op if the cursor is not active.
2761
- *
2762
- * Non-seek commands (query, path, check, history, materialize) pass null for
2763
- * maxTick to avoid the cost of discoverTicks(); the banner then omits the
2764
- * "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
2765
- *
2766
- * @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
2767
- * @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
2768
- * @returns {void}
2769
- */
2770
- function emitCursorWarning(cursorInfo, maxTick) {
2771
- if (cursorInfo.active) {
2772
- const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : '';
2773
- process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`);
2774
- }
2775
- }
2776
-
2777
- /**
2778
- * @param {{options: CliOptions, args: string[]}} params
2779
- * @returns {Promise<{payload: *, exitCode: number}>}
2780
- */
2781
- async function handleView({ options, args }) {
2782
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
2783
- throw usageError('view command requires an interactive terminal (TTY)');
2784
- }
2785
-
2786
- const viewMode = (args[0] === '--list' || args[0] === 'list') ? 'list'
2787
- : (args[0] === '--log' || args[0] === 'log') ? 'log'
2788
- : 'list';
2789
-
2790
- try {
2791
- // @ts-expect-error — optional peer dependency, may not be installed
2792
- const { startTui } = await import('@git-stunts/git-warp-tui');
2793
- await startTui({
2794
- repo: options.repo || '.',
2795
- graph: options.graph || 'default',
2796
- mode: viewMode,
2797
- });
2798
- } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
2799
- if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
2800
- throw usageError(
2801
- 'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
2802
- ' Install with: npm install -g @git-stunts/git-warp-tui',
2803
- );
2804
- }
2805
- throw err;
2806
- }
2807
- return { payload: undefined, exitCode: 0 };
2808
- }
2809
-
2810
- /** @type {Map<string, Function>} */
2811
- const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
2812
- ['info', handleInfo],
2813
- ['query', handleQuery],
2814
- ['path', handlePath],
2815
- ['history', handleHistory],
2816
- ['check', handleCheck],
2817
- ['materialize', handleMaterialize],
2818
- ['seek', handleSeek],
2819
- ['view', handleView],
2820
- ['install-hooks', handleInstallHooks],
2821
- ]));
10
+ const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
2822
11
 
2823
12
  /**
2824
13
  * CLI entry point. Parses arguments, dispatches to the appropriate command handler,
@@ -2826,7 +15,7 @@ const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
2826
15
  * @returns {Promise<void>}
2827
16
  */
2828
17
  async function main() {
2829
- const { options, positionals } = parseArgs(process.argv.slice(2));
18
+ const { options, command, commandArgs } = parseArgs(process.argv.slice(2));
2830
19
 
2831
20
  if (options.help) {
2832
21
  process.stdout.write(HELP_TEXT);
@@ -2837,8 +26,13 @@ async function main() {
2837
26
  if (options.json && options.view) {
2838
27
  throw usageError('--json and --view are mutually exclusive');
2839
28
  }
29
+ if (options.ndjson && options.view) {
30
+ throw usageError('--ndjson and --view are mutually exclusive');
31
+ }
32
+ if (options.json && options.ndjson) {
33
+ throw usageError('--json and --ndjson are mutually exclusive');
34
+ }
2840
35
 
2841
- const command = positionals[0];
2842
36
  if (!command) {
2843
37
  process.stderr.write(HELP_TEXT);
2844
38
  process.exitCode = EXIT_CODES.USAGE;
@@ -2850,14 +44,13 @@ async function main() {
2850
44
  throw usageError(`Unknown command: ${command}`);
2851
45
  }
2852
46
 
2853
- const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
2854
47
  if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
2855
48
  throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
2856
49
  }
2857
50
 
2858
51
  const result = await /** @type {Function} */ (handler)({
2859
52
  command,
2860
- args: positionals.slice(1),
53
+ args: commandArgs,
2861
54
  options,
2862
55
  });
2863
56
 
@@ -2867,7 +60,8 @@ async function main() {
2867
60
  : { payload: result, exitCode: EXIT_CODES.OK };
2868
61
 
2869
62
  if (normalized.payload !== undefined) {
2870
- emit(normalized.payload, { json: options.json, command, view: options.view });
63
+ const format = options.ndjson ? 'ndjson' : options.json ? 'json' : 'text';
64
+ present(normalized.payload, { format, command, view: options.view });
2871
65
  }
2872
66
  // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
2873
67
  process.exit(normalized.exitCode ?? EXIT_CODES.OK);
@@ -2884,8 +78,9 @@ main().catch((error) => {
2884
78
  payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause;
2885
79
  }
2886
80
 
2887
- if (process.argv.includes('--json')) {
2888
- process.stdout.write(`${stableStringify(payload)}\n`);
81
+ if (process.argv.includes('--json') || process.argv.includes('--ndjson')) {
82
+ const stringify = process.argv.includes('--ndjson') ? compactStringify : stableStringify;
83
+ process.stdout.write(`${stringify(payload)}\n`);
2889
84
  } else {
2890
85
  process.stderr.write(renderError(payload));
2891
86
  }