diff --git a/CHANGELOG.md b/CHANGELOG.md index c99b5c8..b6731b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,87 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [10.3.2] — 2026-02-09 — Seek CLI fixes & demo portability + +### Added + +- **`--save=NAME`, `--load=NAME`, `--drop=NAME` equals form**: `parseSeekArgs` now accepts `=`-separated values for `--save`, `--load`, and `--drop`, matching the existing `--tick=VALUE` form. + +### Fixed + +- **BATS CI: missing `append-patch.js` helper**: `test/bats/helpers/append-patch.js` was untracked, so Docker builds (which copy from the git context) never included it — causing test 55 to fail with `MODULE_NOT_FOUND` on Node 20. +- **`seek-demo.tape` not portable**: Replaced hardcoded `$HOME/git/git-stunts/git-warp` with `export PROJECT_ROOT=$(pwd)` captured before entering the temp sandbox. +- **`emitCursorWarning` / `applyCursorCeiling` JSDoc**: Clarified that non-seek commands intentionally pass `null` for `maxTick` to skip the cost of `discoverTicks()`. +- **`_resolveCeiling` treated `undefined` as valid**: The `'ceiling' in options` check returned `undefined` when options contained the key but no value. Switched to `options.ceiling !== undefined` so explicit `null` still overrides the instance ceiling but `undefined` correctly falls through. + +## [10.3.1] — 2026-02-09 — Seek polish, arrowheads & demo GIF + +### Added + +- **Seek demo GIF** (`docs/seek-demo.gif`): Animated walkthrough showing `git warp seek` time-travel — graph topology visually changes at each tick while `git status` proves the worktree is untouched. VHS tape at `docs/seek-demo.tape`. +- **README seek demo**: Embedded `seek-demo.gif` in the CLI section below the seek command examples. +- **ROADMAP backlog**: New `## Backlog` section with two future ideas — structural seek diff (`diffStates()`) and git-cas materialization cache. +- **Op summary renderer** (`src/visualization/renderers/ascii/opSummary.js`): Extracted operation summary formatting from history renderer into a shared module used by both history and seek views. + +### Fixed + +- **ASCII graph arrowheads missing**: `drawArrowhead` was silently dropping arrows when the ELK endpoint fell inside a node's bounding box. Now steps back one cell to place the arrowhead just outside the node border. +- **Seek ASCII renderer**: Reworked swimlane dashboard with improved windowing, writer rows, tick receipt display, and op summary formatting. +- **Seek ceiling via public API**: Replaced direct `graph._seekCeiling` mutation in `materializeOneGraph` and `handleSeek` with `graph.materialize({ ceiling })`, using the public option instead of poking at internals. +- **Seek timeline missing currentTick**: When the active cursor referenced a tick absent from the discovered ticks array, the renderer fell back to index 0 and never showed the current tick marker. Now inserts the cursor tick at the correct sorted position so the window always centres on it. +- **Docs `--tick` signed-value syntax**: Updated GUIDE.md, README.md, and CHANGELOG examples to use `--tick=+N`/`--tick=-N` (equals form) for signed relative values, matching BATS tests and avoiding CLI parser ambiguity. +- **Ceiling cache stale on frontier advance**: `_materializeWithCeiling` cached state keyed only on ceiling + dirty flag, so it could return stale results when new writers appeared or tips advanced. Now snapshots the frontier (writer tip SHAs) alongside the ceiling and invalidates the cache when the frontier changes. +- **`resolveTickValue` duplicate tick 0**: The relative-tick resolver blindly prepended 0 to the ticks array, duplicating it when ticks already contained 0. Now checks before prepending. + +### Changed + +- **History renderer**: Extracted `summarizeOps` and `formatOpSummary` into shared modules, reducing duplication between history and seek views. + +## [10.3.0] — 2026-02-09 — Time Travel (`git warp seek`) + +Adds cursor-based time travel for exploring graph history. Navigate to any Lamport tick, save/load named bookmarks, and see materialized state at any point in time. Existing commands (`info`, `materialize`, `history`, `query`) respect the active cursor. + +### Added + +- **`git warp seek` command**: Step through graph history by Lamport tick. + - `seek --tick N` — position cursor at absolute tick N. + - `seek --tick=+N` / `seek --tick=-N` — step forward/backward relative to current position. + - `seek --latest` — clear cursor and return to the present (latest state). + - `seek --save NAME` / `seek --load NAME` — save and restore named cursor bookmarks. + - `seek --list` — list all saved cursors. + - `seek --drop NAME` — delete a saved cursor. + - `seek` (bare) — show current cursor status. +- **`WarpGraph.discoverTicks()`**: Walks all writer patch chains reading only commit messages (no blob deserialization) to extract sorted Lamport timestamps and per-writer tick breakdowns. +- **`materialize({ ceiling })` option**: Replays only patches with `lamport <= ceiling`, enabling time-travel materialization. Skips auto-checkpoint when ceiling is active to avoid writing snapshots of past state. +- **Cursor persistence via refs**: Active cursor stored at `refs/warp//cursor/active`, saved cursors at `refs/warp//cursor/saved/`. All data stored as JSON blobs. +- **ASCII seek renderer** (`src/visualization/renderers/ascii/seek.js`): Dashboard view with timeline visualization, writer inclusion status, and graph stats at the selected tick. Activated via `--view`. +- **Cursor-aware existing commands**: `info` shows active cursor in summary; `materialize` skips checkpointing when a cursor is active; `history` filters patches to the selected tick; `query` materializes at the cursor ceiling. +- **BATS CLI tests** (`test/bats/cli-seek.bats`, 10 tests): End-to-end integration tests for all seek operations. +- **Domain unit tests** (`test/unit/domain/WarpGraph.seek.test.js`, 12 tests): `discoverTicks()`, `materialize({ ceiling })`, ceiling caching, multi-writer ceiling, `_seekCeiling` instance state. +- **Renderer unit tests** (`test/unit/visualization/ascii-seek-renderer.test.js`, 7 tests): Timeline rendering, writer rows, dashboard layout. + +### Changed + +- **`RefLayout`**: New helpers `buildCursorActiveRef()`, `buildCursorSavedRef()`, `buildCursorSavedPrefix()` for cursor ref path construction. + +### Fixed + +- **Cursor blob validation**: Added `parseCursorBlob()` utility that validates JSON structure and numeric tick before use. `readActiveCursor`, `readSavedCursor`, and `listSavedCursors` now throw descriptive errors on corrupted cursor data instead of crashing. +- **GUIDE.md**: Added `--view seek` to the supported commands table. +- **CHANGELOG**: Fixed `RefLayout` helper names to match exported API (`buildCursorActiveRef`, not `buildCursorRef`). +- **`_materializeWithCeiling` cache**: Cache fast-path no longer returns empty `receipts: []` when `collectReceipts` is true; falls through to full materialization to produce real receipts. +- **`_resolveCeiling` null override**: `materialize({ ceiling: null })` now correctly clears `_seekCeiling` and materializes latest state, instead of ignoring the explicit null. +- **Seek timeline duplicate 0**: `buildSeekTimeline` no longer prepends tick 0 when `ticks` already contains it, preventing a duplicate dot in the timeline. +- **Seek timeline label drift**: Tick labels now stay vertically aligned under their dots for multi-digit tick values by computing target column positions instead of using fixed-width padding. +- **RefLayout docstring**: Added `cursor/active` and `cursor/saved/` to the module-level ref layout listing. +- **BATS seek tests**: Use `--tick=+1` / `--tick=-1` syntax instead of `--tick +1` / `--tick -1` to avoid parser ambiguity with signed numbers. + +### Tests + +- Suite total: 2938 tests across 147 vitest files + 66 BATS CLI tests (up from 2883/142 + 56). +- New seek tests: 23 unit (14 domain + 9 renderer) + 10 BATS CLI = 33 total. +- New parseCursorBlob unit tests: 11 tests covering valid parsing, corrupted JSON, missing/invalid tick. + ## [10.2.1] — 2026-02-09 — Compact ASCII graphs & hero GIF ### Changed diff --git a/README.md b/README.md index bf1a7e6..f92b371 100644 --- a/README.md +++ b/README.md @@ -407,12 +407,26 @@ git warp history --writer alice # Check graph health, status, and GC metrics git warp check +# Time-travel: step through graph history +git warp seek --tick 3 # jump to Lamport tick 3 +git warp seek --tick=+1 # step forward one tick +git warp seek --tick=-1 # step backward one tick +git warp seek --save before-refactor # bookmark current position +git warp seek --load before-refactor # restore bookmark +git warp seek --latest # return to present + # Visualize query results (ascii output by default) git warp query --match 'user:*' --outgoing manages --view ``` All commands accept `--repo ` to target a specific Git repository, `--json` for machine-readable output, and `--view [mode]` for visual output (ascii by default, or browser, svg:FILE, html:FILE). +When a seek cursor is active, `query`, `info`, `materialize`, and `history` automatically show state at the selected tick. + +

+ git warp seek time-travel demo +

