@git-stunts/git-warp 10.1.1 → 10.3.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 +27 -3
- package/bin/warp-graph.js +967 -14
- package/index.js +20 -1
- package/package.json +7 -2
- package/src/domain/WarpGraph.js +232 -1
- package/src/domain/entities/GraphNode.d.ts +30 -0
- package/src/domain/entities/GraphNode.js +9 -0
- package/src/domain/utils/RefLayout.js +87 -15
- package/src/domain/utils/parseCursorBlob.js +51 -0
- package/src/visualization/index.d.ts +41 -0
- package/src/visualization/index.js +7 -1
- package/src/visualization/layouts/elkAdapter.js +7 -7
- package/src/visualization/renderers/ascii/graph.js +30 -15
- package/src/visualization/renderers/ascii/history.js +2 -65
- package/src/visualization/renderers/ascii/index.js +1 -1
- package/src/visualization/renderers/ascii/opSummary.js +73 -0
- package/src/visualization/renderers/ascii/seek.js +330 -0
- package/src/visualization/utils/ansi.js +8 -2
package/bin/warp-graph.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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';
|
|
@@ -10,12 +11,16 @@ import WarpGraph from '../src/domain/WarpGraph.js';
|
|
|
10
11
|
import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
|
|
11
12
|
import HealthCheckService from '../src/domain/services/HealthCheckService.js';
|
|
12
13
|
import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
|
|
14
|
+
import NodeCryptoAdapter from '../src/infrastructure/adapters/NodeCryptoAdapter.js';
|
|
13
15
|
import {
|
|
14
16
|
REF_PREFIX,
|
|
15
17
|
buildCheckpointRef,
|
|
16
18
|
buildCoverageRef,
|
|
17
19
|
buildWritersPrefix,
|
|
18
20
|
parseWriterIdFromRef,
|
|
21
|
+
buildCursorActiveRef,
|
|
22
|
+
buildCursorSavedRef,
|
|
23
|
+
buildCursorSavedPrefix,
|
|
19
24
|
} from '../src/domain/utils/RefLayout.js';
|
|
20
25
|
import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
|
|
21
26
|
import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
|
|
@@ -23,6 +28,8 @@ import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
|
|
|
23
28
|
import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
|
|
24
29
|
import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
|
|
25
30
|
import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
|
|
31
|
+
import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
|
|
32
|
+
import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js';
|
|
26
33
|
import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
|
|
27
34
|
import { renderSvg } from '../src/visualization/renderers/svg/index.js';
|
|
28
35
|
import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
|
|
@@ -44,6 +51,7 @@ Commands:
|
|
|
44
51
|
history Show writer history
|
|
45
52
|
check Report graph health/GC status
|
|
46
53
|
materialize Materialize and checkpoint all graphs
|
|
54
|
+
seek Time-travel: step through graph history by Lamport tick
|
|
47
55
|
view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
|
|
48
56
|
install-hooks Install post-merge git hook
|
|
49
57
|
|
|
@@ -74,6 +82,14 @@ Path options:
|
|
|
74
82
|
|
|
75
83
|
History options:
|
|
76
84
|
--node <id> Filter patches touching node id
|
|
85
|
+
|
|
86
|
+
Seek options:
|
|
87
|
+
--tick <N|+N|-N> Jump to tick N, or step forward/backward
|
|
88
|
+
--latest Clear cursor, return to present
|
|
89
|
+
--save <name> Save current position as named cursor
|
|
90
|
+
--load <name> Restore a saved cursor
|
|
91
|
+
--list List all saved cursors
|
|
92
|
+
--drop <name> Delete a saved cursor
|
|
77
93
|
`;
|
|
78
94
|
|
|
79
95
|
/**
|
|
@@ -169,7 +185,7 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
|
|
|
169
185
|
if (arg === '--view') {
|
|
170
186
|
// Valid view modes: ascii, browser, svg:FILE, html:FILE
|
|
171
187
|
// Don't consume known commands as modes
|
|
172
|
-
const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'install-hooks'];
|
|
188
|
+
const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks'];
|
|
173
189
|
const nextArg = argv[index + 1];
|
|
174
190
|
const isViewMode = nextArg &&
|
|
175
191
|
!nextArg.startsWith('-') &&
|
|
@@ -271,6 +287,17 @@ async function resolveGraphName(persistence, explicitGraph) {
|
|
|
271
287
|
throw usageError('Multiple graphs found; specify --graph');
|
|
272
288
|
}
|
|
273
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Collects metadata about a single graph (writer count, refs, patches, checkpoint).
|
|
292
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
293
|
+
* @param {string} graphName - Name of the graph to inspect
|
|
294
|
+
* @param {Object} [options]
|
|
295
|
+
* @param {boolean} [options.includeWriterIds=false] - Include writer ID list
|
|
296
|
+
* @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
|
|
297
|
+
* @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
|
|
298
|
+
* @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
|
|
299
|
+
* @returns {Promise<Object>} Graph info object
|
|
300
|
+
*/
|
|
274
301
|
async function getGraphInfo(persistence, graphName, {
|
|
275
302
|
includeWriterIds = false,
|
|
276
303
|
includeRefs = false,
|
|
@@ -322,6 +349,7 @@ async function getGraphInfo(persistence, graphName, {
|
|
|
322
349
|
persistence,
|
|
323
350
|
graphName,
|
|
324
351
|
writerId: 'cli',
|
|
352
|
+
crypto: new NodeCryptoAdapter(),
|
|
325
353
|
});
|
|
326
354
|
const writerPatches = {};
|
|
327
355
|
for (const writerId of writerIds) {
|
|
@@ -334,13 +362,29 @@ async function getGraphInfo(persistence, graphName, {
|
|
|
334
362
|
return info;
|
|
335
363
|
}
|
|
336
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Opens a WarpGraph for the given CLI options.
|
|
367
|
+
* @param {Object} options - Parsed CLI options
|
|
368
|
+
* @param {string} [options.repo] - Repository path
|
|
369
|
+
* @param {string} [options.graph] - Explicit graph name
|
|
370
|
+
* @param {string} [options.writer] - Writer ID
|
|
371
|
+
* @returns {Promise<{graph: Object, graphName: string, persistence: Object}>}
|
|
372
|
+
* @throws {CliError} If the specified graph is not found
|
|
373
|
+
*/
|
|
337
374
|
async function openGraph(options) {
|
|
338
375
|
const { persistence } = await createPersistence(options.repo);
|
|
339
376
|
const graphName = await resolveGraphName(persistence, options.graph);
|
|
377
|
+
if (options.graph) {
|
|
378
|
+
const graphNames = await listGraphNames(persistence);
|
|
379
|
+
if (!graphNames.includes(options.graph)) {
|
|
380
|
+
throw notFoundError(`Graph not found: ${options.graph}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
340
383
|
const graph = await WarpGraph.open({
|
|
341
384
|
persistence,
|
|
342
385
|
graphName,
|
|
343
386
|
writerId: options.writer,
|
|
387
|
+
crypto: new NodeCryptoAdapter(),
|
|
344
388
|
});
|
|
345
389
|
return { graph, graphName, persistence };
|
|
346
390
|
}
|
|
@@ -603,6 +647,9 @@ function renderInfo(payload) {
|
|
|
603
647
|
if (graph.coverage?.sha) {
|
|
604
648
|
lines.push(` coverage: ${graph.coverage.sha}`);
|
|
605
649
|
}
|
|
650
|
+
if (graph.cursor?.active) {
|
|
651
|
+
lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
|
|
652
|
+
}
|
|
606
653
|
}
|
|
607
654
|
return `${lines.join('\n')}\n`;
|
|
608
655
|
}
|
|
@@ -742,6 +789,26 @@ function renderError(payload) {
|
|
|
742
789
|
return `Error: ${payload.error.message}\n`;
|
|
743
790
|
}
|
|
744
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Wraps SVG content in a minimal HTML document and writes it to disk.
|
|
794
|
+
* @param {string} filePath - Destination file path
|
|
795
|
+
* @param {string} svgContent - SVG markup to embed
|
|
796
|
+
*/
|
|
797
|
+
function writeHtmlExport(filePath, svgContent) {
|
|
798
|
+
const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
|
|
799
|
+
fs.writeFileSync(filePath, html);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Writes a command result to stdout/stderr in the appropriate format.
|
|
804
|
+
* Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
|
|
805
|
+
* based on the combination of flags.
|
|
806
|
+
* @param {Object} payload - Command result payload
|
|
807
|
+
* @param {Object} options
|
|
808
|
+
* @param {boolean} options.json - Emit JSON to stdout
|
|
809
|
+
* @param {string} options.command - Command name (info, query, path, etc.)
|
|
810
|
+
* @param {string|boolean} options.view - View mode (true for ascii, 'svg:PATH', 'html:PATH', 'browser')
|
|
811
|
+
*/
|
|
745
812
|
function emit(payload, { json, command, view }) {
|
|
746
813
|
if (json) {
|
|
747
814
|
process.stdout.write(`${stableStringify(payload)}\n`);
|
|
@@ -766,6 +833,14 @@ function emit(payload, { json, command, view }) {
|
|
|
766
833
|
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
767
834
|
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
768
835
|
}
|
|
836
|
+
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
837
|
+
const htmlPath = view.slice(5);
|
|
838
|
+
if (!payload._renderedSvg) {
|
|
839
|
+
process.stderr.write('No graph data — skipping HTML export.\n');
|
|
840
|
+
} else {
|
|
841
|
+
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
842
|
+
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
843
|
+
}
|
|
769
844
|
} else if (view) {
|
|
770
845
|
process.stdout.write(`${payload._renderedAscii}\n`);
|
|
771
846
|
} else {
|
|
@@ -783,6 +858,14 @@ function emit(payload, { json, command, view }) {
|
|
|
783
858
|
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
784
859
|
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
785
860
|
}
|
|
861
|
+
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
862
|
+
const htmlPath = view.slice(5);
|
|
863
|
+
if (!payload._renderedSvg) {
|
|
864
|
+
process.stderr.write('No path found — skipping HTML export.\n');
|
|
865
|
+
} else {
|
|
866
|
+
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
867
|
+
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
868
|
+
}
|
|
786
869
|
} else if (view) {
|
|
787
870
|
process.stdout.write(renderPathView(payload));
|
|
788
871
|
} else {
|
|
@@ -818,6 +901,15 @@ function emit(payload, { json, command, view }) {
|
|
|
818
901
|
return;
|
|
819
902
|
}
|
|
820
903
|
|
|
904
|
+
if (command === 'seek') {
|
|
905
|
+
if (view) {
|
|
906
|
+
process.stdout.write(renderSeekView(payload));
|
|
907
|
+
} else {
|
|
908
|
+
process.stdout.write(renderSeek(payload));
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
821
913
|
if (command === 'install-hooks') {
|
|
822
914
|
process.stdout.write(renderInstallHooks(payload));
|
|
823
915
|
return;
|
|
@@ -859,12 +951,19 @@ async function handleInfo({ options }) {
|
|
|
859
951
|
const graphs = [];
|
|
860
952
|
for (const name of graphNames) {
|
|
861
953
|
const includeDetails = detailGraphs.has(name);
|
|
862
|
-
|
|
954
|
+
const info = await getGraphInfo(persistence, name, {
|
|
863
955
|
includeWriterIds: includeDetails || isViewMode,
|
|
864
956
|
includeRefs: includeDetails || isViewMode,
|
|
865
957
|
includeWriterPatches: isViewMode,
|
|
866
958
|
includeCheckpointDate: isViewMode,
|
|
867
|
-
})
|
|
959
|
+
});
|
|
960
|
+
const activeCursor = await readActiveCursor(persistence, name);
|
|
961
|
+
if (activeCursor) {
|
|
962
|
+
info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
|
|
963
|
+
} else {
|
|
964
|
+
info.cursor = { active: false };
|
|
965
|
+
}
|
|
966
|
+
graphs.push(info);
|
|
868
967
|
}
|
|
869
968
|
|
|
870
969
|
return {
|
|
@@ -883,7 +982,9 @@ async function handleInfo({ options }) {
|
|
|
883
982
|
*/
|
|
884
983
|
async function handleQuery({ options, args }) {
|
|
885
984
|
const querySpec = parseQueryArgs(args);
|
|
886
|
-
const { graph, graphName } = await openGraph(options);
|
|
985
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
986
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
987
|
+
emitCursorWarning(cursorInfo, null);
|
|
887
988
|
let builder = graph.query();
|
|
888
989
|
|
|
889
990
|
if (querySpec.match !== null) {
|
|
@@ -904,7 +1005,7 @@ async function handleQuery({ options, args }) {
|
|
|
904
1005
|
const edges = await graph.getEdges();
|
|
905
1006
|
const graphData = queryResultToGraphData(payload, edges);
|
|
906
1007
|
const positioned = await layoutGraph(graphData, { type: 'query' });
|
|
907
|
-
if (typeof options.view === 'string' && options.view.startsWith('svg:')) {
|
|
1008
|
+
if (typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
|
|
908
1009
|
payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
|
|
909
1010
|
} else {
|
|
910
1011
|
payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
|
|
@@ -974,7 +1075,9 @@ function mapQueryError(error) {
|
|
|
974
1075
|
*/
|
|
975
1076
|
async function handlePath({ options, args }) {
|
|
976
1077
|
const pathOptions = parsePathArgs(args);
|
|
977
|
-
const { graph, graphName } = await openGraph(options);
|
|
1078
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
1079
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1080
|
+
emitCursorWarning(cursorInfo, null);
|
|
978
1081
|
|
|
979
1082
|
try {
|
|
980
1083
|
const result = await graph.traverse.shortestPath(
|
|
@@ -994,7 +1097,7 @@ async function handlePath({ options, args }) {
|
|
|
994
1097
|
...result,
|
|
995
1098
|
};
|
|
996
1099
|
|
|
997
|
-
if (options.view && result.found && typeof options.view === 'string' && options.view.startsWith('svg:')) {
|
|
1100
|
+
if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
|
|
998
1101
|
const graphData = pathResultToGraphData(payload);
|
|
999
1102
|
const positioned = await layoutGraph(graphData, { type: 'path' });
|
|
1000
1103
|
payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
|
|
@@ -1020,6 +1123,8 @@ async function handlePath({ options, args }) {
|
|
|
1020
1123
|
*/
|
|
1021
1124
|
async function handleCheck({ options }) {
|
|
1022
1125
|
const { graph, graphName, persistence } = await openGraph(options);
|
|
1126
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1127
|
+
emitCursorWarning(cursorInfo, null);
|
|
1023
1128
|
const health = await getHealth(persistence);
|
|
1024
1129
|
const gcMetrics = await getGcMetrics(graph);
|
|
1025
1130
|
const status = await graph.status();
|
|
@@ -1157,9 +1262,15 @@ function buildCheckPayload({
|
|
|
1157
1262
|
*/
|
|
1158
1263
|
async function handleHistory({ options, args }) {
|
|
1159
1264
|
const historyOptions = parseHistoryArgs(args);
|
|
1160
|
-
const { graph, graphName } = await openGraph(options);
|
|
1265
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
1266
|
+
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1267
|
+
emitCursorWarning(cursorInfo, null);
|
|
1268
|
+
|
|
1161
1269
|
const writerId = options.writer;
|
|
1162
|
-
|
|
1270
|
+
let patches = await graph.getWriterPatches(writerId);
|
|
1271
|
+
if (cursorInfo.active) {
|
|
1272
|
+
patches = patches.filter(({ patch }) => patch.lamport <= cursorInfo.tick);
|
|
1273
|
+
}
|
|
1163
1274
|
if (patches.length === 0) {
|
|
1164
1275
|
throw notFoundError(`No patches found for writer: ${writerId}`);
|
|
1165
1276
|
}
|
|
@@ -1184,12 +1295,23 @@ async function handleHistory({ options, args }) {
|
|
|
1184
1295
|
return { payload, exitCode: EXIT_CODES.OK };
|
|
1185
1296
|
}
|
|
1186
1297
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1298
|
+
/**
|
|
1299
|
+
* Materializes a single graph, creates a checkpoint, and returns summary stats.
|
|
1300
|
+
* When a ceiling tick is provided (seek cursor active), the checkpoint step is
|
|
1301
|
+
* skipped because the user is exploring historical state, not persisting it.
|
|
1302
|
+
* @param {Object} params
|
|
1303
|
+
* @param {Object} params.persistence - GraphPersistencePort adapter
|
|
1304
|
+
* @param {string} params.graphName - Name of the graph to materialize
|
|
1305
|
+
* @param {string} params.writerId - Writer ID for the CLI session
|
|
1306
|
+
* @param {number} [params.ceiling] - Optional seek ceiling tick
|
|
1307
|
+
* @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Object, patchCount: number}>}
|
|
1308
|
+
*/
|
|
1309
|
+
async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
|
|
1310
|
+
const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
|
|
1311
|
+
await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
|
|
1190
1312
|
const nodes = await graph.getNodes();
|
|
1191
1313
|
const edges = await graph.getEdges();
|
|
1192
|
-
const checkpoint = await graph.createCheckpoint();
|
|
1314
|
+
const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
|
|
1193
1315
|
const status = await graph.status();
|
|
1194
1316
|
|
|
1195
1317
|
// Build per-writer patch counts for the view renderer
|
|
@@ -1241,12 +1363,20 @@ async function handleMaterialize({ options }) {
|
|
|
1241
1363
|
}
|
|
1242
1364
|
|
|
1243
1365
|
const results = [];
|
|
1366
|
+
let cursorWarningEmitted = false;
|
|
1244
1367
|
for (const name of targets) {
|
|
1245
1368
|
try {
|
|
1369
|
+
const cursor = await readActiveCursor(persistence, name);
|
|
1370
|
+
const ceiling = cursor ? cursor.tick : undefined;
|
|
1371
|
+
if (cursor && !cursorWarningEmitted) {
|
|
1372
|
+
emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
|
|
1373
|
+
cursorWarningEmitted = true;
|
|
1374
|
+
}
|
|
1246
1375
|
const result = await materializeOneGraph({
|
|
1247
1376
|
persistence,
|
|
1248
1377
|
graphName: name,
|
|
1249
1378
|
writerId: options.writer,
|
|
1379
|
+
ceiling,
|
|
1250
1380
|
});
|
|
1251
1381
|
results.push(result);
|
|
1252
1382
|
} catch (error) {
|
|
@@ -1465,6 +1595,828 @@ function getHookStatusForCheck(repoPath) {
|
|
|
1465
1595
|
}
|
|
1466
1596
|
}
|
|
1467
1597
|
|
|
1598
|
+
// ============================================================================
|
|
1599
|
+
// Cursor I/O Helpers
|
|
1600
|
+
// ============================================================================
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Reads the active seek cursor for a graph from Git ref storage.
|
|
1604
|
+
*
|
|
1605
|
+
* @private
|
|
1606
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
1607
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1608
|
+
* @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if no active cursor
|
|
1609
|
+
* @throws {Error} If the stored blob is corrupted or not valid JSON
|
|
1610
|
+
*/
|
|
1611
|
+
async function readActiveCursor(persistence, graphName) {
|
|
1612
|
+
const ref = buildCursorActiveRef(graphName);
|
|
1613
|
+
const oid = await persistence.readRef(ref);
|
|
1614
|
+
if (!oid) {
|
|
1615
|
+
return null;
|
|
1616
|
+
}
|
|
1617
|
+
const buf = await persistence.readBlob(oid);
|
|
1618
|
+
return parseCursorBlob(buf, 'active cursor');
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Writes (creates or overwrites) the active seek cursor for a graph.
|
|
1623
|
+
*
|
|
1624
|
+
* Serializes the cursor as JSON, stores it as a Git blob, and points
|
|
1625
|
+
* the active cursor ref at that blob.
|
|
1626
|
+
*
|
|
1627
|
+
* @private
|
|
1628
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
1629
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1630
|
+
* @param {{tick: number, mode?: string}} cursor - Cursor state to persist
|
|
1631
|
+
* @returns {Promise<void>}
|
|
1632
|
+
*/
|
|
1633
|
+
async function writeActiveCursor(persistence, graphName, cursor) {
|
|
1634
|
+
const ref = buildCursorActiveRef(graphName);
|
|
1635
|
+
const json = JSON.stringify(cursor);
|
|
1636
|
+
const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
|
|
1637
|
+
await persistence.updateRef(ref, oid);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Removes the active seek cursor for a graph, returning to present state.
|
|
1642
|
+
*
|
|
1643
|
+
* No-op if no active cursor exists.
|
|
1644
|
+
*
|
|
1645
|
+
* @private
|
|
1646
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
1647
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1648
|
+
* @returns {Promise<void>}
|
|
1649
|
+
*/
|
|
1650
|
+
async function clearActiveCursor(persistence, graphName) {
|
|
1651
|
+
const ref = buildCursorActiveRef(graphName);
|
|
1652
|
+
const exists = await persistence.readRef(ref);
|
|
1653
|
+
if (exists) {
|
|
1654
|
+
await persistence.deleteRef(ref);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Reads a named saved cursor from Git ref storage.
|
|
1660
|
+
*
|
|
1661
|
+
* @private
|
|
1662
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
1663
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1664
|
+
* @param {string} name - Saved cursor name
|
|
1665
|
+
* @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if not found
|
|
1666
|
+
* @throws {Error} If the stored blob is corrupted or not valid JSON
|
|
1667
|
+
*/
|
|
1668
|
+
async function readSavedCursor(persistence, graphName, name) {
|
|
1669
|
+
const ref = buildCursorSavedRef(graphName, name);
|
|
1670
|
+
const oid = await persistence.readRef(ref);
|
|
1671
|
+
if (!oid) {
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
const buf = await persistence.readBlob(oid);
|
|
1675
|
+
return parseCursorBlob(buf, `saved cursor '${name}'`);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Persists a cursor under a named saved-cursor ref.
|
|
1680
|
+
*
|
|
1681
|
+
* Serializes the cursor as JSON, stores it as a Git blob, and points
|
|
1682
|
+
* the named saved-cursor ref at that blob.
|
|
1683
|
+
*
|
|
1684
|
+
* @private
|
|
1685
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
1686
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1687
|
+
* @param {string} name - Saved cursor name
|
|
1688
|
+
* @param {{tick: number, mode?: string}} cursor - Cursor state to persist
|
|
1689
|
+
* @returns {Promise<void>}
|
|
1690
|
+
*/
|
|
1691
|
+
async function writeSavedCursor(persistence, graphName, name, cursor) {
|
|
1692
|
+
const ref = buildCursorSavedRef(graphName, name);
|
|
1693
|
+
const json = JSON.stringify(cursor);
|
|
1694
|
+
const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
|
|
1695
|
+
await persistence.updateRef(ref, oid);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Deletes a named saved cursor from Git ref storage.
|
|
1700
|
+
*
|
|
1701
|
+
* No-op if the named cursor does not exist.
|
|
1702
|
+
*
|
|
1703
|
+
* @private
|
|
1704
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
1705
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1706
|
+
* @param {string} name - Saved cursor name to delete
|
|
1707
|
+
* @returns {Promise<void>}
|
|
1708
|
+
*/
|
|
1709
|
+
async function deleteSavedCursor(persistence, graphName, name) {
|
|
1710
|
+
const ref = buildCursorSavedRef(graphName, name);
|
|
1711
|
+
const exists = await persistence.readRef(ref);
|
|
1712
|
+
if (exists) {
|
|
1713
|
+
await persistence.deleteRef(ref);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Lists all saved cursors for a graph, reading each blob to include full cursor state.
|
|
1719
|
+
*
|
|
1720
|
+
* @private
|
|
1721
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
1722
|
+
* @param {string} graphName - Name of the WARP graph
|
|
1723
|
+
* @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
|
|
1724
|
+
* @throws {Error} If any stored blob is corrupted or not valid JSON
|
|
1725
|
+
*/
|
|
1726
|
+
async function listSavedCursors(persistence, graphName) {
|
|
1727
|
+
const prefix = buildCursorSavedPrefix(graphName);
|
|
1728
|
+
const refs = await persistence.listRefs(prefix);
|
|
1729
|
+
const cursors = [];
|
|
1730
|
+
for (const ref of refs) {
|
|
1731
|
+
const name = ref.slice(prefix.length);
|
|
1732
|
+
if (name) {
|
|
1733
|
+
const oid = await persistence.readRef(ref);
|
|
1734
|
+
if (oid) {
|
|
1735
|
+
const buf = await persistence.readBlob(oid);
|
|
1736
|
+
const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
|
|
1737
|
+
cursors.push({ name, ...cursor });
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return cursors;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// ============================================================================
|
|
1745
|
+
// Seek Arg Parser
|
|
1746
|
+
// ============================================================================
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Parses CLI arguments for the `seek` command into a structured spec.
|
|
1750
|
+
*
|
|
1751
|
+
* Supports mutually exclusive actions: `--tick <value>`, `--latest`,
|
|
1752
|
+
* `--save <name>`, `--load <name>`, `--list`, `--drop <name>`.
|
|
1753
|
+
* Defaults to `status` when no flags are provided.
|
|
1754
|
+
*
|
|
1755
|
+
* @private
|
|
1756
|
+
* @param {string[]} args - Raw CLI arguments following the `seek` subcommand
|
|
1757
|
+
* @returns {{action: string, tickValue: string|null, name: string|null}} Parsed spec
|
|
1758
|
+
* @throws {CliError} If arguments are invalid or flags are combined
|
|
1759
|
+
*/
|
|
1760
|
+
function parseSeekArgs(args) {
|
|
1761
|
+
const spec = {
|
|
1762
|
+
action: 'status', // status, tick, latest, save, load, list, drop
|
|
1763
|
+
tickValue: null,
|
|
1764
|
+
name: null,
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
for (let i = 0; i < args.length; i++) {
|
|
1768
|
+
const arg = args[i];
|
|
1769
|
+
|
|
1770
|
+
if (arg === '--tick') {
|
|
1771
|
+
if (spec.action !== 'status') {
|
|
1772
|
+
throw usageError('--tick cannot be combined with other seek flags');
|
|
1773
|
+
}
|
|
1774
|
+
spec.action = 'tick';
|
|
1775
|
+
const val = args[i + 1];
|
|
1776
|
+
if (val === undefined) {
|
|
1777
|
+
throw usageError('Missing value for --tick');
|
|
1778
|
+
}
|
|
1779
|
+
spec.tickValue = val;
|
|
1780
|
+
i += 1;
|
|
1781
|
+
} else if (arg.startsWith('--tick=')) {
|
|
1782
|
+
if (spec.action !== 'status') {
|
|
1783
|
+
throw usageError('--tick cannot be combined with other seek flags');
|
|
1784
|
+
}
|
|
1785
|
+
spec.action = 'tick';
|
|
1786
|
+
spec.tickValue = arg.slice('--tick='.length);
|
|
1787
|
+
} else if (arg === '--latest') {
|
|
1788
|
+
if (spec.action !== 'status') {
|
|
1789
|
+
throw usageError('--latest cannot be combined with other seek flags');
|
|
1790
|
+
}
|
|
1791
|
+
spec.action = 'latest';
|
|
1792
|
+
} else if (arg === '--save') {
|
|
1793
|
+
if (spec.action !== 'status') {
|
|
1794
|
+
throw usageError('--save cannot be combined with other seek flags');
|
|
1795
|
+
}
|
|
1796
|
+
spec.action = 'save';
|
|
1797
|
+
const val = args[i + 1];
|
|
1798
|
+
if (val === undefined || val.startsWith('-')) {
|
|
1799
|
+
throw usageError('Missing name for --save');
|
|
1800
|
+
}
|
|
1801
|
+
spec.name = val;
|
|
1802
|
+
i += 1;
|
|
1803
|
+
} else if (arg.startsWith('--save=')) {
|
|
1804
|
+
if (spec.action !== 'status') {
|
|
1805
|
+
throw usageError('--save cannot be combined with other seek flags');
|
|
1806
|
+
}
|
|
1807
|
+
spec.action = 'save';
|
|
1808
|
+
spec.name = arg.slice('--save='.length);
|
|
1809
|
+
if (!spec.name) {
|
|
1810
|
+
throw usageError('Missing name for --save');
|
|
1811
|
+
}
|
|
1812
|
+
} else if (arg === '--load') {
|
|
1813
|
+
if (spec.action !== 'status') {
|
|
1814
|
+
throw usageError('--load cannot be combined with other seek flags');
|
|
1815
|
+
}
|
|
1816
|
+
spec.action = 'load';
|
|
1817
|
+
const val = args[i + 1];
|
|
1818
|
+
if (val === undefined || val.startsWith('-')) {
|
|
1819
|
+
throw usageError('Missing name for --load');
|
|
1820
|
+
}
|
|
1821
|
+
spec.name = val;
|
|
1822
|
+
i += 1;
|
|
1823
|
+
} else if (arg.startsWith('--load=')) {
|
|
1824
|
+
if (spec.action !== 'status') {
|
|
1825
|
+
throw usageError('--load cannot be combined with other seek flags');
|
|
1826
|
+
}
|
|
1827
|
+
spec.action = 'load';
|
|
1828
|
+
spec.name = arg.slice('--load='.length);
|
|
1829
|
+
if (!spec.name) {
|
|
1830
|
+
throw usageError('Missing name for --load');
|
|
1831
|
+
}
|
|
1832
|
+
} else if (arg === '--list') {
|
|
1833
|
+
if (spec.action !== 'status') {
|
|
1834
|
+
throw usageError('--list cannot be combined with other seek flags');
|
|
1835
|
+
}
|
|
1836
|
+
spec.action = 'list';
|
|
1837
|
+
} else if (arg === '--drop') {
|
|
1838
|
+
if (spec.action !== 'status') {
|
|
1839
|
+
throw usageError('--drop cannot be combined with other seek flags');
|
|
1840
|
+
}
|
|
1841
|
+
spec.action = 'drop';
|
|
1842
|
+
const val = args[i + 1];
|
|
1843
|
+
if (val === undefined || val.startsWith('-')) {
|
|
1844
|
+
throw usageError('Missing name for --drop');
|
|
1845
|
+
}
|
|
1846
|
+
spec.name = val;
|
|
1847
|
+
i += 1;
|
|
1848
|
+
} else if (arg.startsWith('--drop=')) {
|
|
1849
|
+
if (spec.action !== 'status') {
|
|
1850
|
+
throw usageError('--drop cannot be combined with other seek flags');
|
|
1851
|
+
}
|
|
1852
|
+
spec.action = 'drop';
|
|
1853
|
+
spec.name = arg.slice('--drop='.length);
|
|
1854
|
+
if (!spec.name) {
|
|
1855
|
+
throw usageError('Missing name for --drop');
|
|
1856
|
+
}
|
|
1857
|
+
} else if (arg.startsWith('-')) {
|
|
1858
|
+
throw usageError(`Unknown seek option: ${arg}`);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
return spec;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Resolves a tick value (absolute or relative +N/-N) against available ticks.
|
|
1867
|
+
*
|
|
1868
|
+
* For relative values, steps through the sorted tick array (with 0 prepended
|
|
1869
|
+
* as a virtual "empty state" position) by the given delta from the current
|
|
1870
|
+
* position. For absolute values, clamps to maxTick.
|
|
1871
|
+
*
|
|
1872
|
+
* @private
|
|
1873
|
+
* @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2")
|
|
1874
|
+
* @param {number|null} currentTick - Current cursor tick, or null if no active cursor
|
|
1875
|
+
* @param {number[]} ticks - Sorted ascending array of available Lamport ticks
|
|
1876
|
+
* @param {number} maxTick - Maximum tick across all writers
|
|
1877
|
+
* @returns {number} Resolved tick value (clamped to valid range)
|
|
1878
|
+
* @throws {CliError} If tickValue is not a valid integer or relative delta
|
|
1879
|
+
*/
|
|
1880
|
+
function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
|
|
1881
|
+
// Relative: +N or -N
|
|
1882
|
+
if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
|
|
1883
|
+
const delta = parseInt(tickValue, 10);
|
|
1884
|
+
if (!Number.isInteger(delta)) {
|
|
1885
|
+
throw usageError(`Invalid tick delta: ${tickValue}`);
|
|
1886
|
+
}
|
|
1887
|
+
const base = currentTick ?? 0;
|
|
1888
|
+
|
|
1889
|
+
// Find the current position in sorted ticks, then step by delta
|
|
1890
|
+
// Include tick 0 as a virtual "empty state" position (avoid duplicating if already present)
|
|
1891
|
+
const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
|
|
1892
|
+
const currentIdx = allPoints.indexOf(base);
|
|
1893
|
+
const startIdx = currentIdx === -1 ? 0 : currentIdx;
|
|
1894
|
+
const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
|
|
1895
|
+
return allPoints[targetIdx];
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Absolute
|
|
1899
|
+
const n = parseInt(tickValue, 10);
|
|
1900
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1901
|
+
throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// Clamp to maxTick
|
|
1905
|
+
return Math.min(n, maxTick);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// ============================================================================
|
|
1909
|
+
// Seek Handler
|
|
1910
|
+
// ============================================================================
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* Handles the `git warp seek` command across all sub-actions.
|
|
1914
|
+
*
|
|
1915
|
+
* Dispatches to the appropriate logic based on the parsed action:
|
|
1916
|
+
* - `status`: show current cursor position or "no cursor" state
|
|
1917
|
+
* - `tick`: set the cursor to an absolute or relative Lamport tick
|
|
1918
|
+
* - `latest`: clear the cursor, returning to present state
|
|
1919
|
+
* - `save`: persist the active cursor under a name
|
|
1920
|
+
* - `load`: restore a named cursor as the active cursor
|
|
1921
|
+
* - `list`: enumerate all saved cursors
|
|
1922
|
+
* - `drop`: delete a named saved cursor
|
|
1923
|
+
*
|
|
1924
|
+
* @private
|
|
1925
|
+
* @param {Object} params - Command parameters
|
|
1926
|
+
* @param {Object} params.options - CLI options (repo, graph, writer, json)
|
|
1927
|
+
* @param {string[]} params.args - Raw CLI arguments following the `seek` subcommand
|
|
1928
|
+
* @returns {Promise<{payload: Object, exitCode: number}>} Command result with payload and exit code
|
|
1929
|
+
* @throws {CliError} On invalid arguments or missing cursors
|
|
1930
|
+
*/
|
|
1931
|
+
async function handleSeek({ options, args }) {
|
|
1932
|
+
const seekSpec = parseSeekArgs(args);
|
|
1933
|
+
const { graph, graphName, persistence } = await openGraph(options);
|
|
1934
|
+
const activeCursor = await readActiveCursor(persistence, graphName);
|
|
1935
|
+
const { ticks, maxTick, perWriter } = await graph.discoverTicks();
|
|
1936
|
+
const frontierHash = computeFrontierHash(perWriter);
|
|
1937
|
+
if (seekSpec.action === 'list') {
|
|
1938
|
+
const saved = await listSavedCursors(persistence, graphName);
|
|
1939
|
+
return {
|
|
1940
|
+
payload: {
|
|
1941
|
+
graph: graphName,
|
|
1942
|
+
action: 'list',
|
|
1943
|
+
cursors: saved,
|
|
1944
|
+
activeTick: activeCursor ? activeCursor.tick : null,
|
|
1945
|
+
maxTick,
|
|
1946
|
+
},
|
|
1947
|
+
exitCode: EXIT_CODES.OK,
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
if (seekSpec.action === 'drop') {
|
|
1951
|
+
const existing = await readSavedCursor(persistence, graphName, seekSpec.name);
|
|
1952
|
+
if (!existing) {
|
|
1953
|
+
throw notFoundError(`Saved cursor not found: ${seekSpec.name}`);
|
|
1954
|
+
}
|
|
1955
|
+
await deleteSavedCursor(persistence, graphName, seekSpec.name);
|
|
1956
|
+
return {
|
|
1957
|
+
payload: {
|
|
1958
|
+
graph: graphName,
|
|
1959
|
+
action: 'drop',
|
|
1960
|
+
name: seekSpec.name,
|
|
1961
|
+
tick: existing.tick,
|
|
1962
|
+
},
|
|
1963
|
+
exitCode: EXIT_CODES.OK,
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
if (seekSpec.action === 'latest') {
|
|
1967
|
+
await clearActiveCursor(persistence, graphName);
|
|
1968
|
+
await graph.materialize();
|
|
1969
|
+
const nodes = await graph.getNodes();
|
|
1970
|
+
const edges = await graph.getEdges();
|
|
1971
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
1972
|
+
const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
|
|
1973
|
+
return {
|
|
1974
|
+
payload: {
|
|
1975
|
+
graph: graphName,
|
|
1976
|
+
action: 'latest',
|
|
1977
|
+
tick: maxTick,
|
|
1978
|
+
maxTick,
|
|
1979
|
+
ticks,
|
|
1980
|
+
nodes: nodes.length,
|
|
1981
|
+
edges: edges.length,
|
|
1982
|
+
perWriter: serializePerWriter(perWriter),
|
|
1983
|
+
patchCount: countPatchesAtTick(maxTick, perWriter),
|
|
1984
|
+
diff,
|
|
1985
|
+
tickReceipt,
|
|
1986
|
+
cursor: { active: false },
|
|
1987
|
+
},
|
|
1988
|
+
exitCode: EXIT_CODES.OK,
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
if (seekSpec.action === 'save') {
|
|
1992
|
+
if (!activeCursor) {
|
|
1993
|
+
throw usageError('No active cursor to save. Use --tick first.');
|
|
1994
|
+
}
|
|
1995
|
+
await writeSavedCursor(persistence, graphName, seekSpec.name, activeCursor);
|
|
1996
|
+
return {
|
|
1997
|
+
payload: {
|
|
1998
|
+
graph: graphName,
|
|
1999
|
+
action: 'save',
|
|
2000
|
+
name: seekSpec.name,
|
|
2001
|
+
tick: activeCursor.tick,
|
|
2002
|
+
},
|
|
2003
|
+
exitCode: EXIT_CODES.OK,
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
if (seekSpec.action === 'load') {
|
|
2007
|
+
const saved = await readSavedCursor(persistence, graphName, seekSpec.name);
|
|
2008
|
+
if (!saved) {
|
|
2009
|
+
throw notFoundError(`Saved cursor not found: ${seekSpec.name}`);
|
|
2010
|
+
}
|
|
2011
|
+
await graph.materialize({ ceiling: saved.tick });
|
|
2012
|
+
const nodes = await graph.getNodes();
|
|
2013
|
+
const edges = await graph.getEdges();
|
|
2014
|
+
await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2015
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2016
|
+
const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
|
|
2017
|
+
return {
|
|
2018
|
+
payload: {
|
|
2019
|
+
graph: graphName,
|
|
2020
|
+
action: 'load',
|
|
2021
|
+
name: seekSpec.name,
|
|
2022
|
+
tick: saved.tick,
|
|
2023
|
+
maxTick,
|
|
2024
|
+
ticks,
|
|
2025
|
+
nodes: nodes.length,
|
|
2026
|
+
edges: edges.length,
|
|
2027
|
+
perWriter: serializePerWriter(perWriter),
|
|
2028
|
+
patchCount: countPatchesAtTick(saved.tick, perWriter),
|
|
2029
|
+
diff,
|
|
2030
|
+
tickReceipt,
|
|
2031
|
+
cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
|
|
2032
|
+
},
|
|
2033
|
+
exitCode: EXIT_CODES.OK,
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
if (seekSpec.action === 'tick') {
|
|
2037
|
+
const currentTick = activeCursor ? activeCursor.tick : null;
|
|
2038
|
+
const resolvedTick = resolveTickValue(seekSpec.tickValue, currentTick, ticks, maxTick);
|
|
2039
|
+
await graph.materialize({ ceiling: resolvedTick });
|
|
2040
|
+
const nodes = await graph.getNodes();
|
|
2041
|
+
const edges = await graph.getEdges();
|
|
2042
|
+
await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2043
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2044
|
+
const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
|
|
2045
|
+
return {
|
|
2046
|
+
payload: {
|
|
2047
|
+
graph: graphName,
|
|
2048
|
+
action: 'tick',
|
|
2049
|
+
tick: resolvedTick,
|
|
2050
|
+
maxTick,
|
|
2051
|
+
ticks,
|
|
2052
|
+
nodes: nodes.length,
|
|
2053
|
+
edges: edges.length,
|
|
2054
|
+
perWriter: serializePerWriter(perWriter),
|
|
2055
|
+
patchCount: countPatchesAtTick(resolvedTick, perWriter),
|
|
2056
|
+
diff,
|
|
2057
|
+
tickReceipt,
|
|
2058
|
+
cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
|
|
2059
|
+
},
|
|
2060
|
+
exitCode: EXIT_CODES.OK,
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// status (bare seek)
|
|
2065
|
+
if (activeCursor) {
|
|
2066
|
+
await graph.materialize({ ceiling: activeCursor.tick });
|
|
2067
|
+
const nodes = await graph.getNodes();
|
|
2068
|
+
const edges = await graph.getEdges();
|
|
2069
|
+
const prevCounts = readSeekCounts(activeCursor);
|
|
2070
|
+
const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
|
|
2071
|
+
if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
|
|
2072
|
+
await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2073
|
+
}
|
|
2074
|
+
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2075
|
+
const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
|
|
2076
|
+
return {
|
|
2077
|
+
payload: {
|
|
2078
|
+
graph: graphName,
|
|
2079
|
+
action: 'status',
|
|
2080
|
+
tick: activeCursor.tick,
|
|
2081
|
+
maxTick,
|
|
2082
|
+
ticks,
|
|
2083
|
+
nodes: nodes.length,
|
|
2084
|
+
edges: edges.length,
|
|
2085
|
+
perWriter: serializePerWriter(perWriter),
|
|
2086
|
+
patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
|
|
2087
|
+
diff,
|
|
2088
|
+
tickReceipt,
|
|
2089
|
+
cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
|
|
2090
|
+
},
|
|
2091
|
+
exitCode: EXIT_CODES.OK,
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
await graph.materialize();
|
|
2095
|
+
const nodes = await graph.getNodes();
|
|
2096
|
+
const edges = await graph.getEdges();
|
|
2097
|
+
const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
|
|
2098
|
+
return {
|
|
2099
|
+
payload: {
|
|
2100
|
+
graph: graphName,
|
|
2101
|
+
action: 'status',
|
|
2102
|
+
tick: maxTick,
|
|
2103
|
+
maxTick,
|
|
2104
|
+
ticks,
|
|
2105
|
+
nodes: nodes.length,
|
|
2106
|
+
edges: edges.length,
|
|
2107
|
+
perWriter: serializePerWriter(perWriter),
|
|
2108
|
+
patchCount: countPatchesAtTick(maxTick, perWriter),
|
|
2109
|
+
diff: null,
|
|
2110
|
+
tickReceipt,
|
|
2111
|
+
cursor: { active: false },
|
|
2112
|
+
},
|
|
2113
|
+
exitCode: EXIT_CODES.OK,
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
|
|
2119
|
+
*
|
|
2120
|
+
* @private
|
|
2121
|
+
* @param {Map<string, {ticks: number[], tipSha: string|null}>} perWriter - Per-writer tick data
|
|
2122
|
+
* @returns {Object<string, {ticks: number[], tipSha: string|null}>} Plain object keyed by writer ID
|
|
2123
|
+
*/
|
|
2124
|
+
function serializePerWriter(perWriter) {
|
|
2125
|
+
const result = {};
|
|
2126
|
+
for (const [writerId, info] of perWriter) {
|
|
2127
|
+
result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
|
|
2128
|
+
}
|
|
2129
|
+
return result;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* Counts the total number of patches across all writers at or before the given tick.
|
|
2134
|
+
*
|
|
2135
|
+
* @private
|
|
2136
|
+
* @param {number} tick - Lamport tick ceiling (inclusive)
|
|
2137
|
+
* @param {Map<string, {ticks: number[], tipSha: string|null}>} perWriter - Per-writer tick data
|
|
2138
|
+
* @returns {number} Total patch count at or before the given tick
|
|
2139
|
+
*/
|
|
2140
|
+
function countPatchesAtTick(tick, perWriter) {
|
|
2141
|
+
let count = 0;
|
|
2142
|
+
for (const [, info] of perWriter) {
|
|
2143
|
+
for (const t of info.ticks) {
|
|
2144
|
+
if (t <= tick) {
|
|
2145
|
+
count++;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return count;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* Computes a stable fingerprint of the current graph frontier (writer tips).
|
|
2154
|
+
*
|
|
2155
|
+
* Used to suppress seek diffs when graph history may have changed since the
|
|
2156
|
+
* previous cursor snapshot (e.g. new writers/patches, rewritten refs).
|
|
2157
|
+
*
|
|
2158
|
+
* @private
|
|
2159
|
+
* @param {Map<string, {tipSha: string|null}>} perWriter - Per-writer metadata from discoverTicks()
|
|
2160
|
+
* @returns {string} Hex digest of the frontier fingerprint
|
|
2161
|
+
*/
|
|
2162
|
+
function computeFrontierHash(perWriter) {
|
|
2163
|
+
const tips = {};
|
|
2164
|
+
for (const [writerId, info] of perWriter) {
|
|
2165
|
+
tips[writerId] = info?.tipSha || null;
|
|
2166
|
+
}
|
|
2167
|
+
return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex');
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Reads cached seek state counts from a cursor blob.
|
|
2172
|
+
*
|
|
2173
|
+
* Counts may be missing for older cursors (pre-diff support). In that case
|
|
2174
|
+
* callers should treat the counts as unknown and suppress diffs.
|
|
2175
|
+
*
|
|
2176
|
+
* @private
|
|
2177
|
+
* @param {Object|null} cursor - Parsed cursor blob object
|
|
2178
|
+
* @returns {{nodes: number|null, edges: number|null}} Parsed counts
|
|
2179
|
+
*/
|
|
2180
|
+
function readSeekCounts(cursor) {
|
|
2181
|
+
if (!cursor || typeof cursor !== 'object') {
|
|
2182
|
+
return { nodes: null, edges: null };
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
|
|
2186
|
+
const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
|
|
2187
|
+
return { nodes, edges };
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Computes node/edge deltas between the current seek position and the previous cursor.
|
|
2192
|
+
*
|
|
2193
|
+
* Returns null if the previous cursor is missing cached counts.
|
|
2194
|
+
*
|
|
2195
|
+
* @private
|
|
2196
|
+
* @param {Object|null} prevCursor - Cursor object read before updating the position
|
|
2197
|
+
* @param {{nodes: number, edges: number}} next - Current materialized counts
|
|
2198
|
+
* @param {string} frontierHash - Frontier fingerprint of the current graph
|
|
2199
|
+
* @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
|
|
2200
|
+
*/
|
|
2201
|
+
function computeSeekStateDiff(prevCursor, next, frontierHash) {
|
|
2202
|
+
const prev = readSeekCounts(prevCursor);
|
|
2203
|
+
if (prev.nodes === null || prev.edges === null) {
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
|
|
2207
|
+
if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
|
|
2208
|
+
return null;
|
|
2209
|
+
}
|
|
2210
|
+
return {
|
|
2211
|
+
nodes: next.nodes - prev.nodes,
|
|
2212
|
+
edges: next.edges - prev.edges,
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Builds a per-writer operation summary for patches at an exact tick.
|
|
2218
|
+
*
|
|
2219
|
+
* Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and
|
|
2220
|
+
* summarizes patch ops. Typically only a handful of writers have a patch at any
|
|
2221
|
+
* single Lamport tick.
|
|
2222
|
+
*
|
|
2223
|
+
* @private
|
|
2224
|
+
* @param {Object} params
|
|
2225
|
+
* @param {number} params.tick - Lamport tick to summarize
|
|
2226
|
+
* @param {Map<string, {tickShas?: Object}>} params.perWriter - Per-writer tick metadata from discoverTicks()
|
|
2227
|
+
* @param {Object} params.graph - WarpGraph instance
|
|
2228
|
+
* @returns {Promise<Object<string, Object>|null>} Map of writerId → { sha, opSummary }, or null if empty
|
|
2229
|
+
*/
|
|
2230
|
+
async function buildTickReceipt({ tick, perWriter, graph }) {
|
|
2231
|
+
if (!Number.isInteger(tick) || tick <= 0) {
|
|
2232
|
+
return null;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
const receipt = {};
|
|
2236
|
+
|
|
2237
|
+
for (const [writerId, info] of perWriter) {
|
|
2238
|
+
const sha = info?.tickShas?.[tick];
|
|
2239
|
+
if (!sha) {
|
|
2240
|
+
continue;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
const patch = await graph.loadPatchBySha(sha);
|
|
2244
|
+
const ops = Array.isArray(patch?.ops) ? patch.ops : [];
|
|
2245
|
+
receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
return Object.keys(receipt).length > 0 ? receipt : null;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* Renders a seek command payload as a human-readable string for terminal output.
|
|
2253
|
+
*
|
|
2254
|
+
* Handles all seek actions: list, drop, save, latest, load, tick, and status.
|
|
2255
|
+
*
|
|
2256
|
+
* @private
|
|
2257
|
+
* @param {Object} payload - Seek result payload from handleSeek
|
|
2258
|
+
* @returns {string} Formatted output string (includes trailing newline)
|
|
2259
|
+
*/
|
|
2260
|
+
function renderSeek(payload) {
|
|
2261
|
+
const formatDelta = (n) => {
|
|
2262
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
|
|
2263
|
+
return '';
|
|
2264
|
+
}
|
|
2265
|
+
const sign = n > 0 ? '+' : '';
|
|
2266
|
+
return ` (${sign}${n})`;
|
|
2267
|
+
};
|
|
2268
|
+
|
|
2269
|
+
const formatOpSummaryPlain = (summary) => {
|
|
2270
|
+
const order = [
|
|
2271
|
+
['NodeAdd', '+', 'node'],
|
|
2272
|
+
['EdgeAdd', '+', 'edge'],
|
|
2273
|
+
['PropSet', '~', 'prop'],
|
|
2274
|
+
['NodeTombstone', '-', 'node'],
|
|
2275
|
+
['EdgeTombstone', '-', 'edge'],
|
|
2276
|
+
['BlobValue', '+', 'blob'],
|
|
2277
|
+
];
|
|
2278
|
+
|
|
2279
|
+
const parts = [];
|
|
2280
|
+
for (const [opType, symbol, label] of order) {
|
|
2281
|
+
const n = summary?.[opType];
|
|
2282
|
+
if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
|
|
2283
|
+
parts.push(`${symbol}${n}${label}`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
return parts.length > 0 ? parts.join(' ') : '(empty)';
|
|
2287
|
+
};
|
|
2288
|
+
|
|
2289
|
+
const appendReceiptSummary = (baseLine) => {
|
|
2290
|
+
const tickReceipt = payload?.tickReceipt;
|
|
2291
|
+
if (!tickReceipt || typeof tickReceipt !== 'object') {
|
|
2292
|
+
return `${baseLine}\n`;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
const entries = Object.entries(tickReceipt)
|
|
2296
|
+
.filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
|
|
2297
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
2298
|
+
|
|
2299
|
+
if (entries.length === 0) {
|
|
2300
|
+
return `${baseLine}\n`;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
|
|
2304
|
+
const receiptLines = [` Tick ${payload.tick}:`];
|
|
2305
|
+
for (const [writerId, entry] of entries) {
|
|
2306
|
+
const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
|
|
2307
|
+
const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
|
|
2308
|
+
receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
return `${baseLine}\n${receiptLines.join('\n')}\n`;
|
|
2312
|
+
};
|
|
2313
|
+
|
|
2314
|
+
const buildStateStrings = () => {
|
|
2315
|
+
const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
|
|
2316
|
+
const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
|
|
2317
|
+
const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
|
|
2318
|
+
return {
|
|
2319
|
+
nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
|
|
2320
|
+
edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
|
|
2321
|
+
patchesStr: `${payload.patchCount} ${patchLabel}`,
|
|
2322
|
+
};
|
|
2323
|
+
};
|
|
2324
|
+
|
|
2325
|
+
if (payload.action === 'list') {
|
|
2326
|
+
if (payload.cursors.length === 0) {
|
|
2327
|
+
return 'No saved cursors.\n';
|
|
2328
|
+
}
|
|
2329
|
+
const lines = [];
|
|
2330
|
+
for (const c of payload.cursors) {
|
|
2331
|
+
const active = c.tick === payload.activeTick ? ' (active)' : '';
|
|
2332
|
+
lines.push(` ${c.name}: tick ${c.tick}${active}`);
|
|
2333
|
+
}
|
|
2334
|
+
return `${lines.join('\n')}\n`;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
if (payload.action === 'drop') {
|
|
2338
|
+
return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
if (payload.action === 'save') {
|
|
2342
|
+
return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
if (payload.action === 'latest') {
|
|
2346
|
+
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2347
|
+
return appendReceiptSummary(
|
|
2348
|
+
`${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
if (payload.action === 'load') {
|
|
2353
|
+
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2354
|
+
return appendReceiptSummary(
|
|
2355
|
+
`${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
if (payload.action === 'tick') {
|
|
2360
|
+
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2361
|
+
return appendReceiptSummary(
|
|
2362
|
+
`${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// status
|
|
2367
|
+
if (payload.cursor && payload.cursor.active) {
|
|
2368
|
+
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2369
|
+
return appendReceiptSummary(
|
|
2370
|
+
`${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
|
|
2371
|
+
);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* Reads the active cursor and sets `_seekCeiling` on the graph instance
|
|
2379
|
+
* so that subsequent materialize calls respect the time-travel boundary.
|
|
2380
|
+
*
|
|
2381
|
+
* Called by non-seek commands (query, path, check, etc.) that should
|
|
2382
|
+
* honour an active seek cursor.
|
|
2383
|
+
*
|
|
2384
|
+
* @private
|
|
2385
|
+
* @param {Object} graph - WarpGraph instance
|
|
2386
|
+
* @param {Object} persistence - GraphPersistencePort adapter
|
|
2387
|
+
* @param {string} graphName - Name of the WARP graph
|
|
2388
|
+
* @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance
|
|
2389
|
+
*/
|
|
2390
|
+
async function applyCursorCeiling(graph, persistence, graphName) {
|
|
2391
|
+
const cursor = await readActiveCursor(persistence, graphName);
|
|
2392
|
+
if (cursor) {
|
|
2393
|
+
graph._seekCeiling = cursor.tick;
|
|
2394
|
+
return { active: true, tick: cursor.tick, maxTick: null };
|
|
2395
|
+
}
|
|
2396
|
+
return { active: false, tick: null, maxTick: null };
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
/**
|
|
2400
|
+
* Prints a seek cursor warning banner to stderr when a cursor is active.
|
|
2401
|
+
*
|
|
2402
|
+
* No-op if the cursor is not active.
|
|
2403
|
+
*
|
|
2404
|
+
* Non-seek commands (query, path, check, history, materialize) pass null for
|
|
2405
|
+
* maxTick to avoid the cost of discoverTicks(); the banner then omits the
|
|
2406
|
+
* "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
|
|
2407
|
+
*
|
|
2408
|
+
* @private
|
|
2409
|
+
* @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
|
|
2410
|
+
* @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
|
|
2411
|
+
* @returns {void}
|
|
2412
|
+
*/
|
|
2413
|
+
function emitCursorWarning(cursorInfo, maxTick) {
|
|
2414
|
+
if (cursorInfo.active) {
|
|
2415
|
+
const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : '';
|
|
2416
|
+
process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
1468
2420
|
async function handleView({ options, args }) {
|
|
1469
2421
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1470
2422
|
throw usageError('view command requires an interactive terminal (TTY)');
|
|
@@ -1500,6 +2452,7 @@ const COMMANDS = new Map([
|
|
|
1500
2452
|
['history', handleHistory],
|
|
1501
2453
|
['check', handleCheck],
|
|
1502
2454
|
['materialize', handleMaterialize],
|
|
2455
|
+
['seek', handleSeek],
|
|
1503
2456
|
['view', handleView],
|
|
1504
2457
|
['install-hooks', handleInstallHooks],
|
|
1505
2458
|
]);
|
|
@@ -1534,7 +2487,7 @@ async function main() {
|
|
|
1534
2487
|
throw usageError(`Unknown command: ${command}`);
|
|
1535
2488
|
}
|
|
1536
2489
|
|
|
1537
|
-
const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query'];
|
|
2490
|
+
const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
|
|
1538
2491
|
if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
|
|
1539
2492
|
throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
|
|
1540
2493
|
}
|