diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce917543..65a3ca4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,8 @@ jobs: node-version: '22' cache: 'npm' - run: npm ci + - name: 'Gate 5a: v19 capability contract drift' + run: npm run check:capabilities - name: 'Gate 5b: Install pinned Wesley generator' run: >- cargo install diff --git a/README.md b/README.md index efa875e0..a589f195 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,10 @@ under `diagnostics`. Support-plan and host machinery stay behind the runtime. - Missing support should produce an honest receipt status, not a silent whole-history materialization. -Create a missing basis with `git warp checkpoint create`, or ask git-cas to -reconcile retained materializations with -`git warp doctor --repair-materialization-cache`. +Prepare a missing bounded basis with +`git warp repair --lane --action materialization`. Inspect the local +Runtime boundary with `git warp doctor --lane `; physical cache repair +remains owned by git-cas.

For the Nerds™: Optics

diff --git a/bin/cli/capabilities/V19CapabilityContract.generated.ts b/bin/cli/capabilities/V19CapabilityContract.generated.ts new file mode 100644 index 00000000..35bfea10 --- /dev/null +++ b/bin/cli/capabilities/V19CapabilityContract.generated.ts @@ -0,0 +1,296 @@ +/* @generated from v19-capabilities.json. Do not edit by hand. */ + +export const V19_CAPABILITY_CONTRACT = { + "version": "git-warp.capabilities/v19", + "cli": [ + { + "command": "write", + "summary": "Write one Intent to a Lane.", + "usage": "git warp write --lane [--strand ] --intent " + }, + { + "command": "observe", + "summary": "Run one bounded Observer over a Lane.", + "usage": "git warp observe --lane [--strand ] --observer --reading " + }, + { + "command": "fork", + "summary": "Fork a Lane into a named strand Lane.", + "usage": "git warp fork --lane --name " + }, + { + "command": "settle", + "summary": "Preview or apply an immutable Settlement plan.", + "usage": "git warp settle preview --source --strand --target | settle apply --plan " + }, + { + "command": "receipt", + "summary": "Render a canonical Receipt envelope.", + "usage": "git warp receipt show --input " + }, + { + "command": "doctor", + "summary": "Diagnose the local Runtime and Lane.", + "usage": "git warp doctor --lane " + }, + { + "command": "repair", + "summary": "Apply one explicit Runtime repair.", + "usage": "git warp repair --lane --action materialization" + }, + { + "command": "audit", + "summary": "Verify the local Runtime audit trail.", + "usage": "git warp audit --lane [--writer ]" + }, + { + "command": "mcp", + "summary": "Serve the v19 capabilities over MCP stdio.", + "usage": "git warp mcp --repo --writer " + } + ], + "mcp": [ + { + "name": "warp_lane_describe", + "description": "Describe one Runtime Lane.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { + "type": "string", + "minLength": 1 + }, + "strand": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "lane" + ], + "additionalProperties": false + } + }, + { + "name": "warp_intent_write", + "description": "Write one validated Intent to a Lane and return its canonical Receipt.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { + "type": "string", + "minLength": 1 + }, + "strand": { + "type": "string", + "minLength": 1 + }, + "intent": { + "type": "object" + } + }, + "required": [ + "lane", + "intent" + ], + "additionalProperties": false + } + }, + { + "name": "warp_observation_start", + "description": "Start one bounded Observation and retain its Readings for transport.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { + "type": "string", + "minLength": 1 + }, + "strand": { + "type": "string", + "minLength": 1 + }, + "observerId": { + "type": "string", + "minLength": 1 + }, + "reading": { + "type": "object" + } + }, + "required": [ + "lane", + "observerId", + "reading" + ], + "additionalProperties": false + } + }, + { + "name": "warp_observation_read", + "description": "Read the next bounded transport batch from an Observation.", + "inputSchema": { + "type": "object", + "properties": { + "observationId": { + "type": "string", + "minLength": 1 + }, + "cursor": { + "type": "string", + "minLength": 1 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 256 + } + }, + "required": [ + "observationId" + ], + "additionalProperties": false + } + }, + { + "name": "warp_observation_cancel", + "description": "Cancel and discard one retained Observation transport.", + "inputSchema": { + "type": "object", + "properties": { + "observationId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "observationId" + ], + "additionalProperties": false + } + }, + { + "name": "warp_receipt_get", + "description": "Return one canonical Receipt retained by this MCP server.", + "inputSchema": { + "type": "object", + "properties": { + "receiptRef": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "receiptRef" + ], + "additionalProperties": false + } + }, + { + "name": "warp_settlement_preview", + "description": "Preview an immutable Settlement plan between two Lanes.", + "inputSchema": { + "type": "object", + "properties": { + "sourceLane": { + "type": "string", + "minLength": 1 + }, + "sourceStrand": { + "type": "string", + "minLength": 1 + }, + "targetLane": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "sourceLane", + "sourceStrand", + "targetLane" + ], + "additionalProperties": false + } + }, + { + "name": "warp_settlement_apply", + "description": "Revalidate and apply one retained immutable Settlement plan.", + "inputSchema": { + "type": "object", + "properties": { + "planRef": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "planRef" + ], + "additionalProperties": false + } + }, + { + "name": "warp_doctor", + "description": "Diagnose one local Runtime Lane.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "lane" + ], + "additionalProperties": false + } + }, + { + "name": "warp_repair", + "description": "Apply one explicit local Runtime repair.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": "string", + "enum": [ + "materialization" + ] + } + }, + "required": [ + "lane", + "action" + ], + "additionalProperties": false + } + }, + { + "name": "warp_audit", + "description": "Verify the local Runtime audit trail.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { + "type": "string", + "minLength": 1 + }, + "writer": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "lane" + ], + "additionalProperties": false + } + } + ] +} as const; diff --git a/bin/cli/capabilities/v19-capabilities.json b/bin/cli/capabilities/v19-capabilities.json new file mode 100644 index 00000000..104f1071 --- /dev/null +++ b/bin/cli/capabilities/v19-capabilities.json @@ -0,0 +1,196 @@ +{ + "version": "git-warp.capabilities/v19", + "cli": [ + { + "command": "write", + "summary": "Write one Intent to a Lane.", + "usage": "git warp write --lane [--strand ] --intent " + }, + { + "command": "observe", + "summary": "Run one bounded Observer over a Lane.", + "usage": "git warp observe --lane [--strand ] --observer --reading " + }, + { + "command": "fork", + "summary": "Fork a Lane into a named strand Lane.", + "usage": "git warp fork --lane --name " + }, + { + "command": "settle", + "summary": "Preview or apply an immutable Settlement plan.", + "usage": "git warp settle preview --source --strand --target | settle apply --plan " + }, + { + "command": "receipt", + "summary": "Render a canonical Receipt envelope.", + "usage": "git warp receipt show --input " + }, + { + "command": "doctor", + "summary": "Diagnose the local Runtime and Lane.", + "usage": "git warp doctor --lane " + }, + { + "command": "repair", + "summary": "Apply one explicit Runtime repair.", + "usage": "git warp repair --lane --action materialization" + }, + { + "command": "audit", + "summary": "Verify the local Runtime audit trail.", + "usage": "git warp audit --lane [--writer ]" + }, + { + "command": "mcp", + "summary": "Serve the v19 capabilities over MCP stdio.", + "usage": "git warp mcp --repo --writer " + } + ], + "mcp": [ + { + "name": "warp_lane_describe", + "description": "Describe one Runtime Lane.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { "type": "string", "minLength": 1 }, + "strand": { "type": "string", "minLength": 1 } + }, + "required": ["lane"], + "additionalProperties": false + } + }, + { + "name": "warp_intent_write", + "description": "Write one validated Intent to a Lane and return its canonical Receipt.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { "type": "string", "minLength": 1 }, + "strand": { "type": "string", "minLength": 1 }, + "intent": { "type": "object" } + }, + "required": ["lane", "intent"], + "additionalProperties": false + } + }, + { + "name": "warp_observation_start", + "description": "Start one bounded Observation and retain its Readings for transport.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { "type": "string", "minLength": 1 }, + "strand": { "type": "string", "minLength": 1 }, + "observerId": { "type": "string", "minLength": 1 }, + "reading": { "type": "object" } + }, + "required": ["lane", "observerId", "reading"], + "additionalProperties": false + } + }, + { + "name": "warp_observation_read", + "description": "Read the next bounded transport batch from an Observation.", + "inputSchema": { + "type": "object", + "properties": { + "observationId": { "type": "string", "minLength": 1 }, + "cursor": { "type": "string", "minLength": 1 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 256 } + }, + "required": ["observationId"], + "additionalProperties": false + } + }, + { + "name": "warp_observation_cancel", + "description": "Cancel and discard one retained Observation transport.", + "inputSchema": { + "type": "object", + "properties": { + "observationId": { "type": "string", "minLength": 1 } + }, + "required": ["observationId"], + "additionalProperties": false + } + }, + { + "name": "warp_receipt_get", + "description": "Return one canonical Receipt retained by this MCP server.", + "inputSchema": { + "type": "object", + "properties": { + "receiptRef": { "type": "string", "minLength": 1 } + }, + "required": ["receiptRef"], + "additionalProperties": false + } + }, + { + "name": "warp_settlement_preview", + "description": "Preview an immutable Settlement plan between two Lanes.", + "inputSchema": { + "type": "object", + "properties": { + "sourceLane": { "type": "string", "minLength": 1 }, + "sourceStrand": { "type": "string", "minLength": 1 }, + "targetLane": { "type": "string", "minLength": 1 } + }, + "required": ["sourceLane", "sourceStrand", "targetLane"], + "additionalProperties": false + } + }, + { + "name": "warp_settlement_apply", + "description": "Revalidate and apply one retained immutable Settlement plan.", + "inputSchema": { + "type": "object", + "properties": { + "planRef": { "type": "string", "minLength": 1 } + }, + "required": ["planRef"], + "additionalProperties": false + } + }, + { + "name": "warp_doctor", + "description": "Diagnose one local Runtime Lane.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { "type": "string", "minLength": 1 } + }, + "required": ["lane"], + "additionalProperties": false + } + }, + { + "name": "warp_repair", + "description": "Apply one explicit local Runtime repair.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { "type": "string", "minLength": 1 }, + "action": { "type": "string", "enum": ["materialization"] } + }, + "required": ["lane", "action"], + "additionalProperties": false + } + }, + { + "name": "warp_audit", + "description": "Verify the local Runtime audit trail.", + "inputSchema": { + "type": "object", + "properties": { + "lane": { "type": "string", "minLength": 1 }, + "writer": { "type": "string", "minLength": 1 } + }, + "required": ["lane"], + "additionalProperties": false + } + } + ] +} diff --git a/bin/cli/commands/MaterializationRepair.ts b/bin/cli/commands/MaterializationRepair.ts new file mode 100644 index 00000000..a8e06a18 --- /dev/null +++ b/bin/cli/commands/MaterializationRepair.ts @@ -0,0 +1,24 @@ +import { openGraph } from '../shared.ts'; +import type { CliOptions } from '../types.ts'; + +export type MaterializationRepairPayload = Readonly<{ + readonly type: 'Repair'; + readonly action: 'materialization'; + readonly lane: string; + readonly status: 'completed'; +}>; + +export default async function prepareMaterialization( + options: CliOptions, + lane: string, +): Promise { + const { graph } = await openGraph(options); + await graph.materialize(); + await graph.createCheckpoint(); + return Object.freeze({ + type: 'Repair', + action: 'materialization', + lane, + status: 'completed', + }); +} diff --git a/bin/cli/commands/audit.ts b/bin/cli/commands/audit.ts new file mode 100644 index 00000000..e98c3350 --- /dev/null +++ b/bin/cli/commands/audit.ts @@ -0,0 +1,43 @@ +import type { CliOptions } from '../types.ts'; +import { CliError, usageError } from '../infrastructure.ts'; +import { openRequiredLane, withRuntime } from '../v19/V19Runtime.ts'; +import { stableStringify } from '../../presenters/json.ts'; +import handleSubstrateAudit from './verify-audit.ts'; + +export default async function handleAudit({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise<{ + readonly payload: object; + readonly human: string; + readonly exitCode: number; +}> { + if (args.length > 0) { + throw usageError('audit accepts only global v19 options'); + } + return await withRuntime(options, async (runtime, storage) => { + await openRequiredLane(runtime, options.lane); + const result = await handleSubstrateAudit({ + options, + args: [], + storage, + }); + return { + payload: requireObject(result.payload), + human: stableStringify(result.payload), + exitCode: result.exitCode, + }; + }); +} + +function requireObject(value: unknown): object { + if (value === null || typeof value !== 'object') { + throw new CliError('Audit result must be an object', { + code: 'E_AUDIT_RESULT', + }); + } + return value; +} diff --git a/bin/cli/commands/bisect.ts b/bin/cli/commands/bisect.ts deleted file mode 100644 index d6ac9f0f..00000000 --- a/bin/cli/commands/bisect.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { execSync } from 'node:child_process'; -import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; -import { bisectSchema } from '../schemas.ts'; -import { openGraph } from '../shared.ts'; -import BisectService from '../../../src/domain/services/BisectService.ts'; -import type { CliOptions } from '../types.ts'; - -const BISECT_OPTIONS = { - good: { type: 'string' }, - bad: { type: 'string' }, - test: { type: 'string' }, -}; - -/** Parses bisect-specific CLI arguments from the raw argv slice. */ -function parseBisectArgs(args: string[]): { good: string; bad: string; test: string } { - const { values } = parseCommandArgs(args, BISECT_OPTIONS, bisectSchema); - return values; -} - -/** - * Runs a shell command as the bisect test. - * - * @returns true if the command exits 0 (good), false otherwise (bad) - */ -function runTestCommand(testCmd: string, sha: string, graphName: string): boolean { - try { - execSync(testCmd, { - stdio: 'pipe', - env: { - ...process.env, - WARP_BISECT_SHA: sha, - WARP_BISECT_GRAPH: graphName, - }, - }); - return true; - } catch (err: unknown) { - // Non-zero exit (err.status is a number) → test says "bad" - const asRecord = err as Record; - const statusKey = 'status'; - if (err !== undefined && err !== null && typeof asRecord[statusKey] === 'number') { - return false; - } - // Spawn failure (ENOENT, EACCES, etc.) → rethrow so the user sees the real error - throw err; - } -} - -/** Maps a bisect result to a CLI response payload. */ -function mapBisectResult(result: Record): { payload: unknown; exitCode: number } { - if (result['result'] === 'range-error') { - return { - payload: { error: { code: 'E_BISECT_RANGE', message: result['message'] } }, - exitCode: EXIT_CODES.NOT_FOUND, - }; - } - - const payload = { - result: 'found', - firstBadPatch: result['firstBadPatch'], - writerId: result['writerId'], - lamport: result['lamport'], - steps: result['steps'], - totalCandidates: result['totalCandidates'], - }; - - return { payload, exitCode: EXIT_CODES.OK }; -} - -/** Handles the `bisect` command: binary search over patch history. */ -export default async function handleBisect({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - if (options.writer === 'cli') { - throw usageError('bisect requires --writer '); - } - - const { good, bad, test: testCmd } = parseBisectArgs(args); - const { graph, graphName } = await openGraph(options); - const writerId = options.writer; - - const bisect = new BisectService({ - graph: { - getWriterPatches: (selectedWriterId) => graph.getWriterPatches(selectedWriterId), - materialize: async (opts) => await graph.materialize({ ...opts, receipts: false }), - }, - }); - - const result = await bisect.run({ - good, - bad, - writerId, - testFn: (_state: unknown, sha: string) => Promise.resolve(runTestCommand(testCmd, sha, graphName)), - }); - - return mapBisectResult(result as Record); -} diff --git a/bin/cli/commands/check.ts b/bin/cli/commands/check.ts deleted file mode 100644 index 5d12fc26..00000000 --- a/bin/cli/commands/check.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { buildCheckpointRef, buildCoverageRef } from '../../../src/domain/utils/RefLayout.ts'; -import { EXIT_CODES } from '../infrastructure.ts'; -import { openGraph, applyCursorCeiling, emitCursorWarning, readCheckpointDate, createHookInstaller } from '../shared.ts'; -import type { CliOptions, Persistence, WarpGraphInstance } from '../types.ts'; -import type HookPathPort from '../../../src/ports/HookPathPort.ts'; - -/** Performs a health check on the graph persistence. */ -async function getHealth(persistence: Persistence): Promise<{ status: string; components: { repository: { status: string; latencyMs: number }; index: { status: string; loaded: boolean; shardCount?: number } } }> { - try { - const ping = await persistence.ping(); - const repositoryStatus = ping.ok ? 'healthy' : 'unhealthy'; - return { - status: ping.ok ? 'degraded' : 'unhealthy', - components: { - repository: { - status: repositoryStatus, - latencyMs: Math.round(ping.latencyMs * 100) / 100, - }, - index: { status: 'degraded', loaded: false }, - }, - }; - } catch { - return { - status: 'unhealthy', - components: { - repository: { status: 'unhealthy', latencyMs: 0 }, - index: { status: 'degraded', loaded: false }, - }, - }; - } -} - -/** Collects garbage collection metrics for the graph. */ -async function getGcMetrics(graph: WarpGraphInstance): Promise<{ totalTombstones: number; tombstoneRatio: number } | null> { - await graph.materialize(); - const metrics = graph.getGCMetrics(); - if (metrics === null) { - return null; - } - return { - totalTombstones: metrics.tombstoneCount, - tombstoneRatio: metrics.tombstoneRatio, - }; -} - -/** Collects current head SHAs for all writers in the graph. */ -async function collectWriterHeads(graph: WarpGraphInstance): Promise> { - const frontier = await graph.getFrontier(); - const heads = [...frontier.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([writerId, sha]) => ({ writerId, sha })); - return heads; -} - -/** Loads checkpoint information for a graph. */ -async function loadCheckpointInfo(persistence: Persistence, graphName: string): Promise<{ ref: string; sha: string | null; date: string | null; ageSeconds: number | null }> { - const checkpointRef = buildCheckpointRef(graphName); - const checkpointSha = (await persistence.readRef(checkpointRef)) ?? ''; - const checkpointDate = await readCheckpointDate(persistence, checkpointSha); - const checkpointAgeSeconds = computeAgeSeconds(checkpointDate); - - return { - ref: checkpointRef, - sha: checkpointSha !== '' ? checkpointSha : null, - date: checkpointDate, - ageSeconds: checkpointAgeSeconds, - }; -} - -/** Computes the age in seconds for a ISO date string. */ -function computeAgeSeconds(checkpointDate: string | null): number | null { - if (checkpointDate === null || checkpointDate === undefined || checkpointDate === '') { - return null; - } - const parsed = Date.parse(checkpointDate); - if (Number.isNaN(parsed)) { - return null; - } - return Math.max(0, Math.floor((Date.now() - parsed) / 1000)); -} - -/** Loads coverage information for a graph. */ -async function loadCoverageInfo(persistence: Persistence, graphName: string, writerHeads: Array<{ writerId: string; sha: string }>): Promise<{ ref: string; sha: string | null; missingWriters: string[] }> { - const coverageRef = buildCoverageRef(graphName); - const coverageSha = (await persistence.readRef(coverageRef)) ?? ''; - const missingWriters = coverageSha !== '' - ? await findMissingWriters(persistence, writerHeads, coverageSha) - : []; - - return { - ref: coverageRef, - sha: coverageSha !== '' ? coverageSha : null, - missingWriters: missingWriters.sort(), - }; -} - -/** Identifies writers whose heads are not reachable from the coverage commit. */ -async function findMissingWriters(persistence: Persistence, writerHeads: Array<{ writerId: string; sha: string }>, coverageSha: string): Promise { - const missing: string[] = []; - for (const head of writerHeads) { - const reachable = await persistence.isAncestor(head.sha, coverageSha); - if (!reachable) { - missing.push(head.writerId); - } - } - return missing; -} - -/** Builds the structured payload for the check command result. */ -interface CheckPayloadInput { - readonly repo: string; - readonly graphName: string; - readonly health: unknown; - readonly checkpoint: unknown; - readonly writerHeads: Array<{ writerId: string; sha: string }>; - readonly coverage: unknown; - readonly gcMetrics: unknown; - readonly hook: unknown; - readonly status: unknown; -} - -function buildCheckPayload(input: CheckPayloadInput): Record { - const { repo, graphName, health, checkpoint, writerHeads, coverage, gcMetrics, hook, status } = input; - return { - repo, - graph: graphName, - health, - checkpoint, - writers: { count: writerHeads.length, heads: writerHeads }, - coverage, - gc: gcMetrics, - hook: hook ?? null, - status: status ?? null, - }; -} - -/** Returns the status of WARP git hooks for a repository. */ -async function getHookStatusForCheck(repoPath: string, hookPaths: HookPathPort): Promise<{ installed: boolean; version?: string; current?: boolean; foreign?: boolean; hookPath: string } | null> { - try { - const installer = createHookInstaller(hookPaths); - return await installer.getHookStatus(repoPath); - } catch { - return null; - } -} - -/** Handles the `check` command: reports graph health, GC, and hook status. */ -export async function handleCheck({ options }: { options: CliOptions }): Promise<{ payload: unknown; exitCode: number }> { - const { graph, graphName, persistence, cursorStore, hookPaths } = await openGraph(options); - const cursorInfo = await applyCursorCeiling(graph, cursorStore); - emitCursorWarning(cursorInfo, null); - const health = await getHealth(persistence); - const gcMetrics = await getGcMetrics(graph); - const status = await graph.status(); - const writerHeads = await collectWriterHeads(graph); - const checkpoint = await loadCheckpointInfo(persistence, graphName); - const coverage = await loadCoverageInfo(persistence, graphName, writerHeads); - const hook = await getHookStatusForCheck(options.repo, hookPaths); - - return { - payload: buildCheckPayload({ - repo: options.repo, - graphName, - health, - checkpoint, - writerHeads, - coverage, - gcMetrics, - hook, - status, - }), - exitCode: EXIT_CODES.OK, - }; -} - -export default handleCheck; diff --git a/bin/cli/commands/checkpoint.ts b/bin/cli/commands/checkpoint.ts deleted file mode 100644 index a7aec4cf..00000000 --- a/bin/cli/commands/checkpoint.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; -import { openGraph, readActiveCursor, readCheckpointDate } from '../shared.ts'; -import type { CliOptions } from '../types.ts'; - -const CHECKPOINT_OPTIONS = {}; - -const checkpointSchema = z.object({}).strict(); - -function checkpointUsage(): never { - throw usageError('Usage: warp-graph checkpoint '); -} - -type CheckpointPayload = - | { graph: string; checkpoint: string | null; date: string | null } - | { graph: string; checkpoint: string; status: 'created' } - | { graph: string; status: 'coverage-synced' }; - -async function assertNoActiveCursor( - cursorStore: Awaited>['cursorStore'], -): Promise { - const cursor = await readActiveCursor(cursorStore); - if (cursor !== null) { - throw usageError('checkpoint create refuses to run while seek cursor is active; run git warp seek --latest first'); - } -} - -async function checkpointStatus(options: CliOptions): Promise<{ payload: CheckpointPayload; exitCode: number }> { - const { graph, graphName, persistence } = await openGraph(options); - const checkpoint = await graph._readCheckpointSha(); - const date = await readCheckpointDate(persistence, checkpoint); - return { - payload: { graph: graphName, checkpoint, date }, - exitCode: EXIT_CODES.OK, - }; -} - -async function createCheckpoint(options: CliOptions): Promise<{ payload: CheckpointPayload; exitCode: number }> { - const { graph, graphName, cursorStore } = await openGraph(options); - await assertNoActiveCursor(cursorStore); - await graph.materialize(); - const checkpoint = await graph.createCheckpoint(); - return { - payload: { graph: graphName, checkpoint, status: 'created' }, - exitCode: EXIT_CODES.OK, - }; -} - -async function syncCoverage(options: CliOptions): Promise<{ payload: CheckpointPayload; exitCode: number }> { - const { graph, graphName } = await openGraph(options); - await graph.syncCoverage(); - return { - payload: { graph: graphName, status: 'coverage-synced' }, - exitCode: EXIT_CODES.OK, - }; -} - -export default async function handleCheckpoint( - { options, args }: { options: CliOptions; args: string[] }, -): Promise<{ payload: CheckpointPayload; exitCode: number }> { - const action = args[0] ?? 'status'; - const rest = args.slice(1); - parseCommandArgs(rest, CHECKPOINT_OPTIONS, checkpointSchema); - - if (action === 'status') { return await checkpointStatus(options); } - if (action === 'create') { return await createCheckpoint(options); } - if (action === 'sync-coverage') { return await syncCoverage(options); } - return checkpointUsage(); -} diff --git a/bin/cli/commands/debug.ts b/bin/cli/commands/debug.ts deleted file mode 100644 index 7ce68674..00000000 --- a/bin/cli/commands/debug.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { usageError } from '../infrastructure.ts'; - -import * as coordinateTopic from './debug/coordinate.ts'; -import * as conflictsTopic from './debug/conflicts.ts'; -import * as provenanceTopic from './debug/provenance.ts'; -import * as receiptsTopic from './debug/receipts.ts'; -import * as timelineTopic from './debug/timeline.ts'; -import type { CliOptions } from '../types.ts'; - -type DebugTopicModule = { - DEBUG_TOPIC: { name: string; summary: string }; - handleDebugTopic: (params: { options: CliOptions; args: string[] }) => Promise<{ payload: unknown; exitCode: number }>; -}; - -const DEBUG_TOPICS: Record = Object.freeze({ - [coordinateTopic.DEBUG_TOPIC.name]: coordinateTopic, - [conflictsTopic.DEBUG_TOPIC.name]: conflictsTopic, - [provenanceTopic.DEBUG_TOPIC.name]: provenanceTopic, - [receiptsTopic.DEBUG_TOPIC.name]: receiptsTopic, - [timelineTopic.DEBUG_TOPIC.name]: timelineTopic, -}); - -/** Builds the usage text for the debug command listing all available topics. */ -function buildDebugUsage(): string { - const topicLines = Object.values(DEBUG_TOPICS) - .map(({ DEBUG_TOPIC }) => ` ${DEBUG_TOPIC.name.padEnd(12)} ${DEBUG_TOPIC.summary}`); - return [ - 'Usage: warp-graph debug [options]', - ...topicLines, - ].join('\n'); -} - -/** Routes debug subcommands to their respective topic handlers. */ -export default async function handleDebug({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const topicName = args[0]; - const rest = args.slice(1); - - if (topicName === undefined || topicName === null || topicName.length === 0) { - throw usageError(buildDebugUsage()); - } - - const topic = DEBUG_TOPICS[topicName]; - if (!topic) { - throw usageError(`Unknown debug topic: ${topicName}\n${buildDebugUsage()}`); - } - - return await topic.handleDebugTopic({ options, args: rest }); -} diff --git a/bin/cli/commands/debug/conflicts.ts b/bin/cli/commands/debug/conflicts.ts deleted file mode 100644 index 1534bfbc..00000000 --- a/bin/cli/commands/debug/conflicts.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs } from '../../infrastructure.ts'; - -import { openDebugContext, resolveLamportCeiling } from './shared.ts'; -import type { CliOptions, WarpGraphInstance } from '../../types.ts'; - -export const DEBUG_TOPIC = Object.freeze({ - name: 'conflicts', - summary: 'Analyze conflict provenance at the current frontier', -}); - -type ConflictKind = 'supersession' | 'eventual_override' | 'redundancy'; - -const DEBUG_CONFLICT_OPTIONS = { - 'strand': { type: 'string' }, - 'entity-id': { type: 'string' }, - 'target-kind': { type: 'string' }, - 'property-key': { type: 'string' }, - from: { type: 'string' }, - to: { type: 'string' }, - label: { type: 'string' }, - kind: { type: 'string', multiple: true }, - 'writer-id': { type: 'string' }, - 'lamport-ceiling': { type: 'string' }, - evidence: { type: 'string' }, - 'max-patches': { type: 'string' }, -}; - -/** - * Check whether any target-narrowing fields are present without a target-kind. - * Returns true if validation should stop early. - */ -function rejectOrphanTargetFields({ hasTargetField, targetKind, ctx }: { hasTargetField: boolean; targetKind: unknown; ctx: z.RefinementCtx }): boolean { - if (hasTargetField && targetKind === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '--target-kind is required when using --property-key, --from, --to, or --label', - }); - return true; - } - return false; -} - -/** Dispatch target-kind-specific validation to the appropriate sub-validator. */ -function dispatchTargetKindValidation({ targetKind, propertyKey, from, to, label, ctx }: { targetKind: unknown; propertyKey: unknown; from: unknown; to: unknown; label: unknown; ctx: z.RefinementCtx }): void { - if (targetKind === 'node') { - validateNodeSelector({ propertyKey, from, to, label, ctx }); - } else if (targetKind === 'edge') { - validateEdgeSelector({ propertyKey, from, to, label, ctx }); - } else if (targetKind === 'node_property') { - validateNodePropertySelector({ from, to, label, ctx }); - } else if (targetKind === 'edge_property') { - validateEdgePropertySelector({ propertyKey, from, to, label, ctx }); - } -} - -/** Validate the conflict selector shape — ensure target-kind-specific fields are consistent. */ -function validateConflictSelectorShape(val: Record, ctx: z.RefinementCtx): void { - const targetKind = val['target-kind']; - const propertyKey = val['property-key']; - const { from, to, label } = val; - const hasTargetField = propertyKey !== undefined || - from !== undefined || - to !== undefined || - label !== undefined; - - if (rejectOrphanTargetFields({ hasTargetField, targetKind, ctx })) { - return; - } - dispatchTargetKindValidation({ targetKind, propertyKey, from, to, label, ctx }); -} - -/** Validate that node target selectors do not include edge-only fields. */ -function validateNodeSelector({ propertyKey, from, to, label, ctx }: { propertyKey: unknown; from: unknown; to: unknown; label: unknown; ctx: z.RefinementCtx }): void { - if (propertyKey !== undefined || from !== undefined || to !== undefined || label !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'node target selector only supports --entity-id', - }); - } -} - -/** Validate that edge target selectors have required identity fields and no property-key. */ -function validateEdgeSelector({ propertyKey, from, to, label, ctx }: { propertyKey: unknown; from: unknown; to: unknown; label: unknown; ctx: z.RefinementCtx }): void { - if (propertyKey !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'edge target selector does not support --property-key', - }); - } - validateEdgeIdentity({ from, to, label, ctx }); -} - -/** Validate that node_property selectors do not include edge identity fields. */ -function validateNodePropertySelector({ from, to, label, ctx }: { from: unknown; to: unknown; label: unknown; ctx: z.RefinementCtx }): void { - if (from !== undefined || to !== undefined || label !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'node_property target selector only supports --entity-id and --property-key', - }); - } -} - -/** Validate that edge_property selectors require property-key and edge identity fields. */ -function validateEdgePropertySelector({ propertyKey, from, to, label, ctx }: { propertyKey: unknown; from: unknown; to: unknown; label: unknown; ctx: z.RefinementCtx }): void { - if (propertyKey === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '--property-key is required for edge_property target selector', - }); - } - validateEdgeIdentity({ from, to, label, ctx }); -} - -/** Validate that all three edge identity fields (from, to, label) are present. */ -function validateEdgeIdentity({ from, to, label, ctx }: { from: unknown; to: unknown; label: unknown; ctx: z.RefinementCtx }): void { - if (from === undefined || to === undefined || label === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '--from, --to, and --label are required for edge and edge_property target selectors', - }); - } -} - -/** Normalize the kind option into an array of conflict kind strings. */ -function normalizeKinds(kind: ConflictKind | ConflictKind[] | undefined): ConflictKind[] { - if (Array.isArray(kind)) { - return kind; - } - if (kind !== undefined) { - return [kind]; - } - return []; -} - -/** Build a target selector object from raw parsed values, or null if no target-kind given. */ -function buildTargetSelector(val: Record): { targetKind: string; entityId?: string | undefined; propertyKey?: string | undefined; from?: string | undefined; to?: string | undefined; label?: string | undefined } | null { - if (val['target-kind'] === undefined) { - return null; - } - const typed = val as { from?: string; to?: string; label?: string; 'target-kind': string; 'entity-id'?: string; 'property-key'?: string }; - return { - targetKind: typed['target-kind'], - entityId: typed['entity-id'], - propertyKey: typed['property-key'], - from: typed.from, - to: typed.to, - label: typed.label, - }; -} - -/** Return the value if defined, otherwise null. */ -function orNull(value: T | undefined): T | null { - return value === undefined ? null : value; -} - -type ConflictSpec = { - strandId: string | null; - entityId: string | null; - target: { targetKind: 'node' | 'edge' | 'node_property' | 'edge_property'; entityId?: string; propertyKey?: string; from?: string; to?: string; label?: string } | null; - kinds: ConflictKind[]; - writerId: string | null; - lamportCeiling: number | null; - evidence: 'full' | 'summary' | 'standard'; - maxPatches: number | null; -}; - -/** Transform raw parsed conflict CLI values into the internal conflict filter shape. */ -function transformConflictValues(val: Record): ConflictSpec { - const typed = val as { strand?: string; 'entity-id'?: string; kind?: ConflictKind | ConflictKind[]; 'writer-id'?: string; 'lamport-ceiling'?: number; evidence?: string; 'max-patches'?: number }; - return { - strandId: orNull(typed.strand), - entityId: orNull(typed['entity-id']), - target: buildTargetSelector(val) as ConflictSpec['target'], - kinds: normalizeKinds(typed.kind), - writerId: orNull(typed['writer-id']), - lamportCeiling: orNull(typed['lamport-ceiling']), - evidence: (typed.evidence ?? 'standard') as ConflictSpec['evidence'], - maxPatches: orNull(typed['max-patches']), - }; -} - -const debugConflictsSchema = z.object({ - 'strand': z.string().optional(), - 'entity-id': z.string().optional(), - 'target-kind': z.enum(['node', 'edge', 'node_property', 'edge_property']).optional(), - 'property-key': z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), - label: z.string().optional(), - kind: z.union([ - z.enum(['supersession', 'eventual_override', 'redundancy']), - z.array(z.enum(['supersession', 'eventual_override', 'redundancy'])), - ]).optional(), - 'writer-id': z.string().optional(), - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), - evidence: z.enum(['summary', 'standard', 'full']).optional(), - 'max-patches': z.coerce.number().int().positive().optional(), -}).strict().superRefine((val, ctx) => validateConflictSelectorShape(val, ctx)).transform(transformConflictValues); - -/** Spreads a key-value pair only if the value is not null. */ -function spreadNonNull(key: string, value: T | null): Record { - if (value === null) { return {}; } - return { [key]: value }; -} - -/** Build ConflictAnalyzeOptions from the parsed CLI spec and resolved lamport ceiling. */ -function buildConflictAnalyzeOptions( - spec: ConflictSpec, - lamportCeiling: number | null, -): Parameters[0] { - return { - ...spreadNonNull('strandId', spec.strandId), - ...(lamportCeiling !== null ? { at: { lamportCeiling } } : {}), - ...spreadNonNull('entityId', spec.entityId), - ...spreadNonNull('target', spec.target), - ...(spec.kinds.length > 0 ? { kind: spec.kinds } : {}), - ...spreadNonNull('writerId', spec.writerId), - evidence: spec.evidence, - ...(spec.maxPatches !== null ? { scanBudget: { maxPatches: spec.maxPatches } } : {}), - }; -} - -/** Handle the 'conflicts' debug topic — analyze conflict provenance at the current frontier. */ -export async function handleDebugTopic({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values } = parseCommandArgs(args, DEBUG_CONFLICT_OPTIONS, debugConflictsSchema); - const spec = values as unknown as ConflictSpec; - const { graph, graphName, activeCursor } = await openDebugContext(options); - const lamportCeiling = resolveLamportCeiling(spec.lamportCeiling, activeCursor); - const analysis = await graph.analyzeConflicts(buildConflictAnalyzeOptions(spec, lamportCeiling)); - - return { - payload: { - graph: graphName, - debugTopic: 'conflicts', - ...(spec.strandId !== null ? { strandId: spec.strandId } : {}), - ...analysis, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/debug/coordinate.ts b/bin/cli/commands/debug/coordinate.ts deleted file mode 100644 index 89a8d244..00000000 --- a/bin/cli/commands/debug/coordinate.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { z } from 'zod'; - -import { - buildTickReceipt, - computeFrontierHash, - countPatchesAtTick, - countWriterPatchesAtTick, -} from '../../time-travel-shared.ts'; -import { EXIT_CODES, parseCommandArgs } from '../../infrastructure.ts'; - -import { - compareStrings, - materializeForDebug, - openDebugContext, - resolveLamportCeiling, -} from './shared.ts'; -import type { CliOptions, WarpGraphInstance, WriterTickInfo, CursorBlob } from '../../types.ts'; - -export const DEBUG_TOPIC = Object.freeze({ - name: 'coordinate', - summary: 'Inspect the resolved observation coordinate and visible frontier', -}); - -const DEBUG_COORDINATE_OPTIONS = { - 'lamport-ceiling': { type: 'string' }, -}; - -const debugCoordinateSchema = z.object({ - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), -}).strict().transform((val) => ({ - lamportCeiling: val['lamport-ceiling'] ?? null, -})); - -/** Determines how the observation coordinate was resolved. */ -function resolveCoordinateSource({ explicitLamportCeiling, activeCursor }: { explicitLamportCeiling: number | null; activeCursor: CursorBlob | null }): 'explicit' | 'cursor' | 'frontier' { - if (explicitLamportCeiling !== null) { - return 'explicit'; - } - if (activeCursor) { - return 'cursor'; - } - return 'frontier'; -} - -/** Builds a per-writer summary of visible vs total patches at the given tick. */ -function summarizePerWriterCoordinate({ tick, perWriter }: { tick: number; perWriter: Map }): Record { - return Object.fromEntries( - [...perWriter.entries()] - .sort(([a], [b]) => compareStrings(a, b)) - .map(([writerId, info]) => [writerId, { - tipSha: info.tipSha, - totalPatchCount: info.ticks.length, - visiblePatchCount: countWriterPatchesAtTick(tick, info), - }]), - ); -} - -interface ResolvedCoordinateInput { - readonly graph: WarpGraphInstance; - readonly lamportCeiling: number | null; - readonly maxTick: number; - readonly tickCount: number; - readonly perWriter: Map; -} - -interface ResolvedCoordinate { - readonly tick: number; - readonly lamportCeiling: number | null; - readonly maxTick: number; - readonly tickCount: number; - readonly frontierDigest: string; - readonly patchCount: number; - readonly nodes: number; - readonly edges: number; - readonly properties: number; - readonly perWriter: Record; -} - -/** Materializes state at the resolved coordinate and collects summary metrics. */ -async function buildResolvedCoordinate(input: ResolvedCoordinateInput): Promise { - const { graph, lamportCeiling, maxTick, tickCount, perWriter } = input; - const frontierDigest = await computeFrontierHash(perWriter); - await materializeForDebug(graph, { - lamportCeiling, - collectReceipts: false, - }); - - const [nodes, edges, properties] = await Promise.all([ - graph.getNodes(), - graph.getEdges(), - graph.getPropertyCount(), - ]); - - const tick = lamportCeiling ?? maxTick; - - return { - tick, - lamportCeiling, - maxTick, - tickCount, - frontierDigest, - patchCount: countPatchesAtTick(tick, perWriter), - nodes: nodes.length, - edges: edges.length, - properties, - perWriter: summarizePerWriterCoordinate({ tick, perWriter }), - }; -} - -/** Handles the 'coordinate' debug topic — resolves and displays the observation coordinate. */ -export async function handleDebugTopic({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values: rawValues } = parseCommandArgs(args, DEBUG_COORDINATE_OPTIONS, debugCoordinateSchema); - const values = rawValues; - const { graph, graphName, activeCursor } = await openDebugContext(options); - - const lamportCeiling = resolveLamportCeiling(values.lamportCeiling, activeCursor); - const coordinateSource = resolveCoordinateSource({ - explicitLamportCeiling: values.lamportCeiling, - activeCursor, - }); - - const { ticks, maxTick, perWriter } = await graph.discoverTicks(); - const resolvedCoordinate = await buildResolvedCoordinate({ - graph, lamportCeiling, maxTick, tickCount: ticks.length, perWriter, - }); - const tickReceipt = await buildTickReceipt({ tick: resolvedCoordinate.tick, perWriter, graph }); - - return buildCoordinatePayload({ - graphName, coordinateSource, values, activeCursor, resolvedCoordinate, tickReceipt, - }); -} - -/** Assembles the final coordinate payload for CLI output. */ -function buildCoordinatePayload({ graphName, coordinateSource, values, activeCursor, resolvedCoordinate, tickReceipt }: { - graphName: string; - coordinateSource: string; - values: { lamportCeiling: number | null }; - activeCursor: CursorBlob | null; - resolvedCoordinate: ResolvedCoordinate; - tickReceipt: unknown; -}): { payload: unknown; exitCode: number } { - return { - payload: { - graph: graphName, - debugTopic: 'coordinate', - coordinateSource, - requestedLamportCeiling: values.lamportCeiling, - activeCursor: activeCursor - ? { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport' } - : null, - resolvedCoordinate, - tickReceipt, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/debug/provenance.ts b/bin/cli/commands/debug/provenance.ts deleted file mode 100644 index 959eea28..00000000 --- a/bin/cli/commands/debug/provenance.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs } from '../../infrastructure.ts'; - -import { - loadStrandContextForDebug, - materializeForDebug, - openDebugContext, - resolveLamportCeiling, - sortPatchEntriesCausally, - summarizePatchEntries, -} from './shared.ts'; -import type { CliOptions, WarpGraphInstance } from '../../types.ts'; -import type Patch from '../../../../src/domain/types/Patch.ts'; - -export const DEBUG_TOPIC = Object.freeze({ - name: 'provenance', - summary: 'Inspect causal patch provenance for a graph entity', -}); - -const DEBUG_PROVENANCE_OPTIONS = { - 'strand': { type: 'string' }, - 'entity-id': { type: 'string' }, - 'lamport-ceiling': { type: 'string' }, - 'max-patches': { type: 'string' }, -}; - -const debugProvenanceSchema = z.object({ - 'strand': z.string().optional(), - 'entity-id': z.string().min(1, 'Missing value for --entity-id'), - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), - 'max-patches': z.coerce.number().int().positive().optional(), -}).strict().transform((val) => ({ - strandId: val.strand ?? null, - entityId: val['entity-id'], - lamportCeiling: val['lamport-ceiling'] ?? null, - maxPatches: val['max-patches'] ?? null, -})); - -/** Loads patch SHAs for a provenance query, either strand-scoped or graph-wide. */ -async function loadPatchShas(graph: WarpGraphInstance, { strandId, entityId, lamportCeiling }: { strandId: string | null; entityId: string; lamportCeiling: number | null }): Promise { - if (typeof strandId === 'string' && strandId.length > 0) { - return await graph.patchesForStrand( - strandId, - entityId, - lamportCeiling === null ? undefined : { ceiling: lamportCeiling }, - ); - } - await materializeForDebug(graph, { lamportCeiling, collectReceipts: false }); - return await graph.patchesFor(entityId); -} - -/** Loads and sorts patch entries for the given SHAs. */ -async function loadAndSortEntries(graph: WarpGraphInstance, shas: string[]): Promise>> { - const loadedEntries: Array<{ patch: Patch; sha: string }> = await Promise.all( - shas.map(async (sha) => ({ - sha, - patch: await graph.loadPatchBySha(sha), - })), - ); - return summarizePatchEntries(sortPatchEntriesCausally(loadedEntries)); -} - -/** Builds the provenance payload from resolved values and entries. */ -function buildProvenancePayload({ graphName, strandId, strand, entityId, lamportCeiling, entries, maxPatches }: { - graphName: string; - strandId: string | null; - strand: unknown; - entityId: string; - lamportCeiling: number | null; - entries: Array>; - maxPatches: number | null; -}): Record { - const returnedEntries = maxPatches === null ? entries : entries.slice(0, maxPatches); - return { - graph: graphName, - debugTopic: 'provenance', - ...(strandId !== null ? { strandId } : {}), - ...(strand !== null ? { strand } : {}), - entityId, - lamportCeiling, - totalPatches: entries.length, - returnedPatches: returnedEntries.length, - truncated: returnedEntries.length < entries.length, - entries: returnedEntries, - }; -} - -/** Handles the `debug provenance` topic: inspects causal patch provenance for a graph entity. */ -export async function handleDebugTopic({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values: rawValues } = parseCommandArgs(args, DEBUG_PROVENANCE_OPTIONS, debugProvenanceSchema); - const values = rawValues; - const { graph, graphName, activeCursor } = await openDebugContext(options); - const lamportCeiling = resolveLamportCeiling(values.lamportCeiling, activeCursor); - const strandId = typeof values.strandId === 'string' && values.strandId.length > 0 ? values.strandId : null; - const strand = strandId !== null ? await loadStrandContextForDebug(graph, strandId) : null; - const shas = await loadPatchShas(graph, { strandId, entityId: values.entityId, lamportCeiling }); - const entries = await loadAndSortEntries(graph, shas); - - return { - payload: buildProvenancePayload({ graphName, strandId, strand, entityId: values.entityId, lamportCeiling, entries, maxPatches: values.maxPatches }), - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/debug/receipts.ts b/bin/cli/commands/debug/receipts.ts deleted file mode 100644 index 3895415e..00000000 --- a/bin/cli/commands/debug/receipts.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { z } from 'zod'; - -import { OP_TYPES, RESULT_TYPES, type TickReceipt, type OpOutcome } from '../../../../src/domain/types/TickReceipt.ts'; -import { EXIT_CODES, parseCommandArgs } from '../../infrastructure.ts'; - -import { - compareNumbers, - compareStrings, - loadStrandContextForDebug, - matchesShaPrefix, - materializeForDebug, - openDebugContext, - resolveLamportCeiling, -} from './shared.ts'; -import type { CliOptions, WarpGraphInstance } from '../../types.ts'; - -export const DEBUG_TOPIC = Object.freeze({ - name: 'receipts', - summary: 'Inspect reducer tick receipts and per-op outcomes', -}); - -const DEBUG_RECEIPT_OPTIONS = { - 'strand': { type: 'string' }, - 'writer-id': { type: 'string' }, - patch: { type: 'string' }, - target: { type: 'string' }, - result: { type: 'string', multiple: true }, - op: { type: 'string', multiple: true }, - 'lamport-ceiling': { type: 'string' }, - limit: { type: 'string' }, -}; - -/** Return the value if defined, otherwise null. */ -function orNull(value: T | undefined): T | null { - return value === undefined ? null : value; -} - -type ReceiptFilter = { - strandId: string | null; - writerId: string | null; - patch: string | null; - target: string | null; - results: string[]; - opTypes: string[]; - lamportCeiling: number | null; - limit: number | null; -}; - -/** Transform raw parsed CLI values into the internal receipt filter shape. */ -function transformReceiptValues(val: Record): ReceiptFilter { - return { - strandId: orNull(val['strand'] as string | undefined), - writerId: orNull(val['writer-id'] as string | undefined), - patch: orNull(val['patch'] as string | undefined), - target: orNull(val['target'] as string | undefined), - results: normalizeRepeatedOption(val['result'] as string | string[] | undefined), - opTypes: normalizeRepeatedOption(val['op'] as string | string[] | undefined), - lamportCeiling: orNull(val['lamport-ceiling'] as number | undefined), - limit: orNull(val['limit'] as number | undefined), - }; -} - -const debugReceiptsSchema = z.object({ - 'strand': z.string().optional(), - 'writer-id': z.string().optional(), - patch: z.string().optional(), - target: z.string().optional(), - result: z.union([ - z.enum([...RESULT_TYPES] as [string, ...string[]]), - z.array(z.enum([...RESULT_TYPES] as [string, ...string[]])), - ]).optional(), - op: z.union([ - z.enum([...OP_TYPES] as [string, ...string[]]), - z.array(z.enum([...OP_TYPES] as [string, ...string[]])), - ]).optional(), - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), - limit: z.coerce.number().int().positive().optional(), -}).strict().transform(transformReceiptValues); - -/** Normalize a CLI option that may be a single string, an array, or undefined into an array. */ -function normalizeRepeatedOption(value: string | string[] | undefined): string[] { - if (Array.isArray(value)) { - return value; - } - if (value !== undefined && value !== '') { - return [value]; - } - return []; -} - -/** Sort receipts by lamport clock, then writer, then patch SHA for deterministic output. */ -function sortReceipts(receipts: TickReceipt[]): TickReceipt[] { - return [...receipts].sort((a, b) => { - const lamportCmp = compareNumbers(a.lamport, b.lamport); - if (lamportCmp !== 0) { - return lamportCmp; - } - const writerCmp = compareStrings(a.writer, b.writer); - if (writerCmp !== 0) { - return writerCmp; - } - return compareStrings(a.patchSha, b.patchSha); - }); -} - -/** Count occurrences of each result type across all op outcomes. */ -function summarizeResultCounts(ops: OpOutcome[]): { applied: number; superseded: number; redundant: number } { - return ops.reduce((acc, op) => { - if (op.result === 'applied') { - acc.applied += 1; - } else if (op.result === 'superseded') { - acc.superseded += 1; - } else if (op.result === 'redundant') { - acc.redundant += 1; - } - return acc; - }, { applied: 0, superseded: 0, redundant: 0 }); -} - -/** Count occurrences of each op type across all op outcomes. */ -function summarizeOpCounts(ops: OpOutcome[]): Record { - const counts: Record = {}; - for (const op of ops) { - counts[op.op] = (counts[op.op] ?? 0) + 1; - } - return counts; -} - -/** Check whether a target filter matches the given op. */ -function matchesTargetFilter(op: OpOutcome, target: string | null): boolean { - return target === null || op.target === target; -} - -/** Check whether a list filter includes the given value (empty list means no filter). */ -function matchesListFilter(allowed: string[], value: string): boolean { - return allowed.length === 0 || allowed.includes(value); -} - -/** Check whether an op matches all active filters (target, result type, op type). */ -function matchesOpFilters(op: OpOutcome, filters: { target: string | null; results: string[]; opTypes: string[] }): boolean { - return matchesTargetFilter(op, filters.target) && - matchesListFilter(filters.results, op.result) && - matchesListFilter(filters.opTypes, op.op); -} - -/** Filter a receipt by writer, patch SHA, and op-level filters; return null if no ops match. */ -function filterReceipt(receipt: TickReceipt, filters: ReceiptFilter): { patchSha: string; writer: string; lamport: number; totalOps: number; matchedOps: number; ops: OpOutcome[] } | null { - if (filters.writerId !== null && receipt.writer !== filters.writerId) { - return null; - } - if (!matchesShaPrefix(receipt.patchSha, filters.patch)) { - return null; - } - - const ops = receipt.ops.filter((op) => matchesOpFilters(op, filters)); - if (ops.length === 0) { - return null; - } - - return { - patchSha: receipt.patchSha, - writer: receipt.writer, - lamport: receipt.lamport, - totalOps: receipt.ops.length, - matchedOps: ops.length, - ops, - }; -} - -/** Materialize the graph with receipt collection enabled and return the receipts. */ -async function loadReceipts({ graph, lamportCeiling, strandId }: { - graph: WarpGraphInstance; - lamportCeiling: number | null; - strandId: string | null; -}): Promise { - const materialized = await materializeForDebug(graph, { - lamportCeiling, - collectReceipts: true, - strandId, - }) as { state: unknown; receipts: TickReceipt[] }; - return materialized.receipts; -} - -interface ReceiptsPayloadContext { - readonly graphName: string; - readonly values: ReceiptFilter; - readonly strand: unknown; - readonly lamportCeiling: number | null; - readonly sortedReceipts: TickReceipt[]; - readonly returnedReceipts: Array<{ ops: OpOutcome[] }>; - readonly filteredCount: number; -} - -/** Collects the filter parameters from a receipt filter context. */ -function receiptPayloadFilters(values: ReceiptFilter): Record { - return { - writerId: values.writerId, - patch: values.patch, - target: values.target, - results: values.results, - opTypes: values.opTypes, - }; -} - -/** Build the JSON payload for the receipts debug topic. */ -function buildReceiptsPayload(ctx: ReceiptsPayloadContext): Record { - const flattenedOps = ctx.returnedReceipts.flatMap((r) => r.ops); - return { - graph: ctx.graphName, - debugTopic: 'receipts', - ...(ctx.values.strandId !== null ? { strandId: ctx.values.strandId } : {}), - ...(ctx.strand !== null ? { strand: ctx.strand } : {}), - lamportCeiling: ctx.lamportCeiling, - filters: receiptPayloadFilters(ctx.values), - totalReceipts: ctx.sortedReceipts.length, - matchedReceipts: ctx.filteredCount, - returnedReceipts: ctx.returnedReceipts.length, - truncated: ctx.returnedReceipts.length < ctx.filteredCount, - summary: { - results: summarizeResultCounts(flattenedOps), - opTypes: summarizeOpCounts(flattenedOps), - }, - receipts: ctx.returnedReceipts, - }; -} - -/** Handle the 'receipts' debug topic — load, filter, and format tick receipts. */ -export async function handleDebugTopic({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values: rawValues } = parseCommandArgs(args, DEBUG_RECEIPT_OPTIONS, debugReceiptsSchema); - const values = rawValues; - const { graph, graphName, activeCursor } = await openDebugContext(options); - const lamportCeiling = resolveLamportCeiling(values.lamportCeiling, activeCursor); - const strand = values.strandId !== null - ? await loadStrandContextForDebug(graph, values.strandId) - : null; - const sortedReceipts = sortReceipts(await loadReceipts({ - graph, lamportCeiling, strandId: values.strandId, - })); - const filteredReceipts = sortedReceipts - .map((receipt) => filterReceipt(receipt, values)) - .filter((receipt): receipt is NonNullable => receipt !== null); - const returnedReceipts = values.limit === null - ? filteredReceipts - : filteredReceipts.slice(0, values.limit); - - return { - payload: buildReceiptsPayload({ - graphName, values, strand, lamportCeiling, - sortedReceipts, returnedReceipts, filteredCount: filteredReceipts.length, - }), - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/debug/shared.ts b/bin/cli/commands/debug/shared.ts deleted file mode 100644 index bb99d31c..00000000 --- a/bin/cli/commands/debug/shared.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Counts each op type found in a patch operation list. - */ -function summarizeOps(ops: Array<{ type: string }>): Record { - const summary: Record = { NodeAdd: 0, EdgeAdd: 0, PropSet: 0, NodeTombstone: 0, EdgeTombstone: 0, BlobValue: 0 }; - for (const op of ops) { if (op.type in summary) { summary[op.type] = (summary[op.type] ?? 0) + 1; } } - return summary; -} -import { - openGraph, - readActiveCursor, - emitCursorWarning, -} from '../../shared.ts'; -import { notFoundError } from '../../infrastructure.ts'; - -import type { CliOptions, WarpGraphInstance, Persistence, CursorBlob } from '../../types.ts'; -import type WarpCore from '../../../../src/domain/WarpCore.ts'; -import type { RuntimeHostProduct } from '../../../../src/domain/warp/RuntimeHostProduct.ts'; -import type Patch from '../../../../src/domain/types/Patch.ts'; -import type { StrandDescriptor } from '../../../../src/domain/types/StrandDescriptor.ts'; - -type WarpCoreRuntime = WarpCore & RuntimeHostProduct; - -/** - * Opens a graph with debug context including cursor state for exploratory analysis. - */ -export async function openDebugContext(options: CliOptions): Promise<{ graph: WarpGraphInstance; graphName: string; persistence: Persistence; activeCursor: CursorBlob | null }> { - const { graph, graphName, persistence, cursorStore } = await openGraph(options); - const activeCursor = await readActiveCursor(cursorStore); - emitCursorWarning({ - active: activeCursor !== null, - tick: activeCursor?.tick ?? null, - maxTick: null, - }, null); - return { graph, graphName, persistence, activeCursor }; -} - -/** - * Resolves the effective lamport ceiling from an explicit value or cursor state. - */ -export function resolveLamportCeiling(explicitLamportCeiling: number | null, activeCursor: CursorBlob | null): number | null { - return explicitLamportCeiling ?? activeCursor?.tick ?? null; -} - -/** - * Materialize explicitly for debugger commands so they operate against an - * intentional observation coordinate instead of relying on auto-materialize. - * - * The main git-warp CLI does not attach a checkpoint policy or persistent seek - * cache when opening graphs, so this remains a read-only exploratory path. - */ -export async function materializeForDebug(graph: WarpGraphInstance, options: { lamportCeiling: number | null; collectReceipts: boolean; strandId?: string | null }): Promise { - const debugGraph = graph as unknown as WarpCoreRuntime; - const { - lamportCeiling, - collectReceipts, - strandId = null, - } = options; - if (strandId !== null && strandId !== undefined && strandId.length > 0) { - return await materializeStrandForDebug(debugGraph, strandId, { lamportCeiling, collectReceipts }); - } - return await materializeGraphForDebug(debugGraph, { lamportCeiling, collectReceipts }); -} - -/** - * Materializes a single strand with optional ceiling and receipt collection. - */ -async function materializeStrandForDebug(debugGraph: WarpCoreRuntime, strandId: string, opts: { lamportCeiling: number | null; collectReceipts: boolean }): Promise { - const matOpts: Record = {}; - if (opts.collectReceipts) { - matOpts['receipts'] = true; - } - if (opts.lamportCeiling !== null) { - matOpts['ceiling'] = opts.lamportCeiling; - } - return Object.keys(matOpts).length > 0 - ? await debugGraph.materializeStrand(strandId, matOpts) - : await debugGraph.materializeStrand(strandId); -} - -/** - * Materializes the full graph with optional ceiling and receipt collection. - */ -async function materializeGraphForDebug(debugGraph: WarpCoreRuntime, opts: { lamportCeiling: number | null; collectReceipts: boolean }): Promise { - const matOpts: Record = {}; - if (opts.collectReceipts) { - matOpts['receipts'] = true; - } - if (opts.lamportCeiling !== null) { - matOpts['ceiling'] = opts.lamportCeiling; - } - return Object.keys(matOpts).length > 0 - ? await debugGraph.materialize(matOpts) - : await debugGraph.materialize(); -} - -/** - * Retrieves decoded patch entries for a specific strand, optionally capped by lamport ceiling. - */ -export async function getStrandPatchEntriesForDebug(graph: WarpGraphInstance, strandId: string, lamportCeiling: number | null): Promise> { - const debugGraph = graph as unknown as WarpCoreRuntime; - if (lamportCeiling === null) { - return await debugGraph.getStrandPatches(strandId) as Array<{ patch: Patch; sha: string }>; - } - return await debugGraph.getStrandPatches(strandId, { ceiling: lamportCeiling }) as Array<{ patch: Patch; sha: string }>; -} - -/** - * Produces a serializable summary of a strand's context for debug output. - */ -function summarizeStrandContextForDebug(strand: StrandDescriptor): { - strandId: string; - baseLamportCeiling: number | null; - overlayHeadPatchSha: string | null; - overlayPatchCount: number; - overlayWritable: boolean; - braid: { - readOverlayCount: number; - braidedStrandIds: string[]; - }; -} { - return { - strandId: strand.strandId, - baseLamportCeiling: strand.baseObservation.lamportCeiling, - overlayHeadPatchSha: strand.overlay.headPatchSha, - overlayPatchCount: strand.overlay.patchCount, - overlayWritable: strand.overlay.writable, - braid: { - readOverlayCount: strand.braid.readOverlays.length, - braidedStrandIds: strand.braid.readOverlays - .map((overlay) => overlay.strandId) - .sort(compareStrings), - }, - }; -} - -/** - * Loads and summarizes a strand's debug context, throwing if the strand is not found. - */ -export async function loadStrandContextForDebug(graph: WarpGraphInstance, strandId: string): Promise> { - const strand = await graph.getStrand(strandId); - if (!strand) { - throw notFoundError(`Strand not found: ${strandId}`); - } - return summarizeStrandContextForDebug(strand); -} - -/** - * Explicit byte/hex-safe string ordering for deterministic debug output. - */ -export function compareStrings(a: string, b: string): number { - if (a === b) { - return 0; - } - return a < b ? -1 : 1; -} - -/** - * Numeric comparator for deterministic sorting. - */ -export function compareNumbers(a: number, b: number): number { - return a === b ? 0 : (a < b ? -1 : 1); -} - -type DebugOpLike = { type: string; node?: string; from?: string; to?: string }; - -/** - * Adds a string field from an op to the set if it is a non-empty string. - */ -function addIfNonEmptyString(ids: Set, value: unknown): void { - if (typeof value === 'string' && value.length > 0) { - ids.add(value); - } -} - -/** - * Collects unique node/edge endpoint IDs referenced by patch operations. - */ -function collectTouchedIds(ops: DebugOpLike[] | undefined): string[] { - if (!Array.isArray(ops) || ops.length === 0) { - return []; - } - - const ids: Set = new Set(); - for (const op of ops) { - addIfNonEmptyString(ids, op.node); - addIfNonEmptyString(ids, op.from); - addIfNonEmptyString(ids, op.to); - } - - return ([...ids].sort(compareStrings)); -} - -/** - * Returns the lamport value from a patch, defaulting to zero if absent. - */ -function patchLamport(patch: { lamport?: number }): number { - return patch.lamport ?? 0; -} - -/** - * Returns the writer value from a patch, defaulting to empty string if absent. - */ -function patchWriter(patch: { writer?: string }): string { - return patch.writer ?? ''; -} - -/** - * Compares two patch entries by lamport, then writer, then SHA for deterministic ordering. - */ -function comparePatchEntries(a: { patch: { writer?: string; lamport?: number }; sha: string }, b: { patch: { writer?: string; lamport?: number }; sha: string }): number { - return compareNumbers(patchLamport(a.patch), patchLamport(b.patch)) - || compareStrings(patchWriter(a.patch), patchWriter(b.patch)) - || compareStrings(a.sha, b.sha); -} - -/** - * Deterministic causal sort for patch entries. - */ -export function sortPatchEntriesCausally(entries: Array<{ patch: { writer?: string; lamport?: number }; sha: string }>): Array<{ patch: { writer?: string; lamport?: number }; sha: string }> { - return [...entries].sort(comparePatchEntries); -} - -type DebugPatch = { writer?: string; lamport?: number; schema?: number; ops?: DebugOpLike[]; reads?: string[] | undefined; writes?: string[] | undefined }; - -/** - * Safely copies a string array or returns an empty array. - */ -function copyStringArray(arr: unknown): string[] { - if (!Array.isArray(arr)) { - return []; - } - const result: string[] = []; - for (const item of arr as string[]) { - result.push(item); - } - return result; -} - -/** - * Summarizes a single patch entry into a compact debug-friendly shape. - */ -function summarizeSinglePatch({ patch, sha }: { patch: DebugPatch; sha: string }): { sha: string; writer: string; lamport: number; schema: number | undefined; opCount: number; opSummary: Record; reads: string[]; writes: string[]; targets: string[] } { - const ops = Array.isArray(patch.ops) ? patch.ops : []; - return { - sha, - writer: patch.writer ?? '', - lamport: patch.lamport ?? 0, - schema: patch.schema, - opCount: ops.length, - opSummary: ops.length > 0 ? summarizeOps(ops) : {}, - reads: copyStringArray(patch.reads), - writes: copyStringArray(patch.writes), - targets: collectTouchedIds(patch.ops), - }; -} - -/** - * Transforms raw patch entries into compact summaries for debug display. - */ -export function summarizePatchEntries(entries: Array<{ patch: DebugPatch; sha: string }>): Array<{ sha: string; writer: string; lamport: number; schema: number | undefined; opCount: number; opSummary: Record; reads: string[]; writes: string[]; targets: string[] }> { - return entries.map(summarizeSinglePatch); -} - -/** - * Returns true if the SHA matches a given prefix, or if no prefix is provided. - */ -export function matchesShaPrefix(sha: string, prefix: string | null): boolean { - if (prefix === null || prefix === undefined || prefix.length === 0) { - return true; - } - return sha === prefix || sha.startsWith(prefix); -} diff --git a/bin/cli/commands/debug/timeline.ts b/bin/cli/commands/debug/timeline.ts deleted file mode 100644 index d048dd33..00000000 --- a/bin/cli/commands/debug/timeline.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, notFoundError, parseCommandArgs } from '../../infrastructure.ts'; - -import { - getStrandPatchEntriesForDebug, - loadStrandContextForDebug, - openDebugContext, - resolveLamportCeiling, - sortPatchEntriesCausally, - summarizePatchEntries, -} from './shared.ts'; -import type { CliOptions, WarpGraphInstance, CursorBlob } from '../../types.ts'; -import type Patch from '../../../../src/domain/types/Patch.ts'; - -/** Returns true if the value is a non-empty string. */ -function isNonEmptyString(value: string | null | undefined): value is string { - return typeof value === 'string' && value.length > 0; -} - -type PatchEntry = { patch: Patch; sha: string }; - -export const DEBUG_TOPIC = Object.freeze({ - name: 'timeline', - summary: 'Inspect a cross-writer causal patch timeline at a coordinate', -}); - -const DEBUG_TIMELINE_OPTIONS = { - 'strand': { type: 'string' }, - 'entity-id': { type: 'string' }, - 'writer-id': { type: 'string' }, - 'lamport-floor': { type: 'string' }, - 'lamport-ceiling': { type: 'string' }, - limit: { type: 'string' }, -}; - -/** Validates that lamport-floor is not greater than lamport-ceiling. */ -const validateLamportRange = (val: Record): boolean => { - const floor = val['lamport-floor']; - const ceiling = val['lamport-ceiling']; - if (floor === undefined || ceiling === undefined) { - return true; - } - return Number(floor) <= Number(ceiling); -}; - -/** Coerces an optional string to string or null. */ -function strOrNull(value: string | undefined): string | null { - return value ?? null; -} - -/** Coerces an optional number to number or null. */ -function numOrNull(value: number | undefined): number | null { - return value ?? null; -} - -type TimelineValues = { - strandId: string | null; - entityId: string | null; - writerId: string | null; - lamportFloor: number | null; - lamportCeiling: number | null; - limit: number | null; -}; - -/** Normalizes parsed Zod values into a timeline coordinate shape. */ -function normalizeTimelineValues(val: { strand?: string | undefined; 'entity-id'?: string | undefined; 'writer-id'?: string | undefined; 'lamport-floor'?: number | undefined; 'lamport-ceiling'?: number | undefined; limit?: number | undefined }, _ctx: z.RefinementCtx): TimelineValues { - return { - strandId: strOrNull(val.strand), - entityId: strOrNull(val['entity-id']), - writerId: strOrNull(val['writer-id']), - lamportFloor: numOrNull(val['lamport-floor']), - lamportCeiling: numOrNull(val['lamport-ceiling']), - limit: numOrNull(val.limit), - }; -} - -const debugTimelineSchema = z.object({ - 'strand': z.string().optional(), - 'entity-id': z.string().optional(), - 'writer-id': z.string().optional(), - 'lamport-floor': z.coerce.number().int().nonnegative().optional(), - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), - limit: z.coerce.number().int().positive().optional(), -}).strict().refine(validateLamportRange, { - message: '--lamport-floor must be less than or equal to --lamport-ceiling', - path: ['lamport-floor'], -}).transform(normalizeTimelineValues); - -/** Loads all patches for a specific writer. */ -async function loadWriterTimeline({ graph, writerId }: { graph: WarpGraphInstance; writerId: string }): Promise { - return await graph.getWriterPatches(writerId) as PatchEntry[]; -} - -/** Loads all patches that affected a specific entity. */ -async function loadEntityTimeline({ graph, entityId }: { graph: WarpGraphInstance; entityId: string }): Promise { - const shas = await graph.patchesFor(entityId); - const patches = await Promise.all( - shas.map(async (sha) => ({ - sha, - patch: await graph.loadPatchBySha(sha), - })), - ); - return patches as PatchEntry[]; -} - -/** Resolves timeline entries for a non-strand coordinate. */ -async function loadTimelineEntries({ graph, entityId, writerId }: { - graph: WarpGraphInstance; - entityId: string | null; - writerId: string | null; -}): Promise { - if (isNonEmptyString(entityId)) { - const entries = await loadEntityTimeline({ graph, entityId }); - if (isNonEmptyString(writerId)) { - return entries.filter(({ patch }) => patch.writer === writerId); - } - return entries; - } - - if (isNonEmptyString(writerId)) { - return await loadWriterTimeline({ graph, writerId }); - } - - const ids = await graph.discoverWriters(); - const arrays = await Promise.all(ids.map(async (id) => await loadWriterTimeline({ graph, writerId: id }))); - return arrays.flat(); -} - -/** Loads patches for a specific entity within a strand up to an optional ceiling. */ -async function loadStrandEntityEntries({ graph, strandId, entityId, lamportCeiling }: { graph: WarpGraphInstance; strandId: string; entityId: string; lamportCeiling: number | null }): Promise { - const c = (lamportCeiling !== null && lamportCeiling !== undefined) ? { ceiling: lamportCeiling } : undefined; - const shas = await graph.patchesForStrand(strandId, entityId, c); - return await Promise.all( - shas.map(async (sha) => ({ - sha, - patch: await graph.loadPatchBySha(sha), - })), - ) as PatchEntry[]; -} - -/** Loads timeline entries visible from a specific strand at a ceiling. */ -async function loadStrandTimelineEntries({ graph, strandId, lamportCeiling, entityId, writerId }: { - graph: WarpGraphInstance; - strandId: string; - lamportCeiling: number | null; - entityId: string | null; - writerId: string | null; -}): Promise { - const entries = isNonEmptyString(entityId) - ? await loadStrandEntityEntries({ graph, strandId, entityId, lamportCeiling }) - : await getStrandPatchEntriesForDebug(graph, strandId, lamportCeiling); - if (isNonEmptyString(writerId)) { - return entries.filter(({ patch }) => patch.writer === writerId); - } - return entries; -} - -/** Filters patch entries by lamport range. */ -function filterTimelineEntries(entries: PatchEntry[], filters: { lamportFloor: number | null; lamportCeiling: number | null }): PatchEntry[] { - return entries.filter(({ patch }) => isWithinLamportRange(patch, filters)); -} - -/** Returns true if the lamport value is at or above the floor. */ -function isAboveFloor(lamp: number, floor: number | null): boolean { - return floor === null || lamp >= floor; -} - -/** Returns true if the lamport value is at or below the ceiling. */ -function isBelowCeiling(lamp: number, ceiling: number | null): boolean { - return ceiling === null || lamp <= ceiling; -} - -/** Checks whether a patch falls within the lamport range. */ -function isWithinLamportRange(patch: { lamport?: number }, filters: { lamportFloor: number | null; lamportCeiling: number | null }): boolean { - const lamp = patch.lamport ?? 0; - return isAboveFloor(lamp, filters.lamportFloor) && isBelowCeiling(lamp, filters.lamportCeiling); -} - -/** Limits the number of entries returned. */ -function limitTimelineEntries(entries: PatchEntry[], limit: number | null): PatchEntry[] { - if (limit === null || entries.length <= limit) { - return entries; - } - return entries.slice(-limit); -} - -/** Asserts that a writer ID is known by the graph. */ -async function ensureKnownWriter({ graph, writerId }: { graph: WarpGraphInstance; writerId: string }): Promise { - const known = await graph.discoverWriters(); - if (known.includes(writerId)) { return; } - const msg = known.length > 0 ? `Unknown writer: ${writerId}\nKnown writers: ${known.join(', ')}` : `Unknown writer: ${writerId}`; - throw notFoundError(msg); -} - -/** Resolves the semantic source of the timeline coordinate. */ -function resolveCoordinateSource(ceil: number | null, cursor: CursorBlob | null): 'explicit' | 'cursor' | 'frontier' { - if (ceil !== null) { return 'explicit'; } - return cursor ? 'cursor' : 'frontier'; -} - -/** Dispatches timeline resolution based on strand presence. */ -async function resolveTimelineEntries({ graph, values, lamportCeiling }: { - graph: WarpGraphInstance; - values: { strandId: string | null; entityId: string | null; writerId: string | null; lamportCeiling: number | null }; - lamportCeiling: number | null; -}): Promise { - if (isNonEmptyString(values.strandId)) { - return await loadStrandTimelineEntries({ - graph, - strandId: values.strandId, - lamportCeiling, - entityId: values.entityId, - writerId: values.writerId, - }); - } - - return await loadTimelineEntries({ - graph, - entityId: values.entityId, - writerId: values.writerId, - }); -} - -/** Prepares the payload for the debug timeline result. */ -function buildDebugTimelinePayload({ graphName, coordinateSource, values, strand, filteredEntries, returnedEntries, lamportCeiling }: { - graphName: string; - coordinateSource: string; - values: TimelineValues; - strand: Record | null; - filteredEntries: PatchEntry[]; - returnedEntries: PatchEntry[]; - lamportCeiling: number | null; -}): Record { - return { - graph: graphName, - debugTopic: 'timeline', - coordinateSource, - ...(isNonEmptyString(values.strandId) ? { strandId: values.strandId } : {}), - ...(strand !== null && strand !== undefined ? { strand } : {}), - filters: { - entityId: values.entityId, - writerId: values.writerId, - lamportFloor: values.lamportFloor, - lamportCeiling, - }, - totalEntries: filteredEntries.length, - returnedEntries: returnedEntries.length, - truncated: returnedEntries.length < filteredEntries.length, - entries: summarizePatchEntries(returnedEntries), - }; -} - -/** Handles the 'debug timeline' CLI command. */ -export async function handleDebugTopic({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values: rawValues } = parseCommandArgs(args, DEBUG_TIMELINE_OPTIONS, debugTimelineSchema); - const values = rawValues; - const { graph, graphName, activeCursor } = await openDebugContext(options); - const lamportCeiling = resolveLamportCeiling(values.lamportCeiling, activeCursor); - const entries = await resolveTimelineEntries({ graph, values, lamportCeiling }); - - if (entries.length === 0 && isNonEmptyString(values.writerId) && !isNonEmptyString(values.strandId)) { - await ensureKnownWriter({ graph, writerId: values.writerId }); - } - - const filteredEntries = sortPatchEntriesCausally( - filterTimelineEntries(entries, { - lamportFloor: values.lamportFloor, - lamportCeiling, - }) as Array<{ patch: { writer?: string; lamport?: number }; sha: string }>, - ) as PatchEntry[]; - const returnedEntries = limitTimelineEntries(filteredEntries, values.limit); - const strand = isNonEmptyString(values.strandId) ? await loadStrandContextForDebug(graph, values.strandId) : null; - - return { - payload: buildDebugTimelinePayload({ graphName, coordinateSource: resolveCoordinateSource(values.lamportCeiling, activeCursor), values, strand, filteredEntries, returnedEntries, lamportCeiling }), - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/doctor-v19.ts b/bin/cli/commands/doctor-v19.ts new file mode 100644 index 00000000..342b19d1 --- /dev/null +++ b/bin/cli/commands/doctor-v19.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { stableStringify } from '../../presenters/json.ts'; +import handleSubstrateDoctor from './doctor/index.ts'; +import { parseCommandArgs } from '../infrastructure.ts'; +import type { CliOptions } from '../types.ts'; +import { openRequiredLane, withRuntime } from '../v19/V19Runtime.ts'; + +const DOCTOR_SCHEMA = z.object({}).strict(); + +export default async function handleDoctor({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise<{ + readonly payload: object; + readonly human: string; + readonly exitCode: number; +}> { + parseCommandArgs(args, {}, DOCTOR_SCHEMA); + return await withRuntime(options, async (runtime, storage) => { + await openRequiredLane(runtime, options.lane); + const result = await handleSubstrateDoctor({ options, storage }); + return { + ...result, + human: stableStringify(result.payload), + }; + }); +} diff --git a/bin/cli/commands/doctor/checks.ts b/bin/cli/commands/doctor/checks.ts index 603bed77..5baefa5d 100644 --- a/bin/cli/commands/doctor/checks.ts +++ b/bin/cli/commands/doctor/checks.ts @@ -152,7 +152,7 @@ function buildCoverageNoRef(): DoctorFinding { return { id: 'coverage-complete', status: 'warn', code: CODES.COVERAGE_NO_REF, impact: 'operability', message: 'No coverage ref found', - fix: 'Run `git warp materialize` to create a coverage anchor', + fix: 'Run `git warp repair --lane --action materialization` to create a bounded basis', }; } @@ -163,7 +163,7 @@ function buildCoverageFinding(missing: string[]): DoctorFinding { id: 'coverage-complete', status: 'warn', code: CODES.COVERAGE_MISSING_WRITERS, impact: 'operability', message: `Coverage anchor is missing ${missing.length} writer(s): ${missing.join(', ')}`, - fix: 'Run `git warp materialize` to update the coverage anchor', + fix: 'Run `git warp repair --lane --action materialization` to update the bounded basis', evidence: { missingWriters: missing }, }; } @@ -228,7 +228,7 @@ export async function checkCheckpointFresh(ctx: DoctorContext): Promise --action materialization` to create a bounded basis', }; } @@ -253,7 +253,7 @@ function buildCheckpointFinding({ sha, date, ageHours, maxAge }: { sha: string; id: 'checkpoint-fresh', status: 'warn', code: CODES.CHECKPOINT_STALE, impact: 'operability', message: `Checkpoint is ${Math.round(ageHours)} hours old (threshold: ${maxAge}h)`, - fix: 'Run `git warp materialize` to refresh the checkpoint', + fix: 'Run `git warp repair --lane --action materialization` to refresh the bounded basis', evidence: { sha, date, ageHours: Math.round(ageHours) }, }; } diff --git a/bin/cli/commands/doctor/checksAux.ts b/bin/cli/commands/doctor/checksAux.ts index 45e28496..e5ed7024 100644 --- a/bin/cli/commands/doctor/checksAux.ts +++ b/bin/cli/commands/doctor/checksAux.ts @@ -112,7 +112,7 @@ function buildInstalledHookFinding(s: { version?: string; current?: boolean }): return { id: 'hooks-installed', status: 'warn', code: CODES.HOOKS_OUTDATED, impact: 'hygiene', message: `Hook is outdated (${versionLabel})`, - fix: 'Run `git warp install-hooks` to upgrade', + fix: 'Restore the current packaged post-merge hook', evidence: { version: s.version ?? null }, }; } @@ -128,12 +128,12 @@ function buildUninstalledHookFinding(isForeign: boolean): DoctorFinding { return { id: 'hooks-installed', status: 'warn', code: CODES.HOOKS_MISSING, impact: 'hygiene', message: 'Foreign hook present; warp hook not installed', - fix: 'Run `git warp install-hooks` (use --force to replace existing hook)', + fix: 'Replace the existing hook with the current packaged post-merge hook', }; } return { id: 'hooks-installed', status: 'warn', code: CODES.HOOKS_MISSING, impact: 'hygiene', message: 'Post-merge hook is not installed', - fix: 'Run `git warp install-hooks`', + fix: 'Install the current packaged post-merge hook', }; } diff --git a/bin/cli/commands/doctor/checksMaterializationCache.ts b/bin/cli/commands/doctor/checksMaterializationCache.ts index 9834ebde..e4e18676 100644 --- a/bin/cli/commands/doctor/checksMaterializationCache.ts +++ b/bin/cli/commands/doctor/checksMaterializationCache.ts @@ -2,7 +2,6 @@ import type { MaterializationCacheEntryDiagnostic, MaterializationCacheInspection, - MaterializationCacheRepair, } from '../../../../src/ports/MaterializationCacheDiagnosticsPort.ts'; import type { DoctorFinding, FindingEvidence } from './types.ts'; import { CODES } from './codes.ts'; @@ -120,7 +119,7 @@ function invalidCacheFinding( message: inspection.issues.length === 0 ? 'git-cas reported the materialization cache as unhealthy' : `git-cas reported ${inspection.issues.length} materialization-cache structural issue(s)`, - fix: 'Run `git warp doctor --repair-materialization-cache`; repair will remove invalid cache entries but cannot recreate missing bytes', + fix: 'Ask git-cas to remove invalid materialization entries; missing bytes must come from authoritative history', evidence, }; } @@ -139,7 +138,7 @@ function brokenEntriesFinding( : CODES.MATERIALIZATION_CACHE_ENTRY_MALFORMED, impact: 'data_integrity', message: `${entries.length} retained materialization cache entr${entries.length === 1 ? 'y is' : 'ies are'} ${status}`, - fix: 'Run `git warp doctor --repair-materialization-cache`; authoritative WARP history remains the recovery source', + fix: 'Ask git-cas to repair retained materializations; authoritative history remains the recovery source', evidence, }; } @@ -155,7 +154,7 @@ function expiredEntriesFinding( code: CODES.MATERIALIZATION_CACHE_ENTRY_EXPIRED, impact: 'hygiene', message: `${entries.length} retained materialization cache entr${entries.length === 1 ? 'y is' : 'ies are'} expired and collectible`, - fix: 'Run `git warp doctor --repair-materialization-cache` to ask git-cas to sweep expired entries', + fix: 'Ask git-cas to sweep expired retained materialization entries', evidence, }; } @@ -177,49 +176,3 @@ function healthyCacheFinding( evidence, }; } - -export function materializationCacheRepairFinding( - result: MaterializationCacheRepair, -): DoctorFinding { - const partial = repairIsPartial(result.after); - return { - id: 'materialization-cache-repair', - status: partial ? 'warn' : 'ok', - code: partial - ? CODES.MATERIALIZATION_CACHE_PARTIAL_REPAIR - : CODES.MATERIALIZATION_CACHE_REPAIRED, - impact: 'data_integrity', - message: repairMessage(partial, result.removedKeys.length), - evidence: { - removedKeys: [...result.removedKeys], - generation: result.generation, - beforeHealthy: result.before.healthy, - afterHealthy: result.after.healthy, - afterIssues: [...result.after.issues], - }, - }; -} - -function repairIsPartial(inspection: MaterializationCacheInspection): boolean { - return !inspection.healthy || inspection.entries.some( - (entry) => entry.status === 'missing' || entry.status === 'malformed', - ); -} - -function repairMessage(partial: boolean, removed: number): string { - if (partial) { - return 'git-cas repaired recoverable materialization-cache metadata; unrecoverable entries remain reported'; - } - return `git-cas repaired the materialization cache and removed ${removed} collectible entr${removed === 1 ? 'y' : 'ies'}`; -} - -export function materializationCacheRepairFailureFinding(error: unknown): DoctorFinding { - return { - id: 'materialization-cache-repair', - status: 'fail', - code: CODES.CHECK_INTERNAL_ERROR, - impact: 'data_integrity', - message: `Materialization-cache repair failed: ${error instanceof Error ? error.message : String(error)}`, - fix: 'Resolve the repository or git-cas error, then rerun `git warp doctor --repair-materialization-cache`', - }; -} diff --git a/bin/cli/commands/doctor/codes.ts b/bin/cli/commands/doctor/codes.ts index aecbdc2d..75ea2e8f 100644 --- a/bin/cli/commands/doctor/codes.ts +++ b/bin/cli/commands/doctor/codes.ts @@ -40,18 +40,12 @@ export const CODES = { HOOKS_MISSING: 'HOOKS_MISSING', HOOKS_OUTDATED: 'HOOKS_OUTDATED', - // memory-budget - MEMORY_BUDGET_REPORT: 'MEMORY_BUDGET_REPORT', - // materialization-cache MATERIALIZATION_CACHE_OK: 'MATERIALIZATION_CACHE_OK', MATERIALIZATION_CACHE_INVALID: 'MATERIALIZATION_CACHE_INVALID', MATERIALIZATION_CACHE_ENTRY_MISSING: 'MATERIALIZATION_CACHE_ENTRY_MISSING', MATERIALIZATION_CACHE_ENTRY_MALFORMED: 'MATERIALIZATION_CACHE_ENTRY_MALFORMED', MATERIALIZATION_CACHE_ENTRY_EXPIRED: 'MATERIALIZATION_CACHE_ENTRY_EXPIRED', - MATERIALIZATION_CACHE_REPAIRED: 'MATERIALIZATION_CACHE_REPAIRED', - MATERIALIZATION_CACHE_PARTIAL_REPAIR: 'MATERIALIZATION_CACHE_PARTIAL_REPAIR', - // meta CHECK_SKIPPED_BUDGET_EXHAUSTED: 'CHECK_SKIPPED_BUDGET_EXHAUSTED', CHECK_INTERNAL_ERROR: 'CHECK_INTERNAL_ERROR', diff --git a/bin/cli/commands/doctor/index.ts b/bin/cli/commands/doctor/index.ts index eba09943..584a6d33 100644 --- a/bin/cli/commands/doctor/index.ts +++ b/bin/cli/commands/doctor/index.ts @@ -7,10 +7,6 @@ * @module cli/commands/doctor */ -import createBoundedMemoryCapabilityReport - from '../../../../src/domain/memory/createBoundedMemoryCapabilityReport.ts'; -import { parseCommandArgs } from '../../infrastructure.ts'; -import { doctorSchema } from '../../schemas.ts'; import { ALL_CHECKS } from './checks.ts'; import { CODES } from './codes.ts'; import { @@ -18,24 +14,10 @@ import { } from './checksMaterializationCache.ts'; import { createDoctorContext, - repairMaterializationCache, } from './storageCapabilities.ts'; import { DOCTOR_EXIT_CODES, type DoctorFinding, type DoctorPolicy, type DoctorPayload, type DoctorContext } from './types.ts'; import type { CliOptions } from '../../types.ts'; - -const DOCTOR_OPTION_MEMORY_BUDGET = 'memory-budget'; -const DOCTOR_OPTION_LARGE_GRAPH = 'large-graph'; -const DOCTOR_OPTION_REPAIR_MATERIALIZATION_CACHE = 'repair-materialization-cache'; - -const MEMORY_BUDGET_FINDING_ID = 'memory-budget'; -const MEMORY_BUDGET_NOT_SPECIFIED = 'not-specified'; - -const DOCTOR_OPTIONS = { - strict: { type: 'boolean', default: false }, - [DOCTOR_OPTION_MEMORY_BUDGET]: { type: 'string' }, - [DOCTOR_OPTION_LARGE_GRAPH]: { type: 'boolean', default: false }, - [DOCTOR_OPTION_REPAIR_MATERIALIZATION_CACHE]: { type: 'boolean', default: false }, -}; +import type { CliStorageBinding } from '../../shared.ts'; const DEFAULT_POLICY: DoctorPolicy = { strict: false, @@ -53,90 +35,30 @@ const IMPACT_ORDER = { hygiene: 3, } as const; -type DoctorCommandValues = { - readonly strict: boolean; - readonly [DOCTOR_OPTION_MEMORY_BUDGET]: string | undefined; - readonly [DOCTOR_OPTION_LARGE_GRAPH]: boolean; - readonly [DOCTOR_OPTION_REPAIR_MATERIALIZATION_CACHE]: boolean; -}; - -type RawDoctorCommandValues = { - readonly strict: boolean; - readonly [DOCTOR_OPTION_MEMORY_BUDGET]?: string | undefined; - readonly [DOCTOR_OPTION_LARGE_GRAPH]: boolean; - readonly [DOCTOR_OPTION_REPAIR_MATERIALIZATION_CACHE]: boolean; -}; - /** Handles the `git warp doctor` command: runs structural health checks and returns findings. */ -export default async function handleDoctor({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: DoctorPayload; exitCode: number }> { - const { values } = parseCommandArgs(args, DOCTOR_OPTIONS, doctorSchema); - const commandValues = normalizeCommandValues(values); +export default async function handleDoctor({ + options, + storage, +}: { + options: CliOptions; + storage?: CliStorageBinding; +}): Promise<{ payload: DoctorPayload; exitCode: number }> { const startMs = Date.now(); - const ctx = await createDoctorContext(options, { - ...DEFAULT_POLICY, - strict: commandValues.strict, - }); - const memoryFindings = memoryBudgetFindings(commandValues); - const repairFinding = await repairMaterializationCache( - commandValues[DOCTOR_OPTION_REPAIR_MATERIALIZATION_CACHE], - ctx.materializationCacheDiagnostics, - ); + const ctx = await createDoctorContext(options, DEFAULT_POLICY, storage); const { findings, checksRun } = await runChecks(ctx, startMs); - findings.push(...memoryFindings); - if (repairFinding !== null) { findings.push(repairFinding); } findings.sort(compareFinding); const payload = assemblePayload({ repo: options.repo, graph: ctx.graphName, policy: ctx.policy, findings, - checksRun: checksRun + memoryFindings.length + (repairFinding === null ? 0 : 1), + checksRun, startMs, }); const exitCode = computeExitCode(payload.health, ctx.policy.strict); return { payload, exitCode }; } -function normalizeCommandValues(values: RawDoctorCommandValues): DoctorCommandValues { - return { - strict: values.strict, - [DOCTOR_OPTION_MEMORY_BUDGET]: values[DOCTOR_OPTION_MEMORY_BUDGET], - [DOCTOR_OPTION_LARGE_GRAPH]: values[DOCTOR_OPTION_LARGE_GRAPH], - [DOCTOR_OPTION_REPAIR_MATERIALIZATION_CACHE]: - values[DOCTOR_OPTION_REPAIR_MATERIALIZATION_CACHE], - }; -} - -function memoryBudgetFindings(values: DoctorCommandValues): DoctorFinding[] { - if (values[DOCTOR_OPTION_MEMORY_BUDGET] === undefined && !values[DOCTOR_OPTION_LARGE_GRAPH]) { - return []; - } - const report = createBoundedMemoryCapabilityReport(); - return [{ - id: MEMORY_BUDGET_FINDING_ID, - status: 'ok', - code: CODES.MEMORY_BUDGET_REPORT, - impact: 'operability', - message: 'Memory-budget posture reported for large-graph operation.', - evidence: { - requestedBudget: values[DOCTOR_OPTION_MEMORY_BUDGET] ?? MEMORY_BUDGET_NOT_SPECIFIED, - largeGraph: values[DOCTOR_OPTION_LARGE_GRAPH], - safe: mutableNames(report.safeNames()), - transitional: mutableNames(report.transitionalNames()), - diagnostic: mutableNames(report.diagnosticNames()), - legacy: mutableNames(report.legacyNames()), - }, - }]; -} - -function mutableNames(names: readonly string[]): string[] { - const result: string[] = []; - for (const name of names) { - result.push(name); - } - return result; -} - /** Assembles the final DoctorPayload from sorted findings. */ function assemblePayload({ repo, graph, policy, findings, checksRun, startMs }: { repo: string; graph: string; policy: DoctorPolicy; findings: DoctorFinding[]; checksRun: number; startMs: number }): DoctorPayload { let ok = 0; diff --git a/bin/cli/commands/doctor/storageCapabilities.ts b/bin/cli/commands/doctor/storageCapabilities.ts index f04ad9bb..fa788019 100644 --- a/bin/cli/commands/doctor/storageCapabilities.ts +++ b/bin/cli/commands/doctor/storageCapabilities.ts @@ -7,17 +7,16 @@ import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCrypto from '../../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import type RuntimeStorageProviderPort from '../../../../src/ports/RuntimeStorageProviderPort.ts'; -import { createPersistence, resolveGraphName } from '../../shared.ts'; +import { + createPersistence, + resolveGraphName, + type CliStorageBinding, +} from '../../shared.ts'; import type { CliOptions, Persistence } from '../../types.ts'; import type { DoctorContext, - DoctorFinding, DoctorPolicy, } from './types.ts'; -import { - materializationCacheRepairFailureFinding, - materializationCacheRepairFinding, -} from './checksMaterializationCache.ts'; export type DoctorStorageCapabilities = Readonly<{ stateCache: WarpStateCachePort | null; @@ -27,9 +26,11 @@ export type DoctorStorageCapabilities = Readonly<{ export async function createDoctorContext( options: CliOptions, policy: DoctorPolicy, + suppliedStorage?: CliStorageBinding, ): Promise { - const { persistence, runtimeStorage, hookPaths } = await createPersistence(options.repo); - const graphName = await resolveGraphName(persistence, options.graph); + const { persistence, runtimeStorage, hookPaths } = + suppliedStorage ?? await createPersistence(options.repo); + const graphName = await resolveGraphName(persistence, options.lane); const storage = await resolveStorageCapabilities(runtimeStorage, graphName); return { persistence, @@ -59,18 +60,6 @@ export async function resolveStorageCapabilities( }); } -export async function repairMaterializationCache( - requested: boolean, - diagnostics: MaterializationCacheDiagnosticsPort | null, -): Promise { - if (!requested || diagnostics === null) { return null; } - try { - return materializationCacheRepairFinding(await diagnostics.repairCache()); - } catch (error) { - return materializationCacheRepairFailureFinding(error); - } -} - async function collectWriterHeads( persistence: Persistence, graphName: string, diff --git a/bin/cli/commands/fork.ts b/bin/cli/commands/fork.ts index 91cc695f..39ed1e60 100644 --- a/bin/cli/commands/fork.ts +++ b/bin/cli/commands/fork.ts @@ -1,58 +1,67 @@ import { z } from 'zod'; -import { EXIT_CODES, parseCommandArgs } from '../infrastructure.ts'; -import { openGraph } from '../shared.ts'; +import { parseCommandArgs } from '../infrastructure.ts'; import type { CliOptions } from '../types.ts'; +import { openRequiredLane, withRuntime } from '../v19/V19Runtime.ts'; +import type Runtime from '../../../src/application/Runtime.ts'; +import type Lane from '../../../src/domain/api/Lane.ts'; +import type { McpJsonValue } from './mcp/McpJsonValue.ts'; const FORK_OPTIONS = { - from: { type: 'string' }, - at: { type: 'string' }, - 'fork-name': { type: 'string' }, - 'fork-writer': { type: 'string' }, + name: { type: 'string' }, }; -const forkSchema = z.object({ - from: z.string().min(1, 'Missing value for --from'), - at: z.string().min(1, 'Missing value for --at'), - 'fork-name': z.string().min(1, 'Missing value for --fork-name').optional(), - 'fork-writer': z.string().min(1, 'Missing value for --fork-writer').optional(), -}).strict().transform((val) => ({ - from: val.from, - at: val.at, - forkName: val['fork-name'] ?? null, - forkWriter: val['fork-writer'] ?? null, -})); +const FORK_SCHEMA = z.object({ + name: z.string().min(1), +}); -type ForkPayload = { - graph: string; - forkGraph: string; - forkWriter: string; - from: string; - at: string; - status: 'created'; -}; +export default async function handleFork({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise<{ + readonly payload: McpJsonValue; + readonly human: string; +}> { + const { values } = parseCommandArgs( + args, + FORK_OPTIONS, + FORK_SCHEMA, + ); + return await withRuntime( + options, + async (runtime) => + await forkLane(runtime, options.lane, values.name), + ); +} -export default async function handleFork( - { options, args }: { options: CliOptions; args: string[] }, -): Promise<{ payload: ForkPayload; exitCode: number }> { - const { values } = parseCommandArgs(args, FORK_OPTIONS, forkSchema); - const { graph, graphName } = await openGraph(options); - const forked = await graph.fork({ - from: values.from, - at: values.at, - ...(values.forkName !== null ? { forkName: values.forkName } : {}), - ...(values.forkWriter !== null ? { forkWriterId: values.forkWriter } : {}), - }); +async function forkLane( + runtime: Runtime, + laneName: string | null, + forkName: string, +) { + const source = await openRequiredLane(runtime, laneName); + const fork = await runtime.fork(source, { name: forkName }); + return forkResult(source, fork); +} +function forkResult(source: Lane, fork: Lane) { + const payload: McpJsonValue = Object.freeze({ + type: 'Lane', + kind: fork.kind, + name: fork.name, + writer: fork.writer, + source: source.reference, + }); return { - payload: { - graph: graphName, - forkGraph: forked.graphName, - forkWriter: forked.writerId, - from: values.from, - at: values.at, - status: 'created', - }, - exitCode: EXIT_CODES.OK, + payload, + human: [ + `Lane: ${fork.name}`, + `kind: ${fork.kind}`, + `source: ${source.name}`, + `writer: ${fork.writer}`, + ].join('\n'), }; } diff --git a/bin/cli/commands/gc.ts b/bin/cli/commands/gc.ts deleted file mode 100644 index 845dc84d..00000000 --- a/bin/cli/commands/gc.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; -import { openGraph, readActiveCursor } from '../shared.ts'; -import type { CliOptions } from '../types.ts'; - -const GC_OPTIONS = {}; - -const gcSchema = z.object({}).strict(); - -function gcUsage(): never { - throw usageError('Usage: warp-graph gc '); -} - -type GcPayload = - | { graph: string; metrics: ReturnType>['graph']['getGCMetrics']> } - | { graph: string; result: ReturnType>['graph']['maybeRunGC']> } - | { graph: string; result: ReturnType>['graph']['runGC']> }; - -async function assertNoActiveCursor( - cursorStore: Awaited>['cursorStore'], -): Promise { - const cursor = await readActiveCursor(cursorStore); - if (cursor !== null) { - throw usageError('gc refuses to run while seek cursor is active; run git warp seek --latest first'); - } -} - -async function gcStatus(options: CliOptions): Promise<{ payload: GcPayload; exitCode: number }> { - const { graph, graphName } = await openGraph(options); - await graph.materialize(); - return { - payload: { graph: graphName, metrics: graph.getGCMetrics() }, - exitCode: EXIT_CODES.OK, - }; -} - -async function maybeRunGc(options: CliOptions): Promise<{ payload: GcPayload; exitCode: number }> { - const { graph, graphName, cursorStore } = await openGraph(options); - await assertNoActiveCursor(cursorStore); - await graph.materialize(); - const result = graph.maybeRunGC(); - return { - payload: { graph: graphName, result }, - exitCode: EXIT_CODES.OK, - }; -} - -async function runGc(options: CliOptions): Promise<{ payload: GcPayload; exitCode: number }> { - const { graph, graphName, cursorStore } = await openGraph(options); - await assertNoActiveCursor(cursorStore); - await graph.materialize(); - const result = graph.runGC(); - return { - payload: { graph: graphName, result }, - exitCode: EXIT_CODES.OK, - }; -} - -export default async function handleGc( - { options, args }: { options: CliOptions; args: string[] }, -): Promise<{ payload: GcPayload; exitCode: number }> { - const action = args[0] ?? 'status'; - const rest = args.slice(1); - parseCommandArgs(rest, GC_OPTIONS, gcSchema); - - if (action === 'status') { return await gcStatus(options); } - if (action === 'maybe-run') { return await maybeRunGc(options); } - if (action === 'run') { return await runGc(options); } - return gcUsage(); -} diff --git a/bin/cli/commands/history.ts b/bin/cli/commands/history.ts deleted file mode 100644 index d41bf4f4..00000000 --- a/bin/cli/commands/history.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { EXIT_CODES, parseCommandArgs } from '../infrastructure.ts'; -import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.ts'; -import { historySchema } from '../schemas.ts'; -import type { CliOptions, WarpGraphInstance } from '../types.ts'; - -const HISTORY_OPTIONS = { - node: { type: 'string' }, -}; - -type WriterPatchEntry = Awaited>[number]; -type PatchOp = WriterPatchEntry['patch']['ops'][number]; - -type HistoryEntry = { - readonly sha: string; - readonly fullSha: string; - readonly writer: string; - readonly lamport: number; - readonly opCount: number; -}; - -type HistoryPayload = { - readonly graph: string; - readonly writer: string; - readonly node: string | null; - readonly entries: readonly HistoryEntry[]; -}; - -function opNodeMatches(op: PatchOp, nodeId: string): boolean { - return 'node' in op && op.node === nodeId; -} - -function opFromMatches(op: PatchOp, nodeId: string): boolean { - return 'from' in op && op.from === nodeId; -} - -function opToMatches(op: PatchOp, nodeId: string): boolean { - return 'to' in op && op.to === nodeId; -} - -function opTouchesNode(op: PatchOp, nodeId: string): boolean { - return opNodeMatches(op, nodeId) || opFromMatches(op, nodeId) || opToMatches(op, nodeId); -} - -function patchTouchesNode(entry: WriterPatchEntry, nodeId: string | null): boolean { - if (nodeId === null) { - return true; - } - return entry.patch.ops.some((op) => opTouchesNode(op, nodeId)); -} - -function toHistoryEntry(entry: WriterPatchEntry, writer: string): HistoryEntry { - return { - sha: entry.sha.slice(0, 7), - fullSha: entry.sha, - writer, - lamport: entry.patch.lamport, - opCount: entry.patch.ops.length, - }; -} - -/** Handles `git warp history`: lists a writer's patch chain. */ -export default async function handleHistory({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: HistoryPayload; exitCode: number }> { - const { values } = parseCommandArgs(args, HISTORY_OPTIONS, historySchema); - const { graph, graphName, cursorStore } = await openGraph(options); - const cursorInfo = await applyCursorCeiling(graph, cursorStore); - emitCursorWarning(cursorInfo, null); - - const node = values.node ?? null; - const patches = await graph.getWriterPatches(options.writer); - const entries = patches - .filter((entry) => patchTouchesNode(entry, node)) - .sort((a, b) => a.patch.lamport - b.patch.lamport) - .map((entry) => toHistoryEntry(entry, options.writer)); - - return { - payload: { graph: graphName, writer: options.writer, node, entries }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/info.ts b/bin/cli/commands/info.ts deleted file mode 100644 index 7693442e..00000000 --- a/bin/cli/commands/info.ts +++ /dev/null @@ -1,133 +0,0 @@ -import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.ts'; -import type { CorePersistence } from '../../../src/domain/types/WarpPersistence.ts'; -import { openRuntimeHostProduct } from '../../../src/domain/warp/RuntimeHostProduct.ts'; -import type RuntimeStorageProviderPort from '../../../src/ports/RuntimeStorageProviderPort.ts'; -import { - buildCheckpointRef, - buildCoverageRef, - buildWritersPrefix, - parseWriterIdFromRef, -} from '../../../src/domain/utils/RefLayout.ts'; -import { notFoundError } from '../infrastructure.ts'; -import { - createPersistence, - createSeekCursorStore, - listGraphNames, - readActiveCursor, - readCheckpointDate, -} from '../shared.ts'; -import type { CliOptions, Persistence, GraphInfoResult } from '../types.ts'; - -/** Collects metadata about a single graph (writer count, refs, patches, checkpoint). */ -async function getGraphInfo(persistence: Persistence, runtimeStorage: RuntimeStorageProviderPort, graphName: string, { - includeWriterIds = false, - includeRefs = false, - includeWriterPatches = false, - includeCheckpointDate = false, -}: { includeWriterIds?: boolean; includeRefs?: boolean; includeWriterPatches?: boolean; includeCheckpointDate?: boolean } = {}): Promise { - const writersPrefix = buildWritersPrefix(graphName); - const writerRefs = typeof persistence.listRefs === 'function' - ? await persistence.listRefs(writersPrefix) - : []; - const writerIds: string[] = writerRefs - .map((ref) => parseWriterIdFromRef(ref)) - .filter(Boolean) - .sort() as string[]; - - const info: GraphInfoResult = { - name: graphName, - writers: { - count: writerIds.length, - }, - }; - - if (includeWriterIds) { - info.writers.ids = writerIds; - } - - if (includeRefs || includeCheckpointDate) { - const checkpointRef = buildCheckpointRef(graphName); - const checkpointSha = await persistence.readRef(checkpointRef); - - const checkpoint: { ref: string; sha: string | null; date?: string | null } = { ref: checkpointRef, sha: (checkpointSha !== null && checkpointSha !== undefined && checkpointSha.length > 0) ? checkpointSha : null }; - - if (includeCheckpointDate && typeof checkpointSha === 'string' && checkpointSha.length > 0) { - const checkpointDate = await readCheckpointDate(persistence, checkpointSha); - checkpoint.date = checkpointDate; - } - - info.checkpoint = checkpoint; - - if (includeRefs) { - const coverageRef = buildCoverageRef(graphName); - const coverageSha = await persistence.readRef(coverageRef); - info.coverage = { ref: coverageRef, sha: (coverageSha !== null && coverageSha !== undefined && coverageSha.length > 0) ? coverageSha : null }; - } - } - - if (includeWriterPatches && writerIds.length > 0) { - const graph = await openRuntimeHostProduct({ - persistence: persistence as unknown as CorePersistence, - runtimeStorage, - graphName, - writerId: 'cli', - crypto: new WebCryptoAdapter(), - }); - const writerPatches: Record = {}; - for (const writerId of writerIds) { - const patches = await graph.getWriterPatches(writerId); - writerPatches[writerId] = patches.length; - } - info.writerPatches = writerPatches; - } - - return info; -} - -/** Handles the `info` command: summarizes graphs in the repository. */ -export default async function handleInfo({ options }: { options: CliOptions }): Promise<{ repo: string; graphs: GraphInfoResult[] }> { - const { persistence, runtimeStorage } = await createPersistence(options.repo); - const graphNames = await listGraphNames(persistence); - - if (typeof options.graph === 'string' && options.graph.length > 0 && !graphNames.includes(options.graph)) { - throw notFoundError(`Graph not found: ${options.graph}`); - } - - const detailGraphs = new Set(); - if (typeof options.graph === 'string' && options.graph.length > 0) { - detailGraphs.add(options.graph); - } else if (graphNames.length === 1 && graphNames[0] !== undefined) { - detailGraphs.add(graphNames[0]); - } - - // In view mode, include extra data for visualization - const isViewMode = Boolean(options.view); - - const graphs: GraphInfoResult[] = []; - for (const name of graphNames) { - const includeDetails = detailGraphs.has(name); - const info = await getGraphInfo(persistence, runtimeStorage, name, { - includeWriterIds: includeDetails || isViewMode, - includeRefs: includeDetails || isViewMode, - includeWriterPatches: isViewMode, - includeCheckpointDate: isViewMode, - }); - const cursorStore = await createSeekCursorStore(runtimeStorage, name); - const activeCursor = await readActiveCursor(cursorStore); - if (activeCursor) { - info.cursor = { - active: true, - tick: activeCursor.tick, - ...(activeCursor.mode !== undefined ? { mode: activeCursor.mode } : {}), - }; - } else { - info.cursor = { active: false }; - } - graphs.push(info); - } - - return { - repo: options.repo, - graphs, - }; -} diff --git a/bin/cli/commands/install-hooks.ts b/bin/cli/commands/install-hooks.ts deleted file mode 100644 index 83452093..00000000 --- a/bin/cli/commands/install-hooks.ts +++ /dev/null @@ -1,132 +0,0 @@ -import fs from 'node:fs'; -import process from 'node:process'; -import { classifyExistingHook } from '../../../src/domain/services/HookInstaller.ts'; -import { EXIT_CODES, usageError, parseCommandArgs } from '../infrastructure.ts'; -import { installHooksSchema } from '../schemas.ts'; -import { createHookInstaller, createPersistence, isInteractive, promptUser } from '../shared.ts'; -import type { CliOptions } from '../types.ts'; - -const INSTALL_HOOKS_OPTIONS = { - force: { type: 'boolean', default: false }, -}; - -/** Parses CLI arguments for the install-hooks command. */ -function parseInstallHooksArgs(args: string[]): { force: boolean } { - const { values } = parseCommandArgs(args, INSTALL_HOOKS_OPTIONS, installHooksSchema); - return values; -} - -/** Decides which installation strategy to use based on the existing hook state. */ -async function resolveStrategy(classification: { kind: string; version?: string; appended?: boolean }, hookOptions: { force: boolean }, targetVersion: string): Promise { - if (hookOptions.force) { - return 'replace'; - } - - if (classification.kind === 'none') { - return 'install'; - } - - if (classification.kind === 'ours') { - return await promptForOursStrategy(classification, targetVersion); - } - - return await promptForForeignStrategy(); -} - -/** Formats a hook version for user-facing prompts. */ -function formatHookVersion(version: string | undefined): string { - if (version === undefined) { - return 'version unreported'; - } - return `v${version}`; -} - -/** Prompts the user to upgrade an existing warp-managed hook. */ -async function promptForOursStrategy(classification: { kind: string; version?: string; appended?: boolean }, targetVersion: string): Promise { - if (classification.version === targetVersion) { - return 'up-to-date'; - } - - if (!isInteractive()) { - throw usageError('Existing hook found. Use --force or run interactively.'); - } - - const installedVersion = formatHookVersion(classification.version); - const formattedTargetVersion = formatHookVersion(targetVersion); - const answer = await promptUser( - `Upgrade hook from ${installedVersion} to ${formattedTargetVersion}? [Y/n] `, - ); - if (answer === '' || answer.toLowerCase() === 'y') { - return 'upgrade'; - } - return 'skip'; -} - -/** - * Prompts the user to choose how to handle a foreign (non-warp) hook. - * @returns Strategy: 'append', 'replace', or 'skip' - */ -async function promptForForeignStrategy(): Promise { - if (!isInteractive()) { - throw usageError('Existing hook found. Use --force or run interactively.'); - } - - process.stderr.write('Existing post-merge hook found.\n'); - process.stderr.write(' 1) Append (keep existing hook, add warp section)\n'); - process.stderr.write(' 2) Replace (back up existing, install fresh)\n'); - process.stderr.write(' 3) Skip\n'); - const answer = await promptUser('Choose [1-3]: '); - - if (answer === '1') { - return 'append'; - } - if (answer === '2') { - return 'replace'; - } - return 'skip'; -} - -/** Reads the content of a hook file, returning null if it does not exist. */ -function readHookContent(hookPath: string): string | null { - try { - return fs.readFileSync(hookPath, 'utf8'); - } catch (err) { - if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { - return null; - } - throw err; - } -} - -/** Builds the response payload for a no-op strategy (up-to-date or skip). */ -function buildNoOpResponse(strategy: string, status: { hookPath: string }, installer: { version: string }): { payload: unknown; exitCode: number } | null { - if (strategy === 'up-to-date') { - return { - payload: { action: 'up-to-date', hookPath: status.hookPath, version: installer.version }, - exitCode: EXIT_CODES.OK, - }; - } - if (strategy === 'skip') { - return { payload: { action: 'skipped' }, exitCode: EXIT_CODES.OK }; - } - return null; -} - -/** Handles the `install-hooks` command. */ -export default async function handleInstallHooks({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const hookOptions = parseInstallHooksArgs(args); - const { hookPaths } = await createPersistence(options.repo); - const installer = createHookInstaller(hookPaths); - const status = await installer.getHookStatus(options.repo); - const content = readHookContent(status.hookPath); - const classification = classifyExistingHook(content); - const strategy = await resolveStrategy(classification, hookOptions, installer.version); - - const noOp = buildNoOpResponse(strategy, status, installer); - if (noOp !== null) { - return noOp; - } - - const result = await installer.install(options.repo, { strategy: strategy as 'install' | 'upgrade' | 'append' | 'replace' }); - return { payload: result, exitCode: EXIT_CODES.OK }; -} diff --git a/bin/cli/commands/materialize.ts b/bin/cli/commands/materialize.ts deleted file mode 100644 index a28fa898..00000000 --- a/bin/cli/commands/materialize.ts +++ /dev/null @@ -1,104 +0,0 @@ -import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.ts'; -import type { CorePersistence } from '../../../src/domain/types/WarpPersistence.ts'; -import { openRuntimeHostProduct } from '../../../src/domain/warp/RuntimeHostProduct.ts'; -import type RuntimeStorageProviderPort from '../../../src/ports/RuntimeStorageProviderPort.ts'; -import { EXIT_CODES, notFoundError } from '../infrastructure.ts'; -import { - createPersistence, - createSeekCursorStore, - listGraphNames, - readActiveCursor, - emitCursorWarning, -} from '../shared.ts'; -import type { CliOptions, Persistence } from '../types.ts'; - -/** Materializes a single graph, creates a checkpoint, and returns summary stats. */ -async function materializeOneGraph({ persistence, runtimeStorage, graphName, writerId, ceiling }: { persistence: Persistence; runtimeStorage: RuntimeStorageProviderPort; graphName: string; writerId: string; ceiling?: number }): Promise<{ graph: string; nodes: number; edges: number; properties: number; checkpoint: string | null; writers: Record; patchCount: number }> { - const graph = await openRuntimeHostProduct({ - persistence: persistence as unknown as CorePersistence, - runtimeStorage, - graphName, - writerId, - crypto: new WebCryptoAdapter(), - }); - await graph.materialize(ceiling !== undefined ? { ceiling } : undefined); - const nodes = await graph.getNodes(); - const edges = await graph.getEdges(); - const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint(); - const status = await graph.status(); - - // Build per-writer patch counts for the view renderer - const writers: Record = {}; - let totalPatchCount = 0; - for (const wId of Object.keys(status.frontier)) { - const patches = await graph.getWriterPatches(wId); - writers[wId] = patches.length; - totalPatchCount += patches.length; - } - - const properties = await graph.getPropertyCount(); - - return { - graph: graphName, - nodes: nodes.length, - edges: edges.length, - properties, - checkpoint, - writers, - patchCount: totalPatchCount, - }; -} - -/** Handles the `materialize` command: materializes and checkpoints all graphs. */ -export default async function handleMaterialize({ options }: { options: CliOptions }): Promise<{ payload: unknown; exitCode: number }> { - const { persistence, runtimeStorage } = await createPersistence(options.repo); - const graphNames = await listGraphNames(persistence); - - if (graphNames.length === 0) { - return { - payload: { graphs: [] }, - exitCode: EXIT_CODES.OK, - }; - } - - const graphOpt = options.graph; - const hasGraphOpt = typeof graphOpt === 'string' && graphOpt !== ''; - const targets = hasGraphOpt ? [graphOpt] : graphNames; - - if (hasGraphOpt && !graphNames.includes(graphOpt)) { - throw notFoundError(`Graph not found: ${graphOpt}`); - } - - const results: Array<{ graph: string; nodes?: number; edges?: number; properties?: number; checkpoint?: string | null; writers?: Record; patchCount?: number; error?: string }> = []; - let cursorWarningEmitted = false; - for (const name of targets) { - try { - const cursorStore = await createSeekCursorStore(runtimeStorage, name); - const cursor = await readActiveCursor(cursorStore); - 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, - runtimeStorage, - graphName: name, - writerId: options.writer, - ...(ceiling !== undefined ? { ceiling } : {}), - }); - results.push(result); - } catch (error) { - results.push({ - graph: name, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - const allFailed = results.every((r) => 'error' in r); - return { - payload: { graphs: results }, - exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/mcp.ts b/bin/cli/commands/mcp.ts index 70a3e22d..56fc7933 100644 --- a/bin/cli/commands/mcp.ts +++ b/bin/cli/commands/mcp.ts @@ -2,12 +2,13 @@ import process from 'node:process'; import readline from 'node:readline'; import { usageError } from '../infrastructure.ts'; -import { openGraph, readCliPackageVersion } from '../shared.ts'; +import { readCliPackageVersion } from '../shared.ts'; import { handleMcpMessage, mcpParseError, type McpResponse, } from './mcp/McpProtocol.ts'; +import McpRuntimeSession from './mcp/McpRuntimeSession.ts'; import type { CliOptions } from '../types.ts'; type McpCommandResult = { @@ -16,12 +17,12 @@ type McpCommandResult = { readonly completion: Promise; }; -function writeResponse(response: McpResponse): void { - process.stdout.write(`${JSON.stringify(response)}\n`); -} +type McpLineTracker = { + readonly completedFailures: unknown[]; + readonly pending: Set>; +}; -/** Handles `git warp mcp`: starts a local stdio MCP server. */ -export default async function handleMcp({ +export default function handleMcp({ options, args, }: { @@ -29,80 +30,112 @@ export default async function handleMcp({ readonly args: string[]; }): Promise { if (args.length > 0) { - throw usageError('mcp does not accept positional args; use --repo, --graph, and --writer'); + throw usageError( + 'mcp does not accept positional arguments; use --repo and --writer', + ); } - - const { graph } = await openGraph(options); - return createMcpCommandResult(graph, readCliPackageVersion()); + const session = new McpRuntimeSession({ + at: options.repo, + writer: options.writer, + }); + return Promise.resolve( + createMcpCommandResult(session, readCliPackageVersion()), + ); } function createMcpCommandResult( - graph: Parameters[0], + session: McpRuntimeSession, serverVersion: string, ): McpCommandResult { const lines = readline.createInterface({ input: process.stdin, terminal: false, }); - const completion = trackMcpLines(lines, graph, serverVersion); - + const completion = trackMcpLines( + lines, + session, + serverVersion, + ); return { payload: undefined, completion, close: async () => { lines.close(); await completion; + await session.close(); }, }; } function trackMcpLines( lines: readline.Interface, - graph: Parameters[0], + session: McpRuntimeSession, serverVersion: string, ): Promise { const pending = new Set>(); const completedFailures: unknown[] = []; + const tracker = { pending, completedFailures }; const completion = Promise.withResolvers(); - lines.on('line', (line) => { - const operation = dispatchLine(graph, serverVersion, line); - pending.add(operation); - void operation.then( - () => pending.delete(operation), - (error: unknown) => { - pending.delete(operation); - completedFailures.push(error); - }, - ); - }); - lines.on('error', (error: unknown) => { - completedFailures.push(error); - lines.close(); - }); + lines.on('line', (line) => + trackLine(tracker, dispatchLine(session, serverVersion, line)) + ); + lines.on('error', (error: unknown) => + trackInputError(lines, tracker, error) + ); lines.once('close', () => { - void settlePendingDispatches(pending, completedFailures) - .then(completion.resolve, completion.reject); + void settlePendingDispatches( + pending, + completedFailures, + ).then(completion.resolve, completion.reject); }); return completion.promise; } +function trackLine( + tracker: McpLineTracker, + operation: Promise, +): void { + tracker.pending.add(operation); + void operation.then( + () => tracker.pending.delete(operation), + (error: unknown) => { + tracker.pending.delete(operation); + tracker.completedFailures.push(error); + }, + ); +} + +function trackInputError( + lines: readline.Interface, + tracker: McpLineTracker, + error: unknown, +): void { + tracker.completedFailures.push(error); + lines.close(); +} + async function settlePendingDispatches( pending: ReadonlySet>, completedFailures: readonly unknown[], ): Promise { const failures = [...completedFailures]; const results = await Promise.allSettled([...pending]); - failures.push(...results - .filter((result): result is PromiseRejectedResult => result.status === 'rejected') - .map((result) => result.reason as unknown)); + for (const result of results) { + if (result.status === 'rejected') { + failures.push(result.reason); + } + } if (failures.length > 0) { - throw new AggregateError(failures, 'MCP requests failed while stdin was closing'); + throw new AggregateError( + failures, + 'MCP requests failed while stdin was closing', + ); } } async function dispatchLine( - graph: Parameters[0], + session: McpRuntimeSession, serverVersion: string, line: string, ): Promise { @@ -110,8 +143,11 @@ async function dispatchLine( return; } try { - const parsed = JSON.parse(line) as unknown; - const response = await handleMcpMessage(graph, parsed, { serverVersion }); + const response = await handleMcpMessage( + session, + JSON.parse(line), + { serverVersion }, + ); if (response !== null) { writeResponse(response); } @@ -119,3 +155,7 @@ async function dispatchLine( writeResponse(mcpParseError()); } } + +function writeResponse(response: McpResponse): void { + process.stdout.write(`${JSON.stringify(response)}\n`); +} diff --git a/bin/cli/commands/mcp/McpDiagnosticTools.ts b/bin/cli/commands/mcp/McpDiagnosticTools.ts new file mode 100644 index 00000000..ffb401a8 --- /dev/null +++ b/bin/cli/commands/mcp/McpDiagnosticTools.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; + +import type Runtime from '../../../../src/application/Runtime.ts'; +import WarpError from '../../../../src/domain/errors/WarpError.ts'; +import { toMcpJson } from '../../../presenters/V19Json.ts'; +import { closeCliStorages } from '../../shared.ts'; +import prepareMaterialization from '../MaterializationRepair.ts'; +import handleSubstrateDoctor from '../doctor/index.ts'; +import handleSubstrateAudit from '../verify-audit.ts'; +import type { McpDomainTool } from './McpDomainTool.ts'; +import type { McpJsonObject, McpJsonValue } from './McpJsonValue.ts'; +import type McpRuntimeSession from './McpRuntimeSession.ts'; +import { parseMcpToolInput } from './McpToolInput.ts'; + +const LANE_SCHEMA = z.object({ + lane: z.string().min(1), +}).strict(); + +const REPAIR_SCHEMA = z.object({ + lane: z.string().min(1), + action: z.literal('materialization'), +}).strict(); + +const AUDIT_SCHEMA = z.object({ + lane: z.string().min(1), + writer: z.string().min(1).optional(), +}).strict(); + +export const MCP_DIAGNOSTIC_TOOLS: ReadonlyMap = + new Map([ + ['warp_doctor', doctor], + ['warp_repair', repair], + ['warp_audit', audit], + ]); + +async function doctor( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const { lane } = parseMcpToolInput(LANE_SCHEMA, args); + await runtime.lane(lane); + return await runSubstrateDiagnostic(async () => { + const result = await handleSubstrateDoctor({ + options: session.cliOptions(lane), + }); + return toMcpJson(result.payload); + }); +} + +async function repair( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const { lane } = parseMcpToolInput(REPAIR_SCHEMA, args); + await runtime.lane(lane); + return await runSubstrateDiagnostic(async () => + toMcpJson(await prepareMaterialization(session.cliOptions(lane), lane)), + ); +} + +async function audit( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const input = parseMcpToolInput(AUDIT_SCHEMA, args); + await runtime.lane(input.lane); + return await runSubstrateDiagnostic(async () => { + const result = await handleSubstrateAudit({ + options: session.cliOptions(input.lane), + args: + input.writer === undefined + ? [] + : ['--writer', input.writer], + }); + return toMcpJson(requireObject(result.payload)); + }); +} + +async function runSubstrateDiagnostic( + task: () => Promise, +): Promise { + try { + return await task(); + } finally { + await closeCliStorages(); + } +} + +function requireObject(value: unknown): object { + if (value === null || typeof value !== 'object') { + throw new WarpError( + 'Diagnostic result must be an object', + 'E_MCP_DIAGNOSTIC_RESULT', + ); + } + return value; +} diff --git a/bin/cli/commands/mcp/McpDomainTool.ts b/bin/cli/commands/mcp/McpDomainTool.ts new file mode 100644 index 00000000..feb83f09 --- /dev/null +++ b/bin/cli/commands/mcp/McpDomainTool.ts @@ -0,0 +1,9 @@ +import type Runtime from '../../../../src/application/Runtime.ts'; +import type { McpJsonObject, McpJsonValue } from './McpJsonValue.ts'; +import type McpRuntimeSession from './McpRuntimeSession.ts'; + +export type McpDomainTool = ( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +) => Promise; diff --git a/bin/cli/commands/mcp/McpDomainTools.ts b/bin/cli/commands/mcp/McpDomainTools.ts new file mode 100644 index 00000000..f419f6d1 --- /dev/null +++ b/bin/cli/commands/mcp/McpDomainTools.ts @@ -0,0 +1,221 @@ +import { z } from 'zod'; + +import type Runtime from '../../../../src/application/Runtime.ts'; +import { + JSON_INPUT_SCHEMA, + intentFromValue, + observerFromValue, +} from '../../v19/V19DomainInput.ts'; +import { + applyReviewedSettlement, + previewReviewedSettlement, + reviewSettlement, + settlementPlanFields, +} from '../../v19/V19SettlementReview.ts'; +import { + evidenceEnvelope, + readingEnvelope, + receiptEnvelope, +} from '../../../presenters/V19ReadingReceipt.ts'; +import { toMcpJson } from '../../../presenters/V19Json.ts'; +import type { + McpJsonObject, + McpJsonValue, +} from './McpJsonValue.ts'; +import type McpRuntimeSession from './McpRuntimeSession.ts'; +import type { McpDomainTool } from './McpDomainTool.ts'; +import { MCP_DIAGNOSTIC_TOOLS } from './McpDiagnosticTools.ts'; +import { parseMcpToolInput } from './McpToolInput.ts'; + +const LANE_SCHEMA = z.object({ + lane: z.string().min(1), + strand: z.string().min(1).optional(), +}).strict(); + +const WRITE_SCHEMA = z.object({ + lane: z.string().min(1), + strand: z.string().min(1).optional(), + intent: z.record(z.string(), JSON_INPUT_SCHEMA), +}).strict(); + +const OBSERVATION_START_SCHEMA = z.object({ + lane: z.string().min(1), + strand: z.string().min(1).optional(), + observerId: z.string().min(1), + reading: z.record(z.string(), JSON_INPUT_SCHEMA), +}).strict(); + +const OBSERVATION_READ_SCHEMA = z.object({ + observationId: z.string().min(1), + cursor: z.string().min(1).optional(), + limit: z.number().int().min(1).max(256).default(64), +}).strict(); + +const OBSERVATION_ID_SCHEMA = z.object({ + observationId: z.string().min(1), +}).strict(); + +const RECEIPT_SCHEMA = z.object({ + receiptRef: z.string().min(1), +}).strict(); + +const PREVIEW_SCHEMA = z.object({ + sourceLane: z.string().min(1), + sourceStrand: z.string().min(1), + targetLane: z.string().min(1), +}).strict(); + +const APPLY_SCHEMA = z.object({ + planRef: z.string().min(1), +}).strict(); + +export const MCP_DOMAIN_TOOLS: ReadonlyMap = new Map([ + ['warp_lane_describe', describeLane], + ['warp_intent_write', writeIntent], + ['warp_observation_start', startObservation], + ['warp_observation_read', readObservation], + ['warp_observation_cancel', cancelObservation], + ['warp_receipt_get', getReceipt], + ['warp_settlement_preview', previewSettlement], + ['warp_settlement_apply', applySettlement], + ...MCP_DIAGNOSTIC_TOOLS, +]); + +async function describeLane( + runtime: Runtime, + _session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const input = parseMcpToolInput(LANE_SCHEMA, args); + const lane = await openTransportLane(runtime, input); + return Object.freeze({ + type: 'Lane', + kind: lane.kind, + name: lane.name, + writer: lane.writer, + }); +} + +async function writeIntent( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const input = parseMcpToolInput(WRITE_SCHEMA, args); + const lane = await openTransportLane(runtime, input); + const receipt = receiptEnvelope( + await lane.write(intentFromValue(input.intent)), + ); + const receiptRef = session.retainReceipt(receipt); + return Object.freeze({ receiptRef, receipt }); +} + +async function startObservation( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const input = parseMcpToolInput(OBSERVATION_START_SCHEMA, args); + const lane = await openTransportLane(runtime, input); + const observation = lane.observe( + observerFromValue(input.observerId, input.reading), + ); + return session.retainObservation({ + readings: readingEnvelopes(observation), + receipt: async () => receiptEnvelope(await observation.receipt), + }); +} + +async function readObservation( + _runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const input = parseMcpToolInput(OBSERVATION_READ_SCHEMA, args); + return await session.readObservation( + input.observationId, + input.cursor, + input.limit ?? 64, + ); +} + +async function cancelObservation( + _runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const { observationId } = parseMcpToolInput( + OBSERVATION_ID_SCHEMA, + args, + ); + return await session.cancelObservation(observationId); +} + +async function* readingEnvelopes( + readings: AsyncIterable[0]>, +): AsyncIterable { + for await (const observed of readings) { + yield readingEnvelope(observed); + } +} + +function getReceipt( + _runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const { receiptRef } = parseMcpToolInput(RECEIPT_SCHEMA, args); + return Promise.resolve(session.getReceipt(receiptRef)); +} + +async function previewSettlement( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const input = parseMcpToolInput(PREVIEW_SCHEMA, args); + const selector = Object.freeze({ + sourceLane: input.sourceLane, + sourceStrand: input.sourceStrand, + targetLane: input.targetLane, + }); + const preview = await previewReviewedSettlement(runtime, selector); + const planRef = session.retainPlan( + reviewSettlement(selector, preview.plan), + ); + return Object.freeze({ + type: 'SettlementPreview', + planRef, + source: toMcpJson(preview.source), + target: toMcpJson(preview.target), + plan: toMcpJson(settlementPlanFields(preview.plan)), + outcome: toMcpJson(preview.outcome), + evidence: evidenceEnvelope(preview.evidence), + }); +} + +async function applySettlement( + runtime: Runtime, + session: McpRuntimeSession, + args: McpJsonObject, +): Promise { + const { planRef } = parseMcpToolInput(APPLY_SCHEMA, args); + const receipt = receiptEnvelope( + await applyReviewedSettlement(runtime, session.getPlan(planRef)), + ); + const receiptRef = session.retainReceipt(receipt); + return Object.freeze({ receiptRef, receipt }); +} + +async function openTransportLane( + runtime: Runtime, + selector: Readonly<{ + readonly lane: string; + readonly strand?: string | undefined; + }>, +) { + const lane = await runtime.lane(selector.lane); + return selector.strand === undefined + ? lane + : await runtime.strand(lane, { name: selector.strand }); +} diff --git a/bin/cli/commands/mcp/McpProtocol.ts b/bin/cli/commands/mcp/McpProtocol.ts index cc91850a..c1c6928a 100644 --- a/bin/cli/commands/mcp/McpProtocol.ts +++ b/bin/cli/commands/mcp/McpProtocol.ts @@ -1,8 +1,8 @@ import { callMcpTool, listMcpTools, - type McpGraphReadSurface, } from './McpToolCatalog.ts'; +import type McpRuntimeSession from './McpRuntimeSession.ts'; import McpProtocolError from './McpProtocolError.ts'; import { isMcpJsonObject, @@ -12,7 +12,6 @@ import { export { listMcpTools, - type McpGraphReadSurface, } from './McpToolCatalog.ts'; type McpRequestId = string | number | null; @@ -50,7 +49,7 @@ type ToolCallInput = { }; type MethodHandler = ( - graph: McpGraphReadSurface, + session: McpRuntimeSession, request: McpRequest, options: McpOptions, ) => Promise; @@ -68,7 +67,7 @@ export function mcpParseError(): McpResponse { } export async function handleMcpMessage( - graph: McpGraphReadSurface, + session: McpRuntimeSession, message: unknown, options: McpOptions, ): Promise { @@ -80,7 +79,7 @@ export async function handleMcpMessage( return null; } try { - const result = await dispatchRequest(graph, request, options); + const result = await dispatchRequest(session, request, options); return resultResponse(request.id, result); } catch (error) { return errorResponse(request.id, normalizeError(error)); @@ -88,19 +87,19 @@ export async function handleMcpMessage( } async function dispatchRequest( - graph: McpGraphReadSurface, + session: McpRuntimeSession, request: McpRequest, options: McpOptions, ): Promise { const handler = METHOD_HANDLERS.get(request.method); if (handler !== undefined) { - return await handler(graph, request, options); + return await handler(session, request, options); } throw new McpProtocolError(-32601, `Method not found: ${request.method}`); } function handleInitialize( - _graph: McpGraphReadSurface, + _session: McpRuntimeSession, request: McpRequest, options: McpOptions, ): Promise { @@ -119,11 +118,11 @@ function handleToolsList(): Promise { } async function handleToolsCall( - graph: McpGraphReadSurface, + session: McpRuntimeSession, request: McpRequest, ): Promise { const input = readToolCallInput(request.params); - return await callMcpTool(graph, input.name, input.arguments); + return await callMcpTool(session, input.name, input.arguments); } function handleResourcesList(): Promise { diff --git a/bin/cli/commands/mcp/McpRuntimeSession.ts b/bin/cli/commands/mcp/McpRuntimeSession.ts new file mode 100644 index 00000000..28f3d689 --- /dev/null +++ b/bin/cli/commands/mcp/McpRuntimeSession.ts @@ -0,0 +1,234 @@ +import Runtime from '../../../../src/application/Runtime.ts'; +import type { CliOptions } from '../../types.ts'; +import type { ReviewedSettlement } from '../../v19/V19SettlementReview.ts'; +import type { McpJsonValue } from './McpJsonValue.ts'; +import McpProtocolError from './McpProtocolError.ts'; + +type ObservationTransport = { + readonly buffered: McpJsonValue[]; + readonly iterator: AsyncIterator; + readonly receipt: () => Promise; + cursor: number; + receiptRef: string | null; + terminal: boolean; +}; + +type RetainedObservation = Readonly<{ + readonly observationId: string; + readonly terminal: boolean; + readonly receiptRef: string | null; +}>; + +export default class McpRuntimeSession { + readonly #at: string; + readonly #observations = new Map(); + readonly #plans = new Map(); + readonly #receipts = new Map(); + readonly #writer: string; + #nextObservation = 1; + #nextPlan = 1; + #nextReceipt = 1; + #runtime: Promise | null = null; + #tail: Promise = Promise.resolve(); + + constructor(options: { + readonly at: string; + readonly writer: string; + }) { + this.#at = options.at; + this.#writer = options.writer; + } + + run( + task: (runtime: Runtime) => Promise, + ): Promise { + const result = this.#tail.then(async () => { + const runtime = await this.runtime(); + return await task(runtime); + }); + this.#tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + retainReceipt(receipt: McpJsonValue): string { + const reference = `receipt-${this.#nextReceipt}`; + this.#nextReceipt += 1; + this.#receipts.set(reference, receipt); + return reference; + } + + getReceipt(reference: string): McpJsonValue { + const receipt = this.#receipts.get(reference); + if (receipt === undefined) { + throw new McpProtocolError( + -32602, + `Receipt not found: ${reference}`, + ); + } + return receipt; + } + + retainPlan(plan: ReviewedSettlement): string { + const reference = `plan-${this.#nextPlan}`; + this.#nextPlan += 1; + this.#plans.set(reference, plan); + return reference; + } + + getPlan(reference: string): ReviewedSettlement { + const plan = this.#plans.get(reference); + if (plan === undefined) { + throw new McpProtocolError( + -32602, + `Settlement plan not found: ${reference}`, + ); + } + return plan; + } + + retainObservation(options: { + readonly readings: AsyncIterable; + readonly receipt: () => Promise; + }): RetainedObservation { + const identifier = `observation-${this.#nextObservation}`; + this.#nextObservation += 1; + const transport: ObservationTransport = { + buffered: [], + iterator: options.readings[Symbol.asyncIterator](), + receipt: options.receipt, + cursor: 0, + receiptRef: null, + terminal: false, + }; + this.#observations.set(identifier, transport); + return Object.freeze({ + observationId: identifier, + terminal: false, + receiptRef: null, + }); + } + + async readObservation( + identifier: string, + cursor: string | undefined, + limit: number, + ): Promise { + const observation = this.requireObservation(identifier); + this.assertObservationCursor(observation, cursor); + const readings = await this.readObservationPage(observation, limit); + await this.pullObservation(observation); + observation.cursor += readings.length; + return Object.freeze({ + observationId: identifier, + readings: Object.freeze(readings), + cursor: observation.terminal ? null : String(observation.cursor), + terminal: observation.terminal, + receiptRef: observation.receiptRef, + }); + } + + private assertObservationCursor( + observation: ObservationTransport, + cursor: string | undefined, + ): void { + const expectedCursor = String(observation.cursor); + if (cursor !== undefined && cursor !== expectedCursor) { + throw new McpProtocolError( + -32602, + `Observation cursor mismatch: expected ${expectedCursor}`, + ); + } + } + + private async readObservationPage( + observation: ObservationTransport, + limit: number, + ): Promise { + const readings: McpJsonValue[] = []; + while (readings.length < limit) { + await this.pullObservation(observation); + const reading = observation.buffered.shift(); + if (reading === undefined) { + break; + } + readings.push(reading); + } + return readings; + } + + async cancelObservation(identifier: string): Promise { + const observation = this.requireObservation(identifier); + await observation.iterator.return?.(); + this.#observations.delete(identifier); + return Object.freeze({ + observationId: identifier, + canceled: true, + }); + } + + cliOptions(lane: string): CliOptions { + return { + repo: this.#at, + lane, + strand: null, + writer: this.#writer, + writerExplicit: true, + json: true, + jsonl: false, + help: false, + }; + } + + async close(): Promise { + await this.#tail; + await Promise.all( + [...this.#observations.values()].map( + async (observation) => await observation.iterator.return?.(), + ), + ); + this.#observations.clear(); + this.#plans.clear(); + this.#receipts.clear(); + if (this.#runtime !== null) { + await (await this.#runtime).close(); + this.#runtime = null; + } + } + + private runtime(): Promise { + this.#runtime ??= Runtime.open({ + at: this.#at, + writer: this.#writer, + }); + return this.#runtime; + } + + private async pullObservation( + observation: ObservationTransport, + ): Promise { + if (observation.terminal || observation.buffered.length > 0) { + return; + } + const next = await observation.iterator.next(); + if (next.done !== true) { + observation.buffered.push(next.value); + return; + } + observation.terminal = true; + observation.receiptRef = this.retainReceipt(await observation.receipt()); + } + + private requireObservation(identifier: string): ObservationTransport { + const observation = this.#observations.get(identifier); + if (observation === undefined) { + throw new McpProtocolError( + -32602, + `Observation not found: ${identifier}`, + ); + } + return observation; + } +} diff --git a/bin/cli/commands/mcp/McpToolCatalog.ts b/bin/cli/commands/mcp/McpToolCatalog.ts index 81039a61..53e5aeb1 100644 --- a/bin/cli/commands/mcp/McpToolCatalog.ts +++ b/bin/cli/commands/mcp/McpToolCatalog.ts @@ -1,23 +1,15 @@ -import { z } from 'zod'; - import { compactStringify } from '../../../presenters/json.ts'; -import ImmutableBytes from '../../../../src/domain/services/snapshot/ImmutableBytes.ts'; -import McpProtocolError from './McpProtocolError.ts'; +import { V19_CAPABILITY_CONTRACT } from '../../capabilities/V19CapabilityContract.generated.ts'; +import { + MCP_DOMAIN_TOOLS, +} from './McpDomainTools.ts'; +import type { McpDomainTool } from './McpDomainTool.ts'; import type { - QueryPropertyBag, - VisibleEdge, -} from '../../../../src/domain/capabilities/QueryCapability.ts'; -import type { SnapshotPropValue } from '../../../../src/domain/services/snapshot/SnapshotPropValue.ts'; -import type { McpJsonObject, McpJsonValue } from './McpJsonValue.ts'; - -export type McpGraphReadSurface = { - readonly graphName: string; - readonly writerId: string; - hasNode(nodeId: string): Promise; - getNodes(): Promise; - getNodeProps(nodeId: string): Promise; - getEdges(): Promise; -}; + McpJsonObject, + McpJsonValue, +} from './McpJsonValue.ts'; +import McpProtocolError from './McpProtocolError.ts'; +import type McpRuntimeSession from './McpRuntimeSession.ts'; export type McpToolDescriptor = { readonly name: string; @@ -25,180 +17,49 @@ export type McpToolDescriptor = { readonly inputSchema: McpJsonObject; }; -type RegisteredMcpTool = McpToolDescriptor & { - readonly handle: (graph: McpGraphReadSurface, args: McpJsonObject) => Promise; -}; - -const EMPTY_ARGS_SCHEMA = z.object({}).strict(); -const NODE_ID_ARGS_SCHEMA = z.object({ nodeId: z.string().min(1) }).strict(); - -const EMPTY_INPUT_SCHEMA: McpJsonObject = Object.freeze({ - type: 'object', - properties: Object.freeze({}), - additionalProperties: false, -}); - -const NODE_ID_INPUT_SCHEMA: McpJsonObject = Object.freeze({ - type: 'object', - properties: Object.freeze({ - nodeId: Object.freeze({ type: 'string', minLength: 1 }), - }), - required: Object.freeze(['nodeId']), - additionalProperties: false, -}); - -const REGISTERED_TOOLS: readonly RegisteredMcpTool[] = Object.freeze([ - Object.freeze({ - name: 'warp_info', - description: 'Return graph identity and MCP server posture.', - inputSchema: EMPTY_INPUT_SCHEMA, - handle: warpInfo, - }), - Object.freeze({ - name: 'warp_nodes', - description: 'List visible node ids for the opened graph.', - inputSchema: EMPTY_INPUT_SCHEMA, - handle: warpNodes, - }), - Object.freeze({ - name: 'warp_node_props', - description: 'Read visible properties for a single node id.', - inputSchema: NODE_ID_INPUT_SCHEMA, - handle: warpNodeProps, - }), - Object.freeze({ - name: 'warp_edges', - description: 'List visible edges for the opened graph.', - inputSchema: EMPTY_INPUT_SCHEMA, - handle: warpEdges, - }), - Object.freeze({ - name: 'warp_has_node', - description: 'Return whether a visible node id exists.', - inputSchema: NODE_ID_INPUT_SCHEMA, - handle: warpHasNode, - }), -]); - -const TOOLS_BY_NAME = new Map(REGISTERED_TOOLS.map((tool) => [tool.name, tool])); - export function listMcpTools(): readonly McpToolDescriptor[] { - return Object.freeze(REGISTERED_TOOLS.map((tool) => Object.freeze({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - }))); + return Object.freeze( + V19_CAPABILITY_CONTRACT.mcp.map((tool) => + Object.freeze({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }), + ), + ); } export async function callMcpTool( - graph: McpGraphReadSurface, + session: McpRuntimeSession, name: string, args: McpJsonObject, ): Promise { - const tool = TOOLS_BY_NAME.get(name); - if (tool === undefined) { - throw new McpProtocolError(-32602, `Unknown MCP tool: ${name}`); + const handler = requireHandler(name); + const payload = await session.run( + async (runtime) => await handler(runtime, session, args), + ); + return toolResponse(payload); +} + +function requireHandler(name: string): McpDomainTool { + const handler = MCP_DOMAIN_TOOLS.get(name); + if (handler === undefined) { + throw new McpProtocolError( + -32602, + `Unknown MCP tool: ${name}`, + ); } - return toolResponse(await tool.handle(graph, args)); -} - -function warpInfo(graph: McpGraphReadSurface, args: McpJsonObject): Promise { - parseEmptyArgs(args); - return Promise.resolve({ - graph: graph.graphName, - writer: graph.writerId, - posture: 'read-only', - }); -} - -async function warpNodes(graph: McpGraphReadSurface, args: McpJsonObject): Promise { - parseEmptyArgs(args); - return { nodes: await graph.getNodes() }; -} - -async function warpNodeProps(graph: McpGraphReadSurface, args: McpJsonObject): Promise { - const { nodeId } = parseNodeIdArgs(args); - const props = await graph.getNodeProps(nodeId); - return { - nodeId, - props: props === null ? null : propertyBagToJson(props), - }; -} - -async function warpEdges(graph: McpGraphReadSurface, args: McpJsonObject): Promise { - parseEmptyArgs(args); - return { edges: (await graph.getEdges()).map(edgeToJson) }; -} - -async function warpHasNode(graph: McpGraphReadSurface, args: McpJsonObject): Promise { - const { nodeId } = parseNodeIdArgs(args); - return { nodeId, exists: await graph.hasNode(nodeId) }; -} - -function parseEmptyArgs(args: McpJsonObject): void { - const parsed = EMPTY_ARGS_SCHEMA.safeParse(args); - if (!parsed.success) { - throw invalidToolInput(parsed.error.issues.map((issue) => issue.message)); - } -} - -function parseNodeIdArgs(args: McpJsonObject): { readonly nodeId: string } { - const parsed = NODE_ID_ARGS_SCHEMA.safeParse(args); - if (parsed.success) { - return parsed.data; - } - throw invalidToolInput(parsed.error.issues.map((issue) => issue.message)); -} - -function invalidToolInput(issues: readonly string[]): McpProtocolError { - return new McpProtocolError(-32602, 'Invalid MCP tool input', { issues }); -} - -function edgeToJson(edge: VisibleEdge): McpJsonValue { - return { - from: edge.from, - to: edge.to, - label: edge.label, - props: propertyBagToJson(edge.props), - }; -} - -function propertyBagToJson(props: QueryPropertyBag): McpJsonValue { - const bag: { [key: string]: McpJsonValue } = {}; - for (const [key, value] of Object.entries(props)) { - bag[key] = propValueToJson(value); - } - return Object.freeze(bag); -} - -function propValueToJson(value: SnapshotPropValue): McpJsonValue { - if (value instanceof ImmutableBytes) { - return { type: 'bytes', value: value.toArray() }; - } - if (isSnapshotPropArray(value)) { - return Object.freeze(value.map(propValueToJson)); - } - if (value !== null && typeof value === 'object') { - return propObjectToJson(value); - } - return value; -} - -function isSnapshotPropArray(value: SnapshotPropValue): value is readonly SnapshotPropValue[] { - return Array.isArray(value); -} - -function propObjectToJson(value: { readonly [key: string]: SnapshotPropValue }): McpJsonValue { - const objectValue: { [key: string]: McpJsonValue } = {}; - for (const [key, entry] of Object.entries(value)) { - objectValue[key] = propValueToJson(entry); - } - return Object.freeze(objectValue); + return handler; } function toolResponse(payload: McpJsonValue): McpJsonValue { - return { - content: [{ type: 'text', text: compactStringify(payload) }], + return Object.freeze({ + content: Object.freeze([ + Object.freeze({ + type: 'text', + text: compactStringify(payload), + }), + ]), structuredContent: payload, - }; + }); } diff --git a/bin/cli/commands/mcp/McpToolInput.ts b/bin/cli/commands/mcp/McpToolInput.ts new file mode 100644 index 00000000..2dfa02c1 --- /dev/null +++ b/bin/cli/commands/mcp/McpToolInput.ts @@ -0,0 +1,21 @@ +import type { z } from 'zod'; + +import type { McpJsonObject } from './McpJsonValue.ts'; +import McpProtocolError from './McpProtocolError.ts'; + +export function parseMcpToolInput( + schema: z.ZodType, + args: McpJsonObject, +): TOutput { + const parsed = schema.safeParse(args); + if (parsed.success) { + return parsed.data; + } + throw new McpProtocolError( + -32602, + 'Invalid MCP tool input', + { + issues: parsed.error.issues.map((issue) => issue.message), + }, + ); +} diff --git a/bin/cli/commands/observe.ts b/bin/cli/commands/observe.ts new file mode 100644 index 00000000..8318b98a --- /dev/null +++ b/bin/cli/commands/observe.ts @@ -0,0 +1,118 @@ +import { z } from 'zod'; + +import { parseCommandArgs } from '../infrastructure.ts'; +import type { CliOptions } from '../types.ts'; +import { observerFromText } from '../v19/V19DomainInput.ts'; +import { openRequiredLane, withRuntime } from '../v19/V19Runtime.ts'; +import { + readingEnvelope, + receiptEnvelope, + renderReading, + renderReceipt, +} from '../../presenters/V19ReadingReceipt.ts'; +import type { McpJsonValue } from './mcp/McpJsonValue.ts'; +import type Runtime from '../../../src/application/Runtime.ts'; +import type Lane from '../../../src/domain/api/Lane.ts'; +import type Observer from '../../../src/domain/api/Observer.ts'; +import type { ReadingValue } from '../../../src/domain/api/ReadingValue.ts'; + +const OBSERVE_OPTIONS = { + observer: { type: 'string' }, + reading: { type: 'string' }, +}; + +const OBSERVE_SCHEMA = z.object({ + observer: z.string().min(1), + reading: z.string().min(1), +}); + +type ObservationCommandResult = { + readonly payload: McpJsonValue; + readonly human: string; + readonly lines: readonly McpJsonValue[]; +}; + +type ObserveLaneFields = Readonly<{ + readonly observerId: string; + readonly options: CliOptions; + readonly reading: string; + readonly runtime: Runtime; +}>; + +type ObservationResultFields = Readonly<{ + readonly lane: Lane; + readonly observer: Observer; + readonly readings: readonly McpJsonValue[]; + readonly receipt: McpJsonValue; +}>; + +export default async function handleObserve({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise { + const { values } = parseCommandArgs( + args, + OBSERVE_OPTIONS, + OBSERVE_SCHEMA, + ); + return await withRuntime( + options, + async (runtime) => + await observeLane({ + runtime, + options, + observerId: values.observer, + reading: values.reading, + }), + ); +} + +async function observeLane( + fields: ObserveLaneFields, +): Promise { + const { observerId, options, reading, runtime } = fields; + const lane = await openRequiredLane( + runtime, + options.lane, + options.strand, + ); + const observer = observerFromText(observerId, reading); + const observation = lane.observe(observer); + const readings: McpJsonValue[] = []; + for await (const observed of observation) { + readings.push(readingEnvelope(observed)); + } + return observationResult({ + lane, + observer, + readings, + receipt: receiptEnvelope(await observation.receipt), + }); +} + +function observationResult( + fields: ObservationResultFields, +): ObservationCommandResult { + const { lane, observer, readings, receipt } = fields; + const lines = Object.freeze([...readings, receipt]); + return { + payload: Object.freeze({ + type: 'Observation', + lane: lane.name, + observer: Object.freeze({ + id: observer.id, + cardinality: observer.cardinality, + }), + readings: Object.freeze([...readings]), + receipt, + }), + human: [ + ...readings.map(renderReading), + renderReceipt(receipt), + ].join('\n\n'), + lines, + }; +} diff --git a/bin/cli/commands/optic.ts b/bin/cli/commands/optic.ts deleted file mode 100644 index 40bbc7da..00000000 --- a/bin/cli/commands/optic.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { openWarpWorldline } from '../../../src/domain/WarpWorldline.ts'; -import QueryError from '../../../src/domain/errors/QueryError.ts'; -import type ReadIdentity from '../../../src/domain/services/optic/ReadIdentity.ts'; -import type WorldlineOptic from '../../../src/domain/services/optic/WorldlineOptic.ts'; -import type NodeOpticReadResult from '../../../src/domain/services/optic/NodeOpticReadResult.ts'; -import type NodePropertyOpticReadResult from '../../../src/domain/services/optic/NodePropertyOpticReadResult.ts'; -import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.ts'; -import { EXIT_CODES, notFoundError, parseCommandArgs, usageError } from '../infrastructure.ts'; -import { opticWitnessSchema } from '../schemas.ts'; -import { createPersistence, listGraphNames, resolveGraphName } from '../shared.ts'; -import type { CliOptions, Persistence } from '../types.ts'; - -const OPTIC_WITNESS_OPTIONS = { - property: { type: 'string' }, -}; - -type OpticWitnessSelection = { - readonly nodeId: string; - readonly propertyKey: string | null; -}; - -type OpticWitnessResult = NodeOpticReadResult | NodePropertyOpticReadResult; - -type OpticCommandResult = { - readonly payload: unknown; - readonly exitCode: number; -}; - -type OpticBasisEvidence = { - readonly basisId: string; - readonly checkpointSha: string; -}; - -type CompleteWitnessOptions = { - readonly graphName: string; - readonly basis: OpticBasisEvidence; - readonly result: OpticWitnessResult; - readonly selection: OpticWitnessSelection; -}; - -type ObstructedWitnessOptions = { - readonly graphName: string; - readonly basis: OpticBasisEvidence | null; - readonly selection: OpticWitnessSelection; - readonly error: unknown; -}; - -export default async function handleOptic({ - options, - args, -}: { - readonly options: CliOptions; - readonly args: string[]; -}): Promise { - const selection = parseOpticWitnessArgs(args); - const opened = await openOpticWorldline(options); - - return await runOpticWitness(opened, selection); -} - -async function openOpticWorldline(options: CliOptions): Promise<{ - readonly graphName: string; - readonly worldline: Awaited>; -}> { - const { persistence, runtimeStorage } = await createPersistence(options.repo); - const graphName = await resolveOpticGraphName(persistence, options.graph); - return { - graphName, - worldline: await openWarpWorldline({ - persistence, - runtimeStorage, - worldlineName: graphName, - writerId: options.writer, - crypto: new WebCryptoAdapter(), - }), - }; -} - -async function runOpticWitness( - opened: { - readonly graphName: string; - readonly worldline: Awaited>; - }, - selection: OpticWitnessSelection, -): Promise { - let basisEvidence: OpticBasisEvidence | null = null; - try { - basisEvidence = await prepareBasisEvidence(opened); - const coordinate = await opened.worldline.coordinate(); - const result = await readSelection(coordinate.optic(), selection); - return completeWitnessResult({ - graphName: opened.graphName, - basis: basisEvidence, - result, - selection, - }); - } catch (error) { - return obstructedWitnessResult({ - graphName: opened.graphName, - basis: basisEvidence, - selection, - error, - }); - } -} - -async function prepareBasisEvidence(opened: { - readonly graphName: string; - readonly worldline: Awaited>; -}): Promise { - const basis = await opened.worldline.prepareOpticBasis(); - return { - basisId: `checkpoint-tail:${opened.graphName}:${basis.checkpointSha}`, - checkpointSha: basis.checkpointSha, - }; -} - -function completeWitnessResult(options: CompleteWitnessOptions): OpticCommandResult { - return { - payload: witnessPayload(options), - exitCode: EXIT_CODES.OK, - }; -} - -function obstructedWitnessResult(options: ObstructedWitnessOptions): OpticCommandResult { - if (!(options.error instanceof QueryError)) { - throw options.error; - } - return { - payload: obstructedWitnessPayload({ - graphName: options.graphName, - basis: options.basis, - selection: options.selection, - error: options.error, - }), - exitCode: EXIT_CODES.INTERNAL, - }; -} - -function parseOpticWitnessArgs(args: readonly string[]): OpticWitnessSelection { - const subcommand = args[0]; - if (subcommand !== 'witness') { - throw usageError('optic requires subcommand: witness'); - } - const { values, positionals } = parseCommandArgs( - args.slice(1), - OPTIC_WITNESS_OPTIONS, - opticWitnessSchema, - { allowPositionals: true }, - ); - const nodeId = positionals[0]; - if (nodeId === undefined || nodeId.length === 0) { - throw usageError('optic witness requires a node id'); - } - if (positionals.length > 1) { - throw usageError('optic witness accepts exactly one node id'); - } - return { nodeId, propertyKey: values.propertyKey }; -} - -async function resolveOpticGraphName( - persistence: Persistence, - graph: string | null, -): Promise { - const graphName = await resolveGraphName(persistence, graph); - if (typeof graph === 'string' && graph.length > 0) { - const graphNames = await listGraphNames(persistence); - if (!graphNames.includes(graph)) { - throw notFoundError(`Graph not found: ${graph}`); - } - } - return graphName; -} - -async function readSelection( - optic: WorldlineOptic, - selection: OpticWitnessSelection, -): Promise { - const node = optic.node(selection.nodeId); - if (selection.propertyKey === null) { - return await node.read(); - } - return await node.prop(selection.propertyKey).read(); -} - -function witnessPayload(options: { - readonly graphName: string; - readonly basis: OpticBasisEvidence; - readonly result: OpticWitnessResult; - readonly selection: OpticWitnessSelection; -}): object { - const { readIdentity } = options.result; - return { - command: 'optic witness', - graph: options.graphName, - selection: selectionPayload(options.selection), - basisId: options.basis.basisId, - checkpointSha: options.basis.checkpointSha, - completeness: 'complete', - obstruction: null, - read: readPayload(options.result), - evidence: evidencePayload(readIdentity), - readIdentity, - }; -} - -function obstructedWitnessPayload(options: { - readonly graphName: string; - readonly basis: OpticBasisEvidence | null; - readonly selection: OpticWitnessSelection; - readonly error: QueryError; -}): object { - return { - command: 'optic witness', - graph: options.graphName, - selection: selectionPayload(options.selection), - basisId: options.basis?.basisId ?? null, - checkpointSha: options.basis?.checkpointSha ?? null, - completeness: 'obstructed', - obstruction: { - code: options.error.code, - message: options.error.message, - context: options.error.context, - }, - read: null, - evidence: { - touchedShardIds: [], - tailWitnessRange: { - count: 0, - first: null, - last: null, - }, - tailWitnesses: [], - }, - }; -} - -function selectionPayload(selection: OpticWitnessSelection): object { - return { - nodeId: selection.nodeId, - propertyKey: selection.propertyKey, - }; -} - -function readPayload(result: OpticWitnessResult): object { - if ('key' in result) { - return { - kind: 'node-property', - nodeId: result.nodeId, - key: result.key, - exists: result.exists, - value: result.value, - }; - } - return { - kind: 'node', - nodeId: result.nodeId, - alive: result.alive, - }; -} - -function evidencePayload(readIdentity: ReadIdentity): object { - return { - touchedShardIds: readIdentity.checkpointIndexShards, - tailWitnessRange: tailWitnessRange(readIdentity), - tailWitnesses: readIdentity.tailWitnesses, - }; -} - -function tailWitnessRange(readIdentity: ReadIdentity): object { - const witnesses = readIdentity.tailWitnesses; - const first = witnesses[0] ?? null; - const last = witnesses[witnesses.length - 1] ?? null; - return { - count: witnesses.length, - first, - last, - }; -} diff --git a/bin/cli/commands/patch.ts b/bin/cli/commands/patch.ts deleted file mode 100644 index f0d57362..00000000 --- a/bin/cli/commands/patch.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { EXIT_CODES, usageError, notFoundError, parseCommandArgs } from '../infrastructure.ts'; -import { openGraph } from '../shared.ts'; -import { z } from 'zod'; -import type { CliOptions, WarpGraphInstance } from '../types.ts'; - -const PATCH_OPTIONS = { - writer: { type: 'string' }, - limit: { type: 'string' }, -}; - -const patchSchema = z.object({ - writer: z.string().optional(), - limit: z.coerce.number().int().positive().optional(), -}).strict(); - -type WriterPatchEntry = Awaited>[number]; -type PatchOp = WriterPatchEntry['patch']['ops'][number]; - -/** Collects all patches across all writers (or a single writer). */ -async function collectPatches( - graph: WarpGraphInstance, - writerFilter: string | null, -): Promise> { - const writers = (writerFilter !== null && writerFilter !== undefined && writerFilter.length > 0) ? [writerFilter] : await graph.discoverWriters(); - const all: Array = []; - for (const writerId of writers) { - const patches = await graph.getWriterPatches(writerId); - for (const { patch, sha } of patches) { - all.push({ sha, writer: writerId, patch }); - } - } - // Sort by lamport ascending - all.sort((a, b) => (a.patch.lamport ?? 0) - (b.patch.lamport ?? 0)); - return all; -} - -/** Handles the `patch` command: show or list decoded patches. */ -export default async function handlePatch({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - // First positional is the subaction: show or list - const subaction = args[0]; - const rest = args.slice(1); - - if (subaction === 'show') { - return await handlePatchShow({ options, args: rest }); - } - if (subaction === 'list') { - return await handlePatchList({ options, args: rest }); - } - if (subaction === undefined || subaction === null || subaction.length === 0) { - throw usageError('Usage: warp-graph patch [options]\n show Decode and display a single patch\n list List all patches'); - } - throw usageError(`Unknown patch subaction: ${subaction}. Use: show, list`); -} - -/** Decodes and displays a single patch by SHA prefix. */ -async function handlePatchShow({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - if (args[0] === undefined || args[0] === null || args[0].length === 0) { - throw usageError('Usage: warp-graph patch show '); - } - const targetSha = args[0]; - const { graph, graphName } = await openGraph(options); - const allPatches = await collectPatches(graph, null); - - const match = allPatches.find((p) => p.sha === targetSha || p.sha.startsWith(targetSha)); - if (!match) { - throw notFoundError(`Patch not found: ${targetSha}`); - } - - const payload = { - graph: graphName, - sha: match.sha, - writer: match.writer, - lamport: match.patch.lamport, - schema: match.patch.schema, - ops: match.patch.ops, - context: match.patch.context, - }; - - return { payload, exitCode: EXIT_CODES.OK }; -} - -/** Lists all patches, optionally filtered by writer. */ -async function handlePatchList({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values } = parseCommandArgs(args, PATCH_OPTIONS, patchSchema); - const { graph, graphName } = await openGraph(options); - const writerFilter = (values.writer !== undefined && values.writer !== null && values.writer.length > 0) ? values.writer : null; - const allPatches = await collectPatches(graph, writerFilter); - - const limit = values.limit ?? allPatches.length; - const entries = allPatches.slice(0, limit).map((p) => ({ - sha: p.sha.slice(0, 7), - fullSha: p.sha, - writer: p.writer, - lamport: p.patch.lamport, - opCount: Array.isArray(p.patch.ops) ? p.patch.ops.length : 0, - nodeIds: extractNodeIds(Array.isArray(p.patch.ops) ? p.patch.ops : []), - })); - - const payload = { - graph: graphName, - total: allPatches.length, - showing: entries.length, - writerFilter, - entries, - }; - - return { payload, exitCode: EXIT_CODES.OK }; -} - -/** Extracts unique node IDs touched by a patch's operations. */ -function extractNodeIds(ops: PatchOp[]): string[] { - const ids: Set = new Set(); - for (const op of ops) { - if ('node' in op && typeof op.node === 'string' && op.node.length > 0) { - ids.add(op.node); - } - if ('from' in op && typeof op.from === 'string' && op.from.length > 0) { - ids.add(op.from); - } - if ('to' in op && typeof op.to === 'string' && op.to.length > 0) { - ids.add(op.to); - } - } - return [...ids].sort(); -} diff --git a/bin/cli/commands/path.ts b/bin/cli/commands/path.ts deleted file mode 100644 index f27038d5..00000000 --- a/bin/cli/commands/path.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { EXIT_CODES, usageError, parseCommandArgs } from '../infrastructure.ts'; -import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.ts'; -import { pathSchema } from '../schemas.ts'; -import type { CliOptions } from '../types.ts'; - -const PATH_OPTIONS = { - from: { type: 'string' }, - to: { type: 'string' }, - dir: { type: 'string' }, - label: { type: 'string', multiple: true }, - 'max-depth': { type: 'string' }, -}; - -type PathPayload = { - readonly graph: string; - readonly from: string; - readonly to: string; - readonly found: boolean; - readonly path: readonly string[]; - readonly length: number; -}; - -type PathTraversalOptions = { - readonly dir: 'out' | 'in' | 'both'; - readonly labelFilter?: string; - readonly maxDepth?: number; -}; - -function endpointFrom(values: { readonly from: string | null }, positionals: readonly string[]): string { - const value = values.from ?? positionals[0]; - if (value === undefined || value.length === 0) { - throw usageError('path requires --from or a source positional'); - } - return value; -} - -function endpointTo(values: { readonly to: string | null }, positionals: readonly string[]): string { - const value = values.to ?? positionals[1]; - if (value === undefined || value.length === 0) { - throw usageError('path requires --to or a target positional'); - } - return value; -} - -function firstLabel(labels: readonly string[]): string | undefined { - return labels[0]; -} - -function traversalOptions(values: { - readonly dir?: 'out' | 'in' | 'both'; - readonly labels: readonly string[]; - readonly maxDepth?: number; -}): PathTraversalOptions { - const options: PathTraversalOptions = { dir: values.dir ?? 'out' }; - const label = firstLabel(values.labels); - if (label !== undefined) { - return values.maxDepth !== undefined - ? { ...options, labelFilter: label, maxDepth: values.maxDepth } - : { ...options, labelFilter: label }; - } - return values.maxDepth !== undefined - ? { ...options, maxDepth: values.maxDepth } - : options; -} - -/** Handles `git warp path`: finds a shortest path through the read surface. */ -export default async function handlePath({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: PathPayload; exitCode: number }> { - const { values, positionals } = parseCommandArgs(args, PATH_OPTIONS, pathSchema, { allowPositionals: true }); - const from = endpointFrom(values, positionals); - const to = endpointTo(values, positionals); - const { graph, graphName, cursorStore } = await openGraph(options); - const cursorInfo = await applyCursorCeiling(graph, cursorStore); - emitCursorWarning(cursorInfo, null); - await graph.materialize(); - - const result = await graph.traverse.shortestPath(from, to, traversalOptions(values)); - - return { - payload: { - graph: graphName, - from, - to, - found: result.found, - path: result.path, - length: result.length, - }, - exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NO_MATCH, - }; -} diff --git a/bin/cli/commands/query.ts b/bin/cli/commands/query.ts deleted file mode 100644 index f604d190..00000000 --- a/bin/cli/commands/query.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { EXIT_CODES, usageError, parseCommandArgs } from '../infrastructure.ts'; -import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.ts'; -import { querySchema } from '../schemas.ts'; -import type { CliOptions } from '../types.ts'; -import type QueryBuilder from '../../../src/domain/services/query/QueryBuilder.ts'; -import type { QueryResult } from '../../../src/domain/services/query/QueryRunner.ts'; - -const QUERY_OPTIONS = { - match: { type: 'string' }, - outgoing: { type: 'string', multiple: true }, - incoming: { type: 'string', multiple: true }, - 'where-prop': { type: 'string', multiple: true }, - select: { type: 'string' }, -}; - -type QueryOperationToken = - | { readonly kind: 'outgoing'; readonly value: string } - | { readonly kind: 'incoming'; readonly value: string } - | { readonly kind: 'where-prop'; readonly value: string }; - -type QueryCommandResult = { - readonly payload: QueryResult & { readonly graph: string }; - readonly exitCode: number; -}; - -type QueryTokenSpec = { - readonly flag: string; - readonly kind: QueryOperationToken['kind']; -}; - -type QueryTokenReadResult = { - readonly token: QueryOperationToken | null; - readonly consumedNext: boolean; -}; - -type QueryValues = { - readonly match: string | null; - readonly select: string | undefined; -}; - -const QUERY_TOKEN_SPECS: readonly QueryTokenSpec[] = [ - { flag: '--outgoing', kind: 'outgoing' }, - { flag: '--incoming', kind: 'incoming' }, - { flag: '--where-prop', kind: 'where-prop' }, -]; - -function readFlagValue(args: readonly string[], index: number, flag: string): string { - const value = args[index + 1]; - if (value === undefined || value.length === 0 || value.startsWith('--')) { - throw usageError(`Missing value for ${flag}`); - } - return value; -} - -function readInlineFlagValue(arg: string, flag: string): string { - const value = arg.slice(`${flag}=`.length); - if (value.length === 0) { - throw usageError(`Missing value for ${flag}`); - } - return value; -} - -function splitWhereProp(raw: string): { readonly key: string; readonly value: string } { - const equalsIndex = raw.indexOf('='); - if (equalsIndex <= 0) { - throw usageError(`Invalid --where-prop value: ${raw}. Expected key=value.`); - } - const key = raw.slice(0, equalsIndex); - const value = raw.slice(equalsIndex + 1); - return { key, value }; -} - -function isValueFlag(arg: string): boolean { - return arg === '--match' || arg === '--select'; -} - -function inlineSpec(arg: string): QueryTokenSpec | undefined { - return QUERY_TOKEN_SPECS.find((spec) => arg.startsWith(`${spec.flag}=`)); -} - -function bareSpec(arg: string): QueryTokenSpec | undefined { - return QUERY_TOKEN_SPECS.find((spec) => arg === spec.flag); -} - -function readTokenAt(args: readonly string[], index: number): QueryTokenReadResult { - const arg = args[index]; - if (arg === undefined) { - return { token: null, consumedNext: false }; - } - const inline = inlineSpec(arg); - if (inline !== undefined) { - return { token: { kind: inline.kind, value: readInlineFlagValue(arg, inline.flag) }, consumedNext: false }; - } - const bare = bareSpec(arg); - if (bare !== undefined) { - return { token: { kind: bare.kind, value: readFlagValue(args, index, bare.flag) }, consumedNext: true }; - } - return { token: null, consumedNext: isValueFlag(arg) }; -} - -function queryOperationTokens(args: readonly string[]): QueryOperationToken[] { - const operations: QueryOperationToken[] = []; - for (let i = 0; i < args.length; i++) { - const result = readTokenAt(args, i); - if (result.token !== null) { - operations.push(result.token); - } - if (result.consumedNext) { - i++; - } - } - return operations; -} - -function applyWhereProp(builder: QueryBuilder, raw: string): QueryBuilder { - const { key, value } = splitWhereProp(raw); - return builder.where({ [key]: value }); -} - -function selectFields(raw: string | undefined): string[] | undefined { - if (raw === undefined || raw.length === 0) { - return undefined; - } - return raw.split(',').map((field) => field.trim()).filter((field) => field.length > 0); -} - -function assertQueryResult(result: Awaited>): QueryResult { - if ('nodes' in result) { - return result; - } - throw usageError('query command does not support aggregate result output'); -} - -function applyToken(builder: QueryBuilder, token: QueryOperationToken): QueryBuilder { - if (token.kind === 'outgoing') { - return builder.outgoing(token.value); - } - if (token.kind === 'incoming') { - return builder.incoming(token.value); - } - return applyWhereProp(builder, token.value); -} - -function applySelect(builder: QueryBuilder, raw: string | undefined): QueryBuilder { - const fields = selectFields(raw); - return fields !== undefined ? builder.select(fields) : builder; -} - -function buildQueryBuilder(base: QueryBuilder, values: QueryValues, args: readonly string[]): QueryBuilder { - let builder = values.match !== null ? base.match(values.match) : base; - for (const token of queryOperationTokens(args)) { - builder = applyToken(builder, token); - } - return applySelect(builder, values.select); -} - -/** Handles `git warp query`: runs the public query builder from the CLI. */ -export default async function handleQuery({ options, args }: { options: CliOptions; args: string[] }): Promise { - const { values } = parseCommandArgs(args, QUERY_OPTIONS, querySchema); - const { graph, graphName, cursorStore } = await openGraph(options); - const cursorInfo = await applyCursorCeiling(graph, cursorStore); - emitCursorWarning(cursorInfo, null); - await graph.materialize(); - - const builder = buildQueryBuilder(graph.query(), values, args); - const result = assertQueryResult(await builder.run()); - return { - payload: { graph: graphName, ...result }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/receipt.ts b/bin/cli/commands/receipt.ts new file mode 100644 index 00000000..8757259d --- /dev/null +++ b/bin/cli/commands/receipt.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { z } from 'zod'; + +import { + parseCommandArgs, + usageError, + usageErrorFrom, +} from '../infrastructure.ts'; +import type { CliOptions } from '../types.ts'; +import { parseMcpJson } from '../../presenters/V19Json.ts'; +import { renderReceipt } from '../../presenters/V19ReadingReceipt.ts'; +import type { McpJsonValue } from './mcp/McpJsonValue.ts'; + +const RECEIPT_OPTIONS = { + input: { type: 'string' }, +}; + +const RECEIPT_SCHEMA = z.object({ + input: z.string().min(1), +}); + +export default async function handleReceipt({ + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise<{ + readonly payload: McpJsonValue; + readonly human: string; +}> { + return await Promise.resolve(receiptResult(args)); +} + +function receiptResult(args: readonly string[]): { + readonly payload: McpJsonValue; + readonly human: string; +} { + const inputPath = receiptInputPath(args); + try { + const text = readFileSync(inputPath === '-' ? 0 : inputPath, 'utf8'); + const payload = parseMcpJson(JSON.parse(text)); + return { payload, human: renderReceipt(payload) }; + } catch (error) { + throw usageErrorFrom(`Unable to read Receipt from ${inputPath}`, error); + } +} + +function receiptInputPath(args: readonly string[]): string { + const { values, positionals } = parseCommandArgs( + args, + RECEIPT_OPTIONS, + RECEIPT_SCHEMA, + { allowPositionals: true }, + ); + if (positionals.length !== 1 || positionals[0] !== 'show') { + throw usageError('Usage: git warp receipt show --input '); + } + return values.input; +} diff --git a/bin/cli/commands/registry.ts b/bin/cli/commands/registry.ts index c4cb6174..45a4adb6 100644 --- a/bin/cli/commands/registry.ts +++ b/bin/cli/commands/registry.ts @@ -1,68 +1,33 @@ import type { CliOptions } from '../types.ts'; -import handleInfo from './info.ts'; -import handleCheck from './check.ts'; -import handleDoctor from './doctor/index.ts'; -import handleMaterialize from './materialize.ts'; -import handleSeek from './seek.ts'; -import handleQuery from './query.ts'; -import handlePath from './path.ts'; -import handleOptic from './optic.ts'; -import handleHistory from './history.ts'; -import handleDebug from './debug.ts'; -import handleStrand from './strand.ts'; -import handleVerifyAudit from './verify-audit.ts'; -import handleVerifyIndex from './verify-index.ts'; -import handleReindex from './reindex.ts'; -import handleInstallHooks from './install-hooks.ts'; -import handleTrust from './trust.ts'; -import handlePatch from './patch.ts'; -import handleTree from './tree.ts'; -import handleBisect from './bisect.ts'; -import handleMcp from './mcp.ts'; -import handleSync from './sync.ts'; -import handleServe from './serve.ts'; +import handleAudit from './audit.ts'; +import handleDoctor from './doctor-v19.ts'; import handleFork from './fork.ts'; -import handleCheckpoint from './checkpoint.ts'; -import handleGc from './gc.ts'; -import handleWatch from './watch.ts'; +import handleMcp from './mcp.ts'; +import handleObserve from './observe.ts'; +import handleReceipt from './receipt.ts'; +import handleRepair from './repair.ts'; +import handleSettle from './settle.ts'; +import handleWrite from './write.ts'; -/** Opaque handler return value. The entry point normalizes any shape - * into `{ payload, exitCode, close? }` at runtime via type guards. */ -export type CommandHandlerResult = unknown; +export type CommandHandlerResult = object | undefined; -/** Common signature every CLI command handler satisfies. Extra keys - * beyond `options`/`args` are ignored by handlers that don't need them. */ -export type CommandHandler = (opts: { +export type CommandHandler = (options: { readonly options: CliOptions; readonly args: string[]; }) => Promise; -export const COMMANDS: ReadonlyMap = new Map([ - ['info', handleInfo], - ['check', handleCheck], +export const COMMANDS: ReadonlyMap = new Map< + string, + CommandHandler +>([ + ['write', handleWrite], + ['observe', handleObserve], + ['fork', handleFork], + ['settle', handleSettle], + ['receipt', handleReceipt], ['doctor', handleDoctor], - ['materialize', handleMaterialize], - ['seek', handleSeek], - ['query', handleQuery], - ['path', handlePath], - ['optic', handleOptic], - ['history', handleHistory], - ['debug', handleDebug], - ['strand', handleStrand], - ['verify-audit', handleVerifyAudit], - ['verify-index', handleVerifyIndex], - ['reindex', handleReindex], - ['trust', handleTrust], - ['patch', handlePatch], - ['tree', handleTree], - ['bisect', handleBisect], - ['install-hooks', handleInstallHooks], + ['repair', handleRepair], + ['audit', handleAudit], ['mcp', handleMcp], - ['sync', handleSync], - ['serve', handleServe], - ['fork', handleFork], - ['checkpoint', handleCheckpoint], - ['gc', handleGc], - ['watch', handleWatch], ]); diff --git a/bin/cli/commands/reindex.ts b/bin/cli/commands/reindex.ts deleted file mode 100644 index 07ad23d1..00000000 --- a/bin/cli/commands/reindex.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { EXIT_CODES, parseCommandArgs } from '../infrastructure.ts'; -import { reindexSchema } from '../schemas.ts'; -import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.ts'; -import type { CliOptions } from '../types.ts'; - -/** - * Handles the `reindex` command: forces a full bitmap index rebuild - * by clearing cached index state and re-materializing. - */ -export default async function handleReindex({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - parseCommandArgs(args, {}, reindexSchema); - - const { graph, graphName, cursorStore } = await openGraph(options); - const cursorInfo = await applyCursorCeiling(graph, cursorStore); - emitCursorWarning(cursorInfo, null); - - // Clear cached index to force full rebuild - graph.invalidateIndex(); - - try { - await graph.materialize(); - } catch (err) { - return { - payload: { error: (err as Error).message }, - exitCode: EXIT_CODES.INTERNAL, - }; - } - - return { - payload: { - graph: graphName, - status: 'ok', - message: 'Index rebuilt successfully', - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/repair.ts b/bin/cli/commands/repair.ts new file mode 100644 index 00000000..15bc73fc --- /dev/null +++ b/bin/cli/commands/repair.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { parseCommandArgs } from '../infrastructure.ts'; +import type { CliOptions } from '../types.ts'; +import { openRequiredLane, withRuntime } from '../v19/V19Runtime.ts'; +import { stableStringify } from '../../presenters/json.ts'; +import prepareMaterialization from './MaterializationRepair.ts'; + +const REPAIR_OPTIONS = { + action: { type: 'string' }, +}; + +const REPAIR_SCHEMA = z.object({ + action: z.literal('materialization'), +}); + +export default async function handleRepair({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise<{ + readonly payload: object; + readonly human: string; + readonly exitCode: number; +}> { + parseCommandArgs(args, REPAIR_OPTIONS, REPAIR_SCHEMA); + return await withRuntime(options, async (runtime) => { + const lane = await openRequiredLane(runtime, options.lane); + const payload = await prepareMaterialization(options, lane.name); + return { + payload, + human: stableStringify(payload), + exitCode: 0, + }; + }); +} diff --git a/bin/cli/commands/seek.ts b/bin/cli/commands/seek.ts deleted file mode 100644 index 54b24202..00000000 --- a/bin/cli/commands/seek.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { diffStates, StateDiffResult } from '../../../src/domain/services/state/StateDiff.ts'; -import { - clearActiveCursor, - readSavedCursor, - writeSavedCursor, - deleteSavedCursor, - listSavedCursors, -} from './seekCursorHelpers.ts'; -import { - buildTickReceipt, - computeFrontierHash, - countPatchesAtTick, - serializePerWriter, -} from '../time-travel-shared.ts'; -import { EXIT_CODES, usageError, notFoundError, parseCommandArgs } from '../infrastructure.ts'; -import { seekSchema } from '../schemas.ts'; -import { openGraph, readActiveCursor, writeActiveCursor } from '../shared.ts'; -import type { WarpState } from '../../../src/domain/services/JoinReducer.ts'; -import type { CliOptions, WarpGraphInstance, WriterTickInfo, CursorBlob, SeekSpec } from '../types.ts'; - -// ============================================================================ -// Seek Arg Parser -// ============================================================================ - -const SEEK_OPTIONS = { - tick: { type: 'string' }, - latest: { type: 'boolean', default: false }, - save: { type: 'string' }, - load: { type: 'string' }, - list: { type: 'boolean', default: false }, - drop: { type: 'string' }, - diff: { type: 'boolean', default: false }, - 'diff-limit': { type: 'string', default: '2000' }, -}; - -const ABSOLUTE_TICK_PATTERN = /^[0-9]+$/u; -const RELATIVE_TICK_PATTERN = /^[+-][0-9]+$/u; - -/** Parses raw CLI args into a validated seek specification. */ -function parseSeekArgs(args: string[]): SeekSpec { - const { values } = parseCommandArgs(args, SEEK_OPTIONS, seekSchema); - return values as SeekSpec; -} - -// ============================================================================ -// Tick Resolution -// ============================================================================ - -function parseRelativeTickDelta(tickValue: string): number { - if (!RELATIVE_TICK_PATTERN.test(tickValue)) { - throw usageError(`Invalid tick delta: ${tickValue}`); - } - const delta = Number(tickValue); - if (!Number.isInteger(delta)) { - throw usageError(`Invalid tick delta: ${tickValue}`); - } - return delta; -} - -function parseAbsoluteTick(tickValue: string): number { - if (!ABSOLUTE_TICK_PATTERN.test(tickValue)) { - throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`); - } - const n = Number(tickValue); - if (!Number.isInteger(n)) { - throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`); - } - return n; -} - -/** Resolves a tick string (absolute or relative delta) to a concrete tick number. */ -function resolveTickValue(tickValue: string, currentTick: number | null, ticks: number[], maxTick: number): number { - if (tickValue.startsWith('+') || tickValue.startsWith('-')) { - const delta = parseRelativeTickDelta(tickValue); - const base = currentTick ?? 0; - 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] ?? 0; - } - - const n = parseAbsoluteTick(tickValue); - return Math.min(n, maxTick); -} - -// ============================================================================ -// Seek Helpers -// ============================================================================ - -/** Extracts node and edge counts from a cursor blob, returning nulls if absent. */ -function readSeekCounts(cursor: CursorBlob | null): { nodes: number | null; edges: number | null } { - 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 the delta between the previous cursor counts and new counts, if the frontier hash matches. */ -function computeSeekStateDiff(prevCursor: CursorBlob | null, next: { nodes: number; edges: number }, frontierHash: string): { nodes: number; edges: number } | null { - const prev = readSeekCounts(prevCursor); - if (prev.nodes === null || prev.edges === null) { - return null; - } - const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null; - if (typeof prevFrontierHash !== 'string' || prevFrontierHash.length === 0 || prevFrontierHash !== frontierHash) { - return null; - } - return { - nodes: next.nodes - prev.nodes, - edges: next.edges - prev.edges, - }; -} - -/** Materializes two tick states and computes the structural diff between them. */ -async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }: { graph: WarpGraphInstance; prevTick: number | null; currentTick: number; diffLimit: number }): Promise<{ structuralDiff: unknown; diffBaseline: string; baselineTick: number | null; truncated: boolean; totalChanges: number; shownChanges: number }> { - let beforeState: WarpState | null = null; - let diffBaseline = 'empty'; - let baselineTick: number | null = null; - - if (prevTick !== null && prevTick === currentTick) { - const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } }; - return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 }; - } - - if (prevTick !== null && prevTick > 0) { - await graph.materialize({ ceiling: prevTick }); - beforeState = graph._cachedState; - diffBaseline = 'tick'; - baselineTick = prevTick; - } - - await graph.materialize({ ceiling: currentTick }); - const afterState = graph._cachedState; - if (!afterState) { - const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } }; - return applyDiffLimit(empty as StateDiffResult, diffBaseline, baselineTick, diffLimit); - } - const diff = diffStates(beforeState, afterState); - - return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit); -} - -/** Truncates a structural diff to the specified limit, returning metadata about truncation. */ -function applyDiffLimit(diff: StateDiffResult, diffBaseline: string, baselineTick: number | null, diffLimit: number): { structuralDiff: StateDiffResult; diffBaseline: string; baselineTick: number | null; truncated: boolean; totalChanges: number; shownChanges: number } { - const totalChanges = - diff.nodes.added.length + diff.nodes.removed.length + - diff.edges.added.length + diff.edges.removed.length + - diff.props.set.length + diff.props.removed.length; - - if (totalChanges <= diffLimit) { - return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges }; - } - - let remaining = diffLimit; - /** Caps an array to the remaining budget and decrements the budget. */ - const cap = (arr: readonly T[]): T[] => { - const take = Math.min(arr.length, remaining); - remaining -= take; - return arr.slice(0, take); - }; - - const capped = new StateDiffResult({ - nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) }, - edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) }, - props: { set: cap(diff.props.set), removed: cap(diff.props.removed) }, - }); - - const shownChanges = diffLimit - remaining; - return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges }; -} - -// ============================================================================ -// Seek Status Handler -// ============================================================================ - -/** Handles the bare `seek` (no action flags) by returning current cursor status. */ -async function handleSeekStatus({ graph, graphName, activeCursor, ticks, maxTick, perWriter, frontierHash }: { - graph: WarpGraphInstance; - graphName: string; - activeCursor: CursorBlob | null; - ticks: number[]; - maxTick: number; - perWriter: Map; - frontierHash: string; -}): Promise<{ payload: unknown; exitCode: number }> { - if (activeCursor) { - await graph.materialize({ ceiling: activeCursor.tick }); - 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: 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, - }; -} - -// ============================================================================ -// Main Seek Handler -// ============================================================================ - -/** Handles the `git warp seek` command across all sub-actions. */ -export default async function handleSeek({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const seekSpec = parseSeekArgs(args); - const { graph, graphName, cursorStore } = await openGraph(options); - - const activeCursor = await readActiveCursor(cursorStore); - const { ticks, maxTick, perWriter } = await graph.discoverTicks(); - const frontierHash = await computeFrontierHash(perWriter); - if (seekSpec.action === 'list') { - const saved = await listSavedCursors(cursorStore); - return { - payload: { - graph: graphName, - action: 'list', - cursors: saved, - activeTick: activeCursor ? activeCursor.tick : null, - maxTick, - }, - exitCode: EXIT_CODES.OK, - }; - } - if (seekSpec.action === 'drop') { - const dropName = seekSpec.name as string; - const existing = await readSavedCursor(cursorStore, dropName); - if (!existing) { - throw notFoundError(`Saved cursor not found: ${dropName}`); - } - await deleteSavedCursor(cursorStore, dropName); - return { - payload: { - graph: graphName, - action: 'drop', - name: seekSpec.name, - tick: existing.tick, - }, - exitCode: EXIT_CODES.OK, - }; - } - if (seekSpec.action === 'latest') { - const prevTick = activeCursor ? activeCursor.tick : null; - let sdResult: { structuralDiff: unknown; diffBaseline: string; baselineTick: number | null; truncated: boolean; totalChanges: number; shownChanges: number } | null = null; - if (seekSpec.diff) { - sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit }); - } - await clearActiveCursor(cursorStore); - // When --diff already materialized at maxTick, skip redundant re-materialize - if (!sdResult) { - await graph.materialize({ ceiling: maxTick }); - } - 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 }, - ...(sdResult ?? {}), - }, - exitCode: EXIT_CODES.OK, - }; - } - if (seekSpec.action === 'save') { - if (!activeCursor) { - throw usageError('No active cursor to save. Use --tick first.'); - } - await writeSavedCursor(cursorStore, seekSpec.name as string, activeCursor); - return { - payload: { - graph: graphName, - action: 'save', - name: seekSpec.name, - tick: activeCursor.tick, - }, - exitCode: EXIT_CODES.OK, - }; - } - if (seekSpec.action === 'load') { - const loadName = seekSpec.name as string; - const saved = await readSavedCursor(cursorStore, loadName); - if (!saved) { - throw notFoundError(`Saved cursor not found: ${loadName}`); - } - const prevTick = activeCursor ? activeCursor.tick : null; - let sdResult: { structuralDiff: unknown; diffBaseline: string; baselineTick: number | null; truncated: boolean; totalChanges: number; shownChanges: number } | null = null; - if (seekSpec.diff) { - sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit }); - } - // When --diff already materialized at saved.tick, skip redundant call - if (!sdResult) { - await graph.materialize({ ceiling: saved.tick }); - } - const nodes = await graph.getNodes(); - const edges = await graph.getEdges(); - await writeActiveCursor(cursorStore, { 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 }, - ...(sdResult ?? {}), - }, - exitCode: EXIT_CODES.OK, - }; - } - if (seekSpec.action === 'tick') { - const currentTick = activeCursor ? activeCursor.tick : null; - const resolvedTick = resolveTickValue(seekSpec.tickValue as string, currentTick, ticks, maxTick); - let sdResult: { structuralDiff: unknown; diffBaseline: string; baselineTick: number | null; truncated: boolean; totalChanges: number; shownChanges: number } | null = null; - if (seekSpec.diff) { - sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit }); - } - // When --diff already materialized at resolvedTick, skip redundant call - if (!sdResult) { - await graph.materialize({ ceiling: resolvedTick }); - } - const nodes = await graph.getNodes(); - const edges = await graph.getEdges(); - await writeActiveCursor(cursorStore, { 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' }, - ...(sdResult ?? {}), - }, - exitCode: EXIT_CODES.OK, - }; - } - - // status (bare seek) - return await handleSeekStatus({ graph, graphName, activeCursor, ticks, maxTick, perWriter, frontierHash }); -} diff --git a/bin/cli/commands/seekCursorHelpers.ts b/bin/cli/commands/seekCursorHelpers.ts deleted file mode 100644 index 97823abd..00000000 --- a/bin/cli/commands/seekCursorHelpers.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Durable cursor I/O helpers for the seek command. - * Extracted from seek.ts to keep file size under 500 LOC. - */ -import type SeekCursorStorePort from '../../../src/ports/SeekCursorStorePort.ts'; -import type { CursorBlob } from '../types.ts'; - -/** Removes the active seek cursor for a graph, returning to present state. */ -export async function clearActiveCursor(cursorStore: SeekCursorStorePort): Promise { - await cursorStore.clearActive(); -} - -/** Reads a named saved cursor from git-cas retention. */ -export async function readSavedCursor(cursorStore: SeekCursorStorePort, name: string): Promise { - return await cursorStore.readSaved(name); -} - -/** Persists a cursor as a durable named bookmark. */ -export async function writeSavedCursor(cursorStore: SeekCursorStorePort, name: string, cursor: CursorBlob): Promise { - await cursorStore.writeSaved(name, cursor); -} - -/** Deletes a durable named cursor bookmark. */ -export async function deleteSavedCursor(cursorStore: SeekCursorStorePort, name: string): Promise { - await cursorStore.deleteSaved(name); -} - -/** Lists all saved cursors for a graph. */ -export async function listSavedCursors(cursorStore: SeekCursorStorePort): Promise> { - return await cursorStore.listSaved(); -} diff --git a/bin/cli/commands/serve.ts b/bin/cli/commands/serve.ts deleted file mode 100644 index f5e2ba57..00000000 --- a/bin/cli/commands/serve.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { z } from 'zod'; - -import NodeHttpAdapter from '../../../src/infrastructure/adapters/NodeHttpAdapter.ts'; -import SyncSecret from '../../../src/domain/services/sync/SyncSecret.ts'; -import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; -import { openGraph } from '../shared.ts'; -import type { CliOptions } from '../types.ts'; -import type { ServeOptions } from '../../../src/domain/capabilities/SyncCapability.ts'; - -const SERVE_OPTIONS = { - port: { type: 'string' }, - host: { type: 'string' }, - path: { type: 'string' }, - 'max-request-bytes': { type: 'string' }, - 'auth-secret': { type: 'string' }, - 'auth-key-id': { type: 'string' }, - 'auth-mode': { type: 'string' }, - 'allow-writer': { type: 'string', multiple: true }, - 'unsafe-allow-unauthenticated-localhost': { type: 'boolean', default: false }, -}; - -const serveInputSchema = z.object({ - port: z.coerce.number().int().nonnegative({ message: 'port must be a non-negative integer' }), - host: z.string().min(1, 'Missing value for --host').optional(), - path: z.string().min(1, 'Missing value for --path').optional(), - 'max-request-bytes': z.coerce.number().int().positive().optional(), - 'auth-secret': z.string().min(1, 'Missing value for --auth-secret').optional(), - 'auth-key-id': z.string().min(1, 'Missing value for --auth-key-id').optional(), - 'auth-mode': z.enum(['enforce', 'log-only']).optional(), - 'allow-writer': z.union([z.string().min(1), z.array(z.string().min(1))]).optional(), - 'unsafe-allow-unauthenticated-localhost': z.boolean().default(false), -}).strict(); - -type ServeInput = z.infer; - -function optionalString(value: string | undefined): string | null { - if (value === undefined) { return null; } - return value; -} - -function optionalNumber(value: number | undefined): number | null { - if (value === undefined) { return null; } - return value; -} - -function optionalAuthMode(value: ServeInput['auth-mode']): 'enforce' | 'log-only' { - if (value === undefined) { return 'enforce'; } - return value; -} - -function allowedWriterList(value: ServeInput['allow-writer']): string[] { - if (value === undefined) { return []; } - if (Array.isArray(value)) { return value; } - return [value]; -} - -function transformServeInput(val: ServeInput) { - return { - port: val.port, - host: optionalString(val.host), - path: optionalString(val.path), - maxRequestBytes: optionalNumber(val['max-request-bytes']), - authSecret: optionalString(val['auth-secret']), - authKeyId: optionalString(val['auth-key-id']), - authMode: optionalAuthMode(val['auth-mode']), - allowedWriters: allowedWriterList(val['allow-writer']), - unsafeAllowUnauthenticatedLocalhost: val['unsafe-allow-unauthenticated-localhost'], - }; -} - -const serveSchema = serveInputSchema.transform(transformServeInput); - -type ServeValues = z.infer; - -type ServePayload = { - graph: string; - url: string; - status: 'serving'; - auth: 'configured' | 'unsafe-localhost'; -}; - -function requireServeAuth(values: ServeValues): void { - if (values.authKeyId !== null && values.authSecret === null) { - throw usageError('--auth-key-id requires --auth-secret'); - } - if (values.authSecret === null && !values.unsafeAllowUnauthenticatedLocalhost) { - throw usageError('serve requires --auth-secret or --unsafe-allow-unauthenticated-localhost'); - } -} - -function applyServeAddressOptions(options: ServeOptions, values: ServeValues): void { - if (values.host !== null) { options.host = values.host; } - if (values.path !== null) { options.path = values.path; } - if (values.maxRequestBytes !== null) { options.maxRequestBytes = values.maxRequestBytes; } - if (values.allowedWriters.length > 0) { options.allowedWriters = values.allowedWriters; } -} - -function applyServeAuthOptions(options: ServeOptions, values: ServeValues): void { - if (values.authSecret === null) { - options.unsafeAllowUnauthenticatedLocalhost = values.unsafeAllowUnauthenticatedLocalhost; - return; - } - const keyId = values.authKeyId ?? 'default'; - const keys: { [id: string]: SyncSecret } = {}; - keys[keyId] = SyncSecret.fromString(values.authSecret); - options.auth = { keys, mode: values.authMode }; -} - -function buildServeOptions(values: ServeValues): ServeOptions { - requireServeAuth(values); - const options: ServeOptions = { port: values.port, httpPort: new NodeHttpAdapter() }; - applyServeAddressOptions(options, values); - applyServeAuthOptions(options, values); - return options; -} - -export default async function handleServe( - { options, args }: { options: CliOptions; args: string[] }, -): Promise<{ payload: ServePayload; exitCode: number; close: () => Promise }> { - const { values } = parseCommandArgs(args, SERVE_OPTIONS, serveSchema); - const { graph, graphName } = await openGraph(options); - const handle = await graph.serve(buildServeOptions(values)); - return { - payload: { - graph: graphName, - url: handle.url, - status: 'serving', - auth: values.authSecret !== null ? 'configured' : 'unsafe-localhost', - }, - exitCode: EXIT_CODES.OK, - close: async () => { await handle.close(); }, - }; -} diff --git a/bin/cli/commands/settle.ts b/bin/cli/commands/settle.ts new file mode 100644 index 00000000..d9ece798 --- /dev/null +++ b/bin/cli/commands/settle.ts @@ -0,0 +1,171 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { z } from 'zod'; + +import { + parseCommandArgs, + usageError, + usageErrorFrom, +} from '../infrastructure.ts'; +import type { CliOptions } from '../types.ts'; +import { withRuntime } from '../v19/V19Runtime.ts'; +import { + applyReviewedSettlement, + previewReviewedSettlement, + reviewedSettlementFromValue, + settlementPlanFields, + type ReviewedSettlement, + type SettlementSelector, +} from '../v19/V19SettlementReview.ts'; +import { + evidenceEnvelope, + receiptEnvelope, + renderReceipt, +} from '../../presenters/V19ReadingReceipt.ts'; +import { parseMcpJson, toMcpJson } from '../../presenters/V19Json.ts'; +import { stableStringify } from '../../presenters/json.ts'; +import type { McpJsonValue } from './mcp/McpJsonValue.ts'; +import type SettlementPreview from '../../../src/domain/api/SettlementPreview.ts'; + +const PREVIEW_OPTIONS = { + source: { type: 'string' }, + target: { type: 'string' }, + out: { type: 'string' }, +}; + +const PREVIEW_SCHEMA = z.object({ + source: z.string().min(1), + target: z.string().min(1), + out: z.string().min(1).optional(), +}); + +const APPLY_OPTIONS = { + plan: { type: 'string' }, +}; + +const APPLY_SCHEMA = z.object({ + plan: z.string().min(1), +}); + +export default async function handleSettle({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise<{ + readonly payload: McpJsonValue; + readonly human: string; +}> { + const [subcommand, ...subcommandArgs] = args; + if (subcommand === 'preview') { + return await previewSettlement(options, subcommandArgs); + } + if (subcommand === 'apply') { + return await applySettlement(options, subcommandArgs); + } + throw usageError('Usage: git warp settle [options]'); +} + +async function previewSettlement( + options: CliOptions, + args: readonly string[], +): Promise<{ + readonly payload: McpJsonValue; + readonly human: string; +}> { + const { values } = parseCommandArgs( + args, + PREVIEW_OPTIONS, + PREVIEW_SCHEMA, + ); + const selector = settlementSelector(options, values); + const preview = await withRuntime( + options, + async (runtime) => await previewReviewedSettlement(runtime, selector), + ); + const result = previewResult(preview, selector); + if (values.out !== undefined) { + writeFileSync(values.out, `${stableStringify(result.payload)}\n`); + } + return result; +} + +function previewResult( + preview: SettlementPreview, + selector: SettlementSelector, +) { + const payload: McpJsonValue = Object.freeze({ + type: 'SettlementPreview', + selector, + operation: preview.operation, + source: toMcpJson(preview.source), + target: toMcpJson(preview.target), + plan: toMcpJson(settlementPlanFields(preview.plan)), + outcome: toMcpJson(preview.outcome), + evidence: evidenceEnvelope(preview.evidence), + }); + return { + payload, + human: [ + `Settlement preview: ${preview.source.name} -> ${preview.target.name}`, + `outcome: ${preview.outcome.kind}`, + `plan: ${preview.plan.planDigest}`, + ].join('\n'), + }; +} + +function settlementSelector( + options: CliOptions, + values: Readonly<{ readonly source: string; readonly target: string }>, +): SettlementSelector { + return Object.freeze({ + sourceLane: values.source, + sourceStrand: requireSourceStrand(options.strand), + targetLane: values.target, + }); +} + +async function applySettlement( + options: CliOptions, + args: readonly string[], +): Promise<{ + readonly payload: McpJsonValue; + readonly human: string; +}> { + const { values } = parseCommandArgs( + args, + APPLY_OPTIONS, + APPLY_SCHEMA, + ); + const reviewed = readReviewedSettlement(values.plan); + const receipt = await withRuntime( + options, + async (runtime) => await applyReviewedSettlement(runtime, reviewed), + ); + const payload = receiptEnvelope(receipt); + return { + payload, + human: renderReceipt(payload), + }; +} + +function readReviewedSettlement(path: string): ReviewedSettlement { + try { + const parsed = parseMcpJson( + JSON.parse(readFileSync(path, 'utf8')), + ); + return reviewedSettlementFromValue(parsed); + } catch (error) { + throw usageErrorFrom( + `Unable to read reviewed Settlement from ${path}`, + error, + ); + } +} + +function requireSourceStrand(strand: string | null): string { + if (strand === null || strand.length === 0) { + throw usageError('--strand is required for settlement preview'); + } + return strand; +} diff --git a/bin/cli/commands/strand.ts b/bin/cli/commands/strand.ts deleted file mode 100644 index 8227bbba..00000000 --- a/bin/cli/commands/strand.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { usageError } from '../infrastructure.ts'; - -import * as braidSubcommand from './strand/braid.ts'; -import * as compareSubcommand from './strand/compare.ts'; -import * as createSubcommand from './strand/create.ts'; -import * as dropSubcommand from './strand/drop.ts'; -import * as listSubcommand from './strand/list.ts'; -import * as materializeSubcommand from './strand/materialize.ts'; -import * as showSubcommand from './strand/show.ts'; -import * as transferPlanSubcommand from './strand/transfer-plan.ts'; -import type { CliOptions } from '../types.ts'; - -type StrandModule = { - STRAND_SUBCOMMAND: { name: string; summary: string }; - handleStrandSubcommand: (params: { options: CliOptions; args: string[] }) => Promise<{ payload: unknown; exitCode: number }>; -}; - -const STRAND_SUBCOMMANDS: Record = Object.freeze({ - [createSubcommand.STRAND_SUBCOMMAND.name]: createSubcommand, - [braidSubcommand.STRAND_SUBCOMMAND.name]: braidSubcommand, - [listSubcommand.STRAND_SUBCOMMAND.name]: listSubcommand, - [showSubcommand.STRAND_SUBCOMMAND.name]: showSubcommand, - [compareSubcommand.STRAND_SUBCOMMAND.name]: compareSubcommand, - [transferPlanSubcommand.STRAND_SUBCOMMAND.name]: transferPlanSubcommand, - [materializeSubcommand.STRAND_SUBCOMMAND.name]: materializeSubcommand, - [dropSubcommand.STRAND_SUBCOMMAND.name]: dropSubcommand, -}); - -/** Builds the usage help text for the strand subcommand listing all available subcommands. */ -function buildStrandUsage(): string { - const subcommandLines = Object.values(STRAND_SUBCOMMANDS) - .map(({ STRAND_SUBCOMMAND }) => ` ${STRAND_SUBCOMMAND.name.padEnd(12)} ${STRAND_SUBCOMMAND.summary}`); - return [ - 'Usage: warp-graph strand [options]', - ...subcommandLines, - ].join('\n'); -} - -/** Dispatches to the appropriate strand subcommand handler based on the first positional argument. */ -export default async function handleStrand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const subcommandName = args[0]; - const rest = args.slice(1); - - if (subcommandName === undefined || subcommandName.length === 0) { - throw usageError(buildStrandUsage()); - } - - const subcommand = STRAND_SUBCOMMANDS[subcommandName]; - if (!subcommand) { - throw usageError(`Unknown strand subcommand: ${subcommandName}\n${buildStrandUsage()}`); - } - - return await subcommand.handleStrandSubcommand({ options, args: rest }); -} diff --git a/bin/cli/commands/strand/braid.ts b/bin/cli/commands/strand/braid.ts deleted file mode 100644 index 2145059d..00000000 --- a/bin/cli/commands/strand/braid.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs, usageError } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'braid', - summary: 'Pin read-only braid overlays onto a target strand', -}); - -const BRAID_OPTIONS = { - support: { type: 'string', multiple: true }, - 'read-only': { type: 'boolean', default: false }, - writable: { type: 'boolean', default: false }, -}; - -const braidStrandSchema = z.object({ - support: z.array(z.string()).optional(), - 'read-only': z.boolean().default(false), - writable: z.boolean().default(false), -}).strict().superRefine((val, ctx) => { - if (val['read-only'] && val.writable) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '--read-only and --writable are mutually exclusive', - }); - } -}).transform((val) => ({ - braidedStrandIds: val.support ?? [], - writable: val['read-only'] ? false : val.writable ? true : null, -})); - -/** Handles the strand braid subcommand — pins read-only braid overlays onto a target strand. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values, positionals } = parseCommandArgs(args, BRAID_OPTIONS, braidStrandSchema, { - allowPositionals: true, - }); - if (positionals.length !== 1) { - throw usageError( - 'Usage: warp-graph strand braid [--support ...] [--read-only|--writable]', - ); - } - - const strandId = positionals[0] ?? ''; - const { graph, graphName } = await openGraph(options); - const strand = await graph.braidStrand(strandId, values); - - return { - payload: { - graph: graphName, - strandAction: 'braid', - strand, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/strand/compare.ts b/bin/cli/commands/strand/compare.ts deleted file mode 100644 index 89269dd1..00000000 --- a/bin/cli/commands/strand/compare.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs, usageError } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'compare', - summary: 'Compare a strand against base, live, or another strand', -}); - -const COMPARE_OPTIONS = { - against: { type: 'string', default: 'base' }, - 'target-id': { type: 'string' }, - 'lamport-ceiling': { type: 'string' }, - 'against-lamport-ceiling': { type: 'string' }, -}; - -/** Parses and resolves the --against flag into a typed comparison target. */ -function resolveAgainstTarget(rawAgainst: string, ctx: z.RefinementCtx): 'base' | 'live' | { kind: 'strand'; strandId: string } | typeof z.NEVER { - if (rawAgainst === 'base' || rawAgainst === 'live') { - return rawAgainst; - } - if (rawAgainst.startsWith('strand:') && rawAgainst.length > 'strand:'.length) { - return { - kind: 'strand' as const, - strandId: rawAgainst.slice('strand:'.length), - }; - } - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['against'], - message: 'against must be base, live, or strand:', - }); - return z.NEVER; -} - -const compareStrandSchema = z.object({ - against: z.string().default('base'), - 'target-id': z.string().optional(), - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), - 'against-lamport-ceiling': z.coerce.number().int().nonnegative().optional(), -}).strict().transform((val, ctx) => { - const rawAgainst = val.against.trim(); - const against = resolveAgainstTarget(rawAgainst, ctx); - - const comparisonOptions = { - against, - ceiling: val['lamport-ceiling'] ?? null, - againstCeiling: val['against-lamport-ceiling'] ?? null, - targetId: val['target-id'], - } as { - against?: 'base' | 'live' | { kind: 'strand'; strandId: string }; - ceiling?: number | null; - againstCeiling?: number | null; - targetId?: string | null; - }; - - return { - againstRaw: rawAgainst, - comparisonOptions, - }; -}); - -/** Handles the `strand compare` CLI subcommand, comparing a strand against a target. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values, positionals } = parseCommandArgs(args, COMPARE_OPTIONS, compareStrandSchema, { - allowPositionals: true, - }); - if (positionals.length !== 1) { - throw usageError( - 'Usage: warp-graph strand compare [--against base|live|strand:] [--target-id ] [--lamport-ceiling ] [--against-lamport-ceiling ]', - ); - } - - const strandId = positionals[0] ?? ''; - const { graph, graphName } = await openGraph(options); - const comparison = await graph.compareStrand(strandId, values.comparisonOptions); - - return { - payload: { - graph: graphName, - strandAction: 'compare', - strandId, - against: values.againstRaw, - comparison, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/strand/create.ts b/bin/cli/commands/strand/create.ts deleted file mode 100644 index f8540fdd..00000000 --- a/bin/cli/commands/strand/create.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'create', - summary: 'Create a pinned strand descriptor', -}); - -const CREATE_OPTIONS = { - id: { type: 'string' }, - 'lamport-ceiling': { type: 'string' }, - owner: { type: 'string' }, - scope: { type: 'string' }, - 'lease-expires-at': { type: 'string' }, -}; - -const createStrandSchema = z.object({ - id: z.string().optional(), - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), - owner: z.string().optional(), - scope: z.string().optional(), - 'lease-expires-at': z.string().optional(), -}).strict().transform(normalizeStrandValues); - -/** Spreads an optional value into an object if defined. */ -function optSpread(key: string, value: unknown): Record { - return value !== undefined ? { [key]: value } : {}; -} - -/** Normalizes parsed Zod values into strand create options. */ -function normalizeStrandValues(val: { id?: string | undefined; 'lamport-ceiling'?: number | undefined; owner?: string | undefined; scope?: string | undefined; 'lease-expires-at'?: string | undefined }, _ctx: z.RefinementCtx): { strandId?: string; lamportCeiling: number | null; owner?: string; scope?: string; leaseExpiresAt?: string } { - return { - ...optSpread('strandId', val.id), - lamportCeiling: val['lamport-ceiling'] ?? null, - ...optSpread('owner', val.owner), - ...optSpread('scope', val.scope), - ...optSpread('leaseExpiresAt', val['lease-expires-at']), - } as { strandId?: string; lamportCeiling: number | null; owner?: string; scope?: string; leaseExpiresAt?: string }; -} - -/** Handles the strand create subcommand by parsing arguments and creating a new strand descriptor. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values } = parseCommandArgs(args, CREATE_OPTIONS, createStrandSchema); - const { graph, graphName } = await openGraph(options); - const strand = await graph.createStrand(values); - - return { - payload: { - graph: graphName, - strandAction: 'create', - strand, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/strand/drop.ts b/bin/cli/commands/strand/drop.ts deleted file mode 100644 index 4440707d..00000000 --- a/bin/cli/commands/strand/drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs, usageError } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'drop', - summary: 'Delete a strand descriptor', -}); - -const DROP_OPTIONS: Record = {}; -const dropStrandSchema = z.object({}).strict(); - -/** Handles the `strand drop` CLI subcommand, deleting a strand descriptor by ID. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { positionals } = parseCommandArgs(args, DROP_OPTIONS, dropStrandSchema, { allowPositionals: true }); - if (positionals.length !== 1) { - throw usageError('Usage: warp-graph strand drop '); - } - - const strandId = positionals[0] ?? ''; - const { graph, graphName } = await openGraph(options); - const dropped = await graph.dropStrand(strandId); - - return { - payload: { - graph: graphName, - strandAction: 'drop', - strandId, - dropped, - }, - exitCode: dropped ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND, - }; -} diff --git a/bin/cli/commands/strand/list.ts b/bin/cli/commands/strand/list.ts deleted file mode 100644 index c18d73ce..00000000 --- a/bin/cli/commands/strand/list.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'list', - summary: 'List strand descriptors for the graph', -}); - -const LIST_OPTIONS: Record = {}; -const listStrandSchema = z.object({}).strict(); - -/** Lists all strand descriptors for the graph. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - parseCommandArgs(args, LIST_OPTIONS, listStrandSchema); - const { graph, graphName } = await openGraph(options); - const strands = await graph.listStrands(); - - return { - payload: { - graph: graphName, - strandAction: 'list', - strands, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/strand/materialize.ts b/bin/cli/commands/strand/materialize.ts deleted file mode 100644 index 9bb9471f..00000000 --- a/bin/cli/commands/strand/materialize.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, notFoundError, parseCommandArgs, usageError } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'materialize', - summary: 'Materialize the pinned strand coordinate', -}); - -const MATERIALIZE_OPTIONS = { - receipts: { type: 'boolean', default: false }, -}; - -const materializeStrandSchema = z.object({ - receipts: z.boolean().default(false), -}).strict(); - -/** Builds the summary payload from a materialized strand state. */ -function buildMaterializePayload(graphName: string, strand: unknown, materialized: unknown): { payload: unknown; exitCode: number } { - const mat = materialized as { state?: { nodeAlive: unknown; edgeAlive: unknown; prop: Map }; receipts?: unknown[] }; - const state = ( - mat.state !== undefined ? mat.state : mat - ) as { nodeAlive: unknown; edgeAlive: unknown; prop: Map }; - const receipts = mat.state !== undefined ? mat.receipts : undefined; - - return { - payload: { - graph: graphName, - strandAction: 'materialize', - strand, - state, - receipts, - summary: { - nodeCount: (state.nodeAlive as { elements: () => Array }).elements().length, - edgeCount: (state.edgeAlive as { elements: () => Array }).elements().length, - propertyCount: state.prop.size, - receiptCount: receipts?.length ?? 0, - }, - }, - exitCode: EXIT_CODES.OK, - }; -} - -/** Materializes a pinned strand coordinate and returns its state with optional tick receipts. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values, positionals } = parseCommandArgs(args, MATERIALIZE_OPTIONS, materializeStrandSchema, { allowPositionals: true }); - if (positionals.length !== 1) { - throw usageError('Usage: warp-graph strand materialize [--receipts]'); - } - - const strandId = positionals[0] as string; - const { graph, graphName } = await openGraph(options); - const strand = await graph.getStrand(strandId); - if (!strand) { - throw notFoundError(`Strand not found: ${strandId}`); - } - - const materialized = values.receipts - ? await graph.materializeStrand(strandId, { receipts: true }) - : await graph.materializeStrand(strandId); - - return buildMaterializePayload(graphName, strand, materialized); -} diff --git a/bin/cli/commands/strand/show.ts b/bin/cli/commands/strand/show.ts deleted file mode 100644 index 73842ab1..00000000 --- a/bin/cli/commands/strand/show.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, notFoundError, parseCommandArgs, usageError } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'show', - summary: 'Show a single strand descriptor', -}); - -const SHOW_OPTIONS: Record = {}; -const showStrandSchema = z.object({}).strict(); - -/** Shows a single strand descriptor by ID. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { positionals } = parseCommandArgs(args, SHOW_OPTIONS, showStrandSchema, { allowPositionals: true }); - if (positionals.length !== 1) { - throw usageError('Usage: warp-graph strand show '); - } - - const strandId = positionals[0] ?? ''; - const { graph, graphName } = await openGraph(options); - const strand = await graph.getStrand(strandId); - if (!strand) { - throw notFoundError(`Strand not found: ${strandId}`); - } - - return { - payload: { - graph: graphName, - strandAction: 'show', - strand, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/strand/transfer-plan.ts b/bin/cli/commands/strand/transfer-plan.ts deleted file mode 100644 index 0068019f..00000000 --- a/bin/cli/commands/strand/transfer-plan.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from 'zod'; - -import { EXIT_CODES, parseCommandArgs, usageError } from '../../infrastructure.ts'; -import { openGraph } from '../../shared.ts'; -import type { CliOptions } from '../../types.ts'; - -export const STRAND_SUBCOMMAND = Object.freeze({ - name: 'transfer-plan', - summary: 'Plan a deterministic transfer from one strand into live, base, or another strand', -}); - -const TRANSFER_PLAN_OPTIONS = { - into: { type: 'string', default: 'live' }, - 'lamport-ceiling': { type: 'string' }, - 'into-lamport-ceiling': { type: 'string' }, -}; - -/** Parses the raw `into` string into a typed target descriptor. */ -function parseIntoTarget(rawInto: string, ctx: z.RefinementCtx): 'base' | 'live' | { kind: 'strand'; strandId: string } | typeof z.NEVER { - if (rawInto === 'base' || rawInto === 'live') { - return rawInto; - } - if (rawInto.startsWith('strand:') && rawInto.length > 'strand:'.length) { - return { kind: 'strand' as const, strandId: rawInto.slice('strand:'.length) }; - } - ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['into'], message: 'into must be base, live, or strand:' }); - return z.NEVER; -} - -const transferPlanSchema = z.object({ - into: z.string().default('live'), - 'lamport-ceiling': z.coerce.number().int().nonnegative().optional(), - 'into-lamport-ceiling': z.coerce.number().int().nonnegative().optional(), -}).strict().transform((val, ctx) => { - const rawInto = val.into.trim(); - const into = parseIntoTarget(rawInto, ctx); - - return { - intoRaw: rawInto, - transferOptions: { - into, - ceiling: val['lamport-ceiling'] ?? null, - intoCeiling: val['into-lamport-ceiling'] ?? null, - } as { - into?: 'base' | 'live' | { kind: 'strand'; strandId: string }; - ceiling?: number | null; - intoCeiling?: number | null; - }, - }; -}); - -/** Plans a deterministic transfer from one strand into a target. */ -export async function handleStrandSubcommand({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values, positionals } = parseCommandArgs(args, TRANSFER_PLAN_OPTIONS, transferPlanSchema, { - allowPositionals: true, - }); - if (positionals.length !== 1) { - throw usageError( - 'Usage: warp-graph strand transfer-plan [--into live|base|strand:] [--lamport-ceiling ] [--into-lamport-ceiling ]', - ); - } - - const strandId = positionals[0] ?? ''; - const { graph, graphName } = await openGraph(options); - const transferPlan = await graph.planStrandTransfer(strandId, values.transferOptions); - - return { - payload: { - graph: graphName, - strandAction: 'transfer-plan', - strandId, - into: values.intoRaw, - transferPlan, - }, - exitCode: EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/sync.ts b/bin/cli/commands/sync.ts deleted file mode 100644 index 7f1c795f..00000000 --- a/bin/cli/commands/sync.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { z } from 'zod'; - -import SyncSecret from '../../../src/domain/services/sync/SyncSecret.ts'; -import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; -import { openGraph } from '../shared.ts'; -import type { CliOptions, WarpGraphInstance } from '../types.ts'; -import type { SyncRequest, SyncWithOptions } from '../../../src/domain/capabilities/SyncCapability.ts'; - -const SYNC_EMPTY_OPTIONS = {}; - -const SYNC_WITH_OPTIONS = { - path: { type: 'string' }, - retries: { type: 'string' }, - 'base-delay-ms': { type: 'string' }, - 'max-delay-ms': { type: 'string' }, - 'timeout-ms': { type: 'string' }, - materialize: { type: 'boolean', default: false }, - 'auth-secret': { type: 'string' }, - 'auth-key-id': { type: 'string' }, -}; - -const syncEmptySchema = z.object({}).strict(); - -const syncWithInputSchema = z.object({ - path: z.string().min(1, 'Missing value for --path').optional(), - retries: z.coerce.number().int().nonnegative().optional(), - 'base-delay-ms': z.coerce.number().int().nonnegative().optional(), - 'max-delay-ms': z.coerce.number().int().nonnegative().optional(), - 'timeout-ms': z.coerce.number().int().positive().optional(), - materialize: z.boolean().default(false), - 'auth-secret': z.string().min(1, 'Missing value for --auth-secret').optional(), - 'auth-key-id': z.string().min(1, 'Missing value for --auth-key-id').optional(), -}).strict(); - -type SyncWithInput = z.infer; - -function optionalString(value: string | undefined): string | null { - if (value === undefined) { return null; } - return value; -} - -function optionalNumber(value: number | undefined): number | null { - if (value === undefined) { return null; } - return value; -} - -function transformSyncWithInput(val: SyncWithInput) { - return { - path: optionalString(val.path), - retries: optionalNumber(val.retries), - baseDelayMs: optionalNumber(val['base-delay-ms']), - maxDelayMs: optionalNumber(val['max-delay-ms']), - timeoutMs: optionalNumber(val['timeout-ms']), - materialize: val.materialize, - authSecret: optionalString(val['auth-secret']), - authKeyId: optionalString(val['auth-key-id']), - }; -} - -const syncWithSchema = syncWithInputSchema.transform(transformSyncWithInput); - -type SyncWithValues = z.infer; - -function syncUsage(): never { - throw usageError('Usage: warp-graph sync '); -} - -type SyncStatusPayload = { - graph: string; - status: Awaited>; -}; - -type SyncRequestPayload = { - graph: string; - request: SyncRequest; -}; - -type SyncWithPayload = { - graph: string; - remote: string; - applied: number; - attempts: number; - skippedWriters: Awaited>['skippedWriters']; - materialized: boolean; -}; - -type SyncPayload = SyncStatusPayload | SyncRequestPayload | SyncWithPayload; - -async function syncStatus(options: CliOptions): Promise<{ payload: SyncPayload; exitCode: number }> { - const { graph, graphName } = await openGraph(options); - return { - payload: { graph: graphName, status: await graph.status() }, - exitCode: EXIT_CODES.OK, - }; -} - -async function syncRequest(options: CliOptions): Promise<{ payload: SyncPayload; exitCode: number }> { - const { graph, graphName } = await openGraph(options); - return { - payload: { graph: graphName, request: await graph.createSyncRequest() }, - exitCode: EXIT_CODES.OK, - }; -} - -function requireSyncAuth(values: SyncWithValues): void { - if (values.authKeyId !== null && values.authSecret === null) { - throw usageError('--auth-key-id requires --auth-secret'); - } -} - -function applySyncRetryOptions(options: SyncWithOptions, values: SyncWithValues): void { - if (values.retries !== null) { options.retries = values.retries; } - if (values.baseDelayMs !== null) { options.baseDelayMs = values.baseDelayMs; } - if (values.maxDelayMs !== null) { options.maxDelayMs = values.maxDelayMs; } -} - -function applySyncTransportOptions(options: SyncWithOptions, values: SyncWithValues): void { - if (values.path !== null) { options.path = values.path; } - if (values.timeoutMs !== null) { options.timeoutMs = values.timeoutMs; } - if (values.materialize) { options.materialize = true; } -} - -function syncAuth(values: SyncWithValues): NonNullable | null { - if (values.authSecret === null) { return null; } - const auth = { secret: SyncSecret.fromString(values.authSecret) }; - if (values.authKeyId !== null) { return { ...auth, keyId: values.authKeyId }; } - return auth; -} - -function buildSyncWithOptions(values: SyncWithValues): SyncWithOptions { - requireSyncAuth(values); - const options: SyncWithOptions = {}; - applySyncTransportOptions(options, values); - applySyncRetryOptions(options, values); - const auth = syncAuth(values); - if (auth !== null) { options.auth = auth; } - return options; -} - -async function syncWith( - options: CliOptions, - args: string[], -): Promise<{ payload: SyncPayload; exitCode: number }> { - const { values, positionals } = parseCommandArgs(args, SYNC_WITH_OPTIONS, syncWithSchema, { - allowPositionals: true, - }); - const remote = positionals[0]; - if (remote === undefined || remote.length === 0) { - throw usageError('Usage: warp-graph sync with [options]'); - } - if (positionals.length > 1) { - throw usageError('sync with accepts exactly one remote URL'); - } - const { graph, graphName } = await openGraph(options); - const result = await graph.syncWith(remote, buildSyncWithOptions(values)); - return { - payload: { - graph: graphName, - remote, - applied: result.applied, - attempts: result.attempts, - skippedWriters: result.skippedWriters, - materialized: result.state !== undefined, - }, - exitCode: EXIT_CODES.OK, - }; -} - -export default async function handleSync( - { options, args }: { options: CliOptions; args: string[] }, -): Promise<{ payload: SyncPayload; exitCode: number }> { - const action = args[0] ?? 'status'; - const rest = args.slice(1); - - if (action === 'status') { - parseCommandArgs(rest, SYNC_EMPTY_OPTIONS, syncEmptySchema); - return await syncStatus(options); - } - if (action === 'request') { - parseCommandArgs(rest, SYNC_EMPTY_OPTIONS, syncEmptySchema); - return await syncRequest(options); - } - if (action === 'with') { return await syncWith(options, rest); } - return syncUsage(); -} diff --git a/bin/cli/commands/tree.ts b/bin/cli/commands/tree.ts deleted file mode 100644 index 82417293..00000000 --- a/bin/cli/commands/tree.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { EXIT_CODES, usageError, parseCommandArgs } from '../infrastructure.ts'; -import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.ts'; -import { z } from 'zod'; -import type { CliOptions } from '../types.ts'; -import ImmutableBytes from '../../../src/domain/services/snapshot/ImmutableBytes.ts'; -import type { SnapshotPropValue } from '../../../src/domain/services/snapshot/SnapshotPropValue.ts'; - -type TreeProps = Readonly<{ [key: string]: SnapshotPropValue }>; -type TreeEdge = { - readonly from: string; - readonly to: string; - readonly label?: string; -}; -type TreeChild = { - readonly id: string; - readonly label: string; -}; -type TreeChildMap = Map; -type TreeRow = { - id: string; - props?: TreeProps; -}; -type TreeCommandResult = { - payload: unknown; - exitCode: number; -}; -type TreeRenderParams = { - nodeId: string; - childMap: TreeChildMap; - propsMap: Map; - propKeys: string[]; - prefix: string; - isLast: boolean; - visited: Set; - depth: number; - maxDepth: number | undefined; - lines: string[]; -}; - -function formatSnapshotPropValue(value: SnapshotPropValue): string { - if (value instanceof ImmutableBytes) { - return `bytes(${value.length})`; - } - if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - if (Array.isArray(value)) { - return `[${value.map((entry) => formatSnapshotPropValue(entry)).join(', ')}]`; - } - return `{${Object.entries(value) - .map(([key, entry]) => `${key}: ${formatSnapshotPropValue(entry)}`) - .join(', ')}}`; -} - -const TREE_OPTIONS = { - edge: { type: 'string' }, - prop: { type: 'string', multiple: true }, - 'max-depth': { type: 'string' }, -}; - -const treeSchema = z.object({ - edge: z.string().optional(), - prop: z.union([z.string(), z.array(z.string())]).optional(), - 'max-depth': z.coerce.number().int().nonnegative().optional(), -}).strict().transform((val) => ({ - edgeLabel: val.edge ?? null, - props: Array.isArray(val.prop) ? val.prop : val.prop !== undefined ? [val.prop] : [], - maxDepth: val['max-depth'], -})); - -/** Builds a parent-to-children adjacency map from edges. */ -function buildChildMap(edges: TreeEdge[], labelFilter: string | null): TreeChildMap { - const children: TreeChildMap = new Map(); - const hasParent: Set = new Set(); - - for (const edge of edges) { - if (labelFilter !== null && edge.label !== labelFilter) { - continue; - } - if (!children.has(edge.from)) { - children.set(edge.from, []); - } - const fromChildren = children.get(edge.from); - if (fromChildren !== undefined) { - fromChildren.push({ id: edge.to, label: edge.label ?? '' }); - } - hasParent.add(edge.to); - } - - return children; -} - -/** Finds root nodes (nodes with outgoing edges but no incoming edges in the filtered set). */ -function findRoots(nodeIds: string[], edges: TreeEdge[], labelFilter: string | null): string[] { - const hasParent = new Set(); - const hasChild = new Set(); - - for (const edge of edges) { - if (labelFilter !== null && edge.label !== labelFilter) { - continue; - } - hasParent.add(edge.to); - hasChild.add(edge.from); - } - - // Roots: nodes that have children but no parents in the filtered edge set - const roots = nodeIds.filter((id) => !hasParent.has(id) && hasChild.has(id)); - if (roots.length > 0) { - return roots.sort(); - } - - // Fallback: nodes with no incoming edges at all - return nodeIds.filter((id) => !hasParent.has(id)).sort(); -} - -/** Formats annotation string for a node based on requested props. */ -function formatAnnotation(nodeProps: TreeProps, propKeys: string[]): string { - if (propKeys.length === 0 || nodeProps === undefined || nodeProps === null) { - return ''; - } - const parts: string[] = []; - for (const key of propKeys) { - if (Object.prototype.hasOwnProperty.call(nodeProps, key)) { - const propValue = nodeProps[key]; - if (propValue !== undefined) { - const value = formatSnapshotPropValue(propValue); - parts.push(`${key}: ${value}`); - } - } - } - return parts.length > 0 ? ` [${parts.join(', ')}]` : ''; -} - -/** Renders a tree structure as lines with box-drawing characters. */ -function renderTreeNode({ nodeId, childMap, propsMap, propKeys, prefix, isLast, visited, depth, maxDepth, lines }: TreeRenderParams): void { - const connector = depth === 0 ? '' : (isLast ? '\u2514\u2500\u2500 ' : '\u251C\u2500\u2500 '); - const annotation = formatAnnotation(propsMap.get(nodeId) ?? {}, propKeys); - lines.push(`${prefix}${connector}${nodeId}${annotation}`); - - if (visited.has(nodeId)) { - lines.push(`${prefix}${isLast ? ' ' : '\u2502 '} (cycle)`); - return; - } - visited.add(nodeId); - - if (maxDepth !== undefined && depth >= maxDepth) { - const kids = childMap.get(nodeId); - if (kids !== undefined && kids.length > 0) { - lines.push(`${prefix}${isLast ? ' ' : '\u2502 '} ... (${kids.length} children)`); - } - return; - } - - const kids = childMap.get(nodeId) ?? []; - const childPrefix = depth === 0 ? '' : `${prefix}${isLast ? ' ' : '\u2502 '}`; - for (let i = 0; i < kids.length; i++) { - const kid = kids[i]; - if (!kid) { continue; } - renderTreeNode({ - nodeId: kid.id, - childMap, - propsMap, - propKeys, - prefix: childPrefix, - isLast: i === kids.length - 1, - visited, - depth: depth + 1, - maxDepth, - lines, - }); - } -} - -/** Collects all reachable node IDs via DFS from the given roots. */ -function collectReachable(roots: string[], childMap: TreeChildMap, reachable: Set): void { - const stack = [...roots]; - while (stack.length > 0) { - const id = stack.pop() as string; - if (reachable.has(id)) { - continue; - } - reachable.add(id); - const kids = childMap.get(id) ?? []; - for (const kid of kids) { - stack.push(kid.id); - } - } -} - -/** Handles the `tree` command: renders an ASCII tree from graph edges. */ -export default async function handleTree({ options, args }: { options: CliOptions; args: string[] }): Promise { - const { values, positionals } = parseCommandArgs( - args, TREE_OPTIONS, treeSchema, { allowPositionals: true }, - ); - const { graph, graphName, cursorStore } = await openGraph(options); - const cursorInfo = await applyCursorCeiling(graph, cursorStore); - emitCursorWarning(cursorInfo, null); - - const queryResult = await graph.query().run(); - if (!('nodes' in queryResult)) { - throw usageError('Tree query must return node rows'); - } - const edges = await graph.getEdges(); - const rootArg = positionals[0] ?? null; - - const rows: TreeRow[] = queryResult.nodes - .filter((node: { id?: string; props?: TreeProps }): node is TreeRow => typeof node.id === 'string'); - const nodeIds = rows.map((node) => node.id); - const propsMap = new Map( - rows.map((node): [string, TreeProps] => [node.id, node.props ?? {}]), - ); - const childMap = buildChildMap(edges, values.edgeLabel); - - const roots = rootArg !== null ? [rootArg] : findRoots(nodeIds, edges, values.edgeLabel); - - if (rootArg !== null && !nodeIds.includes(rootArg)) { - throw usageError(`Node not found: ${rootArg}`); - } - - const lines: string[] = []; - for (const root of roots) { - renderTreeNode({ - nodeId: root, - childMap, - propsMap, - propKeys: values.props, - prefix: '', - isLast: true, - visited: new Set(), - depth: 0, - maxDepth: values.maxDepth, - lines, - }); - } - - // Collect orphans (nodes not reachable from any root) - const reachable = new Set(); - collectReachable(roots, childMap, reachable); - const orphans = nodeIds.filter((id: string) => !reachable.has(id)); - - const payload = { - graph: graphName, - roots, - tree: lines.join('\n'), - orphanCount: orphans.length, - orphans: orphans.length > 0 ? orphans : undefined, - }; - - return { payload, exitCode: EXIT_CODES.OK }; -} diff --git a/bin/cli/commands/trust.ts b/bin/cli/commands/trust.ts deleted file mode 100644 index bf1bf9e5..00000000 --- a/bin/cli/commands/trust.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * CLI handler for `git warp trust`. - * - * Evaluates writer trust status against signed evidence in the trust - * record chain. Returns a TrustAssessment payload. - * - * @module cli/commands/trust - */ - -import { EXIT_CODES, parseCommandArgs, getEnvVar } from '../infrastructure.ts'; -import { trustSchema } from '../schemas.ts'; -import { createPersistence, resolveGraphName } from '../shared.ts'; -import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import AuditVerifierService from '../../../src/domain/services/audit/AuditVerifierService.ts'; -import TrustCryptoAdapter from '../../../src/infrastructure/adapters/TrustCryptoAdapter.ts'; -import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.ts'; -import type { CliOptions, Persistence } from '../types.ts'; - -const TRUST_OPTIONS = { - mode: { type: 'string' }, - 'trust-pin': { type: 'string' }, -}; - -/** Parses trust command CLI arguments into mode and trust pin values. */ -export function parseTrustArgs(args: string[]): { mode: string | null; trustPin: string | null } { - const { values } = parseCommandArgs(args, TRUST_OPTIONS, trustSchema); - return values; -} - -/** Resolves the trust pin from CLI flag, env, or live ref. */ -function resolveTrustPin(cliPin: string | null): { pin: string | null; source: string; sourceDetail: string | null; status: 'configured' | 'pinned' } { - if (typeof cliPin === 'string' && cliPin.length > 0) { - return { pin: cliPin, source: 'cli_pin', sourceDetail: cliPin, status: 'pinned' }; - } - const envPin = getEnvVar('WARP_TRUST_PIN'); - if (typeof envPin === 'string' && envPin.length > 0) { - return { pin: envPin, source: 'env_pin', sourceDetail: envPin, status: 'pinned' }; - } - return { pin: null, source: 'ref', sourceDetail: null, status: 'configured' }; -} - -/** Discovers all writer IDs from the writers prefix refs. */ -async function discoverWriterIds(persistence: Persistence, graphName: string): Promise { - const prefix = `refs/warp/${graphName}/writers/`; - const refs = await persistence.listRefs(prefix); - return refs - .map((ref: string) => ref.slice(prefix.length)) - .filter((id: string) => id.length > 0) - .sort(); -} - -/** Handles the `git warp trust` command: evaluates writer trust against signed evidence. */ -export default async function handleTrust({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { mode, trustPin } = parseTrustArgs(args); - const { persistence, runtimeStorage, createTrustChain } = await createPersistence(options.repo); - const graphName = await resolveGraphName(persistence, options.graph); - const crypto = new WebCryptoAdapter(); - const storage = await runtimeStorage.createRuntimeStorageServices({ - timelineName: graphName, - codec: defaultCodec, - crypto, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - const trustChain = createTrustChain(crypto); - const verifier = new AuditVerifierService({ - auditLog: storage.auditLog, - codec: defaultCodec, - trustChain, - trustCrypto: new TrustCryptoAdapter(), - }); - - // Resolve pin (determines source + status) - const { pin, source, sourceDetail, status } = resolveTrustPin(trustPin); - const writerIds = await discoverWriterIds(persistence, graphName); - const assessment = await verifier.evaluateTrust(graphName, { - ...(pin !== undefined && pin !== null ? { pin } : {}), - mode: mode ?? 'warn', - writerIds, - source, - sourceDetail, - status, - }); - - const payload = { - graph: graphName, - ...assessment, - }; - - const exitCode = assessment.trustVerdict === 'fail' && (mode === 'enforce') - ? EXIT_CODES.TRUST_FAIL - : EXIT_CODES.OK; - - return { payload, exitCode }; -} diff --git a/bin/cli/commands/verify-audit.ts b/bin/cli/commands/verify-audit.ts index faede96e..ecfa15be 100644 --- a/bin/cli/commands/verify-audit.ts +++ b/bin/cli/commands/verify-audit.ts @@ -4,7 +4,11 @@ import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapte import defaultCrypto from '../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import { EXIT_CODES, parseCommandArgs, getEnvVar } from '../infrastructure.ts'; import { verifyAuditSchema } from '../schemas.ts'; -import { createPersistence, resolveGraphName } from '../shared.ts'; +import { + createPersistence, + resolveGraphName, + type CliStorageBinding, +} from '../shared.ts'; import type { CliOptions } from '../types.ts'; /** @@ -46,10 +50,19 @@ export function parseVerifyAuditArgs(args: string[]): { since: string | undefine } /** Handles the verify-audit command: verifies audit receipt chain integrity. */ -export default async function handleVerifyAudit({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { +export default async function handleVerifyAudit({ + options, + args, + storage: suppliedStorage, +}: { + options: CliOptions; + args: string[]; + storage?: CliStorageBinding; +}): Promise<{ payload: unknown; exitCode: number }> { const { since, writerFilter, trustMode, trustPin } = parseVerifyAuditArgs(args); - const { persistence, runtimeStorage } = await createPersistence(options.repo); - const graphName = await resolveGraphName(persistence, options.graph); + const { persistence, runtimeStorage } = + suppliedStorage ?? await createPersistence(options.repo); + const graphName = await resolveGraphName(persistence, options.lane); const storage = await runtimeStorage.createRuntimeStorageServices({ timelineName: graphName, codec: defaultCodec, diff --git a/bin/cli/commands/verify-index.ts b/bin/cli/commands/verify-index.ts deleted file mode 100644 index ccfc44bd..00000000 --- a/bin/cli/commands/verify-index.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { EXIT_CODES, parseCommandArgs } from '../infrastructure.ts'; -import { verifyIndexSchema } from '../schemas.ts'; -import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.ts'; -import type { CliOptions } from '../types.ts'; - -const VERIFY_INDEX_OPTIONS = { - seed: { type: 'string' }, - 'sample-rate': { type: 'string' }, -}; - -/** - * Handles the `verify-index` command: samples alive nodes and cross-checks - * bitmap index neighbors against adjacency ground truth. - */ -export default async function handleVerifyIndex({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { - const { values } = parseCommandArgs( - args, - VERIFY_INDEX_OPTIONS, - verifyIndexSchema, - ); - const { graph, graphName, cursorStore } = await openGraph(options); - const cursorInfo = await applyCursorCeiling(graph, cursorStore); - emitCursorWarning(cursorInfo, null); - - try { - await graph.materialize(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - payload: { error: message }, - exitCode: EXIT_CODES.INTERNAL, - }; - } - - let result; - try { - result = await Promise.resolve(graph.verifyIndex({ - ...(values.seed !== undefined ? { seed: values.seed } : {}), - sampleRate: values.sampleRate, - })); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const noIndex = /no bitmap index|cannot verify index|index not built/i.test(message); - return { - payload: { error: noIndex ? 'No bitmap index available after materialization' : message }, - exitCode: EXIT_CODES.INTERNAL, - }; - } - - return { - payload: { - graph: graphName, - ...result, - totalChecks: result.passed + result.failed, - }, - exitCode: result.failed > 0 ? EXIT_CODES.INTERNAL : EXIT_CODES.OK, - }; -} diff --git a/bin/cli/commands/watch.ts b/bin/cli/commands/watch.ts deleted file mode 100644 index 454cd662..00000000 --- a/bin/cli/commands/watch.ts +++ /dev/null @@ -1,75 +0,0 @@ -import process from 'node:process'; -import { z } from 'zod'; - -import { compactStringify } from '../../presenters/json.ts'; -import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; -import { openGraph } from '../shared.ts'; -import type { CliOptions, WarpGraphInstance } from '../types.ts'; -import type { StateDiffResult } from '../../../src/domain/services/state/StateDiff.ts'; - -const WATCH_OPTIONS = { - poll: { type: 'string' }, -}; - -const watchSchema = z.object({ - poll: z.coerce.number().int().min(1000, 'poll must be >= 1000').optional(), -}).strict(); - -type WatchValues = z.infer; -type WatchOptions = Parameters[1]; -type WatchSubscription = ReturnType; - -type WatchPayload = { - graph: string; - pattern: string; - status: 'watching'; - eventFormat: 'ndjson'; -}; - -function parseWatchArgs(args: string[]): { pattern: string; values: WatchValues } { - const { values, positionals } = parseCommandArgs(args, WATCH_OPTIONS, watchSchema, { - allowPositionals: true, - }); - if (positionals.length > 1) { - throw usageError('Usage: warp-graph watch [pattern] [--poll ]'); - } - return { pattern: positionals[0] ?? '*', values }; -} - -function watchOptions(graphName: string, pattern: string, values: WatchValues): WatchOptions { - return { - ...(values.poll !== undefined ? { poll: values.poll } : {}), - onChange(diff: StateDiffResult) { - process.stdout.write(`${compactStringify({ type: 'change', graph: graphName, pattern, diff })}\n`); - }, - onError(error) { - process.stderr.write(`watch error: ${String(error)}\n`); - }, - }; -} - -function closeWatch(subscription: WatchSubscription): () => Promise { - return () => { - subscription.unsubscribe(); - return Promise.resolve(); - }; -} - -export default async function handleWatch( - { options, args }: { options: CliOptions; args: string[] }, -): Promise<{ payload: WatchPayload; exitCode: number; close: () => Promise }> { - const { pattern, values } = parseWatchArgs(args); - const { graph, graphName } = await openGraph(options); - const subscription = graph.watch(pattern, watchOptions(graphName, pattern, values)); - - return { - payload: { - graph: graphName, - pattern, - status: 'watching', - eventFormat: 'ndjson', - }, - exitCode: EXIT_CODES.OK, - close: closeWatch(subscription), - }; -} diff --git a/bin/cli/commands/write.ts b/bin/cli/commands/write.ts new file mode 100644 index 00000000..deef3e84 --- /dev/null +++ b/bin/cli/commands/write.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { parseCommandArgs } from '../infrastructure.ts'; +import type { CliOptions } from '../types.ts'; +import { intentFromText } from '../v19/V19DomainInput.ts'; +import { openRequiredLane, withRuntime } from '../v19/V19Runtime.ts'; +import { + receiptEnvelope, + renderReceipt, +} from '../../presenters/V19ReadingReceipt.ts'; + +const WRITE_OPTIONS = { + intent: { type: 'string' }, +}; + +const WRITE_SCHEMA = z.object({ + intent: z.string().min(1), +}); + +export default async function handleWrite({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise<{ + readonly payload: ReturnType; + readonly human: string; +}> { + const { values } = parseCommandArgs( + args, + WRITE_OPTIONS, + WRITE_SCHEMA, + ); + const receipt = await withRuntime(options, async (runtime) => { + const lane = await openRequiredLane( + runtime, + options.lane, + options.strand, + ); + return await lane.write(intentFromText(values.intent)); + }); + const payload = receiptEnvelope(receipt); + return { + payload, + human: renderReceipt(payload), + }; +} diff --git a/bin/cli/infrastructure.ts b/bin/cli/infrastructure.ts index 7ae67e2b..77eb5d55 100644 --- a/bin/cli/infrastructure.ts +++ b/bin/cli/infrastructure.ts @@ -1,8 +1,12 @@ import path from 'node:path'; import process from 'node:process'; -import { parseArgs as nodeParseArgs, type ParseArgsConfig } from 'node:util'; +import { + parseArgs as nodeParseArgs, + type ParseArgsConfig, +} from 'node:util'; import type { ZodType, ZodTypeDef } from 'zod'; +import { V19_CAPABILITY_CONTRACT } from './capabilities/V19CapabilityContract.generated.ts'; import type { CliOptions } from './types.ts'; export const EXIT_CODES = { @@ -10,454 +14,235 @@ export const EXIT_CODES = { USAGE: 1, NOT_FOUND: 2, INTERNAL: 3, - /** Trust policy denial (enforce mode). */ TRUST_FAIL: 4, - /** Valid result but negative (e.g. no path found). Follows grep convention. */ NO_MATCH: 1, }; -/** - * Reads an environment variable across Node, Bun, and Deno runtimes. - */ export function getEnvVar(name: string): string | undefined { - if (typeof process !== 'undefined' && process.env !== null && process.env !== undefined) { - return process.env[name]; - } - if (typeof Deno !== 'undefined') { - - try { return Deno.env.get(name); } catch { return undefined; } - } - return undefined; + return process.env[name]; } -export const HELP_TEXT = `warp-graph [options] -(or: git warp [options]) - -Commands: - info Summarize graphs in the repo - check Report graph health/GC status - doctor Diagnose structural issues and suggest fixes - debug Inspect substrate history and conflict state - coordinate Inspect the resolved observation coordinate - --lamport-ceiling Inspect no later than Lamport tick n - conflicts Analyze conflict provenance at the current frontier - --strand Analyze one pinned strand instead of the live frontier - --entity-id Filter by entity id - --target-kind node, edge, node_property, edge_property - --property-key Property key for *_property targets - --from Edge source for edge selectors - --to Edge destination for edge selectors - --label