+ ## Architecture The codebase follows hexagonal architecture with ports and adapters: diff --git a/ROADMAP.md b/ROADMAP.md index d6b07fd..89de1a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2439,3 +2439,15 @@ Things this project should not try to become: | ECHO | 3 | 3 | 17 | ~820 | | BULKHEAD | 5 | 15 | 49 | ~2,580 | | **Total** | **40** | **67** | **230** | **~11,510** | + +--- + +## Backlog + +Ideas that don't yet have milestone homes. No estimates, no ordering — just a +parking lot so they aren't forgotten. + +| Idea | Description | +|------|-------------| +| **Structural seek diff** | Full `diffStates()` between arbitrary ticks returning added/removed nodes, edges, and properties — not just count deltas. Would power a `--diff` flag on `git warp seek` showing exactly what changed at each tick. | +| **git-cas materialization cache** | Cache `WarpStateV5` at each visited ceiling tick as content-addressed blobs via `@git-stunts/git-cas`, enabling O(1) restoration for previously-visited ticks during seek exploration. Blobs naturally GC unless pinned to a vault. | diff --git a/bin/warp-graph.js b/bin/warp-graph.js index ae23414..015fdbf 100755 --- a/bin/warp-graph.js +++ b/bin/warp-graph.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -17,6 +18,9 @@ import { buildCoverageRef, buildWritersPrefix, parseWriterIdFromRef, + buildCursorActiveRef, + buildCursorSavedRef, + buildCursorSavedPrefix, } from '../src/domain/utils/RefLayout.js'; import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js'; import { renderInfoView } from '../src/visualization/renderers/ascii/info.js'; @@ -24,6 +28,8 @@ import { renderCheckView } from '../src/visualization/renderers/ascii/check.js'; import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js'; import { renderPathView } from '../src/visualization/renderers/ascii/path.js'; import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js'; +import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js'; +import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js'; import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js'; import { renderSvg } from '../src/visualization/renderers/svg/index.js'; import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js'; @@ -45,6 +51,7 @@ Commands: history Show writer history check Report graph health/GC status materialize Materialize and checkpoint all graphs + seek Time-travel: step through graph history by Lamport tick view Interactive TUI graph browser (requires @git-stunts/git-warp-tui) install-hooks Install post-merge git hook @@ -75,6 +82,14 @@ Path options: History options: --node Filter patches touching node id + +Seek options: + --tick Jump to tick N, or step forward/backward + --latest Clear cursor, return to present + --save Save current position as named cursor + --load Restore a saved cursor + --list List all saved cursors + --drop Delete a saved cursor `; /** @@ -170,7 +185,7 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) { if (arg === '--view') { // Valid view modes: ascii, browser, svg:FILE, html:FILE // Don't consume known commands as modes - const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'install-hooks']; + const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks']; const nextArg = argv[index + 1]; const isViewMode = nextArg && !nextArg.startsWith('-') && @@ -632,6 +647,9 @@ function renderInfo(payload) { if (graph.coverage?.sha) { lines.push(` coverage: ${graph.coverage.sha}`); } + if (graph.cursor?.active) { + lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`); + } } return `${lines.join('\n')}\n`; } @@ -883,6 +901,15 @@ function emit(payload, { json, command, view }) { return; } + if (command === 'seek') { + if (view) { + process.stdout.write(renderSeekView(payload)); + } else { + process.stdout.write(renderSeek(payload)); + } + return; + } + if (command === 'install-hooks') { process.stdout.write(renderInstallHooks(payload)); return; @@ -924,12 +951,19 @@ async function handleInfo({ options }) { const graphs = []; for (const name of graphNames) { const includeDetails = detailGraphs.has(name); - graphs.push(await getGraphInfo(persistence, name, { + const info = await getGraphInfo(persistence, name, { includeWriterIds: includeDetails || isViewMode, includeRefs: includeDetails || isViewMode, includeWriterPatches: isViewMode, includeCheckpointDate: isViewMode, - })); + }); + const activeCursor = await readActiveCursor(persistence, name); + if (activeCursor) { + info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode }; + } else { + info.cursor = { active: false }; + } + graphs.push(info); } return { @@ -948,7 +982,9 @@ async function handleInfo({ options }) { */ async function handleQuery({ options, args }) { const querySpec = parseQueryArgs(args); - const { graph, graphName } = await openGraph(options); + const { graph, graphName, persistence } = await openGraph(options); + const cursorInfo = await applyCursorCeiling(graph, persistence, graphName); + emitCursorWarning(cursorInfo, null); let builder = graph.query(); if (querySpec.match !== null) { @@ -1039,7 +1075,9 @@ function mapQueryError(error) { */ async function handlePath({ options, args }) { const pathOptions = parsePathArgs(args); - const { graph, graphName } = await openGraph(options); + const { graph, graphName, persistence } = await openGraph(options); + const cursorInfo = await applyCursorCeiling(graph, persistence, graphName); + emitCursorWarning(cursorInfo, null); try { const result = await graph.traverse.shortestPath( @@ -1085,6 +1123,8 @@ async function handlePath({ options, args }) { */ async function handleCheck({ options }) { const { graph, graphName, persistence } = await openGraph(options); + const cursorInfo = await applyCursorCeiling(graph, persistence, graphName); + emitCursorWarning(cursorInfo, null); const health = await getHealth(persistence); const gcMetrics = await getGcMetrics(graph); const status = await graph.status(); @@ -1222,9 +1262,15 @@ function buildCheckPayload({ */ async function handleHistory({ options, args }) { const historyOptions = parseHistoryArgs(args); - const { graph, graphName } = await openGraph(options); + const { graph, graphName, persistence } = await openGraph(options); + const cursorInfo = await applyCursorCeiling(graph, persistence, graphName); + emitCursorWarning(cursorInfo, null); + const writerId = options.writer; - const patches = await graph.getWriterPatches(writerId); + let patches = await graph.getWriterPatches(writerId); + if (cursorInfo.active) { + patches = patches.filter(({ patch }) => patch.lamport <= cursorInfo.tick); + } if (patches.length === 0) { throw notFoundError(`No patches found for writer: ${writerId}`); } @@ -1251,18 +1297,21 @@ async function handleHistory({ options, args }) { /** * Materializes a single graph, creates a checkpoint, and returns summary stats. + * When a ceiling tick is provided (seek cursor active), the checkpoint step is + * skipped because the user is exploring historical state, not persisting it. * @param {Object} params * @param {Object} params.persistence - GraphPersistencePort adapter * @param {string} params.graphName - Name of the graph to materialize * @param {string} params.writerId - Writer ID for the CLI session - * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string, writers: Object, patchCount: number}>} + * @param {number} [params.ceiling] - Optional seek ceiling tick + * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Object, patchCount: number}>} */ -async function materializeOneGraph({ persistence, graphName, writerId }) { +async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) { const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() }); - await graph.materialize(); + await graph.materialize(ceiling !== undefined ? { ceiling } : undefined); const nodes = await graph.getNodes(); const edges = await graph.getEdges(); - const checkpoint = await graph.createCheckpoint(); + const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint(); const status = await graph.status(); // Build per-writer patch counts for the view renderer @@ -1314,12 +1363,20 @@ async function handleMaterialize({ options }) { } const results = []; + let cursorWarningEmitted = false; for (const name of targets) { try { + const cursor = await readActiveCursor(persistence, name); + const ceiling = cursor ? cursor.tick : undefined; + if (cursor && !cursorWarningEmitted) { + emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null); + cursorWarningEmitted = true; + } const result = await materializeOneGraph({ persistence, graphName: name, writerId: options.writer, + ceiling, }); results.push(result); } catch (error) { @@ -1538,6 +1595,828 @@ function getHookStatusForCheck(repoPath) { } } +// ============================================================================ +// Cursor I/O Helpers +// ============================================================================ + +/** + * Reads the active seek cursor for a graph from Git ref storage. + * + * @private + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if no active cursor + * @throws {Error} If the stored blob is corrupted or not valid JSON + */ +async function readActiveCursor(persistence, graphName) { + const ref = buildCursorActiveRef(graphName); + const oid = await persistence.readRef(ref); + if (!oid) { + return null; + } + const buf = await persistence.readBlob(oid); + return parseCursorBlob(buf, 'active cursor'); +} + +/** + * Writes (creates or overwrites) the active seek cursor for a graph. + * + * Serializes the cursor as JSON, stores it as a Git blob, and points + * the active cursor ref at that blob. + * + * @private + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @param {{tick: number, mode?: string}} cursor - Cursor state to persist + * @returns {Promise} + */ +async function writeActiveCursor(persistence, graphName, cursor) { + const ref = buildCursorActiveRef(graphName); + const json = JSON.stringify(cursor); + const oid = await persistence.writeBlob(Buffer.from(json, 'utf8')); + await persistence.updateRef(ref, oid); +} + +/** + * Removes the active seek cursor for a graph, returning to present state. + * + * No-op if no active cursor exists. + * + * @private + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @returns {Promise} + */ +async function clearActiveCursor(persistence, graphName) { + const ref = buildCursorActiveRef(graphName); + const exists = await persistence.readRef(ref); + if (exists) { + await persistence.deleteRef(ref); + } +} + +/** + * Reads a named saved cursor from Git ref storage. + * + * @private + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @param {string} name - Saved cursor name + * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if not found + * @throws {Error} If the stored blob is corrupted or not valid JSON + */ +async function readSavedCursor(persistence, graphName, name) { + const ref = buildCursorSavedRef(graphName, name); + const oid = await persistence.readRef(ref); + if (!oid) { + return null; + } + const buf = await persistence.readBlob(oid); + return parseCursorBlob(buf, `saved cursor '${name}'`); +} + +/** + * Persists a cursor under a named saved-cursor ref. + * + * Serializes the cursor as JSON, stores it as a Git blob, and points + * the named saved-cursor ref at that blob. + * + * @private + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @param {string} name - Saved cursor name + * @param {{tick: number, mode?: string}} cursor - Cursor state to persist + * @returns {Promise} + */ +async function writeSavedCursor(persistence, graphName, name, cursor) { + const ref = buildCursorSavedRef(graphName, name); + const json = JSON.stringify(cursor); + const oid = await persistence.writeBlob(Buffer.from(json, 'utf8')); + await persistence.updateRef(ref, oid); +} + +/** + * Deletes a named saved cursor from Git ref storage. + * + * No-op if the named cursor does not exist. + * + * @private + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @param {string} name - Saved cursor name to delete + * @returns {Promise} + */ +async function deleteSavedCursor(persistence, graphName, name) { + const ref = buildCursorSavedRef(graphName, name); + const exists = await persistence.readRef(ref); + if (exists) { + await persistence.deleteRef(ref); + } +} + +/** + * Lists all saved cursors for a graph, reading each blob to include full cursor state. + * + * @private + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @returns {Promise>} Array of saved cursors with their names + * @throws {Error} If any stored blob is corrupted or not valid JSON + */ +async function listSavedCursors(persistence, graphName) { + const prefix = buildCursorSavedPrefix(graphName); + const refs = await persistence.listRefs(prefix); + const cursors = []; + for (const ref of refs) { + const name = ref.slice(prefix.length); + if (name) { + const oid = await persistence.readRef(ref); + if (oid) { + const buf = await persistence.readBlob(oid); + const cursor = parseCursorBlob(buf, `saved cursor '${name}'`); + cursors.push({ name, ...cursor }); + } + } + } + return cursors; +} + +// ============================================================================ +// Seek Arg Parser +// ============================================================================ + +/** + * Parses CLI arguments for the `seek` command into a structured spec. + * + * Supports mutually exclusive actions: `--tick `, `--latest`, + * `--save `, `--load `, `--list`, `--drop `. + * Defaults to `status` when no flags are provided. + * + * @private + * @param {string[]} args - Raw CLI arguments following the `seek` subcommand + * @returns {{action: string, tickValue: string|null, name: string|null}} Parsed spec + * @throws {CliError} If arguments are invalid or flags are combined + */ +function parseSeekArgs(args) { + const spec = { + action: 'status', // status, tick, latest, save, load, list, drop + tickValue: null, + name: null, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--tick') { + if (spec.action !== 'status') { + throw usageError('--tick cannot be combined with other seek flags'); + } + spec.action = 'tick'; + const val = args[i + 1]; + if (val === undefined) { + throw usageError('Missing value for --tick'); + } + spec.tickValue = val; + i += 1; + } else if (arg.startsWith('--tick=')) { + if (spec.action !== 'status') { + throw usageError('--tick cannot be combined with other seek flags'); + } + spec.action = 'tick'; + spec.tickValue = arg.slice('--tick='.length); + } else if (arg === '--latest') { + if (spec.action !== 'status') { + throw usageError('--latest cannot be combined with other seek flags'); + } + spec.action = 'latest'; + } else if (arg === '--save') { + if (spec.action !== 'status') { + throw usageError('--save cannot be combined with other seek flags'); + } + spec.action = 'save'; + const val = args[i + 1]; + if (val === undefined || val.startsWith('-')) { + throw usageError('Missing name for --save'); + } + spec.name = val; + i += 1; + } else if (arg.startsWith('--save=')) { + if (spec.action !== 'status') { + throw usageError('--save cannot be combined with other seek flags'); + } + spec.action = 'save'; + spec.name = arg.slice('--save='.length); + if (!spec.name) { + throw usageError('Missing name for --save'); + } + } else if (arg === '--load') { + if (spec.action !== 'status') { + throw usageError('--load cannot be combined with other seek flags'); + } + spec.action = 'load'; + const val = args[i + 1]; + if (val === undefined || val.startsWith('-')) { + throw usageError('Missing name for --load'); + } + spec.name = val; + i += 1; + } else if (arg.startsWith('--load=')) { + if (spec.action !== 'status') { + throw usageError('--load cannot be combined with other seek flags'); + } + spec.action = 'load'; + spec.name = arg.slice('--load='.length); + if (!spec.name) { + throw usageError('Missing name for --load'); + } + } else if (arg === '--list') { + if (spec.action !== 'status') { + throw usageError('--list cannot be combined with other seek flags'); + } + spec.action = 'list'; + } else if (arg === '--drop') { + if (spec.action !== 'status') { + throw usageError('--drop cannot be combined with other seek flags'); + } + spec.action = 'drop'; + const val = args[i + 1]; + if (val === undefined || val.startsWith('-')) { + throw usageError('Missing name for --drop'); + } + spec.name = val; + i += 1; + } else if (arg.startsWith('--drop=')) { + if (spec.action !== 'status') { + throw usageError('--drop cannot be combined with other seek flags'); + } + spec.action = 'drop'; + spec.name = arg.slice('--drop='.length); + if (!spec.name) { + throw usageError('Missing name for --drop'); + } + } else if (arg.startsWith('-')) { + throw usageError(`Unknown seek option: ${arg}`); + } + } + + return spec; +} + +/** + * Resolves a tick value (absolute or relative +N/-N) against available ticks. + * + * For relative values, steps through the sorted tick array (with 0 prepended + * as a virtual "empty state" position) by the given delta from the current + * position. For absolute values, clamps to maxTick. + * + * @private + * @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2") + * @param {number|null} currentTick - Current cursor tick, or null if no active cursor + * @param {number[]} ticks - Sorted ascending array of available Lamport ticks + * @param {number} maxTick - Maximum tick across all writers + * @returns {number} Resolved tick value (clamped to valid range) + * @throws {CliError} If tickValue is not a valid integer or relative delta + */ +function resolveTickValue(tickValue, currentTick, ticks, maxTick) { + // Relative: +N or -N + if (tickValue.startsWith('+') || tickValue.startsWith('-')) { + const delta = parseInt(tickValue, 10); + if (!Number.isInteger(delta)) { + throw usageError(`Invalid tick delta: ${tickValue}`); + } + const base = currentTick ?? 0; + + // Find the current position in sorted ticks, then step by delta + // Include tick 0 as a virtual "empty state" position (avoid duplicating if already present) + const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks]; + const currentIdx = allPoints.indexOf(base); + const startIdx = currentIdx === -1 ? 0 : currentIdx; + const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta)); + return allPoints[targetIdx]; + } + + // Absolute + const n = parseInt(tickValue, 10); + if (!Number.isInteger(n) || n < 0) { + throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`); + } + + // Clamp to maxTick + return Math.min(n, maxTick); +} + +// ============================================================================ +// Seek Handler +// ============================================================================ + +/** + * Handles the `git warp seek` command across all sub-actions. + * + * Dispatches to the appropriate logic based on the parsed action: + * - `status`: show current cursor position or "no cursor" state + * - `tick`: set the cursor to an absolute or relative Lamport tick + * - `latest`: clear the cursor, returning to present state + * - `save`: persist the active cursor under a name + * - `load`: restore a named cursor as the active cursor + * - `list`: enumerate all saved cursors + * - `drop`: delete a named saved cursor + * + * @private + * @param {Object} params - Command parameters + * @param {Object} params.options - CLI options (repo, graph, writer, json) + * @param {string[]} params.args - Raw CLI arguments following the `seek` subcommand + * @returns {Promise<{payload: Object, exitCode: number}>} Command result with payload and exit code + * @throws {CliError} On invalid arguments or missing cursors + */ +async function handleSeek({ options, args }) { + const seekSpec = parseSeekArgs(args); + const { graph, graphName, persistence } = await openGraph(options); + const activeCursor = await readActiveCursor(persistence, graphName); + const { ticks, maxTick, perWriter } = await graph.discoverTicks(); + const frontierHash = computeFrontierHash(perWriter); + if (seekSpec.action === 'list') { + const saved = await listSavedCursors(persistence, graphName); + return { + payload: { + graph: graphName, + action: 'list', + cursors: saved, + activeTick: activeCursor ? activeCursor.tick : null, + maxTick, + }, + exitCode: EXIT_CODES.OK, + }; + } + if (seekSpec.action === 'drop') { + const existing = await readSavedCursor(persistence, graphName, seekSpec.name); + if (!existing) { + throw notFoundError(`Saved cursor not found: ${seekSpec.name}`); + } + await deleteSavedCursor(persistence, graphName, seekSpec.name); + return { + payload: { + graph: graphName, + action: 'drop', + name: seekSpec.name, + tick: existing.tick, + }, + exitCode: EXIT_CODES.OK, + }; + } + if (seekSpec.action === 'latest') { + await clearActiveCursor(persistence, graphName); + await graph.materialize(); + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash); + const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph }); + return { + payload: { + graph: graphName, + action: 'latest', + tick: maxTick, + maxTick, + ticks, + nodes: nodes.length, + edges: edges.length, + perWriter: serializePerWriter(perWriter), + patchCount: countPatchesAtTick(maxTick, perWriter), + diff, + tickReceipt, + cursor: { active: false }, + }, + exitCode: EXIT_CODES.OK, + }; + } + if (seekSpec.action === 'save') { + if (!activeCursor) { + throw usageError('No active cursor to save. Use --tick first.'); + } + await writeSavedCursor(persistence, graphName, seekSpec.name, activeCursor); + return { + payload: { + graph: graphName, + action: 'save', + name: seekSpec.name, + tick: activeCursor.tick, + }, + exitCode: EXIT_CODES.OK, + }; + } + if (seekSpec.action === 'load') { + const saved = await readSavedCursor(persistence, graphName, seekSpec.name); + if (!saved) { + throw notFoundError(`Saved cursor not found: ${seekSpec.name}`); + } + await graph.materialize({ ceiling: saved.tick }); + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash }); + const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash); + const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph }); + return { + payload: { + graph: graphName, + action: 'load', + name: seekSpec.name, + tick: saved.tick, + maxTick, + ticks, + nodes: nodes.length, + edges: edges.length, + perWriter: serializePerWriter(perWriter), + patchCount: countPatchesAtTick(saved.tick, perWriter), + diff, + tickReceipt, + cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name }, + }, + exitCode: EXIT_CODES.OK, + }; + } + if (seekSpec.action === 'tick') { + const currentTick = activeCursor ? activeCursor.tick : null; + const resolvedTick = resolveTickValue(seekSpec.tickValue, currentTick, ticks, maxTick); + await graph.materialize({ ceiling: resolvedTick }); + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash }); + const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash); + const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph }); + return { + payload: { + graph: graphName, + action: 'tick', + tick: resolvedTick, + maxTick, + ticks, + nodes: nodes.length, + edges: edges.length, + perWriter: serializePerWriter(perWriter), + patchCount: countPatchesAtTick(resolvedTick, perWriter), + diff, + tickReceipt, + cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' }, + }, + exitCode: EXIT_CODES.OK, + }; + } + + // status (bare seek) + if (activeCursor) { + await graph.materialize({ ceiling: activeCursor.tick }); + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + const prevCounts = readSeekCounts(activeCursor); + const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null; + if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) { + await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash }); + } + const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash); + const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph }); + return { + payload: { + graph: graphName, + action: 'status', + tick: activeCursor.tick, + maxTick, + ticks, + nodes: nodes.length, + edges: edges.length, + perWriter: serializePerWriter(perWriter), + patchCount: countPatchesAtTick(activeCursor.tick, perWriter), + diff, + tickReceipt, + cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' }, + }, + exitCode: EXIT_CODES.OK, + }; + } + await graph.materialize(); + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph }); + return { + payload: { + graph: graphName, + action: 'status', + tick: maxTick, + maxTick, + ticks, + nodes: nodes.length, + edges: edges.length, + perWriter: serializePerWriter(perWriter), + patchCount: countPatchesAtTick(maxTick, perWriter), + diff: null, + tickReceipt, + cursor: { active: false }, + }, + exitCode: EXIT_CODES.OK, + }; +} + +/** + * Converts the per-writer Map from discoverTicks() into a plain object for JSON output. + * + * @private + * @param {Map} perWriter - Per-writer tick data + * @returns {Object} Plain object keyed by writer ID + */ +function serializePerWriter(perWriter) { + const result = {}; + for (const [writerId, info] of perWriter) { + result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas }; + } + return result; +} + +/** + * Counts the total number of patches across all writers at or before the given tick. + * + * @private + * @param {number} tick - Lamport tick ceiling (inclusive) + * @param {Map} perWriter - Per-writer tick data + * @returns {number} Total patch count at or before the given tick + */ +function countPatchesAtTick(tick, perWriter) { + let count = 0; + for (const [, info] of perWriter) { + for (const t of info.ticks) { + if (t <= tick) { + count++; + } + } + } + return count; +} + +/** + * Computes a stable fingerprint of the current graph frontier (writer tips). + * + * Used to suppress seek diffs when graph history may have changed since the + * previous cursor snapshot (e.g. new writers/patches, rewritten refs). + * + * @private + * @param {Map} perWriter - Per-writer metadata from discoverTicks() + * @returns {string} Hex digest of the frontier fingerprint + */ +function computeFrontierHash(perWriter) { + const tips = {}; + for (const [writerId, info] of perWriter) { + tips[writerId] = info?.tipSha || null; + } + return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex'); +} + +/** + * Reads cached seek state counts from a cursor blob. + * + * Counts may be missing for older cursors (pre-diff support). In that case + * callers should treat the counts as unknown and suppress diffs. + * + * @private + * @param {Object|null} cursor - Parsed cursor blob object + * @returns {{nodes: number|null, edges: number|null}} Parsed counts + */ +function readSeekCounts(cursor) { + if (!cursor || typeof cursor !== 'object') { + return { nodes: null, edges: null }; + } + + const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null; + const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null; + return { nodes, edges }; +} + +/** + * Computes node/edge deltas between the current seek position and the previous cursor. + * + * Returns null if the previous cursor is missing cached counts. + * + * @private + * @param {Object|null} prevCursor - Cursor object read before updating the position + * @param {{nodes: number, edges: number}} next - Current materialized counts + * @param {string} frontierHash - Frontier fingerprint of the current graph + * @returns {{nodes: number, edges: number}|null} Diff object or null when unknown + */ +function computeSeekStateDiff(prevCursor, next, frontierHash) { + const prev = readSeekCounts(prevCursor); + if (prev.nodes === null || prev.edges === null) { + return null; + } + const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null; + if (!prevFrontierHash || prevFrontierHash !== frontierHash) { + return null; + } + return { + nodes: next.nodes - prev.nodes, + edges: next.edges - prev.edges, + }; +} + +/** + * Builds a per-writer operation summary for patches at an exact tick. + * + * Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and + * summarizes patch ops. Typically only a handful of writers have a patch at any + * single Lamport tick. + * + * @private + * @param {Object} params + * @param {number} params.tick - Lamport tick to summarize + * @param {Map} params.perWriter - Per-writer tick metadata from discoverTicks() + * @param {Object} params.graph - WarpGraph instance + * @returns {Promise|null>} Map of writerId → { sha, opSummary }, or null if empty + */ +async function buildTickReceipt({ tick, perWriter, graph }) { + if (!Number.isInteger(tick) || tick <= 0) { + return null; + } + + const receipt = {}; + + for (const [writerId, info] of perWriter) { + const sha = info?.tickShas?.[tick]; + if (!sha) { + continue; + } + + const patch = await graph.loadPatchBySha(sha); + const ops = Array.isArray(patch?.ops) ? patch.ops : []; + receipt[writerId] = { sha, opSummary: summarizeOps(ops) }; + } + + return Object.keys(receipt).length > 0 ? receipt : null; +} + +/** + * Renders a seek command payload as a human-readable string for terminal output. + * + * Handles all seek actions: list, drop, save, latest, load, tick, and status. + * + * @private + * @param {Object} payload - Seek result payload from handleSeek + * @returns {string} Formatted output string (includes trailing newline) + */ +function renderSeek(payload) { + const formatDelta = (n) => { + if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) { + return ''; + } + const sign = n > 0 ? '+' : ''; + return ` (${sign}${n})`; + }; + + const formatOpSummaryPlain = (summary) => { + const order = [ + ['NodeAdd', '+', 'node'], + ['EdgeAdd', '+', 'edge'], + ['PropSet', '~', 'prop'], + ['NodeTombstone', '-', 'node'], + ['EdgeTombstone', '-', 'edge'], + ['BlobValue', '+', 'blob'], + ]; + + const parts = []; + for (const [opType, symbol, label] of order) { + const n = summary?.[opType]; + if (typeof n === 'number' && Number.isFinite(n) && n > 0) { + parts.push(`${symbol}${n}${label}`); + } + } + return parts.length > 0 ? parts.join(' ') : '(empty)'; + }; + + const appendReceiptSummary = (baseLine) => { + const tickReceipt = payload?.tickReceipt; + if (!tickReceipt || typeof tickReceipt !== 'object') { + return `${baseLine}\n`; + } + + const entries = Object.entries(tickReceipt) + .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object') + .sort(([a], [b]) => a.localeCompare(b)); + + if (entries.length === 0) { + return `${baseLine}\n`; + } + + const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length)); + const receiptLines = [` Tick ${payload.tick}:`]; + for (const [writerId, entry] of entries) { + const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : ''; + const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry; + receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`); + } + + return `${baseLine}\n${receiptLines.join('\n')}\n`; + }; + + const buildStateStrings = () => { + const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes'; + const edgeLabel = payload.edges === 1 ? 'edge' : 'edges'; + const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches'; + return { + nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`, + edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`, + patchesStr: `${payload.patchCount} ${patchLabel}`, + }; + }; + + if (payload.action === 'list') { + if (payload.cursors.length === 0) { + return 'No saved cursors.\n'; + } + const lines = []; + for (const c of payload.cursors) { + const active = c.tick === payload.activeTick ? ' (active)' : ''; + lines.push(` ${c.name}: tick ${c.tick}${active}`); + } + return `${lines.join('\n')}\n`; + } + + if (payload.action === 'drop') { + return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`; + } + + if (payload.action === 'save') { + return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`; + } + + if (payload.action === 'latest') { + const { nodesStr, edgesStr } = buildStateStrings(); + return appendReceiptSummary( + `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`, + ); + } + + if (payload.action === 'load') { + const { nodesStr, edgesStr } = buildStateStrings(); + return appendReceiptSummary( + `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`, + ); + } + + if (payload.action === 'tick') { + const { nodesStr, edgesStr, patchesStr } = buildStateStrings(); + return appendReceiptSummary( + `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`, + ); + } + + // status + if (payload.cursor && payload.cursor.active) { + const { nodesStr, edgesStr, patchesStr } = buildStateStrings(); + return appendReceiptSummary( + `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`, + ); + } + + return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`; +} + +/** + * Reads the active cursor and sets `_seekCeiling` on the graph instance + * so that subsequent materialize calls respect the time-travel boundary. + * + * Called by non-seek commands (query, path, check, etc.) that should + * honour an active seek cursor. + * + * @private + * @param {Object} graph - WarpGraph instance + * @param {Object} persistence - GraphPersistencePort adapter + * @param {string} graphName - Name of the WARP graph + * @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance + */ +async function applyCursorCeiling(graph, persistence, graphName) { + const cursor = await readActiveCursor(persistence, graphName); + if (cursor) { + graph._seekCeiling = cursor.tick; + return { active: true, tick: cursor.tick, maxTick: null }; + } + return { active: false, tick: null, maxTick: null }; +} + +/** + * Prints a seek cursor warning banner to stderr when a cursor is active. + * + * No-op if the cursor is not active. + * + * Non-seek commands (query, path, check, history, materialize) pass null for + * maxTick to avoid the cost of discoverTicks(); the banner then omits the + * "of {maxTick}" suffix. Only the seek handler itself populates maxTick. + * + * @private + * @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling + * @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown + * @returns {void} + */ +function emitCursorWarning(cursorInfo, maxTick) { + if (cursorInfo.active) { + const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : ''; + process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`); + } +} + async function handleView({ options, args }) { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw usageError('view command requires an interactive terminal (TTY)'); @@ -1573,6 +2452,7 @@ const COMMANDS = new Map([ ['history', handleHistory], ['check', handleCheck], ['materialize', handleMaterialize], + ['seek', handleSeek], ['view', handleView], ['install-hooks', handleInstallHooks], ]); @@ -1607,7 +2487,7 @@ async function main() { throw usageError(`Unknown command: ${command}`); } - const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query']; + const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek']; if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) { throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`); } diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 865fbe9..f72ce03 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -892,6 +892,8 @@ git warp history --writer alice # Patch history git warp check # Health/GC status git warp materialize # Materialize all graphs git warp materialize --graph my-graph # Single graph +git warp seek --tick 3 # Time-travel to tick 3 +git warp seek --latest # Return to present git warp install-hooks # Install post-merge hook ``` @@ -902,10 +904,50 @@ Visual ASCII output is available with `--view`: ```bash git warp --view info # ASCII visualization git warp --view check # Health status visualization +git warp --view seek # Seek dashboard with timeline ``` `--view` is mutually exclusive with `--json`. +### Time Travel (`seek`) + +The `seek` command lets you navigate through graph history by Lamport tick. When a cursor is active, all read commands (`query`, `info`, `materialize`, `history`) automatically show state at the selected tick. + +```bash +# Jump to an absolute tick +git warp seek --tick 3 + +# Step forward/backward relative to current position (use = for signed values) +git warp seek --tick=+1 +git warp seek --tick=-1 + +# Return to the present (clears the cursor) +git warp seek --latest + +# Save and restore named bookmarks +git warp seek --save before-refactor +git warp seek --load before-refactor + +# List and delete saved bookmarks +git warp seek --list +git warp seek --drop before-refactor + +# Show current cursor status +git warp seek +``` + +**How it works:** The cursor is stored as a lightweight Git ref at `refs/warp//cursor/active`. Saved bookmarks live under `refs/warp//cursor/saved/`. When a cursor is active, `materialize()` replays only patches with `lamport <= tick`, and auto-checkpoint is skipped to avoid writing snapshots of past state. + +**Programmatic API:** + +```javascript +// Discover all ticks without expensive deserialization +const { ticks, maxTick, perWriter } = await graph.discoverTicks(); + +// Materialize at a specific point in time +const state = await graph.materialize({ ceiling: 3 }); +``` + ### Git Hooks WarpGraph ships a `post-merge` hook that runs after `git merge` or `git pull`. If warp refs changed, it prints: @@ -971,6 +1013,7 @@ The `--view` flag enables visual ASCII dashboards for supported commands. Add `- | `--view history` | Patch timeline with operation summaries | | `--view path` | Visual path diagram between nodes | | `--view materialize` | Progress dashboard with statistics | +| `--view seek` | Time-travel dashboard with timeline | **View modes:** - `--view` or `--view=ascii` — ASCII art (default) diff --git a/docs/seek-demo.gif b/docs/seek-demo.gif new file mode 100644 index 0000000..c46d4e6 Binary files /dev/null and b/docs/seek-demo.gif differ diff --git a/docs/seek-demo.tape b/docs/seek-demo.tape new file mode 100644 index 0000000..a630ba7 --- /dev/null +++ b/docs/seek-demo.tape @@ -0,0 +1,101 @@ +# seek-demo.tape — VHS recording of `git warp seek` time-travel +# Record with: vhs docs/seek-demo.tape + +Output docs/seek-demo.gif + +Require git +Require node + +Set Shell "bash" +Set FontFamily "JetBrains Mono" +Set FontSize 14 +Set Width 960 +Set Height 720 +Set Padding 16 +Set TypingSpeed 40ms + +# ── Bootstrap a sandbox repo with a seeded graph ──────────────────────── +Hide +Type "export PROJECT_ROOT=$(pwd)" +Enter +Type "TMPDIR=$(mktemp -d) && cd $TMPDIR && git init -q sandbox && cd sandbox" +Enter +Sleep 500ms +Type "export REPO_PATH=$(pwd)" +Enter +Type "export PATH=$PROJECT_ROOT/bin:$PATH" +Enter +Type "node $PROJECT_ROOT/test/bats/helpers/seed-graph.js" +Enter +Sleep 2s +Type "clear" +Enter +Sleep 500ms +Show + +# ── 1. Present state — full graph ─────────────────────────────────────── +Type "# the graph right now: 3 users, 2 follows edges" +Enter +Sleep 800ms + +Type "git warp --view query --match '*'" +Enter +Sleep 3s + +# ── 2. Rewind to tick 1 — only the first patch (nodes, no edges) ─────── +Type "# rewind to tick 1 — before the edges were added" +Enter +Sleep 800ms + +Type "git warp --view seek --tick 1" +Enter +Sleep 3s + +Type "git warp --view query --match '*'" +Enter +Sleep 3s + +# ── 3. Worktree is untouched ─────────────────────────────────────────── +Type "# the graph changed, but the working tree didn't" +Enter +Sleep 800ms + +Type "git status" +Enter +Sleep 2.5s + +# ── 4. Step forward — edges appear ───────────────────────────────────── +Type "# step forward one tick — edges reappear" +Enter +Sleep 800ms + +Type "git warp --view seek --tick=+1" +Enter +Sleep 3s + +Type "git warp --view query --match '*'" +Enter +Sleep 3s + +# ── 5. Still clean ───────────────────────────────────────────────────── +Type "# still nothing in the worktree" +Enter +Sleep 800ms + +Type "git status" +Enter +Sleep 2.5s + +# ── 6. Return to present ─────────────────────────────────────────────── +Type "# done exploring — return to present" +Enter +Sleep 800ms + +Type "git warp seek --latest" +Enter +Sleep 2s + +# ── Cleanup ───────────────────────────────────────────────────────────── +Hide +Type "rm -rf $TMPDIR" +Enter diff --git a/package.json b/package.json index e343e0f..ebebd58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-warp", - "version": "10.2.1", + "version": "10.3.1", "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.", "type": "module", "license": "Apache-2.0", diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 113b0fd..b5a02bb 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -26,4 +26,4 @@ npm test echo "Running pre-push benchmarks..." npm run benchmark echo "Running pre-push git-warp CLI bats tests..." -docker compose run --build --rm test bats test/bats/warp-graph-cli.bats +docker compose run --build --rm test bats test/bats/ diff --git a/src/domain/WarpGraph.js b/src/domain/WarpGraph.js index 55497f5..61f7694 100644 --- a/src/domain/WarpGraph.js +++ b/src/domain/WarpGraph.js @@ -178,6 +178,15 @@ export default class WarpGraph { /** @type {import('./services/TemporalQuery.js').TemporalQuery|null} */ this._temporalQuery = null; + + /** @type {number|null} */ + this._seekCeiling = null; + + /** @type {number|null} */ + this._cachedCeiling = null; + + /** @type {Map|null} */ + this._cachedFrontier = null; } /** @@ -570,11 +579,16 @@ export default class WarpGraph { * When false or omitted (default), returns just the state for backward * compatibility with zero receipt overhead. * + * When a Lamport ceiling is active (via `options.ceiling` or the + * instance-level `_seekCeiling`), delegates to a ceiling-aware path + * that replays only patches with `lamport <= ceiling`, bypassing + * checkpoints, auto-checkpoint, and GC. + * * Side effects: Updates internal cached state, version vector, last frontier, * and patches-since-checkpoint counter. May trigger auto-checkpoint and GC * based on configured policies. Notifies subscribers if state changed. * - * @param {{receipts?: boolean}} [options] - Optional configuration + * @param {{receipts?: boolean, ceiling?: number|null}} [options] - Optional configuration * @returns {Promise} The materialized graph state, or { state, receipts } when receipts enabled * @throws {Error} If checkpoint loading fails or patch decoding fails * @throws {Error} If writer ref access or patch blob reading fails @@ -583,6 +597,13 @@ export default class WarpGraph { const t0 = this._clock.now(); // ZERO-COST: only resolve receipts flag when options provided const collectReceipts = options && options.receipts; + // Resolve ceiling: explicit option > instance-level seek ceiling > null (latest) + const ceiling = this._resolveCeiling(options); + + // When ceiling is active, delegate to ceiling-aware path (with its own cache) + if (ceiling !== null) { + return await this._materializeWithCeiling(ceiling, collectReceipts, t0); + } try { // Check for checkpoint @@ -658,6 +679,8 @@ export default class WarpGraph { } await this._setMaterializedState(state); + this._cachedCeiling = null; + this._cachedFrontier = null; this._lastFrontier = await this.getFrontier(); this._patchesSinceCheckpoint = patchCount; @@ -698,6 +721,124 @@ export default class WarpGraph { } } + /** + * Resolves the effective ceiling from options and instance state. + * + * Precedence: explicit `ceiling` in options overrides the instance-level + * `_seekCeiling`. Uses the `'ceiling' in options` check, so passing + * `{ ceiling: null }` explicitly clears the seek ceiling for that call + * (returns `null`), while omitting the key falls through to `_seekCeiling`. + * + * @param {{ceiling?: number|null}} [options] - Options object; when the + * `ceiling` key is present (even if `null`), its value takes precedence + * @returns {number|null} Lamport ceiling to apply, or `null` for latest + * @private + */ + _resolveCeiling(options) { + if (options && options.ceiling !== undefined) { + return options.ceiling; + } + return this._seekCeiling; + } + + /** + * Materializes the graph with a Lamport ceiling (time-travel). + * + * Bypasses checkpoints entirely — replays all patches from all writers, + * filtering to only those with `lamport <= ceiling`. Skips auto-checkpoint + * and GC since this is an exploratory read. + * + * Uses a dedicated cache keyed on `ceiling` + frontier snapshot. Cache + * is bypassed when the writer frontier has advanced (new writers or + * updated tips) or when `collectReceipts` is `true` because the cached + * path does not retain receipt data. + * + * @param {number} ceiling - Maximum Lamport tick to include (patches with + * `lamport <= ceiling` are replayed; `ceiling <= 0` yields empty state) + * @param {boolean} collectReceipts - When `true`, return receipts alongside + * state and skip the ceiling cache + * @param {number} t0 - Start timestamp for performance logging + * @returns {Promise} + * Plain state when `collectReceipts` is falsy; `{ state, receipts }` + * when truthy + * @private + */ + async _materializeWithCeiling(ceiling, collectReceipts, t0) { + const frontier = await this.getFrontier(); + + // Cache hit: same ceiling, clean state, AND frontier unchanged. + // Bypass cache when collectReceipts is true — cached path has no receipts. + if ( + this._cachedState && !this._stateDirty && + ceiling === this._cachedCeiling && !collectReceipts && + this._cachedFrontier !== null && + this._cachedFrontier.size === frontier.size && + [...frontier].every(([w, sha]) => this._cachedFrontier.get(w) === sha) + ) { + return this._cachedState; + } + + const writerIds = [...frontier.keys()]; + + if (writerIds.length === 0 || ceiling <= 0) { + const state = createEmptyStateV5(); + this._provenanceIndex = new ProvenanceIndex(); + await this._setMaterializedState(state); + this._cachedCeiling = ceiling; + this._cachedFrontier = frontier; + this._logTiming('materialize', t0, { metrics: '0 patches (ceiling)' }); + if (collectReceipts) { + return { state, receipts: [] }; + } + return state; + } + + const allPatches = []; + for (const writerId of writerIds) { + const writerPatches = await this._loadWriterPatches(writerId); + for (const entry of writerPatches) { + if (entry.patch.lamport <= ceiling) { + allPatches.push(entry); + } + } + } + + let state; + let receipts; + + if (allPatches.length === 0) { + state = createEmptyStateV5(); + if (collectReceipts) { + receipts = []; + } + } else if (collectReceipts) { + const result = reduceV5(allPatches, undefined, { receipts: true }); + state = result.state; + receipts = result.receipts; + } else { + state = reduceV5(allPatches); + } + + this._provenanceIndex = new ProvenanceIndex(); + for (const { patch, sha } of allPatches) { + this._provenanceIndex.addPatch(sha, patch.reads, patch.writes); + } + + await this._setMaterializedState(state); + this._cachedCeiling = ceiling; + this._cachedFrontier = frontier; + + // Skip auto-checkpoint and GC — this is an exploratory read + this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` }); + + if (collectReceipts) { + return { state, receipts }; + } + return state; + } + /** * Joins (merges) another state into the current cached state. * @@ -1010,6 +1151,79 @@ export default class WarpGraph { return writerIds.sort(); } + /** + * Discovers all distinct Lamport ticks across all writers. + * + * Walks each writer's patch chain from tip to root, reading commit + * messages (no CBOR blob deserialization) to extract Lamport timestamps. + * Stops when a non-patch commit (e.g. checkpoint) is encountered. + * Logs a warning for any non-monotonic lamport sequence within a single + * writer's chain. + * + * @returns {Promise<{ + * ticks: number[], + * maxTick: number, + * perWriter: Map + * }>} `ticks` is the sorted (ascending) deduplicated union of all + * Lamport values; `maxTick` is the largest value (0 if none); + * `perWriter` maps each writer ID to its ticks in ascending order + * and its current tip SHA (or `null` if the writer ref is missing) + * @throws {Error} If reading refs or commit metadata fails + */ + async discoverTicks() { + const writerIds = await this.discoverWriters(); + const globalTickSet = new Set(); + const perWriter = new Map(); + + for (const writerId of writerIds) { + const writerRef = buildWriterRef(this._graphName, writerId); + const tipSha = await this._persistence.readRef(writerRef); + const writerTicks = []; + const tickShas = {}; + + if (tipSha) { + let currentSha = tipSha; + let lastLamport = Infinity; + + while (currentSha) { + const nodeInfo = await this._persistence.getNodeInfo(currentSha); + const kind = detectMessageKind(nodeInfo.message); + if (kind !== 'patch') { + break; + } + + const patchMeta = decodePatchMessage(nodeInfo.message); + globalTickSet.add(patchMeta.lamport); + writerTicks.push(patchMeta.lamport); + tickShas[patchMeta.lamport] = currentSha; + + // Check monotonic invariant (walking newest→oldest, lamport should decrease) + if (patchMeta.lamport > lastLamport && this._logger) { + this._logger.warn(`[warp] non-monotonic lamport for writer ${writerId}: ${patchMeta.lamport} > ${lastLamport}`); + } + lastLamport = patchMeta.lamport; + + if (nodeInfo.parents && nodeInfo.parents.length > 0) { + currentSha = nodeInfo.parents[0]; + } else { + break; + } + } + } + + perWriter.set(writerId, { + ticks: writerTicks.reverse(), + tipSha: tipSha || null, + tickShas, + }); + } + + const ticks = [...globalTickSet].sort((a, b) => a - b); + const maxTick = ticks.length > 0 ? ticks[ticks.length - 1] : 0; + + return { ticks, maxTick, perWriter }; + } + // ============================================================================ // Schema Migration Support // ============================================================================ @@ -3025,6 +3239,23 @@ export default class WarpGraph { return cone; } + /** + * Loads a single patch by its SHA. + * + * @param {string} sha - The patch commit SHA + * @returns {Promise} The decoded patch object + * @throws {Error} If the commit is not a patch or loading fails + * + * @public + * @remarks + * Thin wrapper around the internal `_loadPatchBySha` helper. Exposed for + * CLI/debug tooling (e.g. seek tick receipts) that needs to inspect patch + * operations without re-materializing intermediate states. + */ + async loadPatchBySha(sha) { + return await this._loadPatchBySha(sha); + } + /** * Loads a single patch by its SHA. * diff --git a/src/domain/utils/RefLayout.js b/src/domain/utils/RefLayout.js index 4d29a82..a1b5e01 100644 --- a/src/domain/utils/RefLayout.js +++ b/src/domain/utils/RefLayout.js @@ -8,6 +8,8 @@ * - refs/warp//writers/ * - refs/warp//checkpoints/head * - refs/warp//coverage/head + * - refs/warp//cursor/active + * - refs/warp//cursor/saved/ * * @module domain/utils/RefLayout */ @@ -49,20 +51,22 @@ const PATH_TRAVERSAL_PATTERN = /\.\./; * Validates a graph name and throws if invalid. * * Graph names must not contain: - * - Path traversal sequences (`../`) + * - Path traversal sequences (`..`) * - Semicolons (`;`) * - Spaces * - Null bytes (`\0`) * - Empty strings * * @param {string} name - The graph name to validate - * @throws {Error} If the graph name is invalid + * @throws {Error} If the name is not a string, is empty, or contains + * forbidden characters (`..`, `;`, space, `\0`) * @returns {void} * * @example - * validateGraphName('events'); // OK - * validateGraphName('../etc'); // throws - * validateGraphName('my graph'); // throws + * validateGraphName('events'); // OK + * validateGraphName('team/proj'); // OK (slashes allowed) + * validateGraphName('../etc'); // throws — path traversal + * validateGraphName('my graph'); // throws — contains space */ export function validateGraphName(name) { if (typeof name !== 'string') { @@ -94,18 +98,20 @@ export function validateGraphName(name) { * Validates a writer ID and throws if invalid. * * Writer IDs must: - * - Be ASCII ref-safe: only [A-Za-z0-9._-] + * - Be ASCII ref-safe: only `[A-Za-z0-9._-]` * - Be 1-64 characters long * - Not contain `/`, `..`, whitespace, or NUL * * @param {string} id - The writer ID to validate - * @throws {Error} If the writer ID is invalid + * @throws {Error} If the ID is not a string, is empty, exceeds 64 characters, + * or contains forbidden characters (`/`, `..`, whitespace, NUL, non-ASCII) * @returns {void} * * @example - * validateWriterId('node-1'); // OK - * validateWriterId('a/b'); // throws (contains /) - * validateWriterId('x'.repeat(65)); // throws (too long) + * validateWriterId('node-1'); // OK + * validateWriterId('a/b'); // throws — contains forward slash + * validateWriterId('x'.repeat(65)); // throws — exceeds max length + * validateWriterId('has space'); // throws — contains whitespace */ export function validateWriterId(id) { if (typeof id !== 'string') { @@ -157,7 +163,7 @@ export function validateWriterId(id) { * * @param {string} graphName - The name of the graph * @param {string} writerId - The writer's unique identifier - * @returns {string} The full ref path + * @returns {string} The full ref path, e.g. `refs/warp//writers/` * @throws {Error} If graphName or writerId is invalid * * @example @@ -174,7 +180,7 @@ export function buildWriterRef(graphName, writerId) { * Builds the checkpoint head ref path for the given graph. * * @param {string} graphName - The name of the graph - * @returns {string} The full ref path + * @returns {string} The full ref path, e.g. `refs/warp//checkpoints/head` * @throws {Error} If graphName is invalid * * @example @@ -190,7 +196,7 @@ export function buildCheckpointRef(graphName) { * Builds the coverage head ref path for the given graph. * * @param {string} graphName - The name of the graph - * @returns {string} The full ref path + * @returns {string} The full ref path, e.g. `refs/warp//coverage/head` * @throws {Error} If graphName is invalid * * @example @@ -204,10 +210,12 @@ export function buildCoverageRef(graphName) { /** * Builds the writers prefix path for the given graph. - * Useful for listing all writer refs under a graph. + * Useful for listing all writer refs under a graph + * (e.g. via `git for-each-ref`). * * @param {string} graphName - The name of the graph - * @returns {string} The writers prefix path + * @returns {string} The writers prefix path (with trailing slash), + * e.g. `refs/warp//writers/` * @throws {Error} If graphName is invalid * * @example @@ -219,6 +227,70 @@ export function buildWritersPrefix(graphName) { return `${REF_PREFIX}/${graphName}/writers/`; } +/** + * Builds the active cursor ref path for the given graph. + * + * The active cursor is a single ref that stores the current time-travel + * position used by `git warp seek`. It points to a commit SHA representing + * the materialization frontier the user has seeked to. + * + * @param {string} graphName - The name of the graph + * @returns {string} The full ref path, e.g. `refs/warp//cursor/active` + * @throws {Error} If graphName is invalid + * + * @example + * buildCursorActiveRef('events'); + * // => 'refs/warp/events/cursor/active' + */ +export function buildCursorActiveRef(graphName) { + validateGraphName(graphName); + return `${REF_PREFIX}/${graphName}/cursor/active`; +} + +/** + * Builds a saved (named) cursor ref path for the given graph and cursor name. + * + * Saved cursors are bookmarks created by `git warp seek --save `. + * Each saved cursor persists a time-travel position that can be restored + * later without re-seeking. + * + * The cursor name is validated with the same rules as a writer ID + * (ASCII ref-safe: `[A-Za-z0-9._-]`, 1-64 characters). + * + * @param {string} graphName - The name of the graph + * @param {string} name - The cursor bookmark name (validated like a writer ID) + * @returns {string} The full ref path, e.g. `refs/warp//cursor/saved/` + * @throws {Error} If graphName or name is invalid + * + * @example + * buildCursorSavedRef('events', 'before-tui'); + * // => 'refs/warp/events/cursor/saved/before-tui' + */ +export function buildCursorSavedRef(graphName, name) { + validateGraphName(graphName); + validateWriterId(name); + return `${REF_PREFIX}/${graphName}/cursor/saved/${name}`; +} + +/** + * Builds the saved cursor prefix path for the given graph. + * Useful for listing all saved cursor bookmarks under a graph + * (e.g. via `git for-each-ref`). + * + * @param {string} graphName - The name of the graph + * @returns {string} The saved cursor prefix path (with trailing slash), + * e.g. `refs/warp//cursor/saved/` + * @throws {Error} If graphName is invalid + * + * @example + * buildCursorSavedPrefix('events'); + * // => 'refs/warp/events/cursor/saved/' + */ +export function buildCursorSavedPrefix(graphName) { + validateGraphName(graphName); + return `${REF_PREFIX}/${graphName}/cursor/saved/`; +} + // ----------------------------------------------------------------------------- // Parsers // ----------------------------------------------------------------------------- diff --git a/src/domain/utils/parseCursorBlob.js b/src/domain/utils/parseCursorBlob.js new file mode 100644 index 0000000..64df670 --- /dev/null +++ b/src/domain/utils/parseCursorBlob.js @@ -0,0 +1,51 @@ +/** + * Utilities for parsing seek-cursor blobs stored as Git refs. + * + * @module parseCursorBlob + */ + +/** + * Parses and validates a cursor blob (Buffer) into a cursor object. + * + * The blob must contain UTF-8-encoded JSON representing a plain object with at + * minimum a finite numeric `tick` field. Any additional fields (e.g. `mode`, + * `name`) are preserved in the returned object. + * + * @param {Buffer} buf - Raw blob contents (UTF-8 encoded JSON) + * @param {string} label - Human-readable label used in error messages + * (e.g. `"active cursor"`, `"saved cursor 'foo'"`) + * @returns {{ tick: number, mode?: string, [key: string]: unknown }} + * The validated cursor object. `tick` is guaranteed to be a finite number. + * @throws {Error} If `buf` is not valid JSON + * @throws {Error} If the parsed value is not a plain JSON object (e.g. array, + * null, or primitive) + * @throws {Error} If the `tick` field is missing, non-numeric, NaN, or + * Infinity + * + * @example + * const buf = Buffer.from('{"tick":5,"mode":"lamport"}', 'utf8'); + * const cursor = parseCursorBlob(buf, 'active cursor'); + * // => { tick: 5, mode: 'lamport' } + * + * @example + * // Throws: "Corrupted active cursor: blob is not valid JSON" + * parseCursorBlob(Buffer.from('not json', 'utf8'), 'active cursor'); + */ +export function parseCursorBlob(buf, label) { + let obj; + try { + obj = JSON.parse(new TextDecoder().decode(buf)); + } catch { + throw new Error(`Corrupted ${label}: blob is not valid JSON`); + } + + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { + throw new Error(`Corrupted ${label}: expected a JSON object`); + } + + if (typeof obj.tick !== 'number' || !Number.isFinite(obj.tick)) { + throw new Error(`Corrupted ${label}: missing or invalid numeric tick`); + } + + return obj; +} diff --git a/src/visualization/renderers/ascii/graph.js b/src/visualization/renderers/ascii/graph.js index fa150aa..2a59927 100644 --- a/src/visualization/renderers/ascii/graph.js +++ b/src/visualization/renderers/ascii/graph.js @@ -220,6 +220,8 @@ function drawArrowhead(grid, section, nodeSet) { const pc = toCol(prev.x); let arrow; + let ar = er; + let ac = ec; if (er > pr) { arrow = ARROW.down; } else if (er < pr) { @@ -230,8 +232,21 @@ function drawArrowhead(grid, section, nodeSet) { arrow = ARROW.left; } - if (!isNodeCell(nodeSet, er, ec)) { - writeChar(grid, er, ec, arrow); + // If the endpoint is inside a node box, step back one cell into free space + if (isNodeCell(nodeSet, ar, ac)) { + if (er > pr) { + ar = er - 1; + } else if (er < pr) { + ar = er + 1; + } else if (ec > pc) { + ac = ec - 1; + } else { + ac = ec + 1; + } + } + + if (!isNodeCell(nodeSet, ar, ac)) { + writeChar(grid, ar, ac, arrow); } } diff --git a/src/visualization/renderers/ascii/history.js b/src/visualization/renderers/ascii/history.js index 967a576..acf61ff 100644 --- a/src/visualization/renderers/ascii/history.js +++ b/src/visualization/renderers/ascii/history.js @@ -6,75 +6,12 @@ import { colors } from './colors.js'; import { createBox } from './box.js'; import { padRight, padLeft } from '../../utils/unicode.js'; -import { truncate } from '../../utils/truncate.js'; import { TIMELINE } from './symbols.js'; +import { OP_DISPLAY, EMPTY_OP_SUMMARY, summarizeOps, formatOpSummary } from './opSummary.js'; // Default pagination settings const DEFAULT_PAGE_SIZE = 20; -// Operation type to display info mapping -const OP_DISPLAY = { - NodeAdd: { symbol: '+', label: 'node', color: colors.success }, - NodeTombstone: { symbol: '-', label: 'node', color: colors.error }, - EdgeAdd: { symbol: '+', label: 'edge', color: colors.success }, - EdgeTombstone: { symbol: '-', label: 'edge', color: colors.error }, - PropSet: { symbol: '~', label: 'prop', color: colors.warning }, - BlobValue: { symbol: '+', label: 'blob', color: colors.primary }, -}; - -// Default empty operation summary -const EMPTY_OP_SUMMARY = Object.freeze({ - NodeAdd: 0, - EdgeAdd: 0, - PropSet: 0, - NodeTombstone: 0, - EdgeTombstone: 0, - BlobValue: 0, -}); - -/** - * Summarizes operations in a patch. - * @param {Object[]} ops - Array of patch operations - * @returns {Object} Summary with counts by operation type - */ -function summarizeOps(ops) { - const summary = { ...EMPTY_OP_SUMMARY }; - for (const op of ops) { - if (op.type && summary[op.type] !== undefined) { - summary[op.type]++; - } - } - return summary; -} - -/** - * Formats operation summary as a colored string. - * @param {Object} summary - Operation counts by type - * @param {number} maxWidth - Maximum width for the summary string - * @returns {string} Formatted summary string - */ -function formatOpSummary(summary, maxWidth = 40) { - const order = ['NodeAdd', 'EdgeAdd', 'PropSet', 'NodeTombstone', 'EdgeTombstone', 'BlobValue']; - const parts = order - .filter((opType) => summary[opType] > 0) - .map((opType) => { - const display = OP_DISPLAY[opType]; - return { text: `${display.symbol}${summary[opType]}${display.label}`, color: display.color }; - }); - - if (parts.length === 0) { - return colors.muted('(empty)'); - } - - // Truncate plain text first to avoid breaking ANSI escape sequences - const plain = parts.map((p) => p.text).join(' '); - const truncated = truncate(plain, maxWidth); - if (truncated === plain) { - return parts.map((p) => p.color(p.text)).join(' '); - } - return colors.muted(truncated); -} - /** * Ensures entry has an opSummary, computing one if needed. * @param {Object} entry - Patch entry @@ -330,6 +267,6 @@ export function renderHistoryView(payload, options = {}) { return `${box}\n`; } -export { summarizeOps }; +export { summarizeOps, formatOpSummary, OP_DISPLAY, EMPTY_OP_SUMMARY }; export default { renderHistoryView, summarizeOps }; diff --git a/src/visualization/renderers/ascii/index.js b/src/visualization/renderers/ascii/index.js index 1cd1540..296166e 100644 --- a/src/visualization/renderers/ascii/index.js +++ b/src/visualization/renderers/ascii/index.js @@ -9,6 +9,6 @@ export { progressBar } from './progress.js'; export { renderInfoView } from './info.js'; export { renderCheckView } from './check.js'; export { renderMaterializeView } from './materialize.js'; -export { renderHistoryView, summarizeOps } from './history.js'; +export { renderHistoryView, summarizeOps, formatOpSummary, OP_DISPLAY, EMPTY_OP_SUMMARY } from './history.js'; export { renderPathView } from './path.js'; export { renderGraphView } from './graph.js'; diff --git a/src/visualization/renderers/ascii/opSummary.js b/src/visualization/renderers/ascii/opSummary.js new file mode 100644 index 0000000..60b0c6b --- /dev/null +++ b/src/visualization/renderers/ascii/opSummary.js @@ -0,0 +1,73 @@ +/** + * Shared operation summary utilities for ASCII renderers. + * + * Extracted from history.js so other views (e.g. seek) can reuse the same + * op-type ordering, symbols, and formatting. + */ + +import { colors } from './colors.js'; +import { truncate } from '../../utils/truncate.js'; + +// Operation type to display info mapping +export const OP_DISPLAY = Object.freeze({ + NodeAdd: { symbol: '+', label: 'node', color: colors.success }, + NodeTombstone: { symbol: '-', label: 'node', color: colors.error }, + EdgeAdd: { symbol: '+', label: 'edge', color: colors.success }, + EdgeTombstone: { symbol: '-', label: 'edge', color: colors.error }, + PropSet: { symbol: '~', label: 'prop', color: colors.warning }, + BlobValue: { symbol: '+', label: 'blob', color: colors.primary }, +}); + +// Default empty operation summary +export const EMPTY_OP_SUMMARY = Object.freeze({ + NodeAdd: 0, + EdgeAdd: 0, + PropSet: 0, + NodeTombstone: 0, + EdgeTombstone: 0, + BlobValue: 0, +}); + +/** + * Summarizes operations in a patch. + * @param {Object[]} ops - Array of patch operations + * @returns {Object} Summary with counts by operation type + */ +export function summarizeOps(ops) { + const summary = { ...EMPTY_OP_SUMMARY }; + for (const op of ops) { + if (op.type && summary[op.type] !== undefined) { + summary[op.type]++; + } + } + return summary; +} + +/** + * Formats operation summary as a colored string. + * @param {Object} summary - Operation counts by type + * @param {number} maxWidth - Maximum width for the summary string + * @returns {string} Formatted summary string + */ +export function formatOpSummary(summary, maxWidth = 40) { + const order = ['NodeAdd', 'EdgeAdd', 'PropSet', 'NodeTombstone', 'EdgeTombstone', 'BlobValue']; + const parts = order + .filter((opType) => summary[opType] > 0) + .map((opType) => { + const display = OP_DISPLAY[opType]; + return { text: `${display.symbol}${summary[opType]}${display.label}`, color: display.color }; + }); + + if (parts.length === 0) { + return colors.muted('(empty)'); + } + + // Truncate plain text first to avoid breaking ANSI escape sequences + const plain = parts.map((p) => p.text).join(' '); + const truncated = truncate(plain, maxWidth); + if (truncated === plain) { + return parts.map((p) => p.color(p.text)).join(' '); + } + return colors.muted(truncated); +} + diff --git a/src/visualization/renderers/ascii/seek.js b/src/visualization/renderers/ascii/seek.js new file mode 100644 index 0000000..a1fda98 --- /dev/null +++ b/src/visualization/renderers/ascii/seek.js @@ -0,0 +1,330 @@ +/** + * ASCII renderer for the `seek --view` command. + * + * Displays a swimlane dashboard: one horizontal track per writer, with + * relative-offset column headers that map directly to `--tick=+N/-N` CLI + * syntax. Included patches (at or before the cursor) render as filled + * dots on a solid line; excluded (future) patches render as open circles + * on a dotted line. + */ + +import boxen from 'boxen'; +import { colors } from './colors.js'; +import { padRight } from '../../utils/unicode.js'; +import { formatSha, formatWriterName } from './formatters.js'; +import { TIMELINE } from './symbols.js'; +import { formatOpSummary } from './opSummary.js'; + +/** Maximum number of tick columns shown in the windowed view. */ +const MAX_COLS = 9; + +/** Character width of each tick column (marker + connector gap). */ +const COL_W = 6; + +/** Character width reserved for the writer name column. */ +const NAME_W = 10; + +/** Middle-dot used for excluded-zone connectors. */ +const DOT_MID = '\u00B7'; // · + +/** Open circle used for excluded-zone patch markers. */ +const CIRCLE_OPEN = '\u25CB'; // ○ + +function formatDelta(n) { + if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) { + return ''; + } + const sign = n > 0 ? '+' : ''; + return ` (${sign}${n})`; +} + +function pluralize(n, singular, plural) { + return n === 1 ? singular : plural; +} + +function buildReceiptLines(tickReceipt) { + if (!tickReceipt || typeof tickReceipt !== 'object') { + return []; + } + + const entries = Object.entries(tickReceipt) + .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object') + .sort(([a], [b]) => a.localeCompare(b)); + + const lines = []; + for (const [writerId, entry] of entries) { + const sha = typeof entry.sha === 'string' ? entry.sha : null; + const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry; + const name = padRight(formatWriterName(writerId, NAME_W), NAME_W); + const shaStr = sha ? ` ${formatSha(sha)}` : ''; + lines.push(` ${name}${shaStr} ${formatOpSummary(opSummary, 40)}`); + } + + return lines; +} + +// ============================================================================ +// Window +// ============================================================================ + +/** + * Computes a sliding window of tick positions centered on the current tick. + * + * When all points fit within {@link MAX_COLS}, the full array is returned. + * Otherwise a window of MAX_COLS entries is centered on `currentIdx`, with + * clamping at both ends. + * + * @param {number[]} allPoints - All tick positions (including virtual tick 0) + * @param {number} currentIdx - Index of the current tick in `allPoints` + * @returns {{ points: number[], currentCol: number, moreLeft: boolean, moreRight: boolean }} + */ +function computeWindow(allPoints, currentIdx) { + if (allPoints.length <= MAX_COLS) { + return { + points: allPoints, + currentCol: currentIdx, + moreLeft: false, + moreRight: false, + }; + } + + const half = Math.floor(MAX_COLS / 2); + let start = currentIdx - half; + if (start < 0) { + start = 0; + } + let end = start + MAX_COLS; + if (end > allPoints.length) { + end = allPoints.length; + start = end - MAX_COLS; + } + + return { + points: allPoints.slice(start, end), + currentCol: currentIdx - start, + moreLeft: start > 0, + moreRight: end < allPoints.length, + }; +} + +// ============================================================================ +// Header row +// ============================================================================ + +/** + * Builds the column header row showing relative step offsets. + * + * The current tick is rendered as `[N]` (absolute tick number); all other + * columns show their signed step distance (`-2`, `-1`, `+1`, `+2`, etc.) + * matching the `--tick=+N/-N` CLI syntax. + * + * @param {{ points: number[], currentCol: number }} win - Computed window + * @returns {string} Formatted, indented header line + */ +function buildHeaderRow(win) { + const { points, currentCol } = win; + let header = ''; + + for (let i = 0; i < points.length; i++) { + const rel = i - currentCol; + let label; + if (rel === 0) { + label = `[${points[i]}]`; + } else if (rel > 0) { + label = `+${rel}`; + } else { + label = String(rel); + } + header += label.padEnd(COL_W); + } + + const margin = ' '.repeat(NAME_W + 2); + return ` ${margin}${header.trimEnd()}`; +} + +// ============================================================================ +// Writer swimlane +// ============================================================================ + +/** + * Renders a single cell (marker) in the swimlane grid. + * + * @param {boolean} hasPatch - Whether this writer has a patch at this tick + * @param {boolean} incl - Whether this tick is in the included zone + * @returns {string} A single styled character + */ +function renderCell(hasPatch, incl) { + if (hasPatch) { + return incl ? colors.success(TIMELINE.dot) : colors.muted(CIRCLE_OPEN); + } + return incl ? TIMELINE.line : colors.muted(DOT_MID); +} + +/** + * Builds the swimlane track string for a writer across the window columns. + * + * @param {Set} patchSet - Set of ticks where this writer has patches + * @param {number[]} points - Window tick positions + * @param {number} currentTick - Active seek cursor tick + * @returns {string} Styled swimlane track + */ +function buildLane(patchSet, points, currentTick) { + let lane = ''; + for (let i = 0; i < points.length; i++) { + const t = points[i]; + const incl = t <= currentTick; + + if (i > 0) { + const n = COL_W - 1; + lane += incl + ? TIMELINE.line.repeat(n) + : colors.muted(DOT_MID.repeat(n)); + } + + lane += renderCell(patchSet.has(t), incl); + } + return lane; +} + +/** + * Builds one writer's horizontal swimlane row. + * + * Each tick position in the window gets a marker character: + * - `●` (green) — writer has a patch here AND tick ≤ currentTick (included) + * - `○` (muted) — writer has a patch here AND tick > currentTick (excluded) + * - `─` (solid) — no patch, included zone + * - `·` (muted) — no patch, excluded zone + * + * Between consecutive columns, connector characters of the appropriate style + * fill the gap (COL_W − 1 chars). + * + * @param {Object} opts + * @param {string} opts.writerId + * @param {Object} opts.writerInfo - `{ ticks, tipSha, tickShas }` + * @param {{ points: number[] }} opts.win - Computed window + * @param {number} opts.currentTick - Active seek cursor tick + * @returns {string} Formatted, indented swimlane line + */ +function buildWriterSwimRow({ writerId, writerInfo, win, currentTick }) { + const patchSet = new Set(writerInfo.ticks); + const tickShas = writerInfo.tickShas || {}; + const lane = buildLane(patchSet, win.points, currentTick); + + // SHA of the highest included patch + const included = writerInfo.ticks.filter((t) => t <= currentTick); + const maxIncl = included.length > 0 ? included[included.length - 1] : null; + const sha = maxIncl !== null + ? (tickShas[maxIncl] || writerInfo.tipSha) + : writerInfo.tipSha; + + const name = padRight(formatWriterName(writerId, NAME_W), NAME_W); + const shaStr = sha ? ` ${formatSha(sha)}` : ''; + + return ` ${name} ${lane}${shaStr}`; +} + +// ============================================================================ +// Body assembly +// ============================================================================ + +/** + * Builds the tick-position array and index of the current tick. + * + * Ensures the current tick is always present: if `tick` is absent from + * `ticks` (e.g. saved cursor after writer refs changed), it is inserted + * at the correct sorted position so the window always centres on it. + * + * @param {number[]} ticks - Discovered Lamport ticks + * @param {number} tick - Current cursor tick + * @returns {{ allPoints: number[], currentIdx: number }} + */ +function buildTickPoints(ticks, tick) { + const allPoints = (ticks[0] === 0) ? [...ticks] : [0, ...ticks]; + let currentIdx = allPoints.indexOf(tick); + if (currentIdx === -1) { + let ins = allPoints.findIndex((t) => t > tick); + if (ins === -1) { + ins = allPoints.length; + } + allPoints.splice(ins, 0, tick); + currentIdx = ins; + } + return { allPoints, currentIdx }; +} + +/** + * Builds the body lines for the seek dashboard. + * + * @param {Object} payload - Seek payload from the CLI handler + * @returns {string[]} Lines for the box body + */ +function buildSeekBodyLines(payload) { + const { graph, tick, maxTick, ticks, nodes, edges, patchCount, perWriter, diff, tickReceipt } = payload; + const lines = []; + + lines.push(''); + lines.push(` ${colors.bold('GRAPH:')} ${graph}`); + lines.push(` ${colors.bold('POSITION:')} tick ${tick} of ${maxTick}`); + lines.push(''); + + if (ticks.length === 0) { + lines.push(` ${colors.muted('(no ticks)')}`); + } else { + const { allPoints, currentIdx } = buildTickPoints(ticks, tick); + const win = computeWindow(allPoints, currentIdx); + + // Column headers with relative offsets + lines.push(buildHeaderRow(win)); + + // Per-writer swimlanes + const writerEntries = perWriter instanceof Map + ? [...perWriter.entries()] + : Object.entries(perWriter).map(([k, v]) => [k, v]); + + for (const [writerId, writerInfo] of writerEntries) { + lines.push(buildWriterSwimRow({ writerId, writerInfo, win, currentTick: tick })); + } + } + + lines.push(''); + const edgeLabel = pluralize(edges, 'edge', 'edges'); + const nodeLabel = pluralize(nodes, 'node', 'nodes'); + const patchLabel = pluralize(patchCount, 'patch', 'patches'); + + const nodesStr = `${nodes} ${nodeLabel}${formatDelta(diff?.nodes)}`; + const edgesStr = `${edges} ${edgeLabel}${formatDelta(diff?.edges)}`; + lines.push(` ${colors.bold('State:')} ${nodesStr}, ${edgesStr}, ${patchCount} ${patchLabel}`); + + const receiptLines = buildReceiptLines(tickReceipt); + if (receiptLines.length > 0) { + lines.push(''); + lines.push(` ${colors.bold(`Tick ${tick}:`)}`); + lines.push(...receiptLines); + } + lines.push(''); + + return lines; +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Renders the seek view dashboard inside a double-bordered box. + * + * @param {Object} payload - Seek payload from the CLI handler + * @returns {string} Boxen-wrapped ASCII dashboard with trailing newline + */ +export function renderSeekView(payload) { + const lines = buildSeekBodyLines(payload); + const body = lines.join('\n'); + + return `${boxen(body, { + title: ' SEEK ', + titleAlignment: 'center', + padding: 0, + borderStyle: 'double', + borderColor: 'cyan', + })}\n`; +} diff --git a/test/bats/cli-seek.bats b/test/bats/cli-seek.bats new file mode 100644 index 0000000..13d4179 --- /dev/null +++ b/test/bats/cli-seek.bats @@ -0,0 +1,261 @@ +#!/usr/bin/env bats + +load helpers/setup.bash + +setup() { + setup_test_repo + seed_graph "seed-graph.js" +} + +teardown() { + teardown_test_repo +} + +@test "seek --json shows status with no active cursor" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["cursor"]["active"] is False, f"expected cursor.active=False, got {data['cursor']['active']}" +assert len(data["ticks"]) > 0, f"expected ticks array to have entries, got {data['ticks']}" +assert all(isinstance(t, int) for t in data["ticks"]), f"expected all ticks to be integers, got {data['ticks']}" +PY +} + +@test "seek --tick 1 --json sets cursor and materializes at tick 1" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["cursor"]["active"] is True, f"expected cursor.active=True, got {data['cursor']['active']}" +assert data["cursor"]["tick"] == 1, f"expected tick=1, got {data['cursor']['tick']}" +assert data["nodes"] == 3, f"expected 3 nodes at tick 1, got {data['nodes']}" +assert data["edges"] == 0, f"expected 0 edges at tick 1, got {data['edges']}" +assert data["patchCount"] == 1, f"expected 1 patch at tick 1, got {data['patchCount']}" +assert data["diff"] is None, f"expected diff=null at first seek, got {data['diff']}" + +receipt = data.get("tickReceipt") +assert isinstance(receipt, dict), f"expected tickReceipt object, got {receipt}" +assert "alice" in receipt, f"expected tickReceipt to include alice, got keys={list(receipt.keys())}" + +entry = receipt["alice"] +sha = entry.get("sha") +assert isinstance(sha, str) and len(sha) == 40, f"expected 40-char sha, got {sha}" +assert all(c in "0123456789abcdef" for c in sha), f"expected sha to be hex, got {sha}" + +summary = entry.get("opSummary") or {} +assert summary.get("NodeAdd") == 3, f"expected NodeAdd=3, got {summary.get('NodeAdd')}" +assert summary.get("PropSet") == 3, f"expected PropSet=3, got {summary.get('PropSet')}" +PY +} + +@test "seek --tick +1 --json advances to next tick" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick=+1 + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["cursor"]["tick"] == 2, f"expected tick=2, got {data['cursor']['tick']}" +PY +} + +@test "seek --tick=+1 --json includes diff + tickReceipt with sha" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick=+1 + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["tick"] == 2, f"expected tick=2, got {data['tick']}" +assert data["nodes"] == 3, f"expected 3 nodes at tick 2, got {data['nodes']}" +assert data["edges"] == 2, f"expected 2 edges at tick 2, got {data['edges']}" +assert data["patchCount"] == 2, f"expected 2 patches at tick 2, got {data['patchCount']}" + +diff = data.get("diff") +assert isinstance(diff, dict), f"expected diff object, got {diff}" +assert diff.get("nodes") == 0, f"expected nodes diff=0, got {diff.get('nodes')}" +assert diff.get("edges") == 2, f"expected edges diff=2, got {diff.get('edges')}" + +receipt = data.get("tickReceipt") or {} +assert "alice" in receipt, f"expected tickReceipt to include alice, got keys={list(receipt.keys())}" +entry = receipt["alice"] +sha = entry.get("sha") +assert isinstance(sha, str) and len(sha) == 40, f"expected 40-char sha, got {sha}" +summary = entry.get("opSummary") or {} +assert summary.get("EdgeAdd") == 2, f"expected EdgeAdd=2, got {summary.get('EdgeAdd')}" +PY +} + +@test "seek --latest --json clears cursor" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --latest + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["cursor"]["active"] is False, f"expected cursor.active=False, got {data['cursor']['active']}" +PY +} + +@test "seek --save/--load --json round-trips a cursor" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --save bp1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --latest + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --load bp1 + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["cursor"]["active"] is True, f"expected cursor.active=True, got {data['cursor']['active']}" +assert data["cursor"]["tick"] == 1, f"expected tick=1, got {data['cursor']['tick']}" +PY +} + +@test "seek --list --json lists saved cursors" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --save bp1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --list + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +names = [c["name"] for c in data["cursors"]] +assert "bp1" in names, f"expected bp1 in saved cursors, got {names}" +PY +} + +@test "seek --drop --json deletes saved cursor" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --save bp1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --drop bp1 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --list + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +names = [c["name"] for c in data["cursors"]] +assert "bp1" not in names, f"expected bp1 to be removed, but found it in {names}" +PY +} + +@test "seek --tick -1 --json steps backward" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 2 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick=-1 + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["cursor"]["tick"] == 1, f"expected tick=1, got {data['cursor']['tick']}" +PY +} + +@test "query respects active cursor" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + # Capture query output to a file to avoid BATS $output edge cases + local qfile="${TEST_REPO}/query_out.json" + git warp --repo "${TEST_REPO}" --graph demo --json query --match '*' > "${qfile}" + python3 -c " +import json +data = json.load(open('${qfile}')) +assert len(data['nodes']) == 3, f'expected 3 nodes at tick 1, got {len(data[\"nodes\"])}' +" +} + +@test "query returns full node set after --latest clears cursor" { + # Seek to tick 0 (empty state), then clear with --latest. + # Query must return the full 3-node graph, proving cursor was cleared. + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 0 + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --latest + assert_success + + run git warp --repo "${TEST_REPO}" --graph demo --json query --match '*' + assert_success + + echo "$output" | python3 -c " +import json, sys +data = json.load(sys.stdin) +assert len(data['nodes']) == 3, f'expected 3 nodes after latest, got {len(data[\"nodes\"])}' +" +} + +@test "seek plain text output" { + run git warp --repo "${TEST_REPO}" --graph demo seek + assert_success + echo "$output" | grep -q "demo" + echo "$output" | grep -qiE "tick|cursor" +} + +@test "seek plain text output includes receipt summary with sha" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + short_sha="$(JSON="$output" python3 -c 'import json, os; j = json.loads(os.environ["JSON"]); print(j["tickReceipt"]["alice"]["sha"][:7])')" + + run git warp --repo "${TEST_REPO}" --graph demo seek --tick 1 + assert_success + + echo "$output" | grep -q "Tick 1:" + echo "$output" | grep -q "alice" + echo "$output" | grep -q "${short_sha}" + echo "$output" | grep -q "\\+3node" + echo "$output" | grep -q "~3prop" +} + +@test "seek --json suppresses diff when frontier changes" { + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick 1 + assert_success + + # Change frontier by appending a new patch (lamport tick 3). + seed_graph "append-patch.js" + + run git warp --repo "${TEST_REPO}" --graph demo --json seek --tick=+1 + assert_success + + JSON="$output" python3 - <<'PY' +import json, os +data = json.loads(os.environ["JSON"]) +assert data["tick"] == 2, f"expected tick=2, got {data['tick']}" +assert data["maxTick"] == 3, f"expected maxTick=3 after append, got {data['maxTick']}" +assert data["diff"] is None, f"expected diff=null due to frontier change, got {data['diff']}" +PY +} diff --git a/test/bats/helpers/append-patch.js b/test/bats/helpers/append-patch.js new file mode 100644 index 0000000..b4b9371 --- /dev/null +++ b/test/bats/helpers/append-patch.js @@ -0,0 +1,25 @@ +/** + * Appends a small patch to the demo graph to advance the frontier. + * + * Used by BATS time-travel tests to verify seek diffs are suppressed when + * the frontier changes between cursor snapshots. + * + * Expects: + * - REPO_PATH env var + * - PROJECT_ROOT env var (set by setup.bash) + */ + +import { WarpGraph, persistence, crypto } from './seed-setup.js'; + +const graph = await WarpGraph.open({ + persistence, + graphName: 'demo', + writerId: 'alice', + crypto, +}); + +const patch = await graph.createPatch(); +await patch + .setProperty('user:alice', 'role', 'ops') + .commit(); + diff --git a/test/unit/domain/WarpGraph.seek.test.js b/test/unit/domain/WarpGraph.seek.test.js new file mode 100644 index 0000000..c4323d2 --- /dev/null +++ b/test/unit/domain/WarpGraph.seek.test.js @@ -0,0 +1,396 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import WarpGraph from '../../../src/domain/WarpGraph.js'; +import { encode } from '../../../src/infrastructure/codecs/CborCodec.js'; +import { encodePatchMessage } from '../../../src/domain/services/WarpMessageCodec.js'; +import { createMockPersistence } from '../../helpers/warpGraphTestUtils.js'; + +/** + * Creates a minimal schema:2 patch object. + */ +function createPatch(writer, lamport, nodeId) { + return { + schema: 2, + writer, + lamport, + context: { [writer]: lamport }, + ops: [{ type: 'NodeAdd', node: nodeId, dot: { writer, counter: lamport } }], + }; +} + +/** + * A fake 40-char hex SHA for use in tests. + */ +function fakeSha(label) { + const hex = Buffer.from(String(label)).toString('hex'); + return hex.padEnd(40, 'a').slice(0, 40); +} + +/** + * Sets up persistence mocks for multiple writers at once. + * Each writer gets `count` patches with lamport 1..count. + * + * @param {Object} persistence - Mock persistence + * @param {Object} writerSpecs - { writerId: count, ... } + * @param {string} [graphName='test'] + * @returns {Object} writerTips - { writerId: tipSha, ... } + */ +function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test') { + const nodeInfoMap = new Map(); + const blobMap = new Map(); + const writerTips = {}; + + for (const [writer, count] of Object.entries(writerSpecs)) { + const shas = []; + for (let i = 1; i <= count; i++) { + shas.push(fakeSha(`${writer}${i}`)); + } + writerTips[writer] = shas[0]; + + // shas[0] = tip (newest, highest lamport) + // shas[count-1] = oldest (lamport=1) + for (let j = 0; j < count; j++) { + const lamport = count - j; // tip has highest lamport + const patchOid = fakeSha(`blob-${writer}-${lamport}`); + const message = encodePatchMessage({ + graph: graphName, + writer, + lamport, + patchOid, + schema: 2, + }); + const parents = j < count - 1 ? [shas[j + 1]] : []; + nodeInfoMap.set(shas[j], { message, parents }); + + const patch = createPatch(writer, lamport, `n:${writer}:${lamport}`); + blobMap.set(patchOid, encode(patch)); + } + } + + const writerRefs = Object.keys(writerSpecs).map( + (w) => `refs/warp/${graphName}/writers/${w}` + ); + + persistence.getNodeInfo.mockImplementation((sha) => { + const info = nodeInfoMap.get(sha); + if (info) { + return Promise.resolve(info); + } + return Promise.resolve({ message: '', parents: [] }); + }); + + persistence.readBlob.mockImplementation((oid) => { + const buf = blobMap.get(oid); + if (buf) { + return Promise.resolve(buf); + } + return Promise.resolve(Buffer.alloc(0)); + }); + + persistence.readRef.mockImplementation((ref) => { + if (ref === `refs/warp/${graphName}/checkpoints/head`) { + return Promise.resolve(null); + } + for (const [writer, tip] of Object.entries(writerTips)) { + if (ref === `refs/warp/${graphName}/writers/${writer}`) { + return Promise.resolve(tip); + } + } + return Promise.resolve(null); + }); + + persistence.listRefs.mockImplementation((prefix) => { + if (prefix.startsWith(`refs/warp/${graphName}/writers`)) { + return Promise.resolve(writerRefs); + } + return Promise.resolve([]); + }); + + return writerTips; +} + +describe('WarpGraph.seek (time-travel)', () => { + let persistence; + + beforeEach(() => { + persistence = createMockPersistence(); + }); + + // -------------------------------------------------------------------------- + // discoverTicks() + // -------------------------------------------------------------------------- + + describe('discoverTicks()', () => { + it('returns correct sorted ticks for a multi-writer graph', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3, bob: 2 }); + + const result = await graph.discoverTicks(); + + expect(result.ticks).toEqual([1, 2, 3]); + expect(result.maxTick).toBe(3); + }); + + it('returns empty result for a graph with no writers', async () => { + persistence.listRefs.mockResolvedValue([]); + persistence.readRef.mockResolvedValue(null); + + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + const result = await graph.discoverTicks(); + + expect(result.ticks).toEqual([]); + expect(result.maxTick).toBe(0); + expect(result.perWriter.size).toBe(0); + }); + + it('returns per-writer breakdown', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + const tips = setupMultiWriterPersistence(persistence, { alice: 2, bob: 3 }); + + const result = await graph.discoverTicks(); + + expect(result.perWriter.get('alice').ticks).toEqual([1, 2]); + expect(result.perWriter.get('bob').ticks).toEqual([1, 2, 3]); + expect(result.perWriter.get('alice').tipSha).toBe(tips.alice); + }); + }); + + // -------------------------------------------------------------------------- + // materialize({ ceiling }) + // -------------------------------------------------------------------------- + + describe('materialize({ ceiling })', () => { + it('includes only patches at or below the ceiling', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + const state = await graph.materialize({ ceiling: 2 }); + + const nodeIds = [...state.nodeAlive.entries.keys()]; + expect(nodeIds).toHaveLength(2); + expect(nodeIds).toContain('n:alice:1'); + expect(nodeIds).toContain('n:alice:2'); + expect(nodeIds).not.toContain('n:alice:3'); + }); + + it('ceiling of 0 returns empty state', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + const state = await graph.materialize({ ceiling: 0 }); + + expect(state.nodeAlive.entries.size).toBe(0); + }); + + it('ceiling above maxTick yields same as full materialization', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + const fullState = await graph.materialize(); + const fullNodes = [...fullState.nodeAlive.entries.keys()].sort(); + + // Force cache invalidation for second call + graph._stateDirty = true; + graph._cachedCeiling = null; + const ceilingState = await graph.materialize({ ceiling: 999 }); + const ceilingNodes = [...ceilingState.nodeAlive.entries.keys()].sort(); + + expect(ceilingNodes).toEqual(fullNodes); + }); + + it('multi-writer ceiling includes correct cross-writer patches', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 2, bob: 3 }); + + const state = await graph.materialize({ ceiling: 2 }); + + const nodeIds = [...state.nodeAlive.entries.keys()].sort(); + // alice:1, alice:2, bob:1, bob:2 = 4 nodes + expect(nodeIds).toHaveLength(4); + expect(nodeIds).toContain('n:alice:1'); + expect(nodeIds).toContain('n:alice:2'); + expect(nodeIds).toContain('n:bob:1'); + expect(nodeIds).toContain('n:bob:2'); + expect(nodeIds).not.toContain('n:bob:3'); + }); + + it('cache invalidation: different ceilings produce different states', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + const stateA = await graph.materialize({ ceiling: 1 }); + const nodesA = stateA.nodeAlive.entries.size; + + const stateB = await graph.materialize({ ceiling: 3 }); + const nodesB = stateB.nodeAlive.entries.size; + + expect(nodesA).toBe(1); + expect(nodesB).toBe(3); + }); + + it('cache hit: same ceiling returns cached state without re-materialize', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + await graph.materialize({ ceiling: 2 }); + const callCountAfterFirst = persistence.getNodeInfo.mock.calls.length; + + await graph.materialize({ ceiling: 2 }); + const callCountAfterSecond = persistence.getNodeInfo.mock.calls.length; + + // Should not have made additional persistence calls (cache hit) + expect(callCountAfterSecond).toBe(callCountAfterFirst); + }); + + it('_seekCeiling is used when no explicit ceiling is passed', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + graph._seekCeiling = 1; + const state = await graph.materialize(); + + expect(state.nodeAlive.entries.size).toBe(1); + }); + + it('explicit ceiling overrides _seekCeiling', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + graph._seekCeiling = 1; + const state = await graph.materialize({ ceiling: 3 }); + + expect(state.nodeAlive.entries.size).toBe(3); + }); + + it('skips auto-checkpoint when ceiling is active', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + checkpointPolicy: { every: 1 }, + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + const spy = vi.spyOn(graph, 'createCheckpoint').mockResolvedValue(fakeSha('ckpt')); + + await graph.materialize({ ceiling: 2 }); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('cache hit with collectReceipts bypasses cache and returns real receipts', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + // First call: populate the ceiling cache + await graph.materialize({ ceiling: 2 }); + const callCountAfterFirst = persistence.getNodeInfo.mock.calls.length; + + // Second call: same ceiling but with receipts — must NOT use cache + const result = await graph.materialize({ ceiling: 2, receipts: true }); + + expect(result.state).toBeDefined(); + expect(Array.isArray(result.receipts)).toBe(true); + // Must have re-materialized (not returned empty receipts from cache) + const callCountAfterSecond = persistence.getNodeInfo.mock.calls.length; + expect(callCountAfterSecond).toBeGreaterThan(callCountAfterFirst); + }); + + it('cache is invalidated when frontier advances at the same ceiling', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + // Start with one writer + setupMultiWriterPersistence(persistence, { alice: 3 }); + + const stateA = await graph.materialize({ ceiling: 2 }); + expect(stateA.nodeAlive.entries.size).toBe(2); // alice:1, alice:2 + + // A new writer appears — frontier changes + setupMultiWriterPersistence(persistence, { alice: 3, bob: 3 }); + + const stateB = await graph.materialize({ ceiling: 2 }); + // Must see 4 nodes (alice:1, alice:2, bob:1, bob:2), not stale 2 + expect(stateB.nodeAlive.entries.size).toBe(4); + }); + + it('explicit ceiling: null overrides _seekCeiling and materializes latest', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + setupMultiWriterPersistence(persistence, { alice: 3 }); + + graph._seekCeiling = 1; + // Passing ceiling: null should clear the ceiling, giving us all 3 nodes + const state = await graph.materialize({ ceiling: null }); + + expect(state.nodeAlive.entries.size).toBe(3); + }); + }); +}); diff --git a/test/unit/domain/parseCursorBlob.test.js b/test/unit/domain/parseCursorBlob.test.js new file mode 100644 index 0000000..4169071 --- /dev/null +++ b/test/unit/domain/parseCursorBlob.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { parseCursorBlob } from '../../../src/domain/utils/parseCursorBlob.js'; + +describe('parseCursorBlob', () => { + function buf(str) { + return Buffer.from(str, 'utf8'); + } + + it('parses a valid cursor blob', () => { + const result = parseCursorBlob(buf('{"tick":5,"mode":"lamport"}'), 'test cursor'); + expect(result).toEqual({ tick: 5, mode: 'lamport' }); + }); + + it('parses a cursor with only tick', () => { + const result = parseCursorBlob(buf('{"tick":0}'), 'test cursor'); + expect(result).toEqual({ tick: 0 }); + }); + + it('preserves extra fields', () => { + const result = parseCursorBlob(buf('{"tick":3,"mode":"lamport","extra":"ok"}'), 'test'); + expect(result.extra).toBe('ok'); + }); + + it('throws on invalid JSON', () => { + expect(() => parseCursorBlob(buf('not json'), 'active cursor')).toThrow( + 'Corrupted active cursor: blob is not valid JSON' + ); + }); + + it('throws on truncated JSON', () => { + expect(() => parseCursorBlob(buf('{"tick":'), 'active cursor')).toThrow( + 'blob is not valid JSON' + ); + }); + + it('throws on JSON array', () => { + expect(() => parseCursorBlob(buf('[1,2,3]'), 'saved cursor')).toThrow( + 'expected a JSON object' + ); + }); + + it('throws on JSON null', () => { + expect(() => parseCursorBlob(buf('null'), 'saved cursor')).toThrow( + 'expected a JSON object' + ); + }); + + it('throws on missing tick', () => { + expect(() => parseCursorBlob(buf('{"mode":"lamport"}'), "saved cursor 'foo'")).toThrow( + "Corrupted saved cursor 'foo': missing or invalid numeric tick" + ); + }); + + it('throws on non-numeric tick', () => { + expect(() => parseCursorBlob(buf('{"tick":"5"}'), 'active cursor')).toThrow( + 'missing or invalid numeric tick' + ); + }); + + it('throws on NaN tick', () => { + expect(() => parseCursorBlob(buf('{"tick":null}'), 'active cursor')).toThrow( + 'missing or invalid numeric tick' + ); + }); + + it('throws on boolean tick', () => { + expect(() => parseCursorBlob(buf('{"tick":true}'), 'cursor')).toThrow( + 'missing or invalid numeric tick' + ); + }); +}); diff --git a/test/unit/visualization/__snapshots__/ascii-graph-renderer.test.js.snap b/test/unit/visualization/__snapshots__/ascii-graph-renderer.test.js.snap index 356d034..77512cb 100644 --- a/test/unit/visualization/__snapshots__/ascii-graph-renderer.test.js.snap +++ b/test/unit/visualization/__snapshots__/ascii-graph-renderer.test.js.snap @@ -35,7 +35,7 @@ exports[`ASCII graph renderer > renders a 3-node DAG with edges 1`] = ` ║ +─────+ +─────+ ║ ║ │ │ ║ ║ │ │ ║ -║ │ │ ║ +║ ▼ ▼ ║ ║ ┌────────┐ ┌────────┐ ║ ║ │ Beta │ │ Gamma │ ║ ║ └────────┘ └────────┘ ║ diff --git a/test/unit/visualization/ascii-op-summary.test.js b/test/unit/visualization/ascii-op-summary.test.js new file mode 100644 index 0000000..b7400a8 --- /dev/null +++ b/test/unit/visualization/ascii-op-summary.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeOps, formatOpSummary, EMPTY_OP_SUMMARY } from '../../../src/visualization/renderers/ascii/opSummary.js'; +import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; + +describe('opSummary utilities', () => { + it('summarizeOps counts known operation types', () => { + const ops = [ + { type: 'NodeAdd' }, + { type: 'EdgeAdd' }, + { type: 'EdgeAdd' }, + { type: 'PropSet' }, + { type: 'UnknownOp' }, + {}, + ]; + + const summary = summarizeOps(ops); + expect(summary.NodeAdd).toBe(1); + expect(summary.EdgeAdd).toBe(2); + expect(summary.PropSet).toBe(1); + expect(summary.NodeTombstone).toBe(0); + expect(summary.EdgeTombstone).toBe(0); + expect(summary.BlobValue).toBe(0); + }); + + it('formatOpSummary renders a stable textual summary and (empty) for no-ops', () => { + const empty = stripAnsi(formatOpSummary(EMPTY_OP_SUMMARY)); + expect(empty).toContain('(empty)'); + + const summary = { ...EMPTY_OP_SUMMARY, NodeAdd: 2, PropSet: 1 }; + const output = stripAnsi(formatOpSummary(summary)); + expect(output).toContain('+2node'); + expect(output).toContain('~1prop'); + expect(output).not.toContain('(empty)'); + }); +}); + diff --git a/test/unit/visualization/ascii-seek-renderer.test.js b/test/unit/visualization/ascii-seek-renderer.test.js new file mode 100644 index 0000000..5e3bf19 --- /dev/null +++ b/test/unit/visualization/ascii-seek-renderer.test.js @@ -0,0 +1,327 @@ +import { describe, it, expect } from 'vitest'; +import { renderSeekView } from '../../../src/visualization/renderers/ascii/seek.js'; +import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; + +describe('renderSeekView', () => { + it('renders seek status with multiple writers', () => { + const payload = { + graph: 'sandbox', + tick: 1, + maxTick: 2, + ticks: [1, 2], + nodes: 9, + edges: 12, + patchCount: 6, + perWriter: { + alice: { ticks: [1, 2], tipSha: '5f14fc7aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + bob: { ticks: [1, 2], tipSha: '575d6f8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + charlie: { ticks: [1], tipSha: '6804b59aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + expect(output).toContain('SEEK'); + expect(output).toContain('GRAPH: sandbox'); + expect(output).toContain('POSITION: tick 1 of 2'); + expect(output).toContain('alice'); + expect(output).toContain('bob'); + expect(output).toContain('charlie'); + expect(output).toContain('9 nodes, 12 edges'); + }); + + it('renders seek status at tick 0 (empty state)', () => { + const payload = { + graph: 'test', + tick: 0, + maxTick: 3, + ticks: [1, 2, 3], + nodes: 0, + edges: 0, + patchCount: 0, + perWriter: { + alice: { ticks: [1, 2, 3], tipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + expect(output).toContain('POSITION: tick 0 of 3'); + expect(output).toContain('0 nodes, 0 edges'); + // Current tick shown as [0] in header + expect(output).toContain('[0]'); + }); + + it('renders seek at latest tick', () => { + const payload = { + graph: 'mydb', + tick: 5, + maxTick: 5, + ticks: [1, 2, 3, 4, 5], + nodes: 100, + edges: 200, + patchCount: 15, + perWriter: { + writer1: { ticks: [1, 3, 5], tipSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + expect(output).toContain('POSITION: tick 5 of 5'); + expect(output).toContain('100 nodes, 200 edges'); + expect(output).toContain('[5]'); + }); + + it('renders with single writer', () => { + const payload = { + graph: 'solo', + tick: 2, + maxTick: 3, + ticks: [1, 2, 3], + nodes: 5, + edges: 3, + patchCount: 2, + perWriter: { + alice: { ticks: [1, 2, 3], tipSha: 'cccccccccccccccccccccccccccccccccccccccc' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + expect(output).toContain('GRAPH: solo'); + expect(output).toContain('alice'); + expect(output).toContain('5 nodes, 3 edges'); + expect(output).toContain('[2]'); + }); + + it('handles empty graph (no ticks)', () => { + const payload = { + graph: 'empty', + tick: 0, + maxTick: 0, + ticks: [], + nodes: 0, + edges: 0, + patchCount: 0, + perWriter: {}, + }; + + const output = stripAnsi(renderSeekView(payload)); + + expect(output).toContain('POSITION: tick 0 of 0'); + expect(output).toContain('0 nodes, 0 edges'); + expect(output).toContain('(no ticks)'); + }); + + it('renders singular labels for 1 node, 1 edge, 1 patch', () => { + const payload = { + graph: 'tiny', + tick: 1, + maxTick: 1, + ticks: [1], + nodes: 1, + edges: 1, + patchCount: 1, + perWriter: { + alice: { ticks: [1], tipSha: 'dddddddddddddddddddddddddddddddddddddd' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + expect(output).toContain('1 node, 1 edge, 1 patch'); + }); + + it('accepts perWriter as a Map', () => { + const perWriter = new Map([ + ['alice', { ticks: [1, 2], tipSha: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' }], + ]); + + const payload = { + graph: 'maptest', + tick: 1, + maxTick: 2, + ticks: [1, 2], + nodes: 3, + edges: 2, + patchCount: 1, + perWriter, + }; + + const output = stripAnsi(renderSeekView(payload)); + + expect(output).toContain('alice'); + expect(output).toContain('3 nodes, 2 edges'); + }); + + it('does not duplicate tick 0 when ticks already contains 0', () => { + const payload = { + graph: 'zero', + tick: 0, + maxTick: 2, + ticks: [0, 1, 2], + nodes: 0, + edges: 0, + patchCount: 0, + perWriter: { + alice: { ticks: [1, 2], tipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + // [0] should appear exactly once in the header (no duplicate column) + const matches = output.match(/\[0\]/g) || []; + expect(matches.length).toBe(1); + }); + + it('shows relative offsets in column headers', () => { + const payload = { + graph: 'offsets', + tick: 2, + maxTick: 4, + ticks: [1, 2, 3, 4], + nodes: 5, + edges: 3, + patchCount: 3, + perWriter: { + alice: { ticks: [1, 2, 3, 4], tipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + // Header should contain relative labels and the current tick + expect(output).toContain('[2]'); + expect(output).toContain('-1'); + expect(output).toContain('+1'); + expect(output).toContain('+2'); + }); + + it('shows included markers (filled) and excluded markers (open)', () => { + const payload = { + graph: 'markers', + tick: 1, + maxTick: 2, + ticks: [1, 2], + nodes: 5, + edges: 3, + patchCount: 2, + perWriter: { + alice: { + ticks: [1, 2], + tipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + tickShas: { 1: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 2: 'cccccccccccccccccccccccccccccccccccccccc' }, + }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + // Should contain filled dot (●) for included patch and open circle (○) for excluded + expect(output).toContain('\u25CF'); // ● + expect(output).toContain('\u25CB'); // ○ + // SHA should be from tick 1 (the included tick), not the tip + expect(output).toContain('bbbbbbb'); + }); + + it('renders state deltas when diff is provided', () => { + const payload = { + graph: 'delta', + tick: 2, + maxTick: 4, + ticks: [1, 2, 3, 4], + nodes: 10, + edges: 15, + patchCount: 6, + diff: { nodes: 1, edges: 3 }, + perWriter: { + alice: { ticks: [1, 2, 3, 4], tipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + expect(output).toContain('State: 10 nodes (+1), 15 edges (+3), 6 patches'); + }); + + it('renders a per-writer tick receipt section when tickReceipt is provided', () => { + const payload = { + graph: 'receipt', + tick: 1, + maxTick: 2, + ticks: [1, 2], + nodes: 3, + edges: 2, + patchCount: 2, + tickReceipt: { + alice: { + sha: 'deadbeefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + opSummary: { NodeAdd: 1, EdgeAdd: 2, PropSet: 0, NodeTombstone: 0, EdgeTombstone: 0, BlobValue: 0 }, + }, + bob: { + sha: 'cafebabeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + opSummary: { NodeAdd: 0, EdgeAdd: 0, PropSet: 2, NodeTombstone: 0, EdgeTombstone: 0, BlobValue: 0 }, + }, + }, + perWriter: { + alice: { ticks: [1, 2], tipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + bob: { ticks: [1], tipSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + expect(output).toContain('Tick 1:'); + expect(output).toContain('deadbee'); + expect(output).toContain('cafebab'); + expect(output).toContain('+1node'); + expect(output).toContain('+2edge'); + expect(output).toContain('~2prop'); + }); + + it('shows current tick marker when tick is not in ticks array', () => { + // Edge case: cursor references a tick that is absent from ticks + // (e.g. saved cursor after writer refs changed). The renderer must + // still show [5] in the header, not fall back to [0]. + const payload = { + graph: 'orphan', + tick: 5, + maxTick: 10, + ticks: [1, 2, 3, 4, 6, 7, 8, 9, 10], + nodes: 3, + edges: 1, + patchCount: 2, + perWriter: { + alice: { ticks: [1, 3, 6, 9], tipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + // The current tick should appear as [5] in the header + expect(output).toContain('[5]'); + // Should NOT show [0] as current tick + expect(output).not.toMatch(/\[0\]/); + }); + + it('shows current tick marker when many ticks exceed window and tick is missing', () => { + // More than MAX_COLS (9) ticks, and currentTick is absent from array + const payload = { + graph: 'big', + tick: 7, + maxTick: 20, + ticks: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + nodes: 10, + edges: 5, + patchCount: 8, + perWriter: { + alice: { ticks: [1, 5, 10, 15, 20], tipSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, + }, + }; + + const output = stripAnsi(renderSeekView(payload)); + + // The current tick 7 should appear as [7] in the header + expect(output).toContain('[7]'); + }); +});