@git-stunts/git-warp 10.1.2 → 10.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -4
- package/bin/warp-graph.js +1242 -59
- package/index.d.ts +31 -0
- package/index.js +4 -0
- package/package.json +13 -3
- package/src/domain/WarpGraph.js +487 -140
- package/src/domain/crdt/LWW.js +1 -1
- package/src/domain/crdt/ORSet.js +10 -6
- package/src/domain/crdt/VersionVector.js +5 -1
- package/src/domain/errors/EmptyMessageError.js +2 -4
- package/src/domain/errors/ForkError.js +4 -0
- package/src/domain/errors/IndexError.js +4 -0
- package/src/domain/errors/OperationAbortedError.js +4 -0
- package/src/domain/errors/QueryError.js +4 -0
- package/src/domain/errors/SchemaUnsupportedError.js +4 -0
- package/src/domain/errors/ShardCorruptionError.js +2 -6
- package/src/domain/errors/ShardLoadError.js +2 -6
- package/src/domain/errors/ShardValidationError.js +2 -7
- package/src/domain/errors/StorageError.js +2 -6
- package/src/domain/errors/SyncError.js +4 -0
- package/src/domain/errors/TraversalError.js +4 -0
- package/src/domain/errors/WarpError.js +2 -4
- package/src/domain/errors/WormholeError.js +4 -0
- package/src/domain/services/AnchorMessageCodec.js +1 -4
- package/src/domain/services/BitmapIndexBuilder.js +10 -6
- package/src/domain/services/BitmapIndexReader.js +27 -21
- package/src/domain/services/BoundaryTransitionRecord.js +22 -15
- package/src/domain/services/CheckpointMessageCodec.js +1 -7
- package/src/domain/services/CheckpointSerializerV5.js +20 -19
- package/src/domain/services/CheckpointService.js +18 -18
- package/src/domain/services/CommitDagTraversalService.js +13 -1
- package/src/domain/services/DagPathFinding.js +40 -18
- package/src/domain/services/DagTopology.js +7 -6
- package/src/domain/services/DagTraversal.js +5 -3
- package/src/domain/services/Frontier.js +7 -6
- package/src/domain/services/HealthCheckService.js +15 -14
- package/src/domain/services/HookInstaller.js +64 -13
- package/src/domain/services/HttpSyncServer.js +15 -14
- package/src/domain/services/IndexRebuildService.js +12 -12
- package/src/domain/services/IndexStalenessChecker.js +13 -6
- package/src/domain/services/JoinReducer.js +28 -27
- package/src/domain/services/LogicalTraversal.js +7 -6
- package/src/domain/services/MessageCodecInternal.js +2 -0
- package/src/domain/services/ObserverView.js +6 -6
- package/src/domain/services/PatchBuilderV2.js +9 -9
- package/src/domain/services/PatchMessageCodec.js +1 -7
- package/src/domain/services/ProvenanceIndex.js +6 -8
- package/src/domain/services/ProvenancePayload.js +1 -2
- package/src/domain/services/QueryBuilder.js +29 -23
- package/src/domain/services/StateDiff.js +7 -7
- package/src/domain/services/StateSerializerV5.js +8 -6
- package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
- package/src/domain/services/SyncProtocol.js +23 -26
- package/src/domain/services/TemporalQuery.js +4 -3
- package/src/domain/services/TranslationCost.js +4 -4
- package/src/domain/services/WormholeService.js +19 -15
- package/src/domain/types/TickReceipt.js +10 -6
- package/src/domain/types/WarpTypesV2.js +2 -3
- package/src/domain/utils/CachedValue.js +1 -1
- package/src/domain/utils/LRUCache.js +3 -3
- package/src/domain/utils/MinHeap.js +2 -2
- package/src/domain/utils/RefLayout.js +106 -15
- package/src/domain/utils/WriterId.js +2 -2
- package/src/domain/utils/defaultCodec.js +9 -2
- package/src/domain/utils/defaultCrypto.js +36 -0
- package/src/domain/utils/parseCursorBlob.js +51 -0
- package/src/domain/utils/roaring.js +5 -5
- package/src/domain/utils/seekCacheKey.js +32 -0
- package/src/domain/warp/PatchSession.js +3 -3
- package/src/domain/warp/Writer.js +2 -2
- package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
- package/src/infrastructure/adapters/ClockAdapter.js +2 -2
- package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
- package/src/infrastructure/adapters/GitGraphAdapter.js +16 -27
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
- package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
- package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
- package/src/infrastructure/codecs/CborCodec.js +16 -8
- package/src/ports/BlobPort.js +2 -2
- package/src/ports/CodecPort.js +2 -2
- package/src/ports/CommitPort.js +8 -21
- package/src/ports/ConfigPort.js +3 -3
- package/src/ports/CryptoPort.js +7 -7
- package/src/ports/GraphPersistencePort.js +12 -14
- package/src/ports/HttpServerPort.js +1 -5
- package/src/ports/IndexStoragePort.js +1 -0
- package/src/ports/LoggerPort.js +9 -9
- package/src/ports/RefPort.js +5 -5
- package/src/ports/SeekCachePort.js +73 -0
- package/src/ports/TreePort.js +3 -3
- package/src/visualization/layouts/converters.js +14 -7
- package/src/visualization/layouts/elkAdapter.js +24 -11
- package/src/visualization/layouts/elkLayout.js +23 -7
- package/src/visualization/layouts/index.js +3 -3
- package/src/visualization/renderers/ascii/check.js +30 -17
- package/src/visualization/renderers/ascii/graph.js +122 -16
- package/src/visualization/renderers/ascii/history.js +29 -90
- package/src/visualization/renderers/ascii/index.js +1 -1
- package/src/visualization/renderers/ascii/info.js +9 -7
- package/src/visualization/renderers/ascii/materialize.js +20 -16
- package/src/visualization/renderers/ascii/opSummary.js +81 -0
- package/src/visualization/renderers/ascii/path.js +1 -1
- package/src/visualization/renderers/ascii/seek.js +344 -0
- package/src/visualization/renderers/ascii/table.js +1 -1
- package/src/visualization/renderers/svg/index.js +5 -1
package/bin/warp-graph.js
CHANGED
|
@@ -1,32 +1,120 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import process from 'node:process';
|
|
6
7
|
import readline from 'node:readline';
|
|
7
8
|
import { execFileSync } from 'node:child_process';
|
|
9
|
+
// @ts-expect-error — no type declarations for @git-stunts/plumbing
|
|
8
10
|
import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing';
|
|
9
11
|
import WarpGraph from '../src/domain/WarpGraph.js';
|
|
10
12
|
import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
|
|
11
13
|
import HealthCheckService from '../src/domain/services/HealthCheckService.js';
|
|
12
14
|
import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
|
|
15
|
+
import NodeCryptoAdapter from '../src/infrastructure/adapters/NodeCryptoAdapter.js';
|
|
13
16
|
import {
|
|
14
17
|
REF_PREFIX,
|
|
15
18
|
buildCheckpointRef,
|
|
16
19
|
buildCoverageRef,
|
|
17
20
|
buildWritersPrefix,
|
|
18
21
|
parseWriterIdFromRef,
|
|
22
|
+
buildCursorActiveRef,
|
|
23
|
+
buildCursorSavedRef,
|
|
24
|
+
buildCursorSavedPrefix,
|
|
19
25
|
} from '../src/domain/utils/RefLayout.js';
|
|
26
|
+
import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
|
|
20
27
|
import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
|
|
21
28
|
import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
|
|
22
29
|
import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
|
|
23
30
|
import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
|
|
24
31
|
import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
|
|
25
32
|
import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
|
|
33
|
+
import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
|
|
34
|
+
import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js';
|
|
26
35
|
import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
|
|
27
36
|
import { renderSvg } from '../src/visualization/renderers/svg/index.js';
|
|
28
37
|
import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
|
|
29
38
|
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} Persistence
|
|
41
|
+
* @property {(prefix: string) => Promise<string[]>} listRefs
|
|
42
|
+
* @property {(ref: string) => Promise<string|null>} readRef
|
|
43
|
+
* @property {(ref: string, oid: string) => Promise<void>} updateRef
|
|
44
|
+
* @property {(ref: string) => Promise<void>} deleteRef
|
|
45
|
+
* @property {(oid: string) => Promise<Buffer>} readBlob
|
|
46
|
+
* @property {(buf: Buffer) => Promise<string>} writeBlob
|
|
47
|
+
* @property {(sha: string) => Promise<{date?: string|null}>} getNodeInfo
|
|
48
|
+
* @property {(sha: string, coverageSha: string) => Promise<boolean>} isAncestor
|
|
49
|
+
* @property {() => Promise<{ok: boolean}>} ping
|
|
50
|
+
* @property {*} plumbing
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {Object} WarpGraphInstance
|
|
55
|
+
* @property {(opts?: {ceiling?: number}) => Promise<void>} materialize
|
|
56
|
+
* @property {() => Promise<Array<{id: string}>>} getNodes
|
|
57
|
+
* @property {() => Promise<Array<{from: string, to: string, label?: string}>>} getEdges
|
|
58
|
+
* @property {() => Promise<string|null>} createCheckpoint
|
|
59
|
+
* @property {() => *} query
|
|
60
|
+
* @property {{ shortestPath: Function }} traverse
|
|
61
|
+
* @property {(writerId: string) => Promise<Array<{patch: any, sha: string}>>} getWriterPatches
|
|
62
|
+
* @property {() => Promise<{frontier: Record<string, any>}>} status
|
|
63
|
+
* @property {() => Promise<Map<string, any>>} getFrontier
|
|
64
|
+
* @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
|
|
65
|
+
* @property {() => Promise<number>} getPropertyCount
|
|
66
|
+
* @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
|
|
67
|
+
* @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
|
|
68
|
+
* @property {(cache: any) => void} setSeekCache
|
|
69
|
+
* @property {*} seekCache
|
|
70
|
+
* @property {number} [_seekCeiling]
|
|
71
|
+
* @property {boolean} [_provenanceDegraded]
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {Object} WriterTickInfo
|
|
76
|
+
* @property {number[]} ticks
|
|
77
|
+
* @property {string|null} tipSha
|
|
78
|
+
* @property {Record<number, string>} [tickShas]
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {Object} CursorBlob
|
|
83
|
+
* @property {number} tick
|
|
84
|
+
* @property {string} [mode]
|
|
85
|
+
* @property {number} [nodes]
|
|
86
|
+
* @property {number} [edges]
|
|
87
|
+
* @property {string} [frontierHash]
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @typedef {Object} CliOptions
|
|
92
|
+
* @property {string} repo
|
|
93
|
+
* @property {boolean} json
|
|
94
|
+
* @property {string|null} view
|
|
95
|
+
* @property {string|null} graph
|
|
96
|
+
* @property {string} writer
|
|
97
|
+
* @property {boolean} help
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @typedef {Object} GraphInfoResult
|
|
102
|
+
* @property {string} name
|
|
103
|
+
* @property {{count: number, ids?: string[]}} writers
|
|
104
|
+
* @property {{ref: string, sha: string|null, date?: string|null}} [checkpoint]
|
|
105
|
+
* @property {{ref: string, sha: string|null}} [coverage]
|
|
106
|
+
* @property {Record<string, number>} [writerPatches]
|
|
107
|
+
* @property {{active: boolean, tick?: number, mode?: string}} [cursor]
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @typedef {Object} SeekSpec
|
|
112
|
+
* @property {string} action
|
|
113
|
+
* @property {string|null} tickValue
|
|
114
|
+
* @property {string|null} name
|
|
115
|
+
* @property {boolean} noPersistentCache
|
|
116
|
+
*/
|
|
117
|
+
|
|
30
118
|
const EXIT_CODES = {
|
|
31
119
|
OK: 0,
|
|
32
120
|
USAGE: 1,
|
|
@@ -44,6 +132,7 @@ Commands:
|
|
|
44
132
|
history Show writer history
|
|
45
133
|
check Report graph health/GC status
|
|
46
134
|
materialize Materialize and checkpoint all graphs
|
|
135
|
+
seek Time-travel: step through graph history by Lamport tick
|
|
47
136
|
view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
|
|
48
137
|
install-hooks Install post-merge git hook
|
|
49
138
|
|
|
@@ -74,6 +163,14 @@ Path options:
|
|
|
74
163
|
|
|
75
164
|
History options:
|
|
76
165
|
--node <id> Filter patches touching node id
|
|
166
|
+
|
|
167
|
+
Seek options:
|
|
168
|
+
--tick <N|+N|-N> Jump to tick N, or step forward/backward
|
|
169
|
+
--latest Clear cursor, return to present
|
|
170
|
+
--save <name> Save current position as named cursor
|
|
171
|
+
--load <name> Restore a saved cursor
|
|
172
|
+
--list List all saved cursors
|
|
173
|
+
--drop <name> Delete a saved cursor
|
|
77
174
|
`;
|
|
78
175
|
|
|
79
176
|
/**
|
|
@@ -95,20 +192,25 @@ class CliError extends Error {
|
|
|
95
192
|
}
|
|
96
193
|
}
|
|
97
194
|
|
|
195
|
+
/** @param {string} message */
|
|
98
196
|
function usageError(message) {
|
|
99
197
|
return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
|
|
100
198
|
}
|
|
101
199
|
|
|
200
|
+
/** @param {string} message */
|
|
102
201
|
function notFoundError(message) {
|
|
103
202
|
return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
|
|
104
203
|
}
|
|
105
204
|
|
|
205
|
+
/** @param {*} value */
|
|
106
206
|
function stableStringify(value) {
|
|
207
|
+
/** @param {*} input @returns {*} */
|
|
107
208
|
const normalize = (input) => {
|
|
108
209
|
if (Array.isArray(input)) {
|
|
109
210
|
return input.map(normalize);
|
|
110
211
|
}
|
|
111
212
|
if (input && typeof input === 'object') {
|
|
213
|
+
/** @type {Record<string, *>} */
|
|
112
214
|
const sorted = {};
|
|
113
215
|
for (const key of Object.keys(input).sort()) {
|
|
114
216
|
sorted[key] = normalize(input[key]);
|
|
@@ -121,8 +223,10 @@ function stableStringify(value) {
|
|
|
121
223
|
return JSON.stringify(normalize(value), null, 2);
|
|
122
224
|
}
|
|
123
225
|
|
|
226
|
+
/** @param {string[]} argv */
|
|
124
227
|
function parseArgs(argv) {
|
|
125
228
|
const options = createDefaultOptions();
|
|
229
|
+
/** @type {string[]} */
|
|
126
230
|
const positionals = [];
|
|
127
231
|
const optionDefs = [
|
|
128
232
|
{ flag: '--repo', shortFlag: '-r', key: 'repo' },
|
|
@@ -153,6 +257,14 @@ function createDefaultOptions() {
|
|
|
153
257
|
};
|
|
154
258
|
}
|
|
155
259
|
|
|
260
|
+
/**
|
|
261
|
+
* @param {Object} params
|
|
262
|
+
* @param {string[]} params.argv
|
|
263
|
+
* @param {number} params.index
|
|
264
|
+
* @param {Record<string, *>} params.options
|
|
265
|
+
* @param {Array<{flag: string, shortFlag?: string, key: string}>} params.optionDefs
|
|
266
|
+
* @param {string[]} params.positionals
|
|
267
|
+
*/
|
|
156
268
|
function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
|
|
157
269
|
const arg = argv[index];
|
|
158
270
|
|
|
@@ -169,7 +281,7 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
|
|
|
169
281
|
if (arg === '--view') {
|
|
170
282
|
// Valid view modes: ascii, browser, svg:FILE, html:FILE
|
|
171
283
|
// Don't consume known commands as modes
|
|
172
|
-
const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'install-hooks'];
|
|
284
|
+
const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks'];
|
|
173
285
|
const nextArg = argv[index + 1];
|
|
174
286
|
const isViewMode = nextArg &&
|
|
175
287
|
!nextArg.startsWith('-') &&
|
|
@@ -204,8 +316,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
|
|
|
204
316
|
shortFlag: matched.shortFlag,
|
|
205
317
|
allowEmpty: false,
|
|
206
318
|
});
|
|
207
|
-
|
|
208
|
-
|
|
319
|
+
if (result) {
|
|
320
|
+
options[matched.key] = result.value;
|
|
321
|
+
return { consumed: result.consumed };
|
|
322
|
+
}
|
|
209
323
|
}
|
|
210
324
|
|
|
211
325
|
if (arg.startsWith('-')) {
|
|
@@ -216,6 +330,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
|
|
|
216
330
|
return { consumed: argv.length - index - 1, done: true };
|
|
217
331
|
}
|
|
218
332
|
|
|
333
|
+
/**
|
|
334
|
+
* @param {string} arg
|
|
335
|
+
* @param {Array<{flag: string, shortFlag?: string, key: string}>} optionDefs
|
|
336
|
+
*/
|
|
219
337
|
function matchOptionDef(arg, optionDefs) {
|
|
220
338
|
return optionDefs.find((def) =>
|
|
221
339
|
arg === def.flag ||
|
|
@@ -224,6 +342,7 @@ function matchOptionDef(arg, optionDefs) {
|
|
|
224
342
|
);
|
|
225
343
|
}
|
|
226
344
|
|
|
345
|
+
/** @param {string} repoPath @returns {Promise<{persistence: Persistence}>} */
|
|
227
346
|
async function createPersistence(repoPath) {
|
|
228
347
|
const runner = ShellRunnerFactory.create();
|
|
229
348
|
const plumbing = new GitPlumbing({ cwd: repoPath, runner });
|
|
@@ -235,6 +354,7 @@ async function createPersistence(repoPath) {
|
|
|
235
354
|
return { persistence };
|
|
236
355
|
}
|
|
237
356
|
|
|
357
|
+
/** @param {Persistence} persistence @returns {Promise<string[]>} */
|
|
238
358
|
async function listGraphNames(persistence) {
|
|
239
359
|
if (typeof persistence.listRefs !== 'function') {
|
|
240
360
|
return [];
|
|
@@ -257,6 +377,11 @@ async function listGraphNames(persistence) {
|
|
|
257
377
|
return [...names].sort();
|
|
258
378
|
}
|
|
259
379
|
|
|
380
|
+
/**
|
|
381
|
+
* @param {Persistence} persistence
|
|
382
|
+
* @param {string|null} explicitGraph
|
|
383
|
+
* @returns {Promise<string>}
|
|
384
|
+
*/
|
|
260
385
|
async function resolveGraphName(persistence, explicitGraph) {
|
|
261
386
|
if (explicitGraph) {
|
|
262
387
|
return explicitGraph;
|
|
@@ -271,6 +396,17 @@ async function resolveGraphName(persistence, explicitGraph) {
|
|
|
271
396
|
throw usageError('Multiple graphs found; specify --graph');
|
|
272
397
|
}
|
|
273
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Collects metadata about a single graph (writer count, refs, patches, checkpoint).
|
|
401
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
402
|
+
* @param {string} graphName - Name of the graph to inspect
|
|
403
|
+
* @param {Object} [options]
|
|
404
|
+
* @param {boolean} [options.includeWriterIds=false] - Include writer ID list
|
|
405
|
+
* @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
|
|
406
|
+
* @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
|
|
407
|
+
* @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
|
|
408
|
+
* @returns {Promise<GraphInfoResult>} Graph info object
|
|
409
|
+
*/
|
|
274
410
|
async function getGraphInfo(persistence, graphName, {
|
|
275
411
|
includeWriterIds = false,
|
|
276
412
|
includeRefs = false,
|
|
@@ -281,11 +417,12 @@ async function getGraphInfo(persistence, graphName, {
|
|
|
281
417
|
const writerRefs = typeof persistence.listRefs === 'function'
|
|
282
418
|
? await persistence.listRefs(writersPrefix)
|
|
283
419
|
: [];
|
|
284
|
-
const writerIds = writerRefs
|
|
420
|
+
const writerIds = /** @type {string[]} */ (writerRefs
|
|
285
421
|
.map((ref) => parseWriterIdFromRef(ref))
|
|
286
422
|
.filter(Boolean)
|
|
287
|
-
.sort();
|
|
423
|
+
.sort());
|
|
288
424
|
|
|
425
|
+
/** @type {GraphInfoResult} */
|
|
289
426
|
const info = {
|
|
290
427
|
name: graphName,
|
|
291
428
|
writers: {
|
|
@@ -301,6 +438,7 @@ async function getGraphInfo(persistence, graphName, {
|
|
|
301
438
|
const checkpointRef = buildCheckpointRef(graphName);
|
|
302
439
|
const checkpointSha = await persistence.readRef(checkpointRef);
|
|
303
440
|
|
|
441
|
+
/** @type {{ref: string, sha: string|null, date?: string|null}} */
|
|
304
442
|
const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
|
|
305
443
|
|
|
306
444
|
if (includeCheckpointDate && checkpointSha) {
|
|
@@ -322,11 +460,13 @@ async function getGraphInfo(persistence, graphName, {
|
|
|
322
460
|
persistence,
|
|
323
461
|
graphName,
|
|
324
462
|
writerId: 'cli',
|
|
463
|
+
crypto: new NodeCryptoAdapter(),
|
|
325
464
|
});
|
|
465
|
+
/** @type {Record<string, number>} */
|
|
326
466
|
const writerPatches = {};
|
|
327
467
|
for (const writerId of writerIds) {
|
|
328
468
|
const patches = await graph.getWriterPatches(writerId);
|
|
329
|
-
writerPatches[writerId] = patches.length;
|
|
469
|
+
writerPatches[/** @type {string} */ (writerId)] = patches.length;
|
|
330
470
|
}
|
|
331
471
|
info.writerPatches = writerPatches;
|
|
332
472
|
}
|
|
@@ -334,17 +474,31 @@ async function getGraphInfo(persistence, graphName, {
|
|
|
334
474
|
return info;
|
|
335
475
|
}
|
|
336
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Opens a WarpGraph for the given CLI options.
|
|
479
|
+
* @param {CliOptions} options - Parsed CLI options
|
|
480
|
+
* @returns {Promise<{graph: WarpGraphInstance, graphName: string, persistence: Persistence}>}
|
|
481
|
+
* @throws {CliError} If the specified graph is not found
|
|
482
|
+
*/
|
|
337
483
|
async function openGraph(options) {
|
|
338
484
|
const { persistence } = await createPersistence(options.repo);
|
|
339
485
|
const graphName = await resolveGraphName(persistence, options.graph);
|
|
340
|
-
|
|
486
|
+
if (options.graph) {
|
|
487
|
+
const graphNames = await listGraphNames(persistence);
|
|
488
|
+
if (!graphNames.includes(options.graph)) {
|
|
489
|
+
throw notFoundError(`Graph not found: ${options.graph}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const graph = /** @type {WarpGraphInstance} */ (/** @type {*} */ (await WarpGraph.open({ // TODO(ts-cleanup): narrow port type
|
|
341
493
|
persistence,
|
|
342
494
|
graphName,
|
|
343
495
|
writerId: options.writer,
|
|
344
|
-
|
|
496
|
+
crypto: new NodeCryptoAdapter(),
|
|
497
|
+
})));
|
|
345
498
|
return { graph, graphName, persistence };
|
|
346
499
|
}
|
|
347
500
|
|
|
501
|
+
/** @param {string[]} args */
|
|
348
502
|
function parseQueryArgs(args) {
|
|
349
503
|
const spec = {
|
|
350
504
|
match: null,
|
|
@@ -363,6 +517,11 @@ function parseQueryArgs(args) {
|
|
|
363
517
|
return spec;
|
|
364
518
|
}
|
|
365
519
|
|
|
520
|
+
/**
|
|
521
|
+
* @param {string[]} args
|
|
522
|
+
* @param {number} index
|
|
523
|
+
* @param {{match: string|null, select: string[]|null, steps: Array<{type: string, label?: string, key?: string, value?: string}>}} spec
|
|
524
|
+
*/
|
|
366
525
|
function consumeQueryArg(args, index, spec) {
|
|
367
526
|
const stepResult = readTraversalStep(args, index);
|
|
368
527
|
if (stepResult) {
|
|
@@ -406,6 +565,7 @@ function consumeQueryArg(args, index, spec) {
|
|
|
406
565
|
return null;
|
|
407
566
|
}
|
|
408
567
|
|
|
568
|
+
/** @param {string} value */
|
|
409
569
|
function parseWhereProp(value) {
|
|
410
570
|
const [key, ...rest] = value.split('=');
|
|
411
571
|
if (!key || rest.length === 0) {
|
|
@@ -414,6 +574,7 @@ function parseWhereProp(value) {
|
|
|
414
574
|
return { type: 'where-prop', key, value: rest.join('=') };
|
|
415
575
|
}
|
|
416
576
|
|
|
577
|
+
/** @param {string} value */
|
|
417
578
|
function parseSelectFields(value) {
|
|
418
579
|
if (value === '') {
|
|
419
580
|
return [];
|
|
@@ -421,6 +582,10 @@ function parseSelectFields(value) {
|
|
|
421
582
|
return value.split(',').map((field) => field.trim()).filter(Boolean);
|
|
422
583
|
}
|
|
423
584
|
|
|
585
|
+
/**
|
|
586
|
+
* @param {string[]} args
|
|
587
|
+
* @param {number} index
|
|
588
|
+
*/
|
|
424
589
|
function readTraversalStep(args, index) {
|
|
425
590
|
const arg = args[index];
|
|
426
591
|
if (arg !== '--outgoing' && arg !== '--incoming') {
|
|
@@ -432,6 +597,9 @@ function readTraversalStep(args, index) {
|
|
|
432
597
|
return { step: { type: arg.slice(2), label }, consumed };
|
|
433
598
|
}
|
|
434
599
|
|
|
600
|
+
/**
|
|
601
|
+
* @param {{args: string[], index: number, flag: string, shortFlag?: string, allowEmpty?: boolean}} params
|
|
602
|
+
*/
|
|
435
603
|
function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
|
|
436
604
|
const arg = args[index];
|
|
437
605
|
if (matchesOptionFlag(arg, flag, shortFlag)) {
|
|
@@ -445,10 +613,16 @@ function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
|
|
|
445
613
|
return null;
|
|
446
614
|
}
|
|
447
615
|
|
|
616
|
+
/**
|
|
617
|
+
* @param {string} arg
|
|
618
|
+
* @param {string} flag
|
|
619
|
+
* @param {string} [shortFlag]
|
|
620
|
+
*/
|
|
448
621
|
function matchesOptionFlag(arg, flag, shortFlag) {
|
|
449
622
|
return arg === flag || (shortFlag && arg === shortFlag);
|
|
450
623
|
}
|
|
451
624
|
|
|
625
|
+
/** @param {{args: string[], index: number, flag: string, allowEmpty?: boolean}} params */
|
|
452
626
|
function readNextOptionValue({ args, index, flag, allowEmpty }) {
|
|
453
627
|
const value = args[index + 1];
|
|
454
628
|
if (value === undefined || (!allowEmpty && value === '')) {
|
|
@@ -457,6 +631,7 @@ function readNextOptionValue({ args, index, flag, allowEmpty }) {
|
|
|
457
631
|
return { value, consumed: 1 };
|
|
458
632
|
}
|
|
459
633
|
|
|
634
|
+
/** @param {{arg: string, flag: string, allowEmpty?: boolean}} params */
|
|
460
635
|
function readInlineOptionValue({ arg, flag, allowEmpty }) {
|
|
461
636
|
const value = arg.slice(flag.length + 1);
|
|
462
637
|
if (!allowEmpty && value === '') {
|
|
@@ -465,9 +640,12 @@ function readInlineOptionValue({ arg, flag, allowEmpty }) {
|
|
|
465
640
|
return { value, consumed: 0 };
|
|
466
641
|
}
|
|
467
642
|
|
|
643
|
+
/** @param {string[]} args */
|
|
468
644
|
function parsePathArgs(args) {
|
|
469
645
|
const options = createPathOptions();
|
|
646
|
+
/** @type {string[]} */
|
|
470
647
|
const labels = [];
|
|
648
|
+
/** @type {string[]} */
|
|
471
649
|
const positionals = [];
|
|
472
650
|
|
|
473
651
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -479,6 +657,7 @@ function parsePathArgs(args) {
|
|
|
479
657
|
return options;
|
|
480
658
|
}
|
|
481
659
|
|
|
660
|
+
/** @returns {{from: string|null, to: string|null, dir: string|undefined, labelFilter: string|string[]|undefined, maxDepth: number|undefined}} */
|
|
482
661
|
function createPathOptions() {
|
|
483
662
|
return {
|
|
484
663
|
from: null,
|
|
@@ -489,8 +668,12 @@ function createPathOptions() {
|
|
|
489
668
|
};
|
|
490
669
|
}
|
|
491
670
|
|
|
671
|
+
/**
|
|
672
|
+
* @param {{args: string[], index: number, options: ReturnType<typeof createPathOptions>, labels: string[], positionals: string[]}} params
|
|
673
|
+
*/
|
|
492
674
|
function consumePathArg({ args, index, options, labels, positionals }) {
|
|
493
675
|
const arg = args[index];
|
|
676
|
+
/** @type {Array<{flag: string, apply: (value: string) => void}>} */
|
|
494
677
|
const handlers = [
|
|
495
678
|
{ flag: '--from', apply: (value) => { options.from = value; } },
|
|
496
679
|
{ flag: '--to', apply: (value) => { options.to = value; } },
|
|
@@ -515,6 +698,11 @@ function consumePathArg({ args, index, options, labels, positionals }) {
|
|
|
515
698
|
return { consumed: 0 };
|
|
516
699
|
}
|
|
517
700
|
|
|
701
|
+
/**
|
|
702
|
+
* @param {ReturnType<typeof createPathOptions>} options
|
|
703
|
+
* @param {string[]} labels
|
|
704
|
+
* @param {string[]} positionals
|
|
705
|
+
*/
|
|
518
706
|
function finalizePathOptions(options, labels, positionals) {
|
|
519
707
|
if (!options.from) {
|
|
520
708
|
options.from = positionals[0] || null;
|
|
@@ -535,10 +723,12 @@ function finalizePathOptions(options, labels, positionals) {
|
|
|
535
723
|
}
|
|
536
724
|
}
|
|
537
725
|
|
|
726
|
+
/** @param {string} value */
|
|
538
727
|
function parseLabels(value) {
|
|
539
728
|
return value.split(',').map((label) => label.trim()).filter(Boolean);
|
|
540
729
|
}
|
|
541
730
|
|
|
731
|
+
/** @param {string} value */
|
|
542
732
|
function parseMaxDepth(value) {
|
|
543
733
|
const parsed = Number.parseInt(value, 10);
|
|
544
734
|
if (Number.isNaN(parsed)) {
|
|
@@ -547,7 +737,9 @@ function parseMaxDepth(value) {
|
|
|
547
737
|
return parsed;
|
|
548
738
|
}
|
|
549
739
|
|
|
740
|
+
/** @param {string[]} args */
|
|
550
741
|
function parseHistoryArgs(args) {
|
|
742
|
+
/** @type {{node: string|null}} */
|
|
551
743
|
const options = { node: null };
|
|
552
744
|
|
|
553
745
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -578,6 +770,10 @@ function parseHistoryArgs(args) {
|
|
|
578
770
|
return options;
|
|
579
771
|
}
|
|
580
772
|
|
|
773
|
+
/**
|
|
774
|
+
* @param {*} patch
|
|
775
|
+
* @param {string} nodeId
|
|
776
|
+
*/
|
|
581
777
|
function patchTouchesNode(patch, nodeId) {
|
|
582
778
|
const ops = Array.isArray(patch?.ops) ? patch.ops : [];
|
|
583
779
|
for (const op of ops) {
|
|
@@ -591,6 +787,7 @@ function patchTouchesNode(patch, nodeId) {
|
|
|
591
787
|
return false;
|
|
592
788
|
}
|
|
593
789
|
|
|
790
|
+
/** @param {*} payload */
|
|
594
791
|
function renderInfo(payload) {
|
|
595
792
|
const lines = [`Repo: ${payload.repo}`];
|
|
596
793
|
lines.push(`Graphs: ${payload.graphs.length}`);
|
|
@@ -603,10 +800,14 @@ function renderInfo(payload) {
|
|
|
603
800
|
if (graph.coverage?.sha) {
|
|
604
801
|
lines.push(` coverage: ${graph.coverage.sha}`);
|
|
605
802
|
}
|
|
803
|
+
if (graph.cursor?.active) {
|
|
804
|
+
lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
|
|
805
|
+
}
|
|
606
806
|
}
|
|
607
807
|
return `${lines.join('\n')}\n`;
|
|
608
808
|
}
|
|
609
809
|
|
|
810
|
+
/** @param {*} payload */
|
|
610
811
|
function renderQuery(payload) {
|
|
611
812
|
const lines = [
|
|
612
813
|
`Graph: ${payload.graph}`,
|
|
@@ -625,6 +826,7 @@ function renderQuery(payload) {
|
|
|
625
826
|
return `${lines.join('\n')}\n`;
|
|
626
827
|
}
|
|
627
828
|
|
|
829
|
+
/** @param {*} payload */
|
|
628
830
|
function renderPath(payload) {
|
|
629
831
|
const lines = [
|
|
630
832
|
`Graph: ${payload.graph}`,
|
|
@@ -647,6 +849,7 @@ const ANSI_RED = '\x1b[31m';
|
|
|
647
849
|
const ANSI_DIM = '\x1b[2m';
|
|
648
850
|
const ANSI_RESET = '\x1b[0m';
|
|
649
851
|
|
|
852
|
+
/** @param {string} state */
|
|
650
853
|
function colorCachedState(state) {
|
|
651
854
|
if (state === 'fresh') {
|
|
652
855
|
return `${ANSI_GREEN}${state}${ANSI_RESET}`;
|
|
@@ -657,6 +860,7 @@ function colorCachedState(state) {
|
|
|
657
860
|
return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
|
|
658
861
|
}
|
|
659
862
|
|
|
863
|
+
/** @param {*} payload */
|
|
660
864
|
function renderCheck(payload) {
|
|
661
865
|
const lines = [
|
|
662
866
|
`Graph: ${payload.graph}`,
|
|
@@ -707,6 +911,7 @@ function renderCheck(payload) {
|
|
|
707
911
|
return `${lines.join('\n')}\n`;
|
|
708
912
|
}
|
|
709
913
|
|
|
914
|
+
/** @param {*} hook */
|
|
710
915
|
function formatHookStatusLine(hook) {
|
|
711
916
|
if (!hook.installed && hook.foreign) {
|
|
712
917
|
return "Hook: foreign hook present — run 'git warp install-hooks'";
|
|
@@ -720,6 +925,7 @@ function formatHookStatusLine(hook) {
|
|
|
720
925
|
return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
|
|
721
926
|
}
|
|
722
927
|
|
|
928
|
+
/** @param {*} payload */
|
|
723
929
|
function renderHistory(payload) {
|
|
724
930
|
const lines = [
|
|
725
931
|
`Graph: ${payload.graph}`,
|
|
@@ -738,10 +944,28 @@ function renderHistory(payload) {
|
|
|
738
944
|
return `${lines.join('\n')}\n`;
|
|
739
945
|
}
|
|
740
946
|
|
|
947
|
+
/** @param {*} payload */
|
|
741
948
|
function renderError(payload) {
|
|
742
949
|
return `Error: ${payload.error.message}\n`;
|
|
743
950
|
}
|
|
744
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Wraps SVG content in a minimal HTML document and writes it to disk.
|
|
954
|
+
* @param {string} filePath - Destination file path
|
|
955
|
+
* @param {string} svgContent - SVG markup to embed
|
|
956
|
+
*/
|
|
957
|
+
function writeHtmlExport(filePath, svgContent) {
|
|
958
|
+
const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
|
|
959
|
+
fs.writeFileSync(filePath, html);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Writes a command result to stdout/stderr in the appropriate format.
|
|
964
|
+
* Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
|
|
965
|
+
* based on the combination of flags.
|
|
966
|
+
* @param {*} payload - Command result payload
|
|
967
|
+
* @param {{json: boolean, command: string, view: string|null}} options
|
|
968
|
+
*/
|
|
745
969
|
function emit(payload, { json, command, view }) {
|
|
746
970
|
if (json) {
|
|
747
971
|
process.stdout.write(`${stableStringify(payload)}\n`);
|
|
@@ -766,6 +990,14 @@ function emit(payload, { json, command, view }) {
|
|
|
766
990
|
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
767
991
|
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
768
992
|
}
|
|
993
|
+
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
994
|
+
const htmlPath = view.slice(5);
|
|
995
|
+
if (!payload._renderedSvg) {
|
|
996
|
+
process.stderr.write('No graph data — skipping HTML export.\n');
|
|
997
|
+
} else {
|
|
998
|
+
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
999
|
+
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
1000
|
+
}
|
|
769
1001
|
} else if (view) {
|
|
770
1002
|
process.stdout.write(`${payload._renderedAscii}\n`);
|
|
771
1003
|
} else {
|
|
@@ -783,6 +1015,14 @@ function emit(payload, { json, command, view }) {
|
|
|
783
1015
|
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
784
1016
|
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
785
1017
|
}
|
|
1018
|
+
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
1019
|
+
const htmlPath = view.slice(5);
|
|
1020
|
+
if (!payload._renderedSvg) {
|
|
1021
|
+
process.stderr.write('No path found — skipping HTML export.\n');
|
|
1022
|
+
} else {
|
|
1023
|
+
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
1024
|
+
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
1025
|
+
}
|
|
786
1026
|
} else if (view) {
|
|
787
1027
|
process.stdout.write(renderPathView(payload));
|
|
788
1028
|
} else {
|
|
@@ -818,6 +1058,15 @@ function emit(payload, { json, command, view }) {
|
|
|
818
1058
|
return;
|
|
819
1059
|
}
|
|
820
1060
|
|
|
1061
|
+
if (command === 'seek') {
|
|
1062
|
+
if (view) {
|
|
1063
|
+
process.stdout.write(renderSeekView(payload));
|
|
1064
|
+
} else {
|
|
1065
|
+
process.stdout.write(renderSeek(payload));
|
|
1066
|
+
}
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
821
1070
|
if (command === 'install-hooks') {
|
|
822
1071
|
process.stdout.write(renderInstallHooks(payload));
|
|
823
1072
|
return;
|
|
@@ -833,9 +1082,8 @@ function emit(payload, { json, command, view }) {
|
|
|
833
1082
|
|
|
834
1083
|
/**
|
|
835
1084
|
* Handles the `info` command: summarizes graphs in the repository.
|
|
836
|
-
* @param {
|
|
837
|
-
* @
|
|
838
|
-
* @returns {Promise<{repo: string, graphs: Object[]}>} Info payload
|
|
1085
|
+
* @param {{options: CliOptions}} params
|
|
1086
|
+
* @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>} Info payload
|
|
839
1087
|
* @throws {CliError} If the specified graph is not found
|
|
840
1088
|
*/
|
|
841
1089
|
async function handleInfo({ options }) {
|
|
@@ -859,12 +1107,19 @@ async function handleInfo({ options }) {
|
|
|
859
1107
|
const graphs = [];
|
|
860
1108
|
for (const name of graphNames) {
|
|
861
1109
|
const includeDetails = detailGraphs.has(name);
|
|
862
|
-
|
|
1110
|
+
const info = await getGraphInfo(persistence, name, {
|
|
863
1111
|
includeWriterIds: includeDetails || isViewMode,
|
|
864
1112
|
includeRefs: includeDetails || isViewMode,
|
|
865
1113
|
includeWriterPatches: isViewMode,
|
|
866
1114
|
includeCheckpointDate: isViewMode,
|
|
867
|
-
})
|
|
1115
|
+
});
|
|
1116
|
+
const activeCursor = await readActiveCursor(persistence, name);
|
|
1117
|
+
if (activeCursor) {
|
|
1118
|
+
info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
|
|
1119
|
+
} else {
|
|
1120
|
+
info.cursor = { active: false };
|
|
1121
|
+
}
|
|
1122
|
+
graphs.push(info);
|
|
868
1123
|
}
|
|
869
1124
|
|
|
870
1125
|
return {
|
|
@@ -875,15 +1130,15 @@ async function handleInfo({ options }) {
|
|
|
875
1130
|
|
|
876
1131
|
/**
|
|
877
1132
|
* Handles the `query` command: runs a logical graph query.
|
|
878
|
-
* @param {
|
|
879
|
-
* @
|
|
880
|
-
* @param {string[]} params.args - Remaining positional arguments (query spec)
|
|
881
|
-
* @returns {Promise<{payload: Object, exitCode: number}>} Query result payload
|
|
1133
|
+
* @param {{options: CliOptions, args: string[]}} params
|
|
1134
|
+
* @returns {Promise<{payload: *, exitCode: number}>} Query result payload
|
|
882
1135
|
* @throws {CliError} On invalid query options or query execution errors
|
|
883
1136
|
*/
|
|
884
1137
|
async function handleQuery({ options, args }) {
|
|
885
1138
|
const querySpec = parseQueryArgs(args);
|
|
886
|
-
const { graph, graphName } = await openGraph(options);
|
|
1139
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
1140
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1141
|
+
emitCursorWarning(cursorInfo, null);
|
|
887
1142
|
let builder = graph.query();
|
|
888
1143
|
|
|
889
1144
|
if (querySpec.match !== null) {
|
|
@@ -904,7 +1159,7 @@ async function handleQuery({ options, args }) {
|
|
|
904
1159
|
const edges = await graph.getEdges();
|
|
905
1160
|
const graphData = queryResultToGraphData(payload, edges);
|
|
906
1161
|
const positioned = await layoutGraph(graphData, { type: 'query' });
|
|
907
|
-
if (typeof options.view === 'string' && options.view.startsWith('svg:')) {
|
|
1162
|
+
if (typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
|
|
908
1163
|
payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
|
|
909
1164
|
} else {
|
|
910
1165
|
payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
|
|
@@ -920,6 +1175,10 @@ async function handleQuery({ options, args }) {
|
|
|
920
1175
|
}
|
|
921
1176
|
}
|
|
922
1177
|
|
|
1178
|
+
/**
|
|
1179
|
+
* @param {*} builder
|
|
1180
|
+
* @param {Array<{type: string, label?: string, key?: string, value?: string}>} steps
|
|
1181
|
+
*/
|
|
923
1182
|
function applyQuerySteps(builder, steps) {
|
|
924
1183
|
let current = builder;
|
|
925
1184
|
for (const step of steps) {
|
|
@@ -928,6 +1187,10 @@ function applyQuerySteps(builder, steps) {
|
|
|
928
1187
|
return current;
|
|
929
1188
|
}
|
|
930
1189
|
|
|
1190
|
+
/**
|
|
1191
|
+
* @param {*} builder
|
|
1192
|
+
* @param {{type: string, label?: string, key?: string, value?: string}} step
|
|
1193
|
+
*/
|
|
931
1194
|
function applyQueryStep(builder, step) {
|
|
932
1195
|
if (step.type === 'outgoing') {
|
|
933
1196
|
return builder.outgoing(step.label);
|
|
@@ -936,11 +1199,16 @@ function applyQueryStep(builder, step) {
|
|
|
936
1199
|
return builder.incoming(step.label);
|
|
937
1200
|
}
|
|
938
1201
|
if (step.type === 'where-prop') {
|
|
939
|
-
return builder.where((node) => matchesPropFilter(node, step.key, step.value));
|
|
1202
|
+
return builder.where((/** @type {*} */ node) => matchesPropFilter(node, /** @type {string} */ (step.key), /** @type {string} */ (step.value))); // TODO(ts-cleanup): type CLI payload
|
|
940
1203
|
}
|
|
941
1204
|
return builder;
|
|
942
1205
|
}
|
|
943
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* @param {*} node
|
|
1209
|
+
* @param {string} key
|
|
1210
|
+
* @param {string} value
|
|
1211
|
+
*/
|
|
944
1212
|
function matchesPropFilter(node, key, value) {
|
|
945
1213
|
const props = node.props || {};
|
|
946
1214
|
if (!Object.prototype.hasOwnProperty.call(props, key)) {
|
|
@@ -949,6 +1217,11 @@ function matchesPropFilter(node, key, value) {
|
|
|
949
1217
|
return String(props[key]) === value;
|
|
950
1218
|
}
|
|
951
1219
|
|
|
1220
|
+
/**
|
|
1221
|
+
* @param {string} graphName
|
|
1222
|
+
* @param {*} result
|
|
1223
|
+
* @returns {{graph: string, stateHash: *, nodes: *, _renderedSvg?: string, _renderedAscii?: string}}
|
|
1224
|
+
*/
|
|
952
1225
|
function buildQueryPayload(graphName, result) {
|
|
953
1226
|
return {
|
|
954
1227
|
graph: graphName,
|
|
@@ -957,6 +1230,7 @@ function buildQueryPayload(graphName, result) {
|
|
|
957
1230
|
};
|
|
958
1231
|
}
|
|
959
1232
|
|
|
1233
|
+
/** @param {*} error */
|
|
960
1234
|
function mapQueryError(error) {
|
|
961
1235
|
if (error && error.code && String(error.code).startsWith('E_QUERY')) {
|
|
962
1236
|
throw usageError(error.message);
|
|
@@ -966,15 +1240,15 @@ function mapQueryError(error) {
|
|
|
966
1240
|
|
|
967
1241
|
/**
|
|
968
1242
|
* Handles the `path` command: finds a shortest path between two nodes.
|
|
969
|
-
* @param {
|
|
970
|
-
* @
|
|
971
|
-
* @param {string[]} params.args - Remaining positional arguments (path spec)
|
|
972
|
-
* @returns {Promise<{payload: Object, exitCode: number}>} Path result payload
|
|
1243
|
+
* @param {{options: CliOptions, args: string[]}} params
|
|
1244
|
+
* @returns {Promise<{payload: *, exitCode: number}>} Path result payload
|
|
973
1245
|
* @throws {CliError} If --from/--to are missing or a node is not found
|
|
974
1246
|
*/
|
|
975
1247
|
async function handlePath({ options, args }) {
|
|
976
1248
|
const pathOptions = parsePathArgs(args);
|
|
977
|
-
const { graph, graphName } = await openGraph(options);
|
|
1249
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
1250
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1251
|
+
emitCursorWarning(cursorInfo, null);
|
|
978
1252
|
|
|
979
1253
|
try {
|
|
980
1254
|
const result = await graph.traverse.shortestPath(
|
|
@@ -994,7 +1268,7 @@ async function handlePath({ options, args }) {
|
|
|
994
1268
|
...result,
|
|
995
1269
|
};
|
|
996
1270
|
|
|
997
|
-
if (options.view && result.found && typeof options.view === 'string' && options.view.startsWith('svg:')) {
|
|
1271
|
+
if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
|
|
998
1272
|
const graphData = pathResultToGraphData(payload);
|
|
999
1273
|
const positioned = await layoutGraph(graphData, { type: 'path' });
|
|
1000
1274
|
payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
|
|
@@ -1004,7 +1278,7 @@ async function handlePath({ options, args }) {
|
|
|
1004
1278
|
payload,
|
|
1005
1279
|
exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
|
|
1006
1280
|
};
|
|
1007
|
-
} catch (error) {
|
|
1281
|
+
} catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error
|
|
1008
1282
|
if (error && error.code === 'NODE_NOT_FOUND') {
|
|
1009
1283
|
throw notFoundError(error.message);
|
|
1010
1284
|
}
|
|
@@ -1014,12 +1288,13 @@ async function handlePath({ options, args }) {
|
|
|
1014
1288
|
|
|
1015
1289
|
/**
|
|
1016
1290
|
* Handles the `check` command: reports graph health, GC, and hook status.
|
|
1017
|
-
* @param {
|
|
1018
|
-
* @
|
|
1019
|
-
* @returns {Promise<{payload: Object, exitCode: number}>} Health check payload
|
|
1291
|
+
* @param {{options: CliOptions}} params
|
|
1292
|
+
* @returns {Promise<{payload: *, exitCode: number}>} Health check payload
|
|
1020
1293
|
*/
|
|
1021
1294
|
async function handleCheck({ options }) {
|
|
1022
1295
|
const { graph, graphName, persistence } = await openGraph(options);
|
|
1296
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1297
|
+
emitCursorWarning(cursorInfo, null);
|
|
1023
1298
|
const health = await getHealth(persistence);
|
|
1024
1299
|
const gcMetrics = await getGcMetrics(graph);
|
|
1025
1300
|
const status = await graph.status();
|
|
@@ -1044,17 +1319,20 @@ async function handleCheck({ options }) {
|
|
|
1044
1319
|
};
|
|
1045
1320
|
}
|
|
1046
1321
|
|
|
1322
|
+
/** @param {Persistence} persistence */
|
|
1047
1323
|
async function getHealth(persistence) {
|
|
1048
1324
|
const clock = ClockAdapter.node();
|
|
1049
|
-
const healthService = new HealthCheckService({ persistence, clock });
|
|
1325
|
+
const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type
|
|
1050
1326
|
return await healthService.getHealth();
|
|
1051
1327
|
}
|
|
1052
1328
|
|
|
1329
|
+
/** @param {WarpGraphInstance} graph */
|
|
1053
1330
|
async function getGcMetrics(graph) {
|
|
1054
1331
|
await graph.materialize();
|
|
1055
1332
|
return graph.getGCMetrics();
|
|
1056
1333
|
}
|
|
1057
1334
|
|
|
1335
|
+
/** @param {WarpGraphInstance} graph */
|
|
1058
1336
|
async function collectWriterHeads(graph) {
|
|
1059
1337
|
const frontier = await graph.getFrontier();
|
|
1060
1338
|
return [...frontier.entries()]
|
|
@@ -1062,6 +1340,10 @@ async function collectWriterHeads(graph) {
|
|
|
1062
1340
|
.map(([writerId, sha]) => ({ writerId, sha }));
|
|
1063
1341
|
}
|
|
1064
1342
|
|
|
1343
|
+
/**
|
|
1344
|
+
* @param {Persistence} persistence
|
|
1345
|
+
* @param {string} graphName
|
|
1346
|
+
*/
|
|
1065
1347
|
async function loadCheckpointInfo(persistence, graphName) {
|
|
1066
1348
|
const checkpointRef = buildCheckpointRef(graphName);
|
|
1067
1349
|
const checkpointSha = await persistence.readRef(checkpointRef);
|
|
@@ -1076,6 +1358,10 @@ async function loadCheckpointInfo(persistence, graphName) {
|
|
|
1076
1358
|
};
|
|
1077
1359
|
}
|
|
1078
1360
|
|
|
1361
|
+
/**
|
|
1362
|
+
* @param {Persistence} persistence
|
|
1363
|
+
* @param {string|null} checkpointSha
|
|
1364
|
+
*/
|
|
1079
1365
|
async function readCheckpointDate(persistence, checkpointSha) {
|
|
1080
1366
|
if (!checkpointSha) {
|
|
1081
1367
|
return null;
|
|
@@ -1084,6 +1370,7 @@ async function readCheckpointDate(persistence, checkpointSha) {
|
|
|
1084
1370
|
return info.date || null;
|
|
1085
1371
|
}
|
|
1086
1372
|
|
|
1373
|
+
/** @param {string|null} checkpointDate */
|
|
1087
1374
|
function computeAgeSeconds(checkpointDate) {
|
|
1088
1375
|
if (!checkpointDate) {
|
|
1089
1376
|
return null;
|
|
@@ -1095,6 +1382,11 @@ function computeAgeSeconds(checkpointDate) {
|
|
|
1095
1382
|
return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
|
|
1096
1383
|
}
|
|
1097
1384
|
|
|
1385
|
+
/**
|
|
1386
|
+
* @param {Persistence} persistence
|
|
1387
|
+
* @param {string} graphName
|
|
1388
|
+
* @param {Array<{writerId: string, sha: string}>} writerHeads
|
|
1389
|
+
*/
|
|
1098
1390
|
async function loadCoverageInfo(persistence, graphName, writerHeads) {
|
|
1099
1391
|
const coverageRef = buildCoverageRef(graphName);
|
|
1100
1392
|
const coverageSha = await persistence.readRef(coverageRef);
|
|
@@ -1109,6 +1401,11 @@ async function loadCoverageInfo(persistence, graphName, writerHeads) {
|
|
|
1109
1401
|
};
|
|
1110
1402
|
}
|
|
1111
1403
|
|
|
1404
|
+
/**
|
|
1405
|
+
* @param {Persistence} persistence
|
|
1406
|
+
* @param {Array<{writerId: string, sha: string}>} writerHeads
|
|
1407
|
+
* @param {string} coverageSha
|
|
1408
|
+
*/
|
|
1112
1409
|
async function findMissingWriters(persistence, writerHeads, coverageSha) {
|
|
1113
1410
|
const missing = [];
|
|
1114
1411
|
for (const head of writerHeads) {
|
|
@@ -1120,6 +1417,9 @@ async function findMissingWriters(persistence, writerHeads, coverageSha) {
|
|
|
1120
1417
|
return missing;
|
|
1121
1418
|
}
|
|
1122
1419
|
|
|
1420
|
+
/**
|
|
1421
|
+
* @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params
|
|
1422
|
+
*/
|
|
1123
1423
|
function buildCheckPayload({
|
|
1124
1424
|
repo,
|
|
1125
1425
|
graphName,
|
|
@@ -1149,24 +1449,28 @@ function buildCheckPayload({
|
|
|
1149
1449
|
|
|
1150
1450
|
/**
|
|
1151
1451
|
* Handles the `history` command: shows patch history for a writer.
|
|
1152
|
-
* @param {
|
|
1153
|
-
* @
|
|
1154
|
-
* @param {string[]} params.args - Remaining positional arguments (history options)
|
|
1155
|
-
* @returns {Promise<{payload: Object, exitCode: number}>} History payload
|
|
1452
|
+
* @param {{options: CliOptions, args: string[]}} params
|
|
1453
|
+
* @returns {Promise<{payload: *, exitCode: number}>} History payload
|
|
1156
1454
|
* @throws {CliError} If no patches are found for the writer
|
|
1157
1455
|
*/
|
|
1158
1456
|
async function handleHistory({ options, args }) {
|
|
1159
1457
|
const historyOptions = parseHistoryArgs(args);
|
|
1160
|
-
const { graph, graphName } = await openGraph(options);
|
|
1458
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
1459
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1460
|
+
emitCursorWarning(cursorInfo, null);
|
|
1461
|
+
|
|
1161
1462
|
const writerId = options.writer;
|
|
1162
|
-
|
|
1463
|
+
let patches = await graph.getWriterPatches(writerId);
|
|
1464
|
+
if (cursorInfo.active) {
|
|
1465
|
+
patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
|
|
1466
|
+
}
|
|
1163
1467
|
if (patches.length === 0) {
|
|
1164
1468
|
throw notFoundError(`No patches found for writer: ${writerId}`);
|
|
1165
1469
|
}
|
|
1166
1470
|
|
|
1167
1471
|
const entries = patches
|
|
1168
|
-
.filter(({ patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node))
|
|
1169
|
-
.map(({ patch, sha }) => ({
|
|
1472
|
+
.filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
|
|
1473
|
+
.map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
|
|
1170
1474
|
sha,
|
|
1171
1475
|
schema: patch.schema,
|
|
1172
1476
|
lamport: patch.lamport,
|
|
@@ -1184,15 +1488,23 @@ async function handleHistory({ options, args }) {
|
|
|
1184
1488
|
return { payload, exitCode: EXIT_CODES.OK };
|
|
1185
1489
|
}
|
|
1186
1490
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1491
|
+
/**
|
|
1492
|
+
* Materializes a single graph, creates a checkpoint, and returns summary stats.
|
|
1493
|
+
* When a ceiling tick is provided (seek cursor active), the checkpoint step is
|
|
1494
|
+
* skipped because the user is exploring historical state, not persisting it.
|
|
1495
|
+
* @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params
|
|
1496
|
+
* @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record<string, number>, patchCount: number}>}
|
|
1497
|
+
*/
|
|
1498
|
+
async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
|
|
1499
|
+
const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
|
|
1500
|
+
await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
|
|
1190
1501
|
const nodes = await graph.getNodes();
|
|
1191
1502
|
const edges = await graph.getEdges();
|
|
1192
|
-
const checkpoint = await graph.createCheckpoint();
|
|
1503
|
+
const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
|
|
1193
1504
|
const status = await graph.status();
|
|
1194
1505
|
|
|
1195
1506
|
// Build per-writer patch counts for the view renderer
|
|
1507
|
+
/** @type {Record<string, number>} */
|
|
1196
1508
|
const writers = {};
|
|
1197
1509
|
let totalPatchCount = 0;
|
|
1198
1510
|
for (const wId of Object.keys(status.frontier)) {
|
|
@@ -1216,9 +1528,8 @@ async function materializeOneGraph({ persistence, graphName, writerId }) {
|
|
|
1216
1528
|
|
|
1217
1529
|
/**
|
|
1218
1530
|
* Handles the `materialize` command: materializes and checkpoints all graphs.
|
|
1219
|
-
* @param {
|
|
1220
|
-
* @
|
|
1221
|
-
* @returns {Promise<{payload: Object, exitCode: number}>} Materialize result payload
|
|
1531
|
+
* @param {{options: CliOptions}} params
|
|
1532
|
+
* @returns {Promise<{payload: *, exitCode: number}>} Materialize result payload
|
|
1222
1533
|
* @throws {CliError} If the specified graph is not found
|
|
1223
1534
|
*/
|
|
1224
1535
|
async function handleMaterialize({ options }) {
|
|
@@ -1241,12 +1552,20 @@ async function handleMaterialize({ options }) {
|
|
|
1241
1552
|
}
|
|
1242
1553
|
|
|
1243
1554
|
const results = [];
|
|
1555
|
+
let cursorWarningEmitted = false;
|
|
1244
1556
|
for (const name of targets) {
|
|
1245
1557
|
try {
|
|
1558
|
+
const cursor = await readActiveCursor(persistence, name);
|
|
1559
|
+
const ceiling = cursor ? cursor.tick : undefined;
|
|
1560
|
+
if (cursor && !cursorWarningEmitted) {
|
|
1561
|
+
emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
|
|
1562
|
+
cursorWarningEmitted = true;
|
|
1563
|
+
}
|
|
1246
1564
|
const result = await materializeOneGraph({
|
|
1247
1565
|
persistence,
|
|
1248
1566
|
graphName: name,
|
|
1249
1567
|
writerId: options.writer,
|
|
1568
|
+
ceiling,
|
|
1250
1569
|
});
|
|
1251
1570
|
results.push(result);
|
|
1252
1571
|
} catch (error) {
|
|
@@ -1257,13 +1576,14 @@ async function handleMaterialize({ options }) {
|
|
|
1257
1576
|
}
|
|
1258
1577
|
}
|
|
1259
1578
|
|
|
1260
|
-
const allFailed = results.every((r) => r.error);
|
|
1579
|
+
const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload
|
|
1261
1580
|
return {
|
|
1262
1581
|
payload: { graphs: results },
|
|
1263
1582
|
exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
|
|
1264
1583
|
};
|
|
1265
1584
|
}
|
|
1266
1585
|
|
|
1586
|
+
/** @param {*} payload */
|
|
1267
1587
|
function renderMaterialize(payload) {
|
|
1268
1588
|
if (payload.graphs.length === 0) {
|
|
1269
1589
|
return 'No graphs found in repo.\n';
|
|
@@ -1280,6 +1600,7 @@ function renderMaterialize(payload) {
|
|
|
1280
1600
|
return `${lines.join('\n')}\n`;
|
|
1281
1601
|
}
|
|
1282
1602
|
|
|
1603
|
+
/** @param {*} payload */
|
|
1283
1604
|
function renderInstallHooks(payload) {
|
|
1284
1605
|
if (payload.action === 'up-to-date') {
|
|
1285
1606
|
return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
|
|
@@ -1300,7 +1621,7 @@ function createHookInstaller() {
|
|
|
1300
1621
|
const templateDir = path.resolve(__dirname, '..', 'hooks');
|
|
1301
1622
|
const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
1302
1623
|
return new HookInstaller({
|
|
1303
|
-
fs,
|
|
1624
|
+
fs: /** @type {*} */ (fs), // TODO(ts-cleanup): narrow port type
|
|
1304
1625
|
execGitConfig: execGitConfigValue,
|
|
1305
1626
|
version,
|
|
1306
1627
|
templateDir,
|
|
@@ -1308,6 +1629,11 @@ function createHookInstaller() {
|
|
|
1308
1629
|
});
|
|
1309
1630
|
}
|
|
1310
1631
|
|
|
1632
|
+
/**
|
|
1633
|
+
* @param {string} repoPath
|
|
1634
|
+
* @param {string} key
|
|
1635
|
+
* @returns {string|null}
|
|
1636
|
+
*/
|
|
1311
1637
|
function execGitConfigValue(repoPath, key) {
|
|
1312
1638
|
try {
|
|
1313
1639
|
if (key === '--git-dir') {
|
|
@@ -1327,6 +1653,7 @@ function isInteractive() {
|
|
|
1327
1653
|
return Boolean(process.stderr.isTTY);
|
|
1328
1654
|
}
|
|
1329
1655
|
|
|
1656
|
+
/** @param {string} question @returns {Promise<string>} */
|
|
1330
1657
|
function promptUser(question) {
|
|
1331
1658
|
const rl = readline.createInterface({
|
|
1332
1659
|
input: process.stdin,
|
|
@@ -1340,6 +1667,7 @@ function promptUser(question) {
|
|
|
1340
1667
|
});
|
|
1341
1668
|
}
|
|
1342
1669
|
|
|
1670
|
+
/** @param {string[]} args */
|
|
1343
1671
|
function parseInstallHooksArgs(args) {
|
|
1344
1672
|
const options = { force: false };
|
|
1345
1673
|
for (const arg of args) {
|
|
@@ -1352,6 +1680,10 @@ function parseInstallHooksArgs(args) {
|
|
|
1352
1680
|
return options;
|
|
1353
1681
|
}
|
|
1354
1682
|
|
|
1683
|
+
/**
|
|
1684
|
+
* @param {*} classification
|
|
1685
|
+
* @param {{force: boolean}} hookOptions
|
|
1686
|
+
*/
|
|
1355
1687
|
async function resolveStrategy(classification, hookOptions) {
|
|
1356
1688
|
if (hookOptions.force) {
|
|
1357
1689
|
return 'replace';
|
|
@@ -1368,6 +1700,7 @@ async function resolveStrategy(classification, hookOptions) {
|
|
|
1368
1700
|
return await promptForForeignStrategy();
|
|
1369
1701
|
}
|
|
1370
1702
|
|
|
1703
|
+
/** @param {*} classification */
|
|
1371
1704
|
async function promptForOursStrategy(classification) {
|
|
1372
1705
|
const installer = createHookInstaller();
|
|
1373
1706
|
if (classification.version === installer._version) {
|
|
@@ -1409,10 +1742,8 @@ async function promptForForeignStrategy() {
|
|
|
1409
1742
|
|
|
1410
1743
|
/**
|
|
1411
1744
|
* Handles the `install-hooks` command: installs or upgrades the post-merge git hook.
|
|
1412
|
-
* @param {
|
|
1413
|
-
* @
|
|
1414
|
-
* @param {string[]} params.args - Remaining positional arguments (install-hooks options)
|
|
1415
|
-
* @returns {Promise<{payload: Object, exitCode: number}>} Install result payload
|
|
1745
|
+
* @param {{options: CliOptions, args: string[]}} params
|
|
1746
|
+
* @returns {Promise<{payload: *, exitCode: number}>} Install result payload
|
|
1416
1747
|
* @throws {CliError} If an existing hook is found and the session is not interactive
|
|
1417
1748
|
*/
|
|
1418
1749
|
async function handleInstallHooks({ options, args }) {
|
|
@@ -1448,6 +1779,7 @@ async function handleInstallHooks({ options, args }) {
|
|
|
1448
1779
|
};
|
|
1449
1780
|
}
|
|
1450
1781
|
|
|
1782
|
+
/** @param {string} hookPath */
|
|
1451
1783
|
function readHookContent(hookPath) {
|
|
1452
1784
|
try {
|
|
1453
1785
|
return fs.readFileSync(hookPath, 'utf8');
|
|
@@ -1456,6 +1788,7 @@ function readHookContent(hookPath) {
|
|
|
1456
1788
|
}
|
|
1457
1789
|
}
|
|
1458
1790
|
|
|
1791
|
+
/** @param {string} repoPath */
|
|
1459
1792
|
function getHookStatusForCheck(repoPath) {
|
|
1460
1793
|
try {
|
|
1461
1794
|
const installer = createHookInstaller();
|
|
@@ -1465,6 +1798,850 @@ function getHookStatusForCheck(repoPath) {
|
|
|
1465
1798
|
}
|
|
1466
1799
|
}
|
|
1467
1800
|
|
|
1801
|
+
// ============================================================================
|
|
1802
|
+
// Cursor I/O Helpers
|
|
1803
|
+
// ============================================================================
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* Reads the active seek cursor for a graph from Git ref storage.
|
|
1807
|
+
*
|
|
1808
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1809
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1810
|
+
* @returns {Promise<CursorBlob|null>} Cursor object, or null if no active cursor
|
|
1811
|
+
* @throws {Error} If the stored blob is corrupted or not valid JSON
|
|
1812
|
+
*/
|
|
1813
|
+
async function readActiveCursor(persistence, graphName) {
|
|
1814
|
+
const ref = buildCursorActiveRef(graphName);
|
|
1815
|
+
const oid = await persistence.readRef(ref);
|
|
1816
|
+
if (!oid) {
|
|
1817
|
+
return null;
|
|
1818
|
+
}
|
|
1819
|
+
const buf = await persistence.readBlob(oid);
|
|
1820
|
+
return parseCursorBlob(buf, 'active cursor');
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Writes (creates or overwrites) the active seek cursor for a graph.
|
|
1825
|
+
*
|
|
1826
|
+
* Serializes the cursor as JSON, stores it as a Git blob, and points
|
|
1827
|
+
* the active cursor ref at that blob.
|
|
1828
|
+
*
|
|
1829
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1830
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1831
|
+
* @param {CursorBlob} cursor - Cursor state to persist
|
|
1832
|
+
* @returns {Promise<void>}
|
|
1833
|
+
*/
|
|
1834
|
+
async function writeActiveCursor(persistence, graphName, cursor) {
|
|
1835
|
+
const ref = buildCursorActiveRef(graphName);
|
|
1836
|
+
const json = JSON.stringify(cursor);
|
|
1837
|
+
const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
|
|
1838
|
+
await persistence.updateRef(ref, oid);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* Removes the active seek cursor for a graph, returning to present state.
|
|
1843
|
+
*
|
|
1844
|
+
* No-op if no active cursor exists.
|
|
1845
|
+
*
|
|
1846
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1847
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1848
|
+
* @returns {Promise<void>}
|
|
1849
|
+
*/
|
|
1850
|
+
async function clearActiveCursor(persistence, graphName) {
|
|
1851
|
+
const ref = buildCursorActiveRef(graphName);
|
|
1852
|
+
const exists = await persistence.readRef(ref);
|
|
1853
|
+
if (exists) {
|
|
1854
|
+
await persistence.deleteRef(ref);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Reads a named saved cursor from Git ref storage.
|
|
1860
|
+
*
|
|
1861
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1862
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1863
|
+
* @param {string} name - Saved cursor name
|
|
1864
|
+
* @returns {Promise<CursorBlob|null>} Cursor object, or null if not found
|
|
1865
|
+
* @throws {Error} If the stored blob is corrupted or not valid JSON
|
|
1866
|
+
*/
|
|
1867
|
+
async function readSavedCursor(persistence, graphName, name) {
|
|
1868
|
+
const ref = buildCursorSavedRef(graphName, name);
|
|
1869
|
+
const oid = await persistence.readRef(ref);
|
|
1870
|
+
if (!oid) {
|
|
1871
|
+
return null;
|
|
1872
|
+
}
|
|
1873
|
+
const buf = await persistence.readBlob(oid);
|
|
1874
|
+
return parseCursorBlob(buf, `saved cursor '${name}'`);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Persists a cursor under a named saved-cursor ref.
|
|
1879
|
+
*
|
|
1880
|
+
* Serializes the cursor as JSON, stores it as a Git blob, and points
|
|
1881
|
+
* the named saved-cursor ref at that blob.
|
|
1882
|
+
*
|
|
1883
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1884
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1885
|
+
* @param {string} name - Saved cursor name
|
|
1886
|
+
* @param {CursorBlob} cursor - Cursor state to persist
|
|
1887
|
+
* @returns {Promise<void>}
|
|
1888
|
+
*/
|
|
1889
|
+
async function writeSavedCursor(persistence, graphName, name, cursor) {
|
|
1890
|
+
const ref = buildCursorSavedRef(graphName, name);
|
|
1891
|
+
const json = JSON.stringify(cursor);
|
|
1892
|
+
const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
|
|
1893
|
+
await persistence.updateRef(ref, oid);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Deletes a named saved cursor from Git ref storage.
|
|
1898
|
+
*
|
|
1899
|
+
* No-op if the named cursor does not exist.
|
|
1900
|
+
*
|
|
1901
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1902
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1903
|
+
* @param {string} name - Saved cursor name to delete
|
|
1904
|
+
* @returns {Promise<void>}
|
|
1905
|
+
*/
|
|
1906
|
+
async function deleteSavedCursor(persistence, graphName, name) {
|
|
1907
|
+
const ref = buildCursorSavedRef(graphName, name);
|
|
1908
|
+
const exists = await persistence.readRef(ref);
|
|
1909
|
+
if (exists) {
|
|
1910
|
+
await persistence.deleteRef(ref);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* Lists all saved cursors for a graph, reading each blob to include full cursor state.
|
|
1916
|
+
*
|
|
1917
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1918
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1919
|
+
* @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
|
|
1920
|
+
* @throws {Error} If any stored blob is corrupted or not valid JSON
|
|
1921
|
+
*/
|
|
1922
|
+
async function listSavedCursors(persistence, graphName) {
|
|
1923
|
+
const prefix = buildCursorSavedPrefix(graphName);
|
|
1924
|
+
const refs = await persistence.listRefs(prefix);
|
|
1925
|
+
const cursors = [];
|
|
1926
|
+
for (const ref of refs) {
|
|
1927
|
+
const name = ref.slice(prefix.length);
|
|
1928
|
+
if (name) {
|
|
1929
|
+
const oid = await persistence.readRef(ref);
|
|
1930
|
+
if (oid) {
|
|
1931
|
+
const buf = await persistence.readBlob(oid);
|
|
1932
|
+
const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
|
|
1933
|
+
cursors.push({ name, ...cursor });
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return cursors;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// ============================================================================
|
|
1941
|
+
// Seek Arg Parser
|
|
1942
|
+
// ============================================================================
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* @param {string} arg
|
|
1946
|
+
* @param {SeekSpec} spec
|
|
1947
|
+
*/
|
|
1948
|
+
function handleSeekBooleanFlag(arg, spec) {
|
|
1949
|
+
if (arg === '--clear-cache') {
|
|
1950
|
+
if (spec.action !== 'status') {
|
|
1951
|
+
throw usageError('--clear-cache cannot be combined with other seek flags');
|
|
1952
|
+
}
|
|
1953
|
+
spec.action = 'clear-cache';
|
|
1954
|
+
} else if (arg === '--no-persistent-cache') {
|
|
1955
|
+
spec.noPersistentCache = true;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* Parses CLI arguments for the `seek` command into a structured spec.
|
|
1961
|
+
* @param {string[]} args - Raw CLI arguments following the `seek` subcommand
|
|
1962
|
+
* @returns {SeekSpec} Parsed spec
|
|
1963
|
+
*/
|
|
1964
|
+
function parseSeekArgs(args) {
|
|
1965
|
+
/** @type {SeekSpec} */
|
|
1966
|
+
const spec = {
|
|
1967
|
+
action: 'status', // status, tick, latest, save, load, list, drop, clear-cache
|
|
1968
|
+
tickValue: null,
|
|
1969
|
+
name: null,
|
|
1970
|
+
noPersistentCache: false,
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
for (let i = 0; i < args.length; i++) {
|
|
1974
|
+
const arg = args[i];
|
|
1975
|
+
|
|
1976
|
+
if (arg === '--tick') {
|
|
1977
|
+
if (spec.action !== 'status') {
|
|
1978
|
+
throw usageError('--tick cannot be combined with other seek flags');
|
|
1979
|
+
}
|
|
1980
|
+
spec.action = 'tick';
|
|
1981
|
+
const val = args[i + 1];
|
|
1982
|
+
if (val === undefined) {
|
|
1983
|
+
throw usageError('Missing value for --tick');
|
|
1984
|
+
}
|
|
1985
|
+
spec.tickValue = val;
|
|
1986
|
+
i += 1;
|
|
1987
|
+
} else if (arg.startsWith('--tick=')) {
|
|
1988
|
+
if (spec.action !== 'status') {
|
|
1989
|
+
throw usageError('--tick cannot be combined with other seek flags');
|
|
1990
|
+
}
|
|
1991
|
+
spec.action = 'tick';
|
|
1992
|
+
spec.tickValue = arg.slice('--tick='.length);
|
|
1993
|
+
} else if (arg === '--latest') {
|
|
1994
|
+
if (spec.action !== 'status') {
|
|
1995
|
+
throw usageError('--latest cannot be combined with other seek flags');
|
|
1996
|
+
}
|
|
1997
|
+
spec.action = 'latest';
|
|
1998
|
+
} else if (arg === '--save') {
|
|
1999
|
+
if (spec.action !== 'status') {
|
|
2000
|
+
throw usageError('--save cannot be combined with other seek flags');
|
|
2001
|
+
}
|
|
2002
|
+
spec.action = 'save';
|
|
2003
|
+
const val = args[i + 1];
|
|
2004
|
+
if (val === undefined || val.startsWith('-')) {
|
|
2005
|
+
throw usageError('Missing name for --save');
|
|
2006
|
+
}
|
|
2007
|
+
spec.name = val;
|
|
2008
|
+
i += 1;
|
|
2009
|
+
} else if (arg.startsWith('--save=')) {
|
|
2010
|
+
if (spec.action !== 'status') {
|
|
2011
|
+
throw usageError('--save cannot be combined with other seek flags');
|
|
2012
|
+
}
|
|
2013
|
+
spec.action = 'save';
|
|
2014
|
+
spec.name = arg.slice('--save='.length);
|
|
2015
|
+
if (!spec.name) {
|
|
2016
|
+
throw usageError('Missing name for --save');
|
|
2017
|
+
}
|
|
2018
|
+
} else if (arg === '--load') {
|
|
2019
|
+
if (spec.action !== 'status') {
|
|
2020
|
+
throw usageError('--load cannot be combined with other seek flags');
|
|
2021
|
+
}
|
|
2022
|
+
spec.action = 'load';
|
|
2023
|
+
const val = args[i + 1];
|
|
2024
|
+
if (val === undefined || val.startsWith('-')) {
|
|
2025
|
+
throw usageError('Missing name for --load');
|
|
2026
|
+
}
|
|
2027
|
+
spec.name = val;
|
|
2028
|
+
i += 1;
|
|
2029
|
+
} else if (arg.startsWith('--load=')) {
|
|
2030
|
+
if (spec.action !== 'status') {
|
|
2031
|
+
throw usageError('--load cannot be combined with other seek flags');
|
|
2032
|
+
}
|
|
2033
|
+
spec.action = 'load';
|
|
2034
|
+
spec.name = arg.slice('--load='.length);
|
|
2035
|
+
if (!spec.name) {
|
|
2036
|
+
throw usageError('Missing name for --load');
|
|
2037
|
+
}
|
|
2038
|
+
} else if (arg === '--list') {
|
|
2039
|
+
if (spec.action !== 'status') {
|
|
2040
|
+
throw usageError('--list cannot be combined with other seek flags');
|
|
2041
|
+
}
|
|
2042
|
+
spec.action = 'list';
|
|
2043
|
+
} else if (arg === '--drop') {
|
|
2044
|
+
if (spec.action !== 'status') {
|
|
2045
|
+
throw usageError('--drop cannot be combined with other seek flags');
|
|
2046
|
+
}
|
|
2047
|
+
spec.action = 'drop';
|
|
2048
|
+
const val = args[i + 1];
|
|
2049
|
+
if (val === undefined || val.startsWith('-')) {
|
|
2050
|
+
throw usageError('Missing name for --drop');
|
|
2051
|
+
}
|
|
2052
|
+
spec.name = val;
|
|
2053
|
+
i += 1;
|
|
2054
|
+
} else if (arg.startsWith('--drop=')) {
|
|
2055
|
+
if (spec.action !== 'status') {
|
|
2056
|
+
throw usageError('--drop cannot be combined with other seek flags');
|
|
2057
|
+
}
|
|
2058
|
+
spec.action = 'drop';
|
|
2059
|
+
spec.name = arg.slice('--drop='.length);
|
|
2060
|
+
if (!spec.name) {
|
|
2061
|
+
throw usageError('Missing name for --drop');
|
|
2062
|
+
}
|
|
2063
|
+
} else if (arg === '--clear-cache' || arg === '--no-persistent-cache') {
|
|
2064
|
+
handleSeekBooleanFlag(arg, spec);
|
|
2065
|
+
} else if (arg.startsWith('-')) {
|
|
2066
|
+
throw usageError(`Unknown seek option: ${arg}`);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
return spec;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Resolves a tick value (absolute or relative +N/-N) against available ticks.
|
|
2075
|
+
*
|
|
2076
|
+
* For relative values, steps through the sorted tick array (with 0 prepended
|
|
2077
|
+
* as a virtual "empty state" position) by the given delta from the current
|
|
2078
|
+
* position. For absolute values, clamps to maxTick.
|
|
2079
|
+
*
|
|
2080
|
+
* @private
|
|
2081
|
+
* @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2")
|
|
2082
|
+
* @param {number|null} currentTick - Current cursor tick, or null if no active cursor
|
|
2083
|
+
* @param {number[]} ticks - Sorted ascending array of available Lamport ticks
|
|
2084
|
+
* @param {number} maxTick - Maximum tick across all writers
|
|
2085
|
+
* @returns {number} Resolved tick value (clamped to valid range)
|
|
2086
|
+
* @throws {CliError} If tickValue is not a valid integer or relative delta
|
|
2087
|
+
*/
|
|
2088
|
+
function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
|
|
2089
|
+
// Relative: +N or -N
|
|
2090
|
+
if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
|
|
2091
|
+
const delta = parseInt(tickValue, 10);
|
|
2092
|
+
if (!Number.isInteger(delta)) {
|
|
2093
|
+
throw usageError(`Invalid tick delta: ${tickValue}`);
|
|
2094
|
+
}
|
|
2095
|
+
const base = currentTick ?? 0;
|
|
2096
|
+
|
|
2097
|
+
// Find the current position in sorted ticks, then step by delta
|
|
2098
|
+
// Include tick 0 as a virtual "empty state" position (avoid duplicating if already present)
|
|
2099
|
+
const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
|
|
2100
|
+
const currentIdx = allPoints.indexOf(base);
|
|
2101
|
+
const startIdx = currentIdx === -1 ? 0 : currentIdx;
|
|
2102
|
+
const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
|
|
2103
|
+
return allPoints[targetIdx];
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Absolute
|
|
2107
|
+
const n = parseInt(tickValue, 10);
|
|
2108
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
2109
|
+
throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// Clamp to maxTick
|
|
2113
|
+
return Math.min(n, maxTick);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// ============================================================================
|
|
2117
|
+
// Seek Handler
|
|
2118
|
+
// ============================================================================
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* @param {WarpGraphInstance} graph
|
|
2122
|
+
* @param {Persistence} persistence
|
|
2123
|
+
* @param {string} graphName
|
|
2124
|
+
* @param {SeekSpec} seekSpec
|
|
2125
|
+
*/
|
|
2126
|
+
function wireSeekCache(graph, persistence, graphName, seekSpec) {
|
|
2127
|
+
if (seekSpec.noPersistentCache) {
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
graph.setSeekCache(new CasSeekCacheAdapter({
|
|
2131
|
+
persistence,
|
|
2132
|
+
plumbing: persistence.plumbing,
|
|
2133
|
+
graphName,
|
|
2134
|
+
}));
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Handles the `git warp seek` command across all sub-actions.
|
|
2139
|
+
* @param {{options: CliOptions, args: string[]}} params
|
|
2140
|
+
* @returns {Promise<{payload: *, exitCode: number}>}
|
|
2141
|
+
*/
|
|
2142
|
+
async function handleSeek({ options, args }) {
|
|
2143
|
+
const seekSpec = parseSeekArgs(args);
|
|
2144
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
2145
|
+
void wireSeekCache(graph, persistence, graphName, seekSpec);
|
|
2146
|
+
|
|
2147
|
+
// Handle --clear-cache before discovering ticks (no materialization needed)
|
|
2148
|
+
if (seekSpec.action === 'clear-cache') {
|
|
2149
|
+
if (graph.seekCache) {
|
|
2150
|
+
await graph.seekCache.clear();
|
|
2151
|
+
}
|
|
2152
|
+
return {
|
|
2153
|
+
payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' },
|
|
2154
|
+
exitCode: EXIT_CODES.OK,
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const activeCursor = await readActiveCursor(persistence, graphName);
|
|
2159
|
+
const { ticks, maxTick, perWriter } = await graph.discoverTicks();
|
|
2160
|
+
const frontierHash = computeFrontierHash(perWriter);
|
|
2161
|
+
if (seekSpec.action === 'list') {
|
|
2162
|
+
const saved = await listSavedCursors(persistence, graphName);
|
|
2163
|
+
return {
|
|
2164
|
+
payload: {
|
|
2165
|
+
graph: graphName,
|
|
2166
|
+
action: 'list',
|
|
2167
|
+
cursors: saved,
|
|
2168
|
+
activeTick: activeCursor ? activeCursor.tick : null,
|
|
2169
|
+
maxTick,
|
|
2170
|
+
},
|
|
2171
|
+
exitCode: EXIT_CODES.OK,
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
if (seekSpec.action === 'drop') {
|
|
2175
|
+
const dropName = /** @type {string} */ (seekSpec.name);
|
|
2176
|
+
const existing = await readSavedCursor(persistence, graphName, dropName);
|
|
2177
|
+
if (!existing) {
|
|
2178
|
+
throw notFoundError(`Saved cursor not found: ${dropName}`);
|
|
2179
|
+
}
|
|
2180
|
+
await deleteSavedCursor(persistence, graphName, dropName);
|
|
2181
|
+
return {
|
|
2182
|
+
payload: {
|
|
2183
|
+
graph: graphName,
|
|
2184
|
+
action: 'drop',
|
|
2185
|
+
name: seekSpec.name,
|
|
2186
|
+
tick: existing.tick,
|
|
2187
|
+
},
|
|
2188
|
+
exitCode: EXIT_CODES.OK,
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
if (seekSpec.action === 'latest') {
|
|
2192
|
+
await clearActiveCursor(persistence, graphName);
|
|
2193
|
+
await graph.materialize();
|
|
2194
|
+
const nodes = await graph.getNodes();
|
|
2195
|
+
const edges = await graph.getEdges();
|
|
2196
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2197
|
+
const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
|
|
2198
|
+
return {
|
|
2199
|
+
payload: {
|
|
2200
|
+
graph: graphName,
|
|
2201
|
+
action: 'latest',
|
|
2202
|
+
tick: maxTick,
|
|
2203
|
+
maxTick,
|
|
2204
|
+
ticks,
|
|
2205
|
+
nodes: nodes.length,
|
|
2206
|
+
edges: edges.length,
|
|
2207
|
+
perWriter: serializePerWriter(perWriter),
|
|
2208
|
+
patchCount: countPatchesAtTick(maxTick, perWriter),
|
|
2209
|
+
diff,
|
|
2210
|
+
tickReceipt,
|
|
2211
|
+
cursor: { active: false },
|
|
2212
|
+
},
|
|
2213
|
+
exitCode: EXIT_CODES.OK,
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
if (seekSpec.action === 'save') {
|
|
2217
|
+
if (!activeCursor) {
|
|
2218
|
+
throw usageError('No active cursor to save. Use --tick first.');
|
|
2219
|
+
}
|
|
2220
|
+
await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor);
|
|
2221
|
+
return {
|
|
2222
|
+
payload: {
|
|
2223
|
+
graph: graphName,
|
|
2224
|
+
action: 'save',
|
|
2225
|
+
name: seekSpec.name,
|
|
2226
|
+
tick: activeCursor.tick,
|
|
2227
|
+
},
|
|
2228
|
+
exitCode: EXIT_CODES.OK,
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
if (seekSpec.action === 'load') {
|
|
2232
|
+
const loadName = /** @type {string} */ (seekSpec.name);
|
|
2233
|
+
const saved = await readSavedCursor(persistence, graphName, loadName);
|
|
2234
|
+
if (!saved) {
|
|
2235
|
+
throw notFoundError(`Saved cursor not found: ${loadName}`);
|
|
2236
|
+
}
|
|
2237
|
+
await graph.materialize({ ceiling: saved.tick });
|
|
2238
|
+
const nodes = await graph.getNodes();
|
|
2239
|
+
const edges = await graph.getEdges();
|
|
2240
|
+
await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2241
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2242
|
+
const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
|
|
2243
|
+
return {
|
|
2244
|
+
payload: {
|
|
2245
|
+
graph: graphName,
|
|
2246
|
+
action: 'load',
|
|
2247
|
+
name: seekSpec.name,
|
|
2248
|
+
tick: saved.tick,
|
|
2249
|
+
maxTick,
|
|
2250
|
+
ticks,
|
|
2251
|
+
nodes: nodes.length,
|
|
2252
|
+
edges: edges.length,
|
|
2253
|
+
perWriter: serializePerWriter(perWriter),
|
|
2254
|
+
patchCount: countPatchesAtTick(saved.tick, perWriter),
|
|
2255
|
+
diff,
|
|
2256
|
+
tickReceipt,
|
|
2257
|
+
cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
|
|
2258
|
+
},
|
|
2259
|
+
exitCode: EXIT_CODES.OK,
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
if (seekSpec.action === 'tick') {
|
|
2263
|
+
const currentTick = activeCursor ? activeCursor.tick : null;
|
|
2264
|
+
const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
|
|
2265
|
+
await graph.materialize({ ceiling: resolvedTick });
|
|
2266
|
+
const nodes = await graph.getNodes();
|
|
2267
|
+
const edges = await graph.getEdges();
|
|
2268
|
+
await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2269
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2270
|
+
const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
|
|
2271
|
+
return {
|
|
2272
|
+
payload: {
|
|
2273
|
+
graph: graphName,
|
|
2274
|
+
action: 'tick',
|
|
2275
|
+
tick: resolvedTick,
|
|
2276
|
+
maxTick,
|
|
2277
|
+
ticks,
|
|
2278
|
+
nodes: nodes.length,
|
|
2279
|
+
edges: edges.length,
|
|
2280
|
+
perWriter: serializePerWriter(perWriter),
|
|
2281
|
+
patchCount: countPatchesAtTick(resolvedTick, perWriter),
|
|
2282
|
+
diff,
|
|
2283
|
+
tickReceipt,
|
|
2284
|
+
cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
|
|
2285
|
+
},
|
|
2286
|
+
exitCode: EXIT_CODES.OK,
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// status (bare seek)
|
|
2291
|
+
if (activeCursor) {
|
|
2292
|
+
await graph.materialize({ ceiling: activeCursor.tick });
|
|
2293
|
+
const nodes = await graph.getNodes();
|
|
2294
|
+
const edges = await graph.getEdges();
|
|
2295
|
+
const prevCounts = readSeekCounts(activeCursor);
|
|
2296
|
+
const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
|
|
2297
|
+
if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
|
|
2298
|
+
await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2299
|
+
}
|
|
2300
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2301
|
+
const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
|
|
2302
|
+
return {
|
|
2303
|
+
payload: {
|
|
2304
|
+
graph: graphName,
|
|
2305
|
+
action: 'status',
|
|
2306
|
+
tick: activeCursor.tick,
|
|
2307
|
+
maxTick,
|
|
2308
|
+
ticks,
|
|
2309
|
+
nodes: nodes.length,
|
|
2310
|
+
edges: edges.length,
|
|
2311
|
+
perWriter: serializePerWriter(perWriter),
|
|
2312
|
+
patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
|
|
2313
|
+
diff,
|
|
2314
|
+
tickReceipt,
|
|
2315
|
+
cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
|
|
2316
|
+
},
|
|
2317
|
+
exitCode: EXIT_CODES.OK,
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
await graph.materialize();
|
|
2321
|
+
const nodes = await graph.getNodes();
|
|
2322
|
+
const edges = await graph.getEdges();
|
|
2323
|
+
const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
|
|
2324
|
+
return {
|
|
2325
|
+
payload: {
|
|
2326
|
+
graph: graphName,
|
|
2327
|
+
action: 'status',
|
|
2328
|
+
tick: maxTick,
|
|
2329
|
+
maxTick,
|
|
2330
|
+
ticks,
|
|
2331
|
+
nodes: nodes.length,
|
|
2332
|
+
edges: edges.length,
|
|
2333
|
+
perWriter: serializePerWriter(perWriter),
|
|
2334
|
+
patchCount: countPatchesAtTick(maxTick, perWriter),
|
|
2335
|
+
diff: null,
|
|
2336
|
+
tickReceipt,
|
|
2337
|
+
cursor: { active: false },
|
|
2338
|
+
},
|
|
2339
|
+
exitCode: EXIT_CODES.OK,
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
|
|
2345
|
+
*
|
|
2346
|
+
* @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
|
|
2347
|
+
* @returns {Record<string, WriterTickInfo>} Plain object keyed by writer ID
|
|
2348
|
+
*/
|
|
2349
|
+
function serializePerWriter(perWriter) {
|
|
2350
|
+
/** @type {Record<string, WriterTickInfo>} */
|
|
2351
|
+
const result = {};
|
|
2352
|
+
for (const [writerId, info] of perWriter) {
|
|
2353
|
+
result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
|
|
2354
|
+
}
|
|
2355
|
+
return result;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
/**
|
|
2359
|
+
* Counts the total number of patches across all writers at or before the given tick.
|
|
2360
|
+
*
|
|
2361
|
+
* @param {number} tick - Lamport tick ceiling (inclusive)
|
|
2362
|
+
* @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
|
|
2363
|
+
* @returns {number} Total patch count at or before the given tick
|
|
2364
|
+
*/
|
|
2365
|
+
function countPatchesAtTick(tick, perWriter) {
|
|
2366
|
+
let count = 0;
|
|
2367
|
+
for (const [, info] of perWriter) {
|
|
2368
|
+
for (const t of info.ticks) {
|
|
2369
|
+
if (t <= tick) {
|
|
2370
|
+
count++;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return count;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* Computes a stable fingerprint of the current graph frontier (writer tips).
|
|
2379
|
+
*
|
|
2380
|
+
* Used to suppress seek diffs when graph history may have changed since the
|
|
2381
|
+
* previous cursor snapshot (e.g. new writers/patches, rewritten refs).
|
|
2382
|
+
*
|
|
2383
|
+
* @param {Map<string, WriterTickInfo>} perWriter - Per-writer metadata from discoverTicks()
|
|
2384
|
+
* @returns {string} Hex digest of the frontier fingerprint
|
|
2385
|
+
*/
|
|
2386
|
+
function computeFrontierHash(perWriter) {
|
|
2387
|
+
/** @type {Record<string, string|null>} */
|
|
2388
|
+
const tips = {};
|
|
2389
|
+
for (const [writerId, info] of perWriter) {
|
|
2390
|
+
tips[writerId] = info?.tipSha || null;
|
|
2391
|
+
}
|
|
2392
|
+
return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex');
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/**
|
|
2396
|
+
* Reads cached seek state counts from a cursor blob.
|
|
2397
|
+
*
|
|
2398
|
+
* Counts may be missing for older cursors (pre-diff support). In that case
|
|
2399
|
+
* callers should treat the counts as unknown and suppress diffs.
|
|
2400
|
+
*
|
|
2401
|
+
* @param {CursorBlob|null} cursor - Parsed cursor blob object
|
|
2402
|
+
* @returns {{nodes: number|null, edges: number|null}} Parsed counts
|
|
2403
|
+
*/
|
|
2404
|
+
function readSeekCounts(cursor) {
|
|
2405
|
+
if (!cursor || typeof cursor !== 'object') {
|
|
2406
|
+
return { nodes: null, edges: null };
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
|
|
2410
|
+
const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
|
|
2411
|
+
return { nodes, edges };
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/**
|
|
2415
|
+
* Computes node/edge deltas between the current seek position and the previous cursor.
|
|
2416
|
+
*
|
|
2417
|
+
* Returns null if the previous cursor is missing cached counts.
|
|
2418
|
+
*
|
|
2419
|
+
* @param {CursorBlob|null} prevCursor - Cursor object read before updating the position
|
|
2420
|
+
* @param {{nodes: number, edges: number}} next - Current materialized counts
|
|
2421
|
+
* @param {string} frontierHash - Frontier fingerprint of the current graph
|
|
2422
|
+
* @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
|
|
2423
|
+
*/
|
|
2424
|
+
function computeSeekStateDiff(prevCursor, next, frontierHash) {
|
|
2425
|
+
const prev = readSeekCounts(prevCursor);
|
|
2426
|
+
if (prev.nodes === null || prev.edges === null) {
|
|
2427
|
+
return null;
|
|
2428
|
+
}
|
|
2429
|
+
const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
|
|
2430
|
+
if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
|
|
2431
|
+
return null;
|
|
2432
|
+
}
|
|
2433
|
+
return {
|
|
2434
|
+
nodes: next.nodes - prev.nodes,
|
|
2435
|
+
edges: next.edges - prev.edges,
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* Builds a per-writer operation summary for patches at an exact tick.
|
|
2441
|
+
*
|
|
2442
|
+
* Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and
|
|
2443
|
+
* summarizes patch ops. Typically only a handful of writers have a patch at any
|
|
2444
|
+
* single Lamport tick.
|
|
2445
|
+
*
|
|
2446
|
+
* @param {{tick: number, perWriter: Map<string, WriterTickInfo>, graph: WarpGraphInstance}} params
|
|
2447
|
+
* @returns {Promise<Record<string, {sha: string, opSummary: *}>|null>} Map of writerId to { sha, opSummary }, or null if empty
|
|
2448
|
+
*/
|
|
2449
|
+
async function buildTickReceipt({ tick, perWriter, graph }) {
|
|
2450
|
+
if (!Number.isInteger(tick) || tick <= 0) {
|
|
2451
|
+
return null;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/** @type {Record<string, {sha: string, opSummary: *}>} */
|
|
2455
|
+
const receipt = {};
|
|
2456
|
+
|
|
2457
|
+
for (const [writerId, info] of perWriter) {
|
|
2458
|
+
const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload
|
|
2459
|
+
if (!sha) {
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
const patch = await graph.loadPatchBySha(sha);
|
|
2464
|
+
const ops = Array.isArray(patch?.ops) ? patch.ops : [];
|
|
2465
|
+
receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
return Object.keys(receipt).length > 0 ? receipt : null;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/**
|
|
2472
|
+
* Renders a seek command payload as a human-readable string for terminal output.
|
|
2473
|
+
*
|
|
2474
|
+
* Handles all seek actions: list, drop, save, latest, load, tick, and status.
|
|
2475
|
+
*
|
|
2476
|
+
* @param {*} payload - Seek result payload from handleSeek
|
|
2477
|
+
* @returns {string} Formatted output string (includes trailing newline)
|
|
2478
|
+
*/
|
|
2479
|
+
function renderSeek(payload) {
|
|
2480
|
+
const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload
|
|
2481
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
|
|
2482
|
+
return '';
|
|
2483
|
+
}
|
|
2484
|
+
const sign = n > 0 ? '+' : '';
|
|
2485
|
+
return ` (${sign}${n})`;
|
|
2486
|
+
};
|
|
2487
|
+
|
|
2488
|
+
const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
|
|
2489
|
+
const order = [
|
|
2490
|
+
['NodeAdd', '+', 'node'],
|
|
2491
|
+
['EdgeAdd', '+', 'edge'],
|
|
2492
|
+
['PropSet', '~', 'prop'],
|
|
2493
|
+
['NodeTombstone', '-', 'node'],
|
|
2494
|
+
['EdgeTombstone', '-', 'edge'],
|
|
2495
|
+
['BlobValue', '+', 'blob'],
|
|
2496
|
+
];
|
|
2497
|
+
|
|
2498
|
+
const parts = [];
|
|
2499
|
+
for (const [opType, symbol, label] of order) {
|
|
2500
|
+
const n = summary?.[opType];
|
|
2501
|
+
if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
|
|
2502
|
+
parts.push(`${symbol}${n}${label}`);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
return parts.length > 0 ? parts.join(' ') : '(empty)';
|
|
2506
|
+
};
|
|
2507
|
+
|
|
2508
|
+
const appendReceiptSummary = (/** @type {string} */ baseLine) => {
|
|
2509
|
+
const tickReceipt = payload?.tickReceipt;
|
|
2510
|
+
if (!tickReceipt || typeof tickReceipt !== 'object') {
|
|
2511
|
+
return `${baseLine}\n`;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
const entries = Object.entries(tickReceipt)
|
|
2515
|
+
.filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
|
|
2516
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
2517
|
+
|
|
2518
|
+
if (entries.length === 0) {
|
|
2519
|
+
return `${baseLine}\n`;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
|
|
2523
|
+
const receiptLines = [` Tick ${payload.tick}:`];
|
|
2524
|
+
for (const [writerId, entry] of entries) {
|
|
2525
|
+
const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
|
|
2526
|
+
const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
|
|
2527
|
+
receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
return `${baseLine}\n${receiptLines.join('\n')}\n`;
|
|
2531
|
+
};
|
|
2532
|
+
|
|
2533
|
+
const buildStateStrings = () => {
|
|
2534
|
+
const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
|
|
2535
|
+
const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
|
|
2536
|
+
const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
|
|
2537
|
+
return {
|
|
2538
|
+
nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
|
|
2539
|
+
edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
|
|
2540
|
+
patchesStr: `${payload.patchCount} ${patchLabel}`,
|
|
2541
|
+
};
|
|
2542
|
+
};
|
|
2543
|
+
|
|
2544
|
+
if (payload.action === 'clear-cache') {
|
|
2545
|
+
return `${payload.message}\n`;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
if (payload.action === 'list') {
|
|
2549
|
+
if (payload.cursors.length === 0) {
|
|
2550
|
+
return 'No saved cursors.\n';
|
|
2551
|
+
}
|
|
2552
|
+
const lines = [];
|
|
2553
|
+
for (const c of payload.cursors) {
|
|
2554
|
+
const active = c.tick === payload.activeTick ? ' (active)' : '';
|
|
2555
|
+
lines.push(` ${c.name}: tick ${c.tick}${active}`);
|
|
2556
|
+
}
|
|
2557
|
+
return `${lines.join('\n')}\n`;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
if (payload.action === 'drop') {
|
|
2561
|
+
return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
if (payload.action === 'save') {
|
|
2565
|
+
return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
if (payload.action === 'latest') {
|
|
2569
|
+
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2570
|
+
return appendReceiptSummary(
|
|
2571
|
+
`${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
if (payload.action === 'load') {
|
|
2576
|
+
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2577
|
+
return appendReceiptSummary(
|
|
2578
|
+
`${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
|
|
2579
|
+
);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
if (payload.action === 'tick') {
|
|
2583
|
+
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2584
|
+
return appendReceiptSummary(
|
|
2585
|
+
`${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
|
|
2586
|
+
);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// status
|
|
2590
|
+
if (payload.cursor && payload.cursor.active) {
|
|
2591
|
+
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2592
|
+
return appendReceiptSummary(
|
|
2593
|
+
`${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
/**
|
|
2601
|
+
* Reads the active cursor and sets `_seekCeiling` on the graph instance
|
|
2602
|
+
* so that subsequent materialize calls respect the time-travel boundary.
|
|
2603
|
+
*
|
|
2604
|
+
* Called by non-seek commands (query, path, check, etc.) that should
|
|
2605
|
+
* honour an active seek cursor.
|
|
2606
|
+
*
|
|
2607
|
+
* @param {WarpGraphInstance} graph - WarpGraph instance
|
|
2608
|
+
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
2609
|
+
* @param {string} graphName - Name of the WARP graph
|
|
2610
|
+
* @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance
|
|
2611
|
+
*/
|
|
2612
|
+
async function applyCursorCeiling(graph, persistence, graphName) {
|
|
2613
|
+
const cursor = await readActiveCursor(persistence, graphName);
|
|
2614
|
+
if (cursor) {
|
|
2615
|
+
graph._seekCeiling = cursor.tick;
|
|
2616
|
+
return { active: true, tick: cursor.tick, maxTick: null };
|
|
2617
|
+
}
|
|
2618
|
+
return { active: false, tick: null, maxTick: null };
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* Prints a seek cursor warning banner to stderr when a cursor is active.
|
|
2623
|
+
*
|
|
2624
|
+
* No-op if the cursor is not active.
|
|
2625
|
+
*
|
|
2626
|
+
* Non-seek commands (query, path, check, history, materialize) pass null for
|
|
2627
|
+
* maxTick to avoid the cost of discoverTicks(); the banner then omits the
|
|
2628
|
+
* "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
|
|
2629
|
+
*
|
|
2630
|
+
* @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
|
|
2631
|
+
* @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
|
|
2632
|
+
* @returns {void}
|
|
2633
|
+
*/
|
|
2634
|
+
function emitCursorWarning(cursorInfo, maxTick) {
|
|
2635
|
+
if (cursorInfo.active) {
|
|
2636
|
+
const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : '';
|
|
2637
|
+
process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`);
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/**
|
|
2642
|
+
* @param {{options: CliOptions, args: string[]}} params
|
|
2643
|
+
* @returns {Promise<{payload: *, exitCode: number}>}
|
|
2644
|
+
*/
|
|
1468
2645
|
async function handleView({ options, args }) {
|
|
1469
2646
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1470
2647
|
throw usageError('view command requires an interactive terminal (TTY)');
|
|
@@ -1475,13 +2652,14 @@ async function handleView({ options, args }) {
|
|
|
1475
2652
|
: 'list';
|
|
1476
2653
|
|
|
1477
2654
|
try {
|
|
2655
|
+
// @ts-expect-error — optional peer dependency, may not be installed
|
|
1478
2656
|
const { startTui } = await import('@git-stunts/git-warp-tui');
|
|
1479
2657
|
await startTui({
|
|
1480
2658
|
repo: options.repo || '.',
|
|
1481
2659
|
graph: options.graph || 'default',
|
|
1482
2660
|
mode: viewMode,
|
|
1483
2661
|
});
|
|
1484
|
-
} catch (err) {
|
|
2662
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
|
|
1485
2663
|
if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
|
|
1486
2664
|
throw usageError(
|
|
1487
2665
|
'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
|
|
@@ -1493,16 +2671,18 @@ async function handleView({ options, args }) {
|
|
|
1493
2671
|
return { payload: undefined, exitCode: 0 };
|
|
1494
2672
|
}
|
|
1495
2673
|
|
|
1496
|
-
|
|
2674
|
+
/** @type {Map<string, Function>} */
|
|
2675
|
+
const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
|
|
1497
2676
|
['info', handleInfo],
|
|
1498
2677
|
['query', handleQuery],
|
|
1499
2678
|
['path', handlePath],
|
|
1500
2679
|
['history', handleHistory],
|
|
1501
2680
|
['check', handleCheck],
|
|
1502
2681
|
['materialize', handleMaterialize],
|
|
2682
|
+
['seek', handleSeek],
|
|
1503
2683
|
['view', handleView],
|
|
1504
2684
|
['install-hooks', handleInstallHooks],
|
|
1505
|
-
]);
|
|
2685
|
+
]));
|
|
1506
2686
|
|
|
1507
2687
|
/**
|
|
1508
2688
|
* CLI entry point. Parses arguments, dispatches to the appropriate command handler,
|
|
@@ -1534,17 +2714,18 @@ async function main() {
|
|
|
1534
2714
|
throw usageError(`Unknown command: ${command}`);
|
|
1535
2715
|
}
|
|
1536
2716
|
|
|
1537
|
-
const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query'];
|
|
2717
|
+
const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
|
|
1538
2718
|
if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
|
|
1539
2719
|
throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
|
|
1540
2720
|
}
|
|
1541
2721
|
|
|
1542
|
-
const result = await handler({
|
|
2722
|
+
const result = await /** @type {Function} */ (handler)({
|
|
1543
2723
|
command,
|
|
1544
2724
|
args: positionals.slice(1),
|
|
1545
2725
|
options,
|
|
1546
2726
|
});
|
|
1547
2727
|
|
|
2728
|
+
/** @type {{payload: *, exitCode: number}} */
|
|
1548
2729
|
const normalized = result && typeof result === 'object' && 'payload' in result
|
|
1549
2730
|
? result
|
|
1550
2731
|
: { payload: result, exitCode: EXIT_CODES.OK };
|
|
@@ -1552,13 +2733,15 @@ async function main() {
|
|
|
1552
2733
|
if (normalized.payload !== undefined) {
|
|
1553
2734
|
emit(normalized.payload, { json: options.json, command, view: options.view });
|
|
1554
2735
|
}
|
|
1555
|
-
process.
|
|
2736
|
+
// Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
|
|
2737
|
+
process.exit(normalized.exitCode ?? EXIT_CODES.OK);
|
|
1556
2738
|
}
|
|
1557
2739
|
|
|
1558
2740
|
main().catch((error) => {
|
|
1559
2741
|
const exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.INTERNAL;
|
|
1560
2742
|
const code = error instanceof CliError ? error.code : 'E_INTERNAL';
|
|
1561
2743
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
2744
|
+
/** @type {{error: {code: string, message: string, cause?: *}}} */
|
|
1562
2745
|
const payload = { error: { code, message } };
|
|
1563
2746
|
|
|
1564
2747
|
if (error && error.cause) {
|
|
@@ -1570,5 +2753,5 @@ main().catch((error) => {
|
|
|
1570
2753
|
} else {
|
|
1571
2754
|
process.stderr.write(renderError(payload));
|
|
1572
2755
|
}
|
|
1573
|
-
process.exitCode
|
|
2756
|
+
process.exit(exitCode);
|
|
1574
2757
|
});
|