@git-stunts/git-warp 10.1.2 → 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/package.json +7 -2
- package/src/domain/WarpGraph.js +232 -1
- package/src/domain/utils/RefLayout.js +87 -15
- package/src/domain/utils/parseCursorBlob.js +51 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@git-stunts/git-warp",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.3.2",
|
|
4
4
|
"description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -79,7 +79,12 @@
|
|
|
79
79
|
"prepare": "patch-package && node scripts/setup-hooks.js",
|
|
80
80
|
"prepack": "npm run lint && npm run test:local",
|
|
81
81
|
"install:git-warp": "bash scripts/install-git-warp.sh",
|
|
82
|
-
"uninstall:git-warp": "bash scripts/uninstall-git-warp.sh"
|
|
82
|
+
"uninstall:git-warp": "bash scripts/uninstall-git-warp.sh",
|
|
83
|
+
"test:node20": "docker compose -f docker-compose.test.yml --profile node20 run --build --rm test-node20",
|
|
84
|
+
"test:node22": "docker compose -f docker-compose.test.yml --profile node22 run --build --rm test-node22",
|
|
85
|
+
"test:bun": "docker compose -f docker-compose.test.yml --profile bun run --build --rm test-bun",
|
|
86
|
+
"test:deno": "docker compose -f docker-compose.test.yml --profile deno run --build --rm test-deno",
|
|
87
|
+
"test:matrix": "docker compose -f docker-compose.test.yml --profile full up --build --abort-on-container-exit"
|
|
83
88
|
},
|
|
84
89
|
"dependencies": {
|
|
85
90
|
"@git-stunts/alfred": "^0.4.0",
|
package/src/domain/WarpGraph.js
CHANGED
|
@@ -178,6 +178,15 @@ export default class WarpGraph {
|
|
|
178
178
|
|
|
179
179
|
/** @type {import('./services/TemporalQuery.js').TemporalQuery|null} */
|
|
180
180
|
this._temporalQuery = null;
|
|
181
|
+
|
|
182
|
+
/** @type {number|null} */
|
|
183
|
+
this._seekCeiling = null;
|
|
184
|
+
|
|
185
|
+
/** @type {number|null} */
|
|
186
|
+
this._cachedCeiling = null;
|
|
187
|
+
|
|
188
|
+
/** @type {Map<string, string>|null} */
|
|
189
|
+
this._cachedFrontier = null;
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
/**
|
|
@@ -570,11 +579,16 @@ export default class WarpGraph {
|
|
|
570
579
|
* When false or omitted (default), returns just the state for backward
|
|
571
580
|
* compatibility with zero receipt overhead.
|
|
572
581
|
*
|
|
582
|
+
* When a Lamport ceiling is active (via `options.ceiling` or the
|
|
583
|
+
* instance-level `_seekCeiling`), delegates to a ceiling-aware path
|
|
584
|
+
* that replays only patches with `lamport <= ceiling`, bypassing
|
|
585
|
+
* checkpoints, auto-checkpoint, and GC.
|
|
586
|
+
*
|
|
573
587
|
* Side effects: Updates internal cached state, version vector, last frontier,
|
|
574
588
|
* and patches-since-checkpoint counter. May trigger auto-checkpoint and GC
|
|
575
589
|
* based on configured policies. Notifies subscribers if state changed.
|
|
576
590
|
*
|
|
577
|
-
* @param {{receipts?: boolean}} [options] - Optional configuration
|
|
591
|
+
* @param {{receipts?: boolean, ceiling?: number|null}} [options] - Optional configuration
|
|
578
592
|
* @returns {Promise<import('./services/JoinReducer.js').WarpStateV5|{state: import('./services/JoinReducer.js').WarpStateV5, receipts: import('./types/TickReceipt.js').TickReceipt[]}>} The materialized graph state, or { state, receipts } when receipts enabled
|
|
579
593
|
* @throws {Error} If checkpoint loading fails or patch decoding fails
|
|
580
594
|
* @throws {Error} If writer ref access or patch blob reading fails
|
|
@@ -583,6 +597,13 @@ export default class WarpGraph {
|
|
|
583
597
|
const t0 = this._clock.now();
|
|
584
598
|
// ZERO-COST: only resolve receipts flag when options provided
|
|
585
599
|
const collectReceipts = options && options.receipts;
|
|
600
|
+
// Resolve ceiling: explicit option > instance-level seek ceiling > null (latest)
|
|
601
|
+
const ceiling = this._resolveCeiling(options);
|
|
602
|
+
|
|
603
|
+
// When ceiling is active, delegate to ceiling-aware path (with its own cache)
|
|
604
|
+
if (ceiling !== null) {
|
|
605
|
+
return await this._materializeWithCeiling(ceiling, collectReceipts, t0);
|
|
606
|
+
}
|
|
586
607
|
|
|
587
608
|
try {
|
|
588
609
|
// Check for checkpoint
|
|
@@ -658,6 +679,8 @@ export default class WarpGraph {
|
|
|
658
679
|
}
|
|
659
680
|
|
|
660
681
|
await this._setMaterializedState(state);
|
|
682
|
+
this._cachedCeiling = null;
|
|
683
|
+
this._cachedFrontier = null;
|
|
661
684
|
this._lastFrontier = await this.getFrontier();
|
|
662
685
|
this._patchesSinceCheckpoint = patchCount;
|
|
663
686
|
|
|
@@ -698,6 +721,124 @@ export default class WarpGraph {
|
|
|
698
721
|
}
|
|
699
722
|
}
|
|
700
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Resolves the effective ceiling from options and instance state.
|
|
726
|
+
*
|
|
727
|
+
* Precedence: explicit `ceiling` in options overrides the instance-level
|
|
728
|
+
* `_seekCeiling`. Uses the `'ceiling' in options` check, so passing
|
|
729
|
+
* `{ ceiling: null }` explicitly clears the seek ceiling for that call
|
|
730
|
+
* (returns `null`), while omitting the key falls through to `_seekCeiling`.
|
|
731
|
+
*
|
|
732
|
+
* @param {{ceiling?: number|null}} [options] - Options object; when the
|
|
733
|
+
* `ceiling` key is present (even if `null`), its value takes precedence
|
|
734
|
+
* @returns {number|null} Lamport ceiling to apply, or `null` for latest
|
|
735
|
+
* @private
|
|
736
|
+
*/
|
|
737
|
+
_resolveCeiling(options) {
|
|
738
|
+
if (options && options.ceiling !== undefined) {
|
|
739
|
+
return options.ceiling;
|
|
740
|
+
}
|
|
741
|
+
return this._seekCeiling;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Materializes the graph with a Lamport ceiling (time-travel).
|
|
746
|
+
*
|
|
747
|
+
* Bypasses checkpoints entirely — replays all patches from all writers,
|
|
748
|
+
* filtering to only those with `lamport <= ceiling`. Skips auto-checkpoint
|
|
749
|
+
* and GC since this is an exploratory read.
|
|
750
|
+
*
|
|
751
|
+
* Uses a dedicated cache keyed on `ceiling` + frontier snapshot. Cache
|
|
752
|
+
* is bypassed when the writer frontier has advanced (new writers or
|
|
753
|
+
* updated tips) or when `collectReceipts` is `true` because the cached
|
|
754
|
+
* path does not retain receipt data.
|
|
755
|
+
*
|
|
756
|
+
* @param {number} ceiling - Maximum Lamport tick to include (patches with
|
|
757
|
+
* `lamport <= ceiling` are replayed; `ceiling <= 0` yields empty state)
|
|
758
|
+
* @param {boolean} collectReceipts - When `true`, return receipts alongside
|
|
759
|
+
* state and skip the ceiling cache
|
|
760
|
+
* @param {number} t0 - Start timestamp for performance logging
|
|
761
|
+
* @returns {Promise<import('./services/JoinReducer.js').WarpStateV5 |
|
|
762
|
+
* {state: import('./services/JoinReducer.js').WarpStateV5,
|
|
763
|
+
* receipts: import('./types/TickReceipt.js').TickReceipt[]}>}
|
|
764
|
+
* Plain state when `collectReceipts` is falsy; `{ state, receipts }`
|
|
765
|
+
* when truthy
|
|
766
|
+
* @private
|
|
767
|
+
*/
|
|
768
|
+
async _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
769
|
+
const frontier = await this.getFrontier();
|
|
770
|
+
|
|
771
|
+
// Cache hit: same ceiling, clean state, AND frontier unchanged.
|
|
772
|
+
// Bypass cache when collectReceipts is true — cached path has no receipts.
|
|
773
|
+
if (
|
|
774
|
+
this._cachedState && !this._stateDirty &&
|
|
775
|
+
ceiling === this._cachedCeiling && !collectReceipts &&
|
|
776
|
+
this._cachedFrontier !== null &&
|
|
777
|
+
this._cachedFrontier.size === frontier.size &&
|
|
778
|
+
[...frontier].every(([w, sha]) => this._cachedFrontier.get(w) === sha)
|
|
779
|
+
) {
|
|
780
|
+
return this._cachedState;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const writerIds = [...frontier.keys()];
|
|
784
|
+
|
|
785
|
+
if (writerIds.length === 0 || ceiling <= 0) {
|
|
786
|
+
const state = createEmptyStateV5();
|
|
787
|
+
this._provenanceIndex = new ProvenanceIndex();
|
|
788
|
+
await this._setMaterializedState(state);
|
|
789
|
+
this._cachedCeiling = ceiling;
|
|
790
|
+
this._cachedFrontier = frontier;
|
|
791
|
+
this._logTiming('materialize', t0, { metrics: '0 patches (ceiling)' });
|
|
792
|
+
if (collectReceipts) {
|
|
793
|
+
return { state, receipts: [] };
|
|
794
|
+
}
|
|
795
|
+
return state;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const allPatches = [];
|
|
799
|
+
for (const writerId of writerIds) {
|
|
800
|
+
const writerPatches = await this._loadWriterPatches(writerId);
|
|
801
|
+
for (const entry of writerPatches) {
|
|
802
|
+
if (entry.patch.lamport <= ceiling) {
|
|
803
|
+
allPatches.push(entry);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let state;
|
|
809
|
+
let receipts;
|
|
810
|
+
|
|
811
|
+
if (allPatches.length === 0) {
|
|
812
|
+
state = createEmptyStateV5();
|
|
813
|
+
if (collectReceipts) {
|
|
814
|
+
receipts = [];
|
|
815
|
+
}
|
|
816
|
+
} else if (collectReceipts) {
|
|
817
|
+
const result = reduceV5(allPatches, undefined, { receipts: true });
|
|
818
|
+
state = result.state;
|
|
819
|
+
receipts = result.receipts;
|
|
820
|
+
} else {
|
|
821
|
+
state = reduceV5(allPatches);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
this._provenanceIndex = new ProvenanceIndex();
|
|
825
|
+
for (const { patch, sha } of allPatches) {
|
|
826
|
+
this._provenanceIndex.addPatch(sha, patch.reads, patch.writes);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
await this._setMaterializedState(state);
|
|
830
|
+
this._cachedCeiling = ceiling;
|
|
831
|
+
this._cachedFrontier = frontier;
|
|
832
|
+
|
|
833
|
+
// Skip auto-checkpoint and GC — this is an exploratory read
|
|
834
|
+
this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` });
|
|
835
|
+
|
|
836
|
+
if (collectReceipts) {
|
|
837
|
+
return { state, receipts };
|
|
838
|
+
}
|
|
839
|
+
return state;
|
|
840
|
+
}
|
|
841
|
+
|
|
701
842
|
/**
|
|
702
843
|
* Joins (merges) another state into the current cached state.
|
|
703
844
|
*
|
|
@@ -1010,6 +1151,79 @@ export default class WarpGraph {
|
|
|
1010
1151
|
return writerIds.sort();
|
|
1011
1152
|
}
|
|
1012
1153
|
|
|
1154
|
+
/**
|
|
1155
|
+
* Discovers all distinct Lamport ticks across all writers.
|
|
1156
|
+
*
|
|
1157
|
+
* Walks each writer's patch chain from tip to root, reading commit
|
|
1158
|
+
* messages (no CBOR blob deserialization) to extract Lamport timestamps.
|
|
1159
|
+
* Stops when a non-patch commit (e.g. checkpoint) is encountered.
|
|
1160
|
+
* Logs a warning for any non-monotonic lamport sequence within a single
|
|
1161
|
+
* writer's chain.
|
|
1162
|
+
*
|
|
1163
|
+
* @returns {Promise<{
|
|
1164
|
+
* ticks: number[],
|
|
1165
|
+
* maxTick: number,
|
|
1166
|
+
* perWriter: Map<string, {ticks: number[], tipSha: string|null}>
|
|
1167
|
+
* }>} `ticks` is the sorted (ascending) deduplicated union of all
|
|
1168
|
+
* Lamport values; `maxTick` is the largest value (0 if none);
|
|
1169
|
+
* `perWriter` maps each writer ID to its ticks in ascending order
|
|
1170
|
+
* and its current tip SHA (or `null` if the writer ref is missing)
|
|
1171
|
+
* @throws {Error} If reading refs or commit metadata fails
|
|
1172
|
+
*/
|
|
1173
|
+
async discoverTicks() {
|
|
1174
|
+
const writerIds = await this.discoverWriters();
|
|
1175
|
+
const globalTickSet = new Set();
|
|
1176
|
+
const perWriter = new Map();
|
|
1177
|
+
|
|
1178
|
+
for (const writerId of writerIds) {
|
|
1179
|
+
const writerRef = buildWriterRef(this._graphName, writerId);
|
|
1180
|
+
const tipSha = await this._persistence.readRef(writerRef);
|
|
1181
|
+
const writerTicks = [];
|
|
1182
|
+
const tickShas = {};
|
|
1183
|
+
|
|
1184
|
+
if (tipSha) {
|
|
1185
|
+
let currentSha = tipSha;
|
|
1186
|
+
let lastLamport = Infinity;
|
|
1187
|
+
|
|
1188
|
+
while (currentSha) {
|
|
1189
|
+
const nodeInfo = await this._persistence.getNodeInfo(currentSha);
|
|
1190
|
+
const kind = detectMessageKind(nodeInfo.message);
|
|
1191
|
+
if (kind !== 'patch') {
|
|
1192
|
+
break;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const patchMeta = decodePatchMessage(nodeInfo.message);
|
|
1196
|
+
globalTickSet.add(patchMeta.lamport);
|
|
1197
|
+
writerTicks.push(patchMeta.lamport);
|
|
1198
|
+
tickShas[patchMeta.lamport] = currentSha;
|
|
1199
|
+
|
|
1200
|
+
// Check monotonic invariant (walking newest→oldest, lamport should decrease)
|
|
1201
|
+
if (patchMeta.lamport > lastLamport && this._logger) {
|
|
1202
|
+
this._logger.warn(`[warp] non-monotonic lamport for writer ${writerId}: ${patchMeta.lamport} > ${lastLamport}`);
|
|
1203
|
+
}
|
|
1204
|
+
lastLamport = patchMeta.lamport;
|
|
1205
|
+
|
|
1206
|
+
if (nodeInfo.parents && nodeInfo.parents.length > 0) {
|
|
1207
|
+
currentSha = nodeInfo.parents[0];
|
|
1208
|
+
} else {
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
perWriter.set(writerId, {
|
|
1215
|
+
ticks: writerTicks.reverse(),
|
|
1216
|
+
tipSha: tipSha || null,
|
|
1217
|
+
tickShas,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const ticks = [...globalTickSet].sort((a, b) => a - b);
|
|
1222
|
+
const maxTick = ticks.length > 0 ? ticks[ticks.length - 1] : 0;
|
|
1223
|
+
|
|
1224
|
+
return { ticks, maxTick, perWriter };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1013
1227
|
// ============================================================================
|
|
1014
1228
|
// Schema Migration Support
|
|
1015
1229
|
// ============================================================================
|
|
@@ -3025,6 +3239,23 @@ export default class WarpGraph {
|
|
|
3025
3239
|
return cone;
|
|
3026
3240
|
}
|
|
3027
3241
|
|
|
3242
|
+
/**
|
|
3243
|
+
* Loads a single patch by its SHA.
|
|
3244
|
+
*
|
|
3245
|
+
* @param {string} sha - The patch commit SHA
|
|
3246
|
+
* @returns {Promise<Object>} The decoded patch object
|
|
3247
|
+
* @throws {Error} If the commit is not a patch or loading fails
|
|
3248
|
+
*
|
|
3249
|
+
* @public
|
|
3250
|
+
* @remarks
|
|
3251
|
+
* Thin wrapper around the internal `_loadPatchBySha` helper. Exposed for
|
|
3252
|
+
* CLI/debug tooling (e.g. seek tick receipts) that needs to inspect patch
|
|
3253
|
+
* operations without re-materializing intermediate states.
|
|
3254
|
+
*/
|
|
3255
|
+
async loadPatchBySha(sha) {
|
|
3256
|
+
return await this._loadPatchBySha(sha);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3028
3259
|
/**
|
|
3029
3260
|
* Loads a single patch by its SHA.
|
|
3030
3261
|
*
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - refs/warp/<graph>/writers/<writer_id>
|
|
9
9
|
* - refs/warp/<graph>/checkpoints/head
|
|
10
10
|
* - refs/warp/<graph>/coverage/head
|
|
11
|
+
* - refs/warp/<graph>/cursor/active
|
|
12
|
+
* - refs/warp/<graph>/cursor/saved/<name>
|
|
11
13
|
*
|
|
12
14
|
* @module domain/utils/RefLayout
|
|
13
15
|
*/
|
|
@@ -49,20 +51,22 @@ const PATH_TRAVERSAL_PATTERN = /\.\./;
|
|
|
49
51
|
* Validates a graph name and throws if invalid.
|
|
50
52
|
*
|
|
51
53
|
* Graph names must not contain:
|
|
52
|
-
* - Path traversal sequences (
|
|
54
|
+
* - Path traversal sequences (`..`)
|
|
53
55
|
* - Semicolons (`;`)
|
|
54
56
|
* - Spaces
|
|
55
57
|
* - Null bytes (`\0`)
|
|
56
58
|
* - Empty strings
|
|
57
59
|
*
|
|
58
60
|
* @param {string} name - The graph name to validate
|
|
59
|
-
* @throws {Error} If the
|
|
61
|
+
* @throws {Error} If the name is not a string, is empty, or contains
|
|
62
|
+
* forbidden characters (`..`, `;`, space, `\0`)
|
|
60
63
|
* @returns {void}
|
|
61
64
|
*
|
|
62
65
|
* @example
|
|
63
|
-
* validateGraphName('events');
|
|
64
|
-
* validateGraphName('
|
|
65
|
-
* validateGraphName('
|
|
66
|
+
* validateGraphName('events'); // OK
|
|
67
|
+
* validateGraphName('team/proj'); // OK (slashes allowed)
|
|
68
|
+
* validateGraphName('../etc'); // throws — path traversal
|
|
69
|
+
* validateGraphName('my graph'); // throws — contains space
|
|
66
70
|
*/
|
|
67
71
|
export function validateGraphName(name) {
|
|
68
72
|
if (typeof name !== 'string') {
|
|
@@ -94,18 +98,20 @@ export function validateGraphName(name) {
|
|
|
94
98
|
* Validates a writer ID and throws if invalid.
|
|
95
99
|
*
|
|
96
100
|
* Writer IDs must:
|
|
97
|
-
* - Be ASCII ref-safe: only [A-Za-z0-9._-]
|
|
101
|
+
* - Be ASCII ref-safe: only `[A-Za-z0-9._-]`
|
|
98
102
|
* - Be 1-64 characters long
|
|
99
103
|
* - Not contain `/`, `..`, whitespace, or NUL
|
|
100
104
|
*
|
|
101
105
|
* @param {string} id - The writer ID to validate
|
|
102
|
-
* @throws {Error} If the
|
|
106
|
+
* @throws {Error} If the ID is not a string, is empty, exceeds 64 characters,
|
|
107
|
+
* or contains forbidden characters (`/`, `..`, whitespace, NUL, non-ASCII)
|
|
103
108
|
* @returns {void}
|
|
104
109
|
*
|
|
105
110
|
* @example
|
|
106
|
-
* validateWriterId('node-1');
|
|
107
|
-
* validateWriterId('a/b');
|
|
108
|
-
* validateWriterId('x'.repeat(65));
|
|
111
|
+
* validateWriterId('node-1'); // OK
|
|
112
|
+
* validateWriterId('a/b'); // throws — contains forward slash
|
|
113
|
+
* validateWriterId('x'.repeat(65)); // throws — exceeds max length
|
|
114
|
+
* validateWriterId('has space'); // throws — contains whitespace
|
|
109
115
|
*/
|
|
110
116
|
export function validateWriterId(id) {
|
|
111
117
|
if (typeof id !== 'string') {
|
|
@@ -157,7 +163,7 @@ export function validateWriterId(id) {
|
|
|
157
163
|
*
|
|
158
164
|
* @param {string} graphName - The name of the graph
|
|
159
165
|
* @param {string} writerId - The writer's unique identifier
|
|
160
|
-
* @returns {string} The full ref path
|
|
166
|
+
* @returns {string} The full ref path, e.g. `refs/warp/<graphName>/writers/<writerId>`
|
|
161
167
|
* @throws {Error} If graphName or writerId is invalid
|
|
162
168
|
*
|
|
163
169
|
* @example
|
|
@@ -174,7 +180,7 @@ export function buildWriterRef(graphName, writerId) {
|
|
|
174
180
|
* Builds the checkpoint head ref path for the given graph.
|
|
175
181
|
*
|
|
176
182
|
* @param {string} graphName - The name of the graph
|
|
177
|
-
* @returns {string} The full ref path
|
|
183
|
+
* @returns {string} The full ref path, e.g. `refs/warp/<graphName>/checkpoints/head`
|
|
178
184
|
* @throws {Error} If graphName is invalid
|
|
179
185
|
*
|
|
180
186
|
* @example
|
|
@@ -190,7 +196,7 @@ export function buildCheckpointRef(graphName) {
|
|
|
190
196
|
* Builds the coverage head ref path for the given graph.
|
|
191
197
|
*
|
|
192
198
|
* @param {string} graphName - The name of the graph
|
|
193
|
-
* @returns {string} The full ref path
|
|
199
|
+
* @returns {string} The full ref path, e.g. `refs/warp/<graphName>/coverage/head`
|
|
194
200
|
* @throws {Error} If graphName is invalid
|
|
195
201
|
*
|
|
196
202
|
* @example
|
|
@@ -204,10 +210,12 @@ export function buildCoverageRef(graphName) {
|
|
|
204
210
|
|
|
205
211
|
/**
|
|
206
212
|
* Builds the writers prefix path for the given graph.
|
|
207
|
-
* Useful for listing all writer refs under a graph
|
|
213
|
+
* Useful for listing all writer refs under a graph
|
|
214
|
+
* (e.g. via `git for-each-ref`).
|
|
208
215
|
*
|
|
209
216
|
* @param {string} graphName - The name of the graph
|
|
210
|
-
* @returns {string} The writers prefix path
|
|
217
|
+
* @returns {string} The writers prefix path (with trailing slash),
|
|
218
|
+
* e.g. `refs/warp/<graphName>/writers/`
|
|
211
219
|
* @throws {Error} If graphName is invalid
|
|
212
220
|
*
|
|
213
221
|
* @example
|
|
@@ -219,6 +227,70 @@ export function buildWritersPrefix(graphName) {
|
|
|
219
227
|
return `${REF_PREFIX}/${graphName}/writers/`;
|
|
220
228
|
}
|
|
221
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Builds the active cursor ref path for the given graph.
|
|
232
|
+
*
|
|
233
|
+
* The active cursor is a single ref that stores the current time-travel
|
|
234
|
+
* position used by `git warp seek`. It points to a commit SHA representing
|
|
235
|
+
* the materialization frontier the user has seeked to.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} graphName - The name of the graph
|
|
238
|
+
* @returns {string} The full ref path, e.g. `refs/warp/<graphName>/cursor/active`
|
|
239
|
+
* @throws {Error} If graphName is invalid
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* buildCursorActiveRef('events');
|
|
243
|
+
* // => 'refs/warp/events/cursor/active'
|
|
244
|
+
*/
|
|
245
|
+
export function buildCursorActiveRef(graphName) {
|
|
246
|
+
validateGraphName(graphName);
|
|
247
|
+
return `${REF_PREFIX}/${graphName}/cursor/active`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Builds a saved (named) cursor ref path for the given graph and cursor name.
|
|
252
|
+
*
|
|
253
|
+
* Saved cursors are bookmarks created by `git warp seek --save <name>`.
|
|
254
|
+
* Each saved cursor persists a time-travel position that can be restored
|
|
255
|
+
* later without re-seeking.
|
|
256
|
+
*
|
|
257
|
+
* The cursor name is validated with the same rules as a writer ID
|
|
258
|
+
* (ASCII ref-safe: `[A-Za-z0-9._-]`, 1-64 characters).
|
|
259
|
+
*
|
|
260
|
+
* @param {string} graphName - The name of the graph
|
|
261
|
+
* @param {string} name - The cursor bookmark name (validated like a writer ID)
|
|
262
|
+
* @returns {string} The full ref path, e.g. `refs/warp/<graphName>/cursor/saved/<name>`
|
|
263
|
+
* @throws {Error} If graphName or name is invalid
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* buildCursorSavedRef('events', 'before-tui');
|
|
267
|
+
* // => 'refs/warp/events/cursor/saved/before-tui'
|
|
268
|
+
*/
|
|
269
|
+
export function buildCursorSavedRef(graphName, name) {
|
|
270
|
+
validateGraphName(graphName);
|
|
271
|
+
validateWriterId(name);
|
|
272
|
+
return `${REF_PREFIX}/${graphName}/cursor/saved/${name}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Builds the saved cursor prefix path for the given graph.
|
|
277
|
+
* Useful for listing all saved cursor bookmarks under a graph
|
|
278
|
+
* (e.g. via `git for-each-ref`).
|
|
279
|
+
*
|
|
280
|
+
* @param {string} graphName - The name of the graph
|
|
281
|
+
* @returns {string} The saved cursor prefix path (with trailing slash),
|
|
282
|
+
* e.g. `refs/warp/<graphName>/cursor/saved/`
|
|
283
|
+
* @throws {Error} If graphName is invalid
|
|
284
|
+
*
|
|
285
|
+
* @example
|
|
286
|
+
* buildCursorSavedPrefix('events');
|
|
287
|
+
* // => 'refs/warp/events/cursor/saved/'
|
|
288
|
+
*/
|
|
289
|
+
export function buildCursorSavedPrefix(graphName) {
|
|
290
|
+
validateGraphName(graphName);
|
|
291
|
+
return `${REF_PREFIX}/${graphName}/cursor/saved/`;
|
|
292
|
+
}
|
|
293
|
+
|
|
222
294
|
// -----------------------------------------------------------------------------
|
|
223
295
|
// Parsers
|
|
224
296
|
// -----------------------------------------------------------------------------
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for parsing seek-cursor blobs stored as Git refs.
|
|
3
|
+
*
|
|
4
|
+
* @module parseCursorBlob
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parses and validates a cursor blob (Buffer) into a cursor object.
|
|
9
|
+
*
|
|
10
|
+
* The blob must contain UTF-8-encoded JSON representing a plain object with at
|
|
11
|
+
* minimum a finite numeric `tick` field. Any additional fields (e.g. `mode`,
|
|
12
|
+
* `name`) are preserved in the returned object.
|
|
13
|
+
*
|
|
14
|
+
* @param {Buffer} buf - Raw blob contents (UTF-8 encoded JSON)
|
|
15
|
+
* @param {string} label - Human-readable label used in error messages
|
|
16
|
+
* (e.g. `"active cursor"`, `"saved cursor 'foo'"`)
|
|
17
|
+
* @returns {{ tick: number, mode?: string, [key: string]: unknown }}
|
|
18
|
+
* The validated cursor object. `tick` is guaranteed to be a finite number.
|
|
19
|
+
* @throws {Error} If `buf` is not valid JSON
|
|
20
|
+
* @throws {Error} If the parsed value is not a plain JSON object (e.g. array,
|
|
21
|
+
* null, or primitive)
|
|
22
|
+
* @throws {Error} If the `tick` field is missing, non-numeric, NaN, or
|
|
23
|
+
* Infinity
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const buf = Buffer.from('{"tick":5,"mode":"lamport"}', 'utf8');
|
|
27
|
+
* const cursor = parseCursorBlob(buf, 'active cursor');
|
|
28
|
+
* // => { tick: 5, mode: 'lamport' }
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* // Throws: "Corrupted active cursor: blob is not valid JSON"
|
|
32
|
+
* parseCursorBlob(Buffer.from('not json', 'utf8'), 'active cursor');
|
|
33
|
+
*/
|
|
34
|
+
export function parseCursorBlob(buf, label) {
|
|
35
|
+
let obj;
|
|
36
|
+
try {
|
|
37
|
+
obj = JSON.parse(new TextDecoder().decode(buf));
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error(`Corrupted ${label}: blob is not valid JSON`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
43
|
+
throw new Error(`Corrupted ${label}: expected a JSON object`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof obj.tick !== 'number' || !Number.isFinite(obj.tick)) {
|
|
47
|
+
throw new Error(`Corrupted ${label}: missing or invalid numeric tick`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return obj;
|
|
51
|
+
}
|
|
@@ -6,20 +6,20 @@ const LAYOUT_PRESETS = {
|
|
|
6
6
|
query: {
|
|
7
7
|
'elk.algorithm': 'layered',
|
|
8
8
|
'elk.direction': 'DOWN',
|
|
9
|
-
'elk.spacing.nodeNode': '
|
|
10
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '
|
|
9
|
+
'elk.spacing.nodeNode': '30',
|
|
10
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '40',
|
|
11
11
|
},
|
|
12
12
|
path: {
|
|
13
13
|
'elk.algorithm': 'layered',
|
|
14
14
|
'elk.direction': 'RIGHT',
|
|
15
|
-
'elk.spacing.nodeNode': '
|
|
16
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '
|
|
15
|
+
'elk.spacing.nodeNode': '30',
|
|
16
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '40',
|
|
17
17
|
},
|
|
18
18
|
slice: {
|
|
19
19
|
'elk.algorithm': 'layered',
|
|
20
20
|
'elk.direction': 'DOWN',
|
|
21
|
-
'elk.spacing.nodeNode': '
|
|
22
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '
|
|
21
|
+
'elk.spacing.nodeNode': '30',
|
|
22
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '40',
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
|
|
@@ -46,7 +46,7 @@ function estimateNodeWidth(label) {
|
|
|
46
46
|
return Math.max((label?.length ?? 0) * charWidth + padding, minWidth);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const NODE_HEIGHT =
|
|
49
|
+
const NODE_HEIGHT = 30;
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Converts normalised graph data to an ELK graph JSON object.
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* ASCII graph renderer: maps ELK-positioned nodes and edges onto a character grid.
|
|
3
3
|
*
|
|
4
4
|
* Pixel-to-character scaling:
|
|
5
|
-
* cellW =
|
|
5
|
+
* cellW = 10, cellH = 10
|
|
6
|
+
* ELK uses NODE_HEIGHT=40, nodeNode=40, betweenLayers=60.
|
|
7
|
+
* At cellH=10: 40px → 4 rows, compact 3-row nodes fit with natural gaps.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { createBox } from './box.js';
|
|
@@ -11,8 +13,8 @@ import { ARROW } from './symbols.js';
|
|
|
11
13
|
|
|
12
14
|
// ── Scaling constants ────────────────────────────────────────────────────────
|
|
13
15
|
|
|
14
|
-
const CELL_W =
|
|
15
|
-
const CELL_H =
|
|
16
|
+
const CELL_W = 10;
|
|
17
|
+
const CELL_H = 10;
|
|
16
18
|
const MARGIN = 2;
|
|
17
19
|
|
|
18
20
|
// ── Box-drawing characters (short keys for tight grid-stamping loops) ───────
|
|
@@ -76,8 +78,8 @@ function writeString(grid, r, c, str) {
|
|
|
76
78
|
function stampNode(grid, node) {
|
|
77
79
|
const r = toRow(node.y);
|
|
78
80
|
const c = toCol(node.x);
|
|
79
|
-
const w = Math.max(
|
|
80
|
-
const h =
|
|
81
|
+
const w = Math.max(toCol(node.width), 4);
|
|
82
|
+
const h = 3; // Always: border + label + border
|
|
81
83
|
|
|
82
84
|
// Top border
|
|
83
85
|
writeChar(grid, r, c, BOX.tl);
|
|
@@ -87,10 +89,8 @@ function stampNode(grid, node) {
|
|
|
87
89
|
writeChar(grid, r, c + w - 1, BOX.tr);
|
|
88
90
|
|
|
89
91
|
// Side borders
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
writeChar(grid, r + j, c + w - 1, BOX.v);
|
|
93
|
-
}
|
|
92
|
+
writeChar(grid, r + 1, c, BOX.v);
|
|
93
|
+
writeChar(grid, r + 1, c + w - 1, BOX.v);
|
|
94
94
|
|
|
95
95
|
// Bottom border
|
|
96
96
|
writeChar(grid, r + h - 1, c, BOX.bl);
|
|
@@ -99,13 +99,13 @@ function stampNode(grid, node) {
|
|
|
99
99
|
}
|
|
100
100
|
writeChar(grid, r + h - 1, c + w - 1, BOX.br);
|
|
101
101
|
|
|
102
|
-
// Label (
|
|
102
|
+
// Label (always on row 1)
|
|
103
103
|
const label = node.label ?? node.id;
|
|
104
104
|
const maxLabel = w - 4;
|
|
105
105
|
const truncated = label.length > maxLabel
|
|
106
106
|
? `${label.slice(0, Math.max(maxLabel - 1, 1))}\u2026`
|
|
107
107
|
: label;
|
|
108
|
-
const labelRow = r +
|
|
108
|
+
const labelRow = r + 1;
|
|
109
109
|
const labelCol = c + Math.max(1, Math.floor((w - truncated.length) / 2));
|
|
110
110
|
writeString(grid, labelRow, labelCol, truncated);
|
|
111
111
|
}
|
|
@@ -220,6 +220,8 @@ function drawArrowhead(grid, section, nodeSet) {
|
|
|
220
220
|
const pc = toCol(prev.x);
|
|
221
221
|
|
|
222
222
|
let arrow;
|
|
223
|
+
let ar = er;
|
|
224
|
+
let ac = ec;
|
|
223
225
|
if (er > pr) {
|
|
224
226
|
arrow = ARROW.down;
|
|
225
227
|
} else if (er < pr) {
|
|
@@ -230,8 +232,21 @@ function drawArrowhead(grid, section, nodeSet) {
|
|
|
230
232
|
arrow = ARROW.left;
|
|
231
233
|
}
|
|
232
234
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
+
// If the endpoint is inside a node box, step back one cell into free space
|
|
236
|
+
if (isNodeCell(nodeSet, ar, ac)) {
|
|
237
|
+
if (er > pr) {
|
|
238
|
+
ar = er - 1;
|
|
239
|
+
} else if (er < pr) {
|
|
240
|
+
ar = er + 1;
|
|
241
|
+
} else if (ec > pc) {
|
|
242
|
+
ac = ec - 1;
|
|
243
|
+
} else {
|
|
244
|
+
ac = ec + 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!isNodeCell(nodeSet, ar, ac)) {
|
|
249
|
+
writeChar(grid, ar, ac, arrow);
|
|
235
250
|
}
|
|
236
251
|
}
|
|
237
252
|
|
|
@@ -282,8 +297,8 @@ function buildNodeSet(nodes) {
|
|
|
282
297
|
for (const node of nodes) {
|
|
283
298
|
const r = toRow(node.y);
|
|
284
299
|
const c = toCol(node.x);
|
|
285
|
-
const w = Math.max(
|
|
286
|
-
const h =
|
|
300
|
+
const w = Math.max(toCol(node.width), 4);
|
|
301
|
+
const h = 3; // Match compact node height
|
|
287
302
|
for (let dr = 0; dr < h; dr++) {
|
|
288
303
|
for (let dc = 0; dc < w; dc++) {
|
|
289
304
|
set.add(`${r + dr},${c + dc}`);
|