Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
e237b13
feat: make InMemoryGraphAdapter and defaultCrypto browser-compatible …
flyingrobots Mar 7, 2026
2d4ccd5
feat: add Browsa demo app — 4-viewport WARP graph in the browser
flyingrobots Mar 7, 2026
02d7c78
feat(browsa): replace force-directed layout with ELK Sugiyama
flyingrobots Mar 7, 2026
d765720
feat(browsa): viewport-culled rendering with pan/zoom
flyingrobots Mar 7, 2026
1cf9151
feat(browsa): object-pooled SVG rendering — zero DOM churn on pan/zoom
flyingrobots Mar 7, 2026
8e5be83
fix(browsa): resolve materialization failures in browser
flyingrobots Mar 7, 2026
e617ef3
fix(browsa): node deletion + scenario runner
flyingrobots Mar 7, 2026
622f529
fix(types): widen TreePort.readTree() return from Buffer to Uint8Array
flyingrobots Mar 7, 2026
ec3cf04
refactor(browsa): rename DaCone to Inspector
flyingrobots Mar 8, 2026
e64b979
feat(serve): add WebSocketServerPort and WarpServeService
flyingrobots Mar 8, 2026
9f28b75
docs: add TASKS.md with full browsa architecture pivot plan
flyingrobots Mar 8, 2026
8ed8c64
feat(serve): add NodeWsAdapter — WebSocket server for Node.js
flyingrobots Mar 8, 2026
0ea9e01
feat(cli): add `git warp serve` command
flyingrobots Mar 8, 2026
fc20b4c
docs: mark T2 and T3 as done in TASKS.md
flyingrobots Mar 8, 2026
d684bda
feat(browsa): add WarpSocket — framework-agnostic WebSocket client
flyingrobots Mar 8, 2026
99d6a81
docs: mark T4 as done in TASKS.md
flyingrobots Mar 8, 2026
f99ded7
feat(browsa): rewire Vue app from in-memory WarpGraph to WarpSocket
flyingrobots Mar 8, 2026
36e0e7b
docs(roadmap): add P7 git-cas modernization items (B158–B164)
flyingrobots Mar 8, 2026
b476bae
feat(deps): upgrade @git-stunts/git-cas to v5.2.4, enable CDC chunking
flyingrobots Mar 8, 2026
4776bb4
feat(seek-cache): add encryption support and observability bridge
flyingrobots Mar 8, 2026
0ac14b7
feat(cas): add BlobStoragePort and CasBlobAdapter for CAS-backed blob…
flyingrobots Mar 8, 2026
7acbd96
chore(browsa): remove dead Vite stubs and simplify config (T7)
flyingrobots Mar 8, 2026
d683354
docs(browsa): update README, CHANGELOG, and TASKS for T7–T9
flyingrobots Mar 8, 2026
2a032e1
fix(vitest): exclude Deno runtime tests from local test runs
flyingrobots Mar 8, 2026
3fc1c8f
feat(serve): add Bun and Deno WebSocket adapters with runtime detecti…
flyingrobots Mar 8, 2026
b721858
feat(serve): add --static flag for serving built SPA on same port (T11)
flyingrobots Mar 8, 2026
3403fd8
feat(seek-cache): use restoreStream() with fallback for I/O pipelinin…
flyingrobots Mar 8, 2026
cca81ca
feat(encryption): thread patchBlobStorage through WarpGraph and all p…
flyingrobots Mar 8, 2026
380bcba
test(encryption): add graph encryption integration tests (B164)
flyingrobots Mar 8, 2026
9b0e0a6
docs(encryption): update ROADMAP, CHANGELOG, index.d.ts for B163+B164
flyingrobots Mar 8, 2026
a8bac2c
test(browsa): add concurrent out-of-order and timeout tests for WarpS…
flyingrobots Mar 8, 2026
f61040d
refactor(types): narrow Buffer|Uint8Array JSDoc unions to Uint8Array
flyingrobots Mar 8, 2026
3362754
fix(test): replace Buffer.equals() with toEqual() for Uint8Array comp…
flyingrobots Mar 8, 2026
c4717ff
fix(test): use TextDecoder for Uint8Array writeBlob mock assertions
flyingrobots Mar 8, 2026
4337ab9
test(content-attachment): replace Buffer APIs with Uint8Array equival…
flyingrobots Mar 8, 2026
c2bf993
fix(test): replace Buffer with TextEncoder/TextDecoder in StreamingBi…
flyingrobots Mar 8, 2026
4835bad
fix(trust): make TrustRecordService.verifyChain async and await in tests
flyingrobots Mar 8, 2026
ef3a379
feat!: eliminate Buffer from domain layer — Uint8Array migration (v14…
flyingrobots Mar 8, 2026
97cf615
fix: resolve 13 PR review findings — security, bugs, and hardening
flyingrobots Mar 8, 2026
911cf97
fix(types): resolve pre-existing tsc strict-mode errors in test files
flyingrobots Mar 8, 2026
fb50215
fix(types): annotate implicit-any params in WarpSocket and update CHA…
flyingrobots Mar 8, 2026
650cd1c
fix(types): resolve pre-existing tsc strict-mode errors in source files
flyingrobots Mar 8, 2026
5f2056d
fix(serve): sanitize writerId to remove invalid characters
flyingrobots Mar 8, 2026
d303011
fix(browsa): derive WebSocket URL from window.location
flyingrobots Mar 8, 2026
ab20f39
refactor: rebrand Browsa to Git WARP Inspector
flyingrobots Mar 8, 2026
a2c8b70
refactor: extract Inspector to git-warp-web-inspector repo
flyingrobots Mar 8, 2026
a6559bc
fix(adapter): replace top-level await with lazy crypto probe in InMem…
flyingrobots Mar 8, 2026
4b2f00a
fix(adapters): address CodeRabbit review issues in WebSocket adapters
flyingrobots Mar 8, 2026
66f8e82
perf(bytes): replace btoa/atob with table-based base64 encode/decode
flyingrobots Mar 8, 2026
a393b35
fix: address CodeRabbit review issues across 5 files
flyingrobots Mar 8, 2026
04eb57e
fix(adapter): expand CasBlobAdapter legacy blob allowlist and extract…
flyingrobots Mar 8, 2026
025441b
fix(types): accept null graphOption in resolveTargetGraphs
flyingrobots Mar 8, 2026
4f7a568
refactor: extract wsAdapterUtils and use CasError codes for blob fall…
flyingrobots Mar 8, 2026
8377d3a
fix(serve): accept Infinity ceiling in seek and strengthen ephemeral …
flyingrobots Mar 8, 2026
07620d5
fix(arch): remove node:crypto from domain seekCacheKey
flyingrobots Mar 8, 2026
faab4b7
fix(arch): delete TrustCrypto domain-to-infrastructure re-export shim
flyingrobots Mar 8, 2026
2a3cf74
fix(arch): remove process.stdout.columns from visualization layer
flyingrobots Mar 8, 2026
69a5fde
refactor(infra): extract shared toPortRequest into httpAdapterUtils
flyingrobots Mar 8, 2026
bc892e5
refactor(infra): extract lazy CAS init helper
flyingrobots Mar 8, 2026
4cd08e1
fix(types): add JSDoc type annotation to renderPathView wrapper
flyingrobots Mar 8, 2026
7459745
docs: update CHANGELOG for architecture audit fixes
flyingrobots Mar 8, 2026
9569118
fix(serve): await async mutate ops to prevent silent blob data loss
flyingrobots Mar 8, 2026
8d4ad5f
fix(adapter): resolve actual port in DenoWsAdapter for port-0 support
flyingrobots Mar 8, 2026
21b8112
docs(serve): document intentional edge property omission in serialize…
flyingrobots Mar 8, 2026
7726538
fix(security): resolve symlinks in static file handler to prevent tra…
flyingrobots Mar 8, 2026
709feff
docs: update CHANGELOG for PR review fixes
flyingrobots Mar 8, 2026
60d8f00
fix(types): resolve tsc errors in DenoWsAdapter and its test mock
flyingrobots Mar 8, 2026
33c23db
fix(serve): address PR review round 2 issues
flyingrobots Mar 8, 2026
0478abf
feat(serve): add --writer-id flag for explicit writer identity
flyingrobots Mar 8, 2026
b496640
fix(review): resolve PR review round 3 findings
flyingrobots Mar 8, 2026
4b40f9f
fix(review): resolve PR review round 4 findings
flyingrobots Mar 8, 2026
745f254
fix(types): export all index.d.ts runtime symbols from index.js
flyingrobots Mar 8, 2026
ab9a56c
fix(review): resolve remaining PR comments
flyingrobots Mar 8, 2026
7c0eb32
fix(review): resolve self-review findings
flyingrobots Mar 8, 2026
432184d
test: add unit tests for httpAdapterUtils and lazyCasInit
flyingrobots Mar 8, 2026
c9f1bd2
fix(types): resolve tsc strict-mode errors in new test files
flyingrobots Mar 8, 2026
135a833
fix(review): resolve 10 CodeRabbit review findings (round 5)
flyingrobots Mar 8, 2026
d3e0138
fix(serve): harden WarpServeService listen() and error sanitization (…
flyingrobots Mar 8, 2026
f80f281
fix(packaging): browser types, jsr include, CLI help, doc fixes
flyingrobots Mar 8, 2026
12806b7
fix(ws-adapters): buffer pre-handler messages and improve error obser…
flyingrobots Mar 8, 2026
96c2a1f
fix(domain): harden serve validation, null-guard blob retrieval, sani…
flyingrobots Mar 8, 2026
87e9ce7
test: improve quality, cleanup, and coverage across all test suites
flyingrobots Mar 8, 2026
e0c95c0
fix(ws): buffer pre-handler messages in NodeWsAdapter
flyingrobots Mar 8, 2026
26675c5
fix(security): treat wildcard bind addresses as non-loopback
flyingrobots Mar 8, 2026
77d5230
fix(packaging): add missing browser/sha1sync type exports and files
flyingrobots Mar 8, 2026
90f106c
fix(domain): null-guard _readPatchBlob readBlob return
flyingrobots Mar 8, 2026
001fe0e
fix(adapter): narrow JSDoc types and add null-guard in CasBlobAdapter
flyingrobots Mar 8, 2026
d107125
fix(docs): standardize CHANGELOG sections and formatting
flyingrobots Mar 8, 2026
a0214ee
fix(serve): extract message ID before async, tighten types, document …
flyingrobots Mar 8, 2026
d094fa7
docs(changelog): add entries for serve ID extraction and type tightening
flyingrobots Mar 8, 2026
f7b225a
fix(infra): safe Buffer-to-Uint8Array in staticFileHandler
flyingrobots Mar 8, 2026
ff53355
fix(test): remove dead code and tighten assertion
flyingrobots Mar 8, 2026
4b51cac
fix(infra): document shared crypto probe state in InMemoryGraphAdapter
flyingrobots Mar 8, 2026
66f69e3
fix(types): resolve tsc errors in readPatchBlob test and WarpServeSer…
flyingrobots Mar 8, 2026
477a211
fix(contracts): add BlobStoragePort and EncryptionError to type-surfa…
flyingrobots Mar 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,9 @@ git warp history --writer alice

# Check graph health, status, and GC metrics
git warp check

# Start WebSocket server for browser viewer
git warp serve [--port 3000] [--host 127.0.0.1] [--static <dir>] [--expose] [--writer-id <id>]
```

### Time-Travel (Seek)
Expand Down Expand Up @@ -591,6 +594,12 @@ All commands accept `--repo <path>` to target a specific Git repository, `--json
<img src="docs/seek-demo.gif" alt="git warp seek time-travel demo" width="600">
</p>

### Git WARP Inspector

The [Git WARP Inspector](https://github.com/git-stunts/git-warp-web-inspector) is an interactive browser-based graph viewer that connects to a live `git warp serve` instance over WebSocket. It renders graphs using ELK layout, supports time-travel via seek, and shows real-time diffs as the graph changes.

See the [git-warp-web-inspector](https://github.com/git-stunts/git-warp-web-inspector) repository for setup and development instructions.

## Architecture

```mermaid
Expand Down
45 changes: 40 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ROADMAP — @git-stunts/git-warp

> **Current version:** v13.0.0
> **Current version:** v14.0.0
> **Last reconciled:** 2026-03-04 (priority triage: 45 standalone items sorted into P0–P6 tiers, wave-based execution order, dependency chains mapped)
> **Completed milestones:** [docs/ROADMAP/COMPLETED.md](docs/ROADMAP/COMPLETED.md)

Expand Down Expand Up @@ -189,10 +189,13 @@ No dependencies. Do these first.

### P1 — Correctness & Test Infrastructure

B36 and B37 improve velocity for all future test work — do them early. B19 + B22 batch as one PR (Conformance Property Pack).
B36 and B37 improve velocity for all future test work — do them early. B19 + B22 batch as one PR (Conformance Property Pack). B165 and B167 completed (Defensive Hardening Sprint); B166 remains.

| ID | Item | Effort |
|----|------|--------|
| B165 | ✅ **WARPSERVESERVICE `listen()` DEFERRED STATE MUTATION** — `listen()` now defers `_server` assignment and subscription registration until bind succeeds; on failure, cleans up subscriptions. `_onConnection` catch now sends generic `"Internal error"` instead of raw `err.message`. **File:** `src/domain/services/WarpServeService.js` | S |
| B166 | **ADAPTER CLEANUP CONTRACTS** — `NodeWsAdapter.close()` doesn't reset `state.wss`/`state.httpServer`/remove listeners after shutdown; `listenWithHttp` error path leaks partial state. **File:** `src/infrastructure/adapters/NodeWsAdapter.js` | M |
| B167 | ✅ **SERVE TEST COVERAGE GAPS** — Added tests for: listen-failure cleanup (leaked subscriptions), double-listen guard, error sanitization (no internal detail leakage), `attachContent`/`attachEdgeContent` smoke tests through mutation pipeline. **File:** `test/unit/domain/services/WarpServeService.test.js` | S |
| B36 | **FLUENT STATE BUILDER FOR TESTS** — `StateBuilder` helper replacing manual `WarpStateV5` literals | M |
| B37 | **SHARED MOCK PERSISTENCE FIXTURE** — dedup `createMockPersistence()` across trust test files | S |
| B48 | **ESLINT BAN `= {}` CONSTRUCTOR DEFAULTS WITH REQUIRED PARAMS** — catches the pattern where `= {}` silently makes required options optional at the type level (found in CommitDagTraversalService, DagTraversal, DagPathFinding, DagTopology, BitmapIndexReader) | S |
Expand Down Expand Up @@ -250,6 +253,7 @@ No hard dependencies. Pick up opportunistically after P2.
|----|------|--------|
| B155 | **`levels()` AS LIGHTWEIGHT `--view` LAYOUT** — `levels()` is exactly the Y-axis assignment a layered DAG layout needs. For simple DAGs, `levels()` + left-to-right X sweep could produce clean layouts without the 2.5MB ELK import. Offer `--view --layout=levels` as an instant rendering mode, reserving ELK for complex graphs. **Files:** `src/visualization/layouts/`, `bin/cli/commands/view.js` | M |
| B156 | **STRUCTURAL DIFF VIA TRANSITIVE REDUCTION** — compute `transitiveReduction(stateA)` vs `transitiveReduction(stateB)` to produce a compact structural diff that strips implied edges and shows only "load-bearing" changes. Natural fit for H1 (Time-Travel Delta Engine) as `warp diff --mode=structural`. | L |
| B157 | ✅ **BROWSER COMPATIBILITY (Phase 1-3)** — Make `InMemoryGraphAdapter` and `defaultCrypto` browser-safe by lazy-loading `node:crypto`/`node:stream`. New `sha1sync` utility for browser content addressing. New `browser.js` entry point and `./browser`+`./sha1sync` package exports. | M |

### P6 — Documentation & Process

Expand All @@ -267,6 +271,20 @@ Low urgency. Fold into PRs that already touch related files.
| B129 | **CONTRIBUTOR REVIEW-LOOP HYGIENE GUIDE** — add section to `CONTRIBUTING.md` covering commit sizing, CodeRabbit cooldown strategy, and when to request bot review. From BACKLOG 2026-02-27. | S |
| B147 | **RFC FIELD COUNT DRIFT DETECTOR** — script that counts WarpGraph instance fields (grep `this._` in constructor) and warns if design RFC field counts diverge. Prevents stale numbers in `warpgraph-decomposition.md`. From B145 PR review. **Depends on:** B143 RFC (exists) | S |

### P7 — git-cas Modernization

Upgrade from `@git-stunts/git-cas` v3.0.0 to v5.2.4 and leverage new capabilities. Currently git-warp only uses git-cas for the seek cache (`CasSeekCacheAdapter`). The v4.x/v5.x releases add ObservabilityPort, streaming restore, CDC chunking (98.4% chunk reuse on edits), envelope encryption (DEK/KEK), and key rotation.

| ID | Item | Effort |
|----|------|--------|
| B158 | ✅ **UPGRADE `@git-stunts/git-cas` TO v5** — bumped `^3.0.0` → `^5.2.4`. 4872 tests pass, zero regressions. | S |
| B159 | ✅ **CDC CHUNKING FOR SEEK CACHE** — `CasSeekCacheAdapter._initCas()` now constructs CAS with `chunking: { strategy: 'cdc' }`. ~98% chunk reuse on incremental snapshots. | S |
| B160 | ✅ **BLOB ATTACHMENTS VIA CAS** — New `BlobStoragePort` + `CasBlobAdapter` provide a hexagonal abstraction for content blob storage. `PatchBuilderV2.attachContent()`/`attachEdgeContent()` use CAS (chunked, CDC-deduped, optionally encrypted) when `blobStorage` is injected; fall back to raw `persistence.writeBlob()` without it. `getContent()`/`getEdgeContent()` retrieve via `blobStorage.retrieve()` with automatic fallback to raw Git blobs for pre-CAS content. Wired through `WarpGraph`, `Writer`, and all patch creation paths. 16 new tests (4909 total). | M |
| B161 | ✅ **ENCRYPTED SEEK CACHE** — `CasSeekCacheAdapter` now accepts optional `encryptionKey` constructor param. When set, all `store()` and `restore()` calls pass the key to git-cas for AES-256-GCM encryption/decryption. 6 new tests (52 total). | S |
| B162 | ✅ **OBSERVABILITY ALIGNMENT** — new `LoggerObservabilityBridge` adapter translates git-cas `ObservabilityPort` calls (metric, log, span) into git-warp `LoggerPort` calls. `CasSeekCacheAdapter` accepts optional `logger` param; when provided, CAS operations surface through git-warp's structured logging. 7 new bridge tests + 2 adapter tests. | M |
| B163 | ✅ **STREAMING RESTORE FOR LARGE STATES** — `CasSeekCacheAdapter.get()` now prefers `cas.restoreStream()` (git-cas v4+) for I/O pipelining, accumulating chunks via async iterator. Falls back to `cas.restore()` for older git-cas. 2 new tests (58 total). | M |
| B164 | ✅ **GRAPH ENCRYPTION AT REST** — New `patchBlobStorage` option on `WarpGraph.open()`. When a `BlobStoragePort` (e.g. `CasBlobAdapter` with encryption key) is injected, patch CBOR is written/read via CAS instead of raw Git blobs. `eg-encrypted: true` trailer marks encrypted commits. All 6 read sites + write path threaded. `EncryptionError` thrown when reading encrypted patches without key. Mixed encrypted/plain patches supported via backward-compatible fallback. 14 new tests (4969 total). | L |

### Uncategorized / Platform

| ID | Item | Effort |
Expand Down Expand Up @@ -352,6 +370,16 @@ Internal chain: B97 (P0, Wave 1) → B85 → B57. B123 is the largest — may sp
18. **B156** — structural diff (if H1 is in play)
19. Docs/process items (B34, B35, B76, B79, B102–B104, B129, B147) folded into related PRs

#### Wave 7: git-cas Modernization (P7)

20. **B158** — upgrade `@git-stunts/git-cas` to v5 (unblocks all P7 items)
21. **B159** — CDC chunking for seek cache (quick win after B158)
22. **B161** — encrypted seek cache
23. **B160** — blob attachments via CAS
24. **B162** — observability alignment
25. **B163** — streaming restore for large states
26. **B164** — graph encryption at rest (largest, last)

### Dependency Chains

```text
Expand All @@ -366,6 +394,13 @@ B154 (P0) ─────┘ adjList dedup (quick fix)
B151 (P4) ──→ B152 (P4) closure streaming → full async generator API

B36 (P1) ──→ (improves velocity for B99, B19, B22, future tests)

B158 (P7) ──→ B159 (P7) CDC seek cache
├──→ B160 (P7) blob attachments
├──→ B161 (P7) encrypted seek cache
├──→ B162 (P7) observability alignment
├──→ B163 (P7) streaming restore
└──→ B164 (P7) graph encryption at rest
```

---
Expand All @@ -381,11 +416,11 @@ B36 (P1) ──→ (improves velocity for B99, B19, B22, future tests)
| **Milestone (M12)** | 18 | B66, B67, B70, B73, B75, B105–B115, B117, B118 |
| **Milestone (M13)** | 1 | B116 (internal: DONE; wire-format: DEFERRED) |
| **Milestone (M14)** | 16 | B130–B145 |
| **Standalone** | 45 | B12, B19, B22, B28, B34–B37, B43, B48, B49, B53, B54, B57, B76, B79–B81, B83, B85–B88, B95–B99, B102–B104, B119, B123, B127–B129, B147, B149–B156 |
| **Standalone (done)** | 29 | B26, B44, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B100, B120–B122, B124, B125, B126, B146, B148 |
| **Standalone** | 46 | B12, B19, B22, B28, B34–B37, B43, B48, B49, B53, B54, B57, B76, B79–B81, B83, B85–B88, B95–B99, B102–B104, B119, B123, B127–B129, B147, B149–B156, B166 |
| **Standalone (done)** | 39 | B26, B44, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B100, B120–B122, B124, B125, B126, B146, B148, B157, B158, B159, B160, B161, B162, B163, B164, B165, B167 |
| **Deferred** | 7 | B4, B7, B16, B20, B21, B27, B101 |
| **Rejected** | 7 | B5, B6, B13, B17, B18, B25, B45 |
| **Total tracked** | **133** total; 29 standalone done | |
| **Total tracked** | **144** total; 39 standalone done | |

### STANK.md Cross-Reference

Expand Down
2 changes: 2 additions & 0 deletions bin/cli/commands/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import handleTrust from './trust.js';
import handlePatch from './patch.js';
import handleTree from './tree.js';
import handleBisect from './bisect.js';
import handleServe from './serve.js';

/** @type {Map<string, Function>} */
export const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
Expand All @@ -35,4 +36,5 @@ export const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
['bisect', handleBisect],
['view', handleView],
['install-hooks', handleInstallHooks],
['serve', handleServe],
]));
3 changes: 2 additions & 1 deletion bin/cli/commands/seek.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { summarizeOps } from '../../../src/visualization/renderers/ascii/history.js';
import { diffStates } from '../../../src/domain/services/StateDiff.js';
import { textEncode } from '../../../src/domain/utils/bytes.js';
import {
buildCursorActiveRef,
buildCursorSavedRef,
Expand Down Expand Up @@ -68,7 +69,7 @@ async function readSavedCursor(persistence, graphName, name) {
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'));
const oid = await persistence.writeBlob(textEncode(json));
await persistence.updateRef(ref, oid);
}

Expand Down
208 changes: 208 additions & 0 deletions bin/cli/commands/serve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import process from 'node:process';
import { resolve } from 'node:path';
import { stat } from 'node:fs/promises';
import { parseCommandArgs, usageError, notFoundError } from '../infrastructure.js';
import { serveSchema } from '../schemas.js';
import { createPersistence, listGraphNames } from '../shared.js';
import WarpGraph from '../../../src/domain/WarpGraph.js';
import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.js';
import WarpServeService from '../../../src/domain/services/WarpServeService.js';

/**
* Creates the appropriate WebSocket adapter for the current runtime.
*
* @param {string|null} [staticDir]
* @returns {Promise<import('../../../src/ports/WebSocketServerPort.js').default>}
*/
async function createWsAdapter(staticDir) {
const opts = staticDir ? { staticDir } : {};
if (globalThis.Bun) {
const { default: BunWsAdapter } = await import(
'../../../src/infrastructure/adapters/BunWsAdapter.js'
);
return new BunWsAdapter(opts);
}
if (globalThis.Deno) {
const { default: DenoWsAdapter } = await import(
'../../../src/infrastructure/adapters/DenoWsAdapter.js'
);
return new DenoWsAdapter(opts);
}
const { default: NodeWsAdapter } = await import(
'../../../src/infrastructure/adapters/NodeWsAdapter.js'
);
return new NodeWsAdapter(opts);
}

/**
* Returns true when the host string resolves to the loopback interface.
*
* Wildcard addresses (`0.0.0.0`, `::`, `0:0:0:0:0:0:0:0`) bind to ALL
* interfaces — including public ones — and are intentionally NOT treated
* as loopback. {@link assertExposeSafety} will require `--expose` for them.
*
* @param {string} h
* @returns {boolean}
*/
function isLoopback(h) {
return h === '127.0.0.1' || h === '::1' || h === 'localhost' || h.startsWith('127.');
}

/** @typedef {import('../types.js').CliOptions} CliOptions */

const SERVE_OPTIONS = {
port: { type: 'string', default: '3000' },
host: { type: 'string', default: '127.0.0.1' },
static: { type: 'string' },
expose: { type: 'boolean', default: false },
'writer-id': { type: 'string' },
};

/**
* Opens WarpGraph instances for the specified graph names.
*
* @param {import('../types.js').Persistence} persistence
* @param {string[]} graphNames
* @param {string} writerId
* @returns {Promise<Array<import('../../../src/domain/WarpGraph.js').default>>}
*/
async function openGraphs(persistence, graphNames, writerId) {
const graphs = [];
for (const graphName of graphNames) {
const graph = await WarpGraph.open({
persistence: /** @type {import('../../../src/domain/types/WarpPersistence.js').CorePersistence} */ (/** @type {unknown} */ (persistence)),
graphName,
writerId,
crypto: new WebCryptoAdapter(),
});
graphs.push(graph);
}
return graphs;
}

/**
* Resolve and validate the `--static` directory, if provided.
*
* @param {string|undefined} raw
* @returns {Promise<string|null>}
*/
async function resolveStaticDir(raw) {
if (!raw) {
return null;
}
const dir = resolve(raw);
const st = await stat(dir).catch(() => null);
if (!st || !st.isDirectory()) {
throw usageError(`--static path is not a directory: ${raw}`);
}
return dir;
}

/**
* Determine which graphs to serve and validate the selection.
*
* @param {import('../types.js').Persistence} persistence
* @param {string|null} [graphOption]
* @returns {Promise<string[]>}
*/
async function resolveTargetGraphs(persistence, graphOption) {
const graphNames = await listGraphNames(persistence);
if (graphNames.length === 0) {
throw usageError('No WARP graphs found in this repository');
}
if (graphOption && !graphNames.includes(graphOption)) {
throw notFoundError(`Graph not found: ${graphOption}`);
}
return graphOption ? [graphOption] : graphNames;
}

/**
* Build a unique writerId from the host and requested port.
* When port is 0 the OS assigns an ephemeral port, so a timestamp
* component prevents collisions across successive invocations.
*
* @param {string} host
* @param {number} port
* @returns {string}
*/
function deriveWriterId(host, port) {
const portLabel = port === 0
? `ephemeral-${Date.now().toString(36)}-${process.pid}`
: String(port);
return `serve-${host}-${portLabel}`.replace(/[^A-Za-z0-9._-]/g, '-');
}

/**
* Bracket an IPv6 host for use in URLs.
*
* @param {string} h
* @returns {string}
*/
function bracketHost(h) {
return h.includes(':') ? `[${h}]` : h;
}

/**
* Guards against binding to a non-loopback address without --expose.
*
* @param {string} host
* @param {boolean} expose
*/
function assertExposeSafety(host, expose) {
if (!isLoopback(host) && !expose) {
throw usageError(
`Binding to non-loopback address '${host}' exposes the server to the network. ` +
'Pass --expose to confirm this is intentional.',
);
}
}

/**
* Logs startup information to stderr.
*
* @param {{url: string, targetGraphs: string[], staticDir: string|null, urlHost: string, port: number}} info
*/
function logStartup({ url, targetGraphs, staticDir, urlHost, port }) {
process.stderr.write(`Listening on ${url}\n`);
process.stderr.write(`Serving graph(s): ${targetGraphs.join(', ')}\n`);
if (staticDir) {
process.stderr.write(`Serving static files from ${staticDir}\n`);
process.stderr.write(`Open http://${urlHost}:${port} in your browser\n`);
}
}

/**
* Handles the `serve` command: starts a WebSocket server exposing
* graph(s) in the repository for browser-based viewing and mutation.
*
* @param {{options: CliOptions, args: string[]}} params
* @returns {Promise<{payload: {url: string, host: string, port: number, graphs: string[]}, close: () => Promise<void>}>}
*/
export default async function handleServe({ options, args }) {
const { values } = parseCommandArgs(args, SERVE_OPTIONS, serveSchema, { allowPositionals: false });
const { port, host, expose, writerId: explicitWriterId } = values;
assertExposeSafety(host, expose);

const staticDir = await resolveStaticDir(values.static);
const { persistence } = await createPersistence(options.repo);
const targetGraphs = await resolveTargetGraphs(persistence, options.graph);

const writerId = explicitWriterId || deriveWriterId(host, port);
const graphs = await openGraphs(persistence, targetGraphs, writerId);
const wsPort = await createWsAdapter(staticDir);
const service = new WarpServeService({ wsPort, graphs });
const addr = await service.listen(port, host);

const urlHost = bracketHost(addr.host);
const url = `ws://${urlHost}:${addr.port}`;
logStartup({ url, targetGraphs, staticDir, urlHost, port: addr.port });

return {
payload: { url, host: addr.host, port: addr.port, graphs: targetGraphs },
// WarpServeService.close() unsubscribes all graph subscriptions and
// shuts down the WebSocket server. WarpGraph/GitGraphAdapter hold no
// long-lived resources beyond in-memory state, so process exit is
// sufficient for their cleanup.
close: () => service.close(),
};
}
8 changes: 7 additions & 1 deletion bin/cli/infrastructure.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ Commands:
patch Decode and inspect raw patches
tree ASCII tree traversal from root nodes
bisect Binary search for first bad patch in writer history
serve Start WebSocket server for browser-based graph viewer
--port <n> Port to bind (default: 3000, 0 = OS-assigned)
--host <addr> Bind address (default: 127.0.0.1)
--expose Allow binding to non-loopback addresses
--static <dir> Serve static files (SPA) on the same port
--writer-id <id> Explicit writer identity (default: derived from host:port)
view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
install-hooks Install post-merge git hook

Expand Down Expand Up @@ -154,7 +160,7 @@ export function notFoundError(message) {
return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
}

export const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'doctor', 'materialize', 'seek', 'verify-audit', 'verify-index', 'reindex', 'trust', 'patch', 'tree', 'bisect', 'install-hooks', 'view'];
export const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'doctor', 'materialize', 'seek', 'verify-audit', 'verify-index', 'reindex', 'trust', 'patch', 'tree', 'bisect', 'install-hooks', 'serve', 'view'];

const BASE_OPTIONS = {
repo: { type: 'string', short: 'r' },
Expand Down
Loading
Loading