diff --git a/apps/launcher/src/protocol-bootstrap.ts b/apps/launcher/src/protocol-bootstrap.ts index dbc2413..f10ce35 100644 --- a/apps/launcher/src/protocol-bootstrap.ts +++ b/apps/launcher/src/protocol-bootstrap.ts @@ -1,10 +1,13 @@ import type { Plugin } from 'vite'; -export function protocolBootstrapPlugin(sessionUrl: URL): Plugin { - const bootstrap = JSON.stringify({ sessionUrl: sessionUrl.href }).replaceAll( - '<', - '\\u003c', - ); +export function protocolBootstrapPlugin( + sessionUrl: URL, + projectPath?: string, +): Plugin { + const bootstrap = JSON.stringify({ + sessionUrl: sessionUrl.href, + ...(projectPath === undefined ? {} : { projectPath }), + }).replaceAll('<', '\\u003c'); return { name: 'codex-git-protocol-bootstrap', diff --git a/apps/launcher/src/repository-protocol-adapter.ts b/apps/launcher/src/repository-protocol-adapter.ts new file mode 100644 index 0000000..7333f74 --- /dev/null +++ b/apps/launcher/src/repository-protocol-adapter.ts @@ -0,0 +1,179 @@ +import { basename } from 'node:path'; + +import type { + RepositoryOpenResult, + PublishedWorktreeSnapshot, + RefreshState, +} from '@codex-git/repository-engine'; +import { + repositorySnapshotSchema, + type RepositorySnapshotResult, +} from '@codex-git/protocol'; + +export function toProtocolRepositorySnapshot( + result: RepositoryOpenResult, + projectPath: string, +): RepositorySnapshotResult { + if (result.kind === 'not_repository') { + return { + kind: 'non_repository', + projectPath, + message: 'The Current Project is not inside a Git Repository.', + }; + } + if (result.kind === 'failed') { + return { + kind: 'failed', + projectPath, + message: result.refresh.error.message, + }; + } + const source = result.repository; + const main = + source.worktrees.find(({ role }) => role === 'main') ?? source.worktrees[0]; + const repositoryPath = + main?.canonicalPath ?? main?.displayPath ?? projectPath; + return repositorySnapshotSchema.parse({ + kind: 'repository', + repositoryId: source.repositoryId, + repositoryRevision: source.repositoryRevision, + topologyRevision: source.topologyRevision, + refsRevision: source.refsRevision, + displayName: displayName(repositoryPath), + path: repositoryPath, + refresh: refresh(source.refresh), + fetch: { kind: 'never' }, + fetchAvailable: false, + worktrees: source.worktrees.map(worktree), + remotes: source.remotes, + operations: source.operations.map((operation) => ({ + operationId: operation.operationId, + category: operation.category, + phase: operation.phase, + progress: operation.progress, + })), + }); +} + +function worktree(source: PublishedWorktreeSnapshot) { + const path = source.canonicalPath ?? source.displayPath; + return { + worktreeId: source.worktreeId, + worktreeRevision: source.worktreeRevision, + generation: source.generation, + role: source.role, + displayName: displayName(path), + path, + availability: + source.availability.kind === 'available' + ? source.availability + : { + kind: source.availability.kind, + reason: source.availability.reason, + }, + freshness: worktreeFreshness(source), + head: + source.head.kind === 'detached' + ? source.head + : source.head.objectId === null + ? { kind: 'initial' as const } + : { + kind: 'local_branch' as const, + displayName: source.head.displayName, + objectId: source.head.objectId, + }, + indexTree: null, + status: worktreeStatus(source), + upstream: upstream(source), + changes: [], + nativeTargets: [], + }; +} + +function refresh(source: RefreshState) { + if (source.kind === 'fresh') return { kind: 'current' as const }; + return { kind: source.kind, message: source.error.message } as const; +} + +function worktreeFreshness(source: PublishedWorktreeSnapshot) { + switch (source.freshness.kind) { + case 'fresh': + return { kind: 'current' as const }; + case 'stale': + case 'failed': + return { + kind: source.freshness.kind, + message: source.freshness.error.message, + } as const; + case 'unavailable': + return { + kind: 'failed' as const, + message: + source.availability.kind === 'unavailable' + ? source.availability.reason + : 'The Worktree is unavailable.', + }; + } +} + +function worktreeStatus(source: PublishedWorktreeSnapshot) { + if (source.status === null) { + return { + kind: 'unavailable' as const, + reason: + source.availability.kind === 'unavailable' + ? source.availability.reason + : 'The Worktree status is unavailable.', + }; + } + if (source.status.clean) return { kind: 'clean' as const }; + return { + kind: 'changed' as const, + conflictCount: source.status.conflicted, + stagedCount: source.status.staged, + trackedChangeCount: source.status.unstaged, + untrackedCount: source.status.untracked, + }; +} + +function upstream(source: PublishedWorktreeSnapshot) { + switch (source.upstream.kind) { + case 'tracking': + return { + kind: 'tracking' as const, + displayName: source.upstream.displayName, + ahead: + source.upstream.aheadBehind.kind === 'cached' + ? source.upstream.aheadBehind.ahead + : null, + behind: + source.upstream.aheadBehind.kind === 'cached' + ? source.upstream.aheadBehind.behind + : null, + fetchedAt: null, + }; + case 'unpublished': + return { + kind: 'unpublished' as const, + remoteName: null, + fetchedAt: null, + }; + case 'not_applicable': + return { + kind: 'not-applicable' as const, + reason: + source.upstream.reason === 'detached_head' + ? 'Detached HEAD has no Upstream.' + : 'The configured Upstream is unsupported.', + }; + case 'unavailable': + return { + kind: 'unavailable' as const, + reason: 'The Upstream is temporarily unavailable.', + }; + } +} + +function displayName(path: string): string { + return basename(path) || path; +} diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index 7865e16..3d27d57 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -12,6 +12,7 @@ import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; import { createServer as createViteServer, type ViteDevServer } from 'vite'; import { protocolBootstrapPlugin } from './protocol-bootstrap.js'; +import { toProtocolRepositorySnapshot } from './repository-protocol-adapter.js'; const loopbackHost = '127.0.0.1'; const uiConfigPath = fileURLToPath( @@ -37,6 +38,7 @@ export async function startStandaloneRuntime( let surfaceServer: ViteDevServer | undefined; let hostConnection: HostConnection | null = null; let repositorySession: RepositorySession | undefined; + let openedRepositoryId: RepositoryId | undefined; let invalidationPump = Promise.resolve(); async function closeResources(): Promise { @@ -50,23 +52,40 @@ export async function startStandaloneRuntime( } try { - protocolServer = await startLoopbackServer({ allowedOrigins: ['null'] }); if (options.projectPath !== undefined) { repositorySession = await createRepositoryEngine().open( options.projectPath as AbsolutePath, ); const opened = await repositorySession.requestRefresh(); if (opened.kind === 'repository') { - invalidationPump = forwardRepositoryInvalidations( - repositorySession, - protocolServer, - opened.repository.repositoryId, - ); + openedRepositoryId = opened.repository.repositoryId; } } + protocolServer = await startLoopbackServer({ + allowedOrigins: ['null'], + handlers: + repositorySession === undefined || options.projectPath === undefined + ? undefined + : { + snapshot: async () => + toProtocolRepositorySnapshot( + await repositorySession!.requestRefresh(), + options.projectPath!, + ), + }, + }); + if (repositorySession !== undefined && openedRepositoryId !== undefined) { + invalidationPump = forwardRepositoryInvalidations( + repositorySession, + protocolServer, + openedRepositoryId, + ); + } surfaceServer = await createViteServer({ configFile: uiConfigPath, - plugins: [protocolBootstrapPlugin(protocolServer.sessionUrl)], + plugins: [ + protocolBootstrapPlugin(protocolServer.sessionUrl, options.projectPath), + ], server: { host: loopbackHost, port: options.surfacePort ?? 5173, diff --git a/apps/server/src/protocol-dispatch.test.ts b/apps/server/src/protocol-dispatch.test.ts index bd346d5..9bc939c 100644 --- a/apps/server/src/protocol-dispatch.test.ts +++ b/apps/server/src/protocol-dispatch.test.ts @@ -32,19 +32,28 @@ afterEach(async () => { describe('protocol HTTP dispatch', () => { it('routes snapshots with redacted diagnostics without advertising absent handlers', async () => { const snapshot = repositorySnapshotSchema.parse({ + kind: 'repository', repositoryId: 'repository_0123456789abcdef0123456789abcdef', repositoryRevision: 4, topologyRevision: 2, refsRevision: 3, + displayName: 'repository', + path: '/workspace/repository', refresh: { kind: 'stale', message: 'Authorization: Bearer fixture-snapshot-token', }, + fetch: { kind: 'never' }, + fetchAvailable: true, worktrees: [ { worktreeId: 'worktree_0123456789abcdef0123456789abcdef', worktreeRevision: 1, generation: 'generation_0123456789abcdef0123456789abcdef', + role: 'main', + displayName: 'repository', + path: '/workspace/repository', + availability: { kind: 'available' }, freshness: { kind: 'failed', message: @@ -56,6 +65,10 @@ describe('protocol HTTP dispatch', () => { kind: 'unavailable', reason: 'token=fixture-unavailable-secret', }, + upstream: { + kind: 'not-applicable', + reason: 'The branch has no configured Upstream.', + }, changes: Array.from({ length: 2_000 }, (_, index) => ({ baseline: 'index_to_working_tree', displayPath: 'x'.repeat(4_096), @@ -770,20 +783,33 @@ function nativeSnapshot( duplicate?: readonly string[], ): ReturnType { return repositorySnapshotSchema.parse({ + kind: 'repository', repositoryId: 'repository_99999999999999999999999999999999', repositoryRevision: 1, topologyRevision: 1, refsRevision: 1, + displayName: 'repository', + path: '/workspace/repository', refresh: { kind: 'current' }, + fetch: { kind: 'never' }, + fetchAvailable: true, worktrees: [ { worktreeId: 'worktree_99999999999999999999999999999999', worktreeRevision: 1, generation: 'generation_99999999999999999999999999999999', + role: 'main', + displayName: 'repository', + path: '/workspace/repository', + availability: { kind: 'available' }, freshness: { kind: 'current' }, head: { kind: 'initial' }, indexTree: null, status: { kind: 'clean' }, + upstream: { + kind: 'not-applicable', + reason: 'The branch has no configured Upstream.', + }, changes: [], nativeTargets: [ { targetId, actions }, diff --git a/apps/server/src/protocol-dispatch.ts b/apps/server/src/protocol-dispatch.ts index 9326b07..c9ddeb8 100644 --- a/apps/server/src/protocol-dispatch.ts +++ b/apps/server/src/protocol-dispatch.ts @@ -25,6 +25,7 @@ import { type CommitDraftUpdate, type CommandEnvelope, type RepositorySnapshot, + type RepositorySnapshotResult, type OperationReceipt, type OperationId, type OperationResult, @@ -32,7 +33,7 @@ import { type NativeActionRequest, type NativeActionResult, type NativeTargetId, - repositorySnapshotSchema, + repositorySnapshotResultSchema, type SessionMetadata, type WorktreeId, type DiagnosticRedactor, @@ -57,7 +58,7 @@ export interface ProtocolHandlers { ) => Awaitable; readonly nativeActions?: NativeActionHandler; readonly diff?: (request: DiffRequest) => Awaitable; - readonly snapshot?: () => Awaitable; + readonly snapshot?: () => Awaitable; } export interface ProtocolDispatchResponse { @@ -163,11 +164,14 @@ export function createProtocolDispatcher( }, async dispatch(endpoint, body) { if (endpoint === 'snapshot' && handlers.snapshot !== undefined) { - const snapshot = repositorySnapshotSchema.safeParse( + const snapshot = repositorySnapshotResultSchema.safeParse( await handlers.snapshot(), ); if (!snapshot.success) return invalidHandlerResponse(); - const nativeActions = collectNativeActions(snapshot.data); + const nativeActions = + snapshot.data.kind === 'repository' + ? collectNativeActions(snapshot.data) + : new Map>(); if (nativeActions === undefined) return invalidHandlerResponse(); issuedNativeActions = nativeActions; return { status: 200, value: snapshot.data }; diff --git a/apps/ui/src/RepositoryOverview.test.tsx b/apps/ui/src/RepositoryOverview.test.tsx index 2001397..5255b70 100644 --- a/apps/ui/src/RepositoryOverview.test.tsx +++ b/apps/ui/src/RepositoryOverview.test.tsx @@ -110,6 +110,56 @@ describe('Repository overview', () => { expect(emptyMarkup).not.toContain('Fetch'); }); + it('disables Fetch entry points when the runtime has no Fetch capability', () => { + const fixture = createOverviewFixture('one-worktree'); + const source = fixture.source.getSnapshot(); + if (source.kind !== 'repository') + throw new Error('Expected Repository fixture'); + fixture.publish({ + kind: 'repository', + snapshot: { ...source.snapshot, fetchAvailable: false }, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain( + 'Fetch actions are not available in this version.', + ); + expect(markup).toMatch( + /aria-label="Fetch origin for codex-git"[^>]*disabled=""/u, + ); + }); + + it('counts Worktree availability independently from status freshness', () => { + const fixture = createOverviewFixture('one-worktree'); + const source = fixture.source.getSnapshot(); + if (source.kind !== 'repository') + throw new Error('Expected Repository fixture'); + fixture.publish({ + kind: 'repository', + snapshot: { + ...source.snapshot, + worktrees: source.snapshot.worktrees.map((worktree) => ({ + ...worktree, + availability: { kind: 'available' }, + status: { + kind: 'unavailable', + reason: 'Status observation failed.', + }, + })), + }, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('
Available
1
'); + expect(markup).toContain('
Unavailable
0
'); + }); + it('clears a stale file selection when the selected Branch changes', () => { const fixture = createOverviewFixture('one-worktree'); const store = createRepositoryStore(fixture.source); diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index 96ffc42..456c676 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -76,12 +76,17 @@ export function RepositoryOverview({ ); } - if (state.source.kind === 'non-repository') { + if ( + state.source.kind === 'non-repository' || + state.source.kind === 'failed' + ) { return (

Current Project

- No Git Repository + {state.source.kind === 'non-repository' + ? 'No Git Repository' + : 'Repository unavailable'}

{state.source.message}

{state.source.projectPath} @@ -99,8 +104,12 @@ export function RepositoryOverview({ (worktree) => worktree.worktreeId === state.selectedWorktreeId, ); const unavailableCount = snapshot.worktrees.filter( - (worktree) => worktree.status.kind === 'unavailable', + (worktree) => + worktree.availability?.kind === 'unavailable' || + (worktree.availability === undefined && + worktree.status.kind === 'unavailable'), ).length; + const fetchAvailable = snapshot.fetchAvailable !== false; return (
@@ -148,7 +157,13 @@ export function RepositoryOverview({ {snapshot.remotes.map((remote) => ( ) : null} + {!fetchAvailable && snapshot.remotes.length > 0 ? ( +

Fetch actions are not available in this version.

+ ) : null} @@ -423,8 +447,11 @@ function fetchLabel( function upstreamLabel( upstream: import('./repository-overview-model.js').UpstreamOverview, ): string { - if (upstream.kind === 'tracking') - return `${upstream.displayName} · ${upstream.ahead} ahead, ${upstream.behind} behind (cached)`; + if (upstream.kind === 'tracking') { + return upstream.ahead === null || upstream.behind === null + ? `${upstream.displayName} · comparison unavailable` + : `${upstream.displayName} · ${upstream.ahead} ahead, ${upstream.behind} behind (cached)`; + } if (upstream.kind === 'unpublished') return 'Unpublished'; return upstream.reason; } @@ -432,7 +459,9 @@ function upstreamLabel( function upstreamFreshnessLabel( upstream: import('./repository-overview-model.js').UpstreamOverview, ): string { - if (upstream.kind === 'not-applicable') return upstream.reason; + if (upstream.kind === 'not-applicable' || upstream.kind === 'unavailable') { + return upstream.reason; + } return upstream.fetchedAt === null ? 'No successful Fetch recorded' : `Cached from Fetch ${formatTime(upstream.fetchedAt)}`; diff --git a/apps/ui/src/main.tsx b/apps/ui/src/main.tsx index a32056a..e060f8e 100644 --- a/apps/ui/src/main.tsx +++ b/apps/ui/src/main.tsx @@ -2,6 +2,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.js'; +import { + createRuntimeRepositoryStore, + readProtocolBootstrap, +} from './runtime-repository-store.js'; import './overview.css'; import './styles.css'; @@ -11,8 +15,16 @@ if (!(rootElement instanceof HTMLElement)) { throw new Error('Missing #root element'); } +const bootstrap = readProtocolBootstrap(); +const store = + bootstrap === undefined ? undefined : createRuntimeRepositoryStore(bootstrap); + createRoot(rootElement).render( - + , ); + +if (import.meta.hot !== undefined && store !== undefined) { + import.meta.hot.dispose(() => store.dispose()); +} diff --git a/apps/ui/src/protocol-repository-source.test.ts b/apps/ui/src/protocol-repository-source.test.ts new file mode 100644 index 0000000..fe2f09c --- /dev/null +++ b/apps/ui/src/protocol-repository-source.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from 'vitest'; + +import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '@codex-git/protocol'; + +import { createProtocolRepositorySource } from './protocol-repository-source.js'; + +describe('ProtocolRepositorySource', () => { + it('negotiates the protocol and publishes the authoritative snapshot', async () => { + const requests: Array<{ + readonly url: string; + readonly version: string | null; + }> = []; + const sessionUrl = + 'http://127.0.0.1:4173/instance/fixture-token/v1/session'; + const source = createProtocolRepositorySource({ + projectPath: '/projects/codex-git', + sessionUrl, + createEventSource: () => new FakeEventSource(), + fetch: async (input, init) => { + const url = String(input); + requests.push({ + url, + version: new Headers(init?.headers).get(PROTOCOL_VERSION_HEADER), + }); + return jsonResponse( + url.endsWith('/session') ? sessionMetadata : repositorySnapshot, + ); + }, + }); + + expect(source.getSnapshot()).toEqual({ + kind: 'loading', + message: 'Resolving the Current Project…', + }); + await until(() => source.getSnapshot().kind === 'repository'); + + expect(source.getSnapshot()).toMatchObject({ + kind: 'repository', + snapshot: { + displayName: 'codex-git', + path: '/projects/codex-git', + refresh: { kind: 'current' }, + worktrees: [ + { + displayName: 'codex-git', + path: '/projects/codex-git', + role: 'main', + status: { kind: 'clean' }, + upstream: { kind: 'unpublished' }, + }, + ], + }, + }); + expect(requests).toEqual([ + { url: sessionUrl, version: String(PROTOCOL_VERSION) }, + { + url: sessionUrl.replace(/\/session$/u, '/snapshot'), + version: String(PROTOCOL_VERSION), + }, + ]); + }); + + it('refetches for a newer Repository invalidation and ignores stale revisions', async () => { + const events = new FakeEventSource(); + let snapshotRequests = 0; + const sessionUrl = + 'http://127.0.0.1:4173/instance/fixture-token/v1/session'; + const source = createProtocolRepositorySource({ + projectPath: '/projects/codex-git', + sessionUrl, + createEventSource: () => events, + fetch: async (input) => { + const url = String(input); + if (url.endsWith('/session')) return jsonResponse(sessionMetadata); + snapshotRequests += 1; + return jsonResponse({ + ...repositorySnapshot, + repositoryRevision: snapshotRequests, + }); + }, + }); + const unsubscribe = source.subscribe(() => undefined); + await until( + () => + source.getSnapshot().kind === 'repository' && snapshotRequests === 1, + ); + + events.emit({ + kind: 'repository_revision', + repositoryId: repositorySnapshot.repositoryId, + repositoryRevision: 1, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(snapshotRequests).toBe(1); + + events.emit({ + kind: 'repository_revision', + repositoryId: repositorySnapshot.repositoryId, + repositoryRevision: 2, + }); + await until(() => { + const state = source.getSnapshot(); + return ( + state.kind === 'repository' && state.snapshot.repositoryRevision === 2 + ); + }); + expect(source.getSnapshot()).toMatchObject({ + kind: 'repository', + snapshot: { repositoryRevision: 2 }, + }); + unsubscribe(); + expect(events.closed).toBe(true); + }); + + it('keeps the last good snapshot when a manual refresh fails', async () => { + let snapshotRequests = 0; + const source = createProtocolRepositorySource({ + projectPath: '/projects/codex-git', + sessionUrl: 'http://127.0.0.1:4173/instance/fixture-token/v1/session', + createEventSource: () => new FakeEventSource(), + fetch: async (input) => { + if (String(input).endsWith('/session')) { + return jsonResponse(sessionMetadata); + } + snapshotRequests += 1; + return snapshotRequests === 1 + ? jsonResponse(repositorySnapshot) + : new Response(null, { status: 503 }); + }, + }); + await until(() => source.getSnapshot().kind === 'repository'); + + source.requestRefresh(); + await until(() => { + const state = source.getSnapshot(); + return ( + state.kind === 'repository' && state.snapshot.refresh.kind === 'failed' + ); + }); + + expect(source.getSnapshot()).toMatchObject({ + kind: 'repository', + snapshot: { + repositoryId: repositorySnapshot.repositoryId, + refresh: { + kind: 'failed', + message: 'The Repository snapshot could not be loaded.', + }, + }, + }); + }); + + it('refetches when a newer invalidation arrives during an in-flight snapshot', async () => { + const events = new FakeEventSource(); + const inFlight = deferred(); + let snapshotRequests = 0; + const source = createProtocolRepositorySource({ + projectPath: '/projects/codex-git', + sessionUrl: 'http://127.0.0.1:4173/instance/fixture-token/v1/session', + createEventSource: () => events, + fetch: async (input) => { + if (String(input).endsWith('/session')) { + return jsonResponse(sessionMetadata); + } + snapshotRequests += 1; + if (snapshotRequests === 1) return jsonResponse(repositorySnapshot); + if (snapshotRequests === 2) return inFlight.promise; + return jsonResponse({ + ...repositorySnapshot, + repositoryRevision: 3, + }); + }, + }); + await until(() => source.getSnapshot().kind === 'repository'); + + source.requestRefresh(); + await until(() => snapshotRequests === 2); + events.emit({ + kind: 'repository_revision', + repositoryId: repositorySnapshot.repositoryId, + repositoryRevision: 3, + }); + inFlight.resolve( + jsonResponse({ ...repositorySnapshot, repositoryRevision: 2 }), + ); + + await until(() => { + const state = source.getSnapshot(); + return ( + snapshotRequests === 3 && + state.kind === 'repository' && + state.snapshot.repositoryRevision === 3 + ); + }); + }); +}); + +class FakeEventSource { + closed = false; + #listener?: (event: MessageEvent) => void; + + addEventListener( + _type: 'invalidation', + listener: (event: MessageEvent) => void, + ) { + this.#listener = listener; + } + + close() { + this.closed = true; + } + + emit(value: unknown) { + this.#listener?.({ data: JSON.stringify(value) } as MessageEvent); + } +} + +const sessionMetadata = { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + branchSearch: false, + commands: false, + commitDrafts: false, + diff: false, + events: true, + nativeActions: false, + operationRecovery: false, + }, + limits: { + diffOutputBytes: 2_097_152, + draftBytes: 65_536, + requestBodyBytes: 262_144, + }, +}; + +const repositorySnapshot = { + kind: 'repository', + repositoryId: 'repository_0123456789abcdef0123456789abcdef', + repositoryRevision: 1, + topologyRevision: 1, + refsRevision: 1, + displayName: 'codex-git', + path: '/projects/codex-git', + refresh: { kind: 'current' }, + fetch: { kind: 'never' }, + fetchAvailable: false, + worktrees: [ + { + worktreeId: 'worktree_0123456789abcdef0123456789abcdef', + worktreeRevision: 1, + generation: 'generation_0123456789abcdef0123456789abcdef', + role: 'main', + displayName: 'codex-git', + path: '/projects/codex-git', + availability: { kind: 'available' }, + freshness: { kind: 'current' }, + head: { + kind: 'local_branch', + displayName: 'dev', + objectId: '0123456789abcdef0123456789abcdef01234567', + }, + indexTree: null, + status: { kind: 'clean' }, + upstream: { kind: 'unpublished', remoteName: null, fetchedAt: null }, + changes: [], + nativeTargets: [], + }, + ], + remotes: [], + operations: [], +}; + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + headers: { 'content-type': 'application/json' }, + status: 200, + }); +} + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +async function until(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error('Timed out waiting for ProtocolRepositorySource state.'); +} diff --git a/apps/ui/src/protocol-repository-source.ts b/apps/ui/src/protocol-repository-source.ts new file mode 100644 index 0000000..7e4a86a --- /dev/null +++ b/apps/ui/src/protocol-repository-source.ts @@ -0,0 +1,187 @@ +import { + PROTOCOL_VERSION, + PROTOCOL_VERSION_HEADER, + repositorySnapshotResultSchema, + sessionMetadataSchema, + sseInvalidationSchema, +} from '@codex-git/protocol'; + +import type { + RepositoryOverviewSource, + RepositoryOverviewSourceState, +} from './repository-overview-model.js'; + +interface EventSourceLike { + addEventListener( + type: 'invalidation', + listener: (event: MessageEvent) => void, + ): void; + close(): void; +} + +export interface ProtocolRepositorySourceOptions { + readonly projectPath: string; + readonly sessionUrl: string; + readonly fetch?: typeof globalThis.fetch; + readonly createEventSource?: (url: string) => EventSourceLike; +} + +export function createProtocolRepositorySource( + options: ProtocolRepositorySourceOptions, +): RepositoryOverviewSource { + const listeners = new Set<() => void>(); + const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis); + const createEventSource = + options.createEventSource ?? ((url: string) => new EventSource(url)); + let state: RepositoryOverviewSourceState = { + kind: 'loading', + message: 'Resolving the Current Project…', + }; + let events: EventSourceLike | undefined; + let active = true; + let refresh: Promise | undefined; + let snapshotInvalidated = false; + + const publish = (next: RepositoryOverviewSourceState) => { + if (!active) return; + state = next; + listeners.forEach((listener) => listener()); + }; + + const requestSnapshot = () => { + if (refresh !== undefined) return refresh; + refresh = fetchSnapshot(fetcher, options.sessionUrl) + .then((snapshot) => { + if (snapshot.kind === 'repository') { + publish({ kind: 'repository', snapshot }); + return; + } + publish({ + kind: + snapshot.kind === 'non_repository' ? 'non-repository' : 'failed', + projectPath: snapshot.projectPath, + message: snapshot.message, + }); + }) + .catch(() => { + const message = 'The Repository snapshot could not be loaded.'; + publish( + state.kind === 'repository' + ? { + kind: 'repository', + snapshot: { + ...state.snapshot, + refresh: { kind: 'failed', message }, + }, + } + : { kind: 'failed', projectPath: options.projectPath, message }, + ); + }) + .finally(() => { + refresh = undefined; + if (snapshotInvalidated) { + snapshotInvalidated = false; + void requestSnapshot(); + } + }); + return refresh; + }; + + void negotiate(fetcher, options.sessionUrl) + .then((metadata) => { + if (!active) return; + if (metadata.capabilities.events) { + events = createEventSource(endpointUrl(options.sessionUrl, 'events')); + events.addEventListener('invalidation', (event) => { + if (!requiresSnapshot(state, event.data)) return; + if (refresh === undefined) { + void requestSnapshot(); + } else { + snapshotInvalidated = true; + } + }); + } + return requestSnapshot(); + }) + .catch(() => { + publish({ + kind: 'failed', + projectPath: options.projectPath, + message: 'The local Git protocol could not be negotiated.', + }); + }); + + return { + getSnapshot: () => state, + subscribe(listener) { + if (!active) return () => undefined; + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + active = false; + events?.close(); + } + }; + }, + requestRefresh() { + void requestSnapshot(); + }, + requestFetch() { + // Fetch is enabled by Issue #13. The overview remains truthful until then. + }, + }; +} + +function requiresSnapshot( + state: RepositoryOverviewSourceState, + data: string, +): boolean { + let decoded: unknown; + try { + decoded = JSON.parse(data); + } catch { + return false; + } + const parsed = sseInvalidationSchema.safeParse(decoded); + if (!parsed.success || state.kind !== 'repository') return false; + const invalidation = parsed.data; + if (invalidation.kind === 'operation_progress') return true; + if (invalidation.repositoryId !== state.snapshot.repositoryId) return false; + if (invalidation.kind === 'repository_revision') { + return invalidation.repositoryRevision > state.snapshot.repositoryRevision; + } + const worktree = state.snapshot.worktrees.find( + ({ worktreeId }) => worktreeId === invalidation.worktreeId, + ); + return ( + invalidation.repositoryRevision > state.snapshot.repositoryRevision || + worktree === undefined || + invalidation.worktreeRevision > worktree.worktreeRevision + ); +} + +async function negotiate(fetcher: typeof fetch, sessionUrl: string) { + const response = await protocolFetch(fetcher, sessionUrl); + if (!response.ok) throw new Error('Protocol negotiation failed.'); + return sessionMetadataSchema.parse(await response.json()); +} + +async function fetchSnapshot(fetcher: typeof fetch, sessionUrl: string) { + const response = await protocolFetch( + fetcher, + endpointUrl(sessionUrl, 'snapshot'), + ); + if (!response.ok) throw new Error('Repository snapshot failed.'); + return repositorySnapshotResultSchema.parse(await response.json()); +} + +function protocolFetch(fetcher: typeof fetch, url: string) { + return fetcher(url, { + headers: { [PROTOCOL_VERSION_HEADER]: String(PROTOCOL_VERSION) }, + }); +} + +function endpointUrl(sessionUrl: string, endpoint: 'events' | 'snapshot') { + return sessionUrl.replace(/\/session$/u, `/${endpoint}`); +} diff --git a/apps/ui/src/repository-overview-model.ts b/apps/ui/src/repository-overview-model.ts index e94d2f3..03038e3 100644 --- a/apps/ui/src/repository-overview-model.ts +++ b/apps/ui/src/repository-overview-model.ts @@ -22,8 +22,8 @@ export type UpstreamOverview = | { readonly kind: 'tracking'; readonly displayName: string; - readonly ahead: number; - readonly behind: number; + readonly ahead: number | null; + readonly behind: number | null; readonly fetchedAt: string | null; } | { @@ -31,7 +31,8 @@ export type UpstreamOverview = readonly remoteName: string | null; readonly fetchedAt: string | null; } - | { readonly kind: 'not-applicable'; readonly reason: string }; + | { readonly kind: 'not-applicable'; readonly reason: string } + | { readonly kind: 'unavailable'; readonly reason: string }; export interface WorktreeOverviewSnapshot { readonly worktreeId: ProtocolWorktree['worktreeId']; @@ -40,6 +41,7 @@ export interface WorktreeOverviewSnapshot { readonly role: 'main' | 'linked'; readonly displayName: string; readonly path: string; + readonly availability?: ProtocolWorktree['availability']; readonly codexTitle?: string; readonly freshness: ProtocolWorktree['freshness']; readonly head: ProtocolWorktree['head']; @@ -60,6 +62,7 @@ export interface RepositoryOverviewSnapshot { readonly path: string; readonly refresh: RepositorySnapshot['refresh']; readonly fetch: FetchFreshness; + readonly fetchAvailable?: boolean; readonly remotes: readonly ProtocolRemote[]; readonly operations: readonly ProtocolOperation[]; readonly worktrees: readonly WorktreeOverviewSnapshot[]; @@ -72,6 +75,11 @@ export type RepositoryOverviewSourceState = readonly projectPath: string; readonly message: string; } + | { + readonly kind: 'failed'; + readonly projectPath: string; + readonly message: string; + } | { readonly kind: 'repository'; readonly snapshot: RepositoryOverviewSnapshot; diff --git a/apps/ui/src/runtime-repository-store.ts b/apps/ui/src/runtime-repository-store.ts new file mode 100644 index 0000000..07d6921 --- /dev/null +++ b/apps/ui/src/runtime-repository-store.ts @@ -0,0 +1,33 @@ +import { createProtocolRepositorySource } from './protocol-repository-source.js'; +import { + createRepositoryStore, + type RepositoryStore, +} from './repository-store.js'; + +export interface ProtocolBootstrap { + readonly projectPath: string; + readonly sessionUrl: string; +} + +export function createRuntimeRepositoryStore( + bootstrap: ProtocolBootstrap, +): RepositoryStore { + return createRepositoryStore(createProtocolRepositorySource(bootstrap)); +} + +export function readProtocolBootstrap(): ProtocolBootstrap | undefined { + const value: unknown = globalThis.__CODEX_GIT_PROTOCOL__; + if (typeof value !== 'object' || value === null) return undefined; + if (!('projectPath' in value) || !('sessionUrl' in value)) return undefined; + return typeof value.projectPath === 'string' && + value.projectPath.length > 0 && + typeof value.sessionUrl === 'string' && + value.sessionUrl.length > 0 + ? { projectPath: value.projectPath, sessionUrl: value.sessionUrl } + : undefined; +} + +declare global { + // The standalone runtime injects this value before the UI module loads. + var __CODEX_GIT_PROTOCOL__: unknown; +} diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 764b809..a172b87 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -114,6 +114,50 @@ export const refreshStateSchema = z.discriminatedUnion('kind', [ }), ]); +export const fetchFreshnessSchema = z.discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('never') }), + z.strictObject({ + kind: z.literal('current'), + fetchedAt: z.string().datetime(), + }), + z.strictObject({ + kind: z.enum(['stale', 'failed']), + fetchedAt: z.string().datetime().nullable(), + message: z.string().min(1).max(512), + }), +]); + +export const upstreamOverviewSchema = z.discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('tracking'), + displayName: z.string().min(1).max(1_024), + ahead: z.number().int().nonnegative().nullable(), + behind: z.number().int().nonnegative().nullable(), + fetchedAt: z.string().datetime().nullable(), + }), + z.strictObject({ + kind: z.literal('unpublished'), + remoteName: z.string().min(1).max(256).nullable(), + fetchedAt: z.string().datetime().nullable(), + }), + z.strictObject({ + kind: z.literal('not-applicable'), + reason: z.string().min(1).max(512), + }), + z.strictObject({ + kind: z.literal('unavailable'), + reason: z.string().min(1).max(512), + }), +]); + +export const worktreeAvailabilitySchema = z.discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('available') }), + z.strictObject({ + kind: z.literal('unavailable'), + reason: z.string().min(1).max(512), + }), +]); + export const headStateSchema = z.discriminatedUnion('kind', [ z.strictObject({ kind: z.literal('initial') }), z.strictObject({ @@ -209,25 +253,49 @@ export const worktreeSnapshotSchema = z.strictObject({ worktreeId: worktreeIdSchema, worktreeRevision: revisionSchema, generation: worktreeGenerationSchema, + role: z.enum(['main', 'linked']), + displayName: z.string().min(1).max(1_024), + path: z.string().min(1).max(4_096), + availability: worktreeAvailabilitySchema, freshness: refreshStateSchema, head: headStateSchema, indexTree: objectIdSchema.nullable(), status: worktreeStatusSchema, + upstream: upstreamOverviewSchema, changes: z.array(changedFileSchema).max(2_000).readonly(), nativeTargets: z.array(nativeTargetDescriptorSchema).readonly(), }); export const repositorySnapshotSchema = z.strictObject({ + kind: z.literal('repository'), repositoryId: repositoryIdSchema, repositoryRevision: revisionSchema, topologyRevision: revisionSchema, refsRevision: revisionSchema, + displayName: z.string().min(1).max(1_024), + path: z.string().min(1).max(4_096), refresh: refreshStateSchema, + fetch: fetchFreshnessSchema, + fetchAvailable: z.boolean(), worktrees: z.array(worktreeSnapshotSchema).readonly(), remotes: z.array(remoteSummarySchema).readonly(), operations: z.array(operationSummarySchema).readonly(), }); +export const repositorySnapshotResultSchema = z.union([ + repositorySnapshotSchema, + z.strictObject({ + kind: z.literal('non_repository'), + projectPath: z.string().min(1).max(4_096), + message: z.string().min(1).max(512), + }), + z.strictObject({ + kind: z.literal('failed'), + projectPath: z.string().min(1).max(4_096), + message: z.string().min(1).max(512), + }), +]); + export type DiffRequest = z.infer; export type DiffResult = z.infer; export type BranchSearchRequest = z.infer; @@ -235,4 +303,7 @@ export type BranchSearchResult = z.infer; export type CommitDraftUpdate = z.infer; export type CommitDraft = z.infer; export type RepositorySnapshot = z.infer; +export type RepositorySnapshotResult = z.infer< + typeof repositorySnapshotResultSchema +>; export type WorktreeSnapshot = z.infer; diff --git a/tests/contract/protocol.contract.test.ts b/tests/contract/protocol.contract.test.ts index 4dc9af7..fc55b1b 100644 --- a/tests/contract/protocol.contract.test.ts +++ b/tests/contract/protocol.contract.test.ts @@ -94,20 +94,33 @@ describe('protocol runtime schemas', () => { it('accepts a coherent repository snapshot made only of opaque authority', () => { const result = repositorySnapshotSchema.safeParse({ + kind: 'repository', repositoryId: 'repository_0123456789abcdef0123456789abcdef', repositoryRevision: 4, topologyRevision: 2, refsRevision: 3, + displayName: 'repository', + path: '/workspace/repository', refresh: { kind: 'current' }, + fetch: { kind: 'never' }, + fetchAvailable: true, worktrees: [ { worktreeId: 'worktree_0123456789abcdef0123456789abcdef', worktreeRevision: 7, generation: 'generation_0123456789abcdef0123456789abcdef', + role: 'main', + displayName: 'repository', + path: '/workspace/repository', + availability: { kind: 'available' }, freshness: { kind: 'current' }, head: { kind: 'initial' }, indexTree: null, status: { kind: 'clean' }, + upstream: { + kind: 'not-applicable', + reason: 'The branch has no configured Upstream.', + }, changes: [ { fileId: 'file_0123456789abcdef0123456789abcdef', diff --git a/tests/e2e/protocol-runtime.e2e.test.ts b/tests/e2e/protocol-runtime.e2e.test.ts index 09b6409..2a6a5a7 100644 --- a/tests/e2e/protocol-runtime.e2e.test.ts +++ b/tests/e2e/protocol-runtime.e2e.test.ts @@ -1,5 +1,6 @@ -import { writeFile } from 'node:fs/promises'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from 'vitest'; @@ -7,7 +8,10 @@ import { startStandaloneRuntime, type StandaloneRuntime, } from '@codex-git/launcher'; -import { PROTOCOL_VERSION_HEADER } from '@codex-git/protocol'; +import { + PROTOCOL_VERSION_HEADER, + repositorySnapshotSchema, +} from '@codex-git/protocol'; import { createTemporaryGitRepository, @@ -16,12 +20,18 @@ import { const runtimes: StandaloneRuntime[] = []; const repositories: TemporaryGitRepository[] = []; +const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); await Promise.all( repositories.splice(0).map((repository) => repository.dispose()), ); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); }); describe('protocol runtime composition', () => { @@ -95,8 +105,109 @@ describe('protocol runtime composition', () => { expect(frame).toMatch(/"kind":"repository_revision"/u); await reader.cancel(); }); + + it('serves an authoritative Repository overview snapshot', async () => { + const repository = await createRepositoryWithCommit(); + const runtime = await startStandaloneRuntime({ + projectPath: repository.path, + surfacePort: 0, + }); + runtimes.push(runtime); + const surface = await (await fetch(runtime.surfaceUrl)).text(); + expect(protocolBootstrap(surface)).toMatchObject({ + projectPath: repository.path, + sessionUrl: runtime.sessionUrl.href, + }); + const snapshotUrl = new URL( + runtime.sessionUrl.pathname.replace(/\/session$/u, '/snapshot'), + runtime.sessionUrl, + ); + + const response = await fetch(snapshotUrl, { + headers: { + origin: runtime.surfaceUrl.origin, + [PROTOCOL_VERSION_HEADER]: '1', + }, + }); + const body = await response.json(); + const canonicalPath = await realpath(repository.path); + const branchName = ( + await repository.git('branch', '--show-current') + ).stdout.trim(); + + expect(response.status).toBe(200); + expect(repositorySnapshotSchema.safeParse(body).success).toBe(true); + expect(body).toMatchObject({ + displayName: repository.path.split('/').at(-1), + path: canonicalPath, + refresh: { kind: 'current' }, + fetch: { kind: 'never' }, + fetchAvailable: false, + worktrees: [ + { + role: 'main', + displayName: repository.path.split('/').at(-1), + path: canonicalPath, + availability: { kind: 'available' }, + freshness: { kind: 'current' }, + head: { kind: 'local_branch', displayName: branchName }, + status: { kind: 'clean' }, + upstream: { kind: 'unpublished' }, + }, + ], + }); + }); + + it('serves a typed non-Repository result for the Current Project', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'codex-git-project-')); + temporaryDirectories.push(projectPath); + const runtime = await startStandaloneRuntime({ + projectPath, + surfacePort: 0, + }); + runtimes.push(runtime); + const snapshotUrl = new URL( + runtime.sessionUrl.pathname.replace(/\/session$/u, '/snapshot'), + runtime.sessionUrl, + ); + + const response = await fetch(snapshotUrl, { + headers: { + origin: runtime.surfaceUrl.origin, + [PROTOCOL_VERSION_HEADER]: '1', + }, + }); + + expect({ body: await response.json(), status: response.status }).toEqual({ + body: { + kind: 'non_repository', + projectPath, + message: 'The Current Project is not inside a Git Repository.', + }, + status: 200, + }); + }); }); +function protocolBootstrap(surface: string): unknown { + const match = surface.match( + /globalThis\.__CODEX_GIT_PROTOCOL__ = (\{.*?\});/u, + ); + if (match === null) throw new Error('Protocol bootstrap is absent.'); + return JSON.parse(match[1] ?? '{}'); +} + +async function createRepositoryWithCommit(): Promise { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + await repository.git('config', 'user.name', 'Codex Git Tests'); + await repository.git('config', 'user.email', 'codex-git@example.test'); + await writeFile(join(repository.path, 'README.md'), 'fixture\n'); + await repository.git('add', '--', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Create fixture'); + return repository; +} + async function readFrameWithin( reader: ReadableStreamDefaultReader, milliseconds: number,