From 32cec129402013dc52c09b05a4700e9b07a77223 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Mon, 31 Aug 2026 05:38:38 +0800 Subject: [PATCH] feat: add exact-target navigation and provenance --- apps/launcher/src/codex-runtime.ts | 5 +- .../src/repository-protocol-adapter.ts | 120 +++++++++- apps/launcher/src/standalone-runtime.ts | 218 ++++++++++++++++-- apps/server/src/protocol-dispatch.test.ts | 2 + apps/ui/src/DiffReview.tsx | 56 ++--- .../RepositoryOverview.interactions.test.tsx | 32 +++ apps/ui/src/RepositoryOverview.test.tsx | 38 +++ apps/ui/src/RepositoryOverview.tsx | 82 ++++++- apps/ui/src/native-action-presentation.ts | 68 ++++++ apps/ui/src/overview-fixtures.ts | 32 ++- .../ui/src/protocol-repository-source.test.ts | 1 + apps/ui/src/repository-overview-model.ts | 3 +- .../codex-cdp/src/adapter.test.ts | 36 +++ .../host-adapter/codex-cdp/src/connection.ts | 15 +- .../codex-cdp/src/dedicated-adapter.ts | 4 + .../codex-cdp/src/remote-renderer.ts | 5 +- packages/host-adapter/package.json | 1 + packages/host-adapter/src/index.ts | 30 ++- .../host-adapter/standalone/src/adapter.ts | 4 + packages/protocol/src/schemas.ts | 13 ++ packages/repository-engine/src/index.ts | 9 + .../src/observation-publication.ts | 18 +- .../src/repository-engine.ts | 42 +++- .../src/repository-refresh.ts | 2 + .../src/repository-session.test.ts | 58 +++++ .../src/repository-session.ts | 37 +++ .../src/worktree-provenance.test.ts | 89 +++++++ .../src/worktree-provenance.ts | 95 ++++++++ tests/contract/host-adapter.contract.test.ts | 28 +++ tests/contract/protocol.contract.test.ts | 17 +- tests/e2e/codex-runtime.e2e.test.ts | 152 +++++++++++- tests/e2e/protocol-runtime.e2e.test.ts | 148 +++++++++++- .../worktree-provenance.integration.test.ts | 144 ++++++++++++ 33 files changed, 1524 insertions(+), 80 deletions(-) create mode 100644 apps/ui/src/native-action-presentation.ts create mode 100644 packages/repository-engine/src/worktree-provenance.test.ts create mode 100644 packages/repository-engine/src/worktree-provenance.ts create mode 100644 tests/integration/worktree-provenance.integration.test.ts diff --git a/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts index ac98f36..beb3539 100644 --- a/apps/launcher/src/codex-runtime.ts +++ b/apps/launcher/src/codex-runtime.ts @@ -30,9 +30,12 @@ export interface CodexRuntime extends StandaloneRuntime { export async function startCodexRuntime( options: CodexRuntimeOptions, ): Promise { - const standalone = await startStandaloneRuntime(options); let instance: DedicatedCodexInstance | null = null; let connection: HostConnection | null = null; + const standalone = await startStandaloneRuntime({ + ...options, + nativeHostConnection: () => connection, + }); let host: 'codex' | 'standalone' = 'standalone'; let closing = false; let monitor = Promise.resolve(); diff --git a/apps/launcher/src/repository-protocol-adapter.ts b/apps/launcher/src/repository-protocol-adapter.ts index 0be4be0..e94a89b 100644 --- a/apps/launcher/src/repository-protocol-adapter.ts +++ b/apps/launcher/src/repository-protocol-adapter.ts @@ -1,4 +1,4 @@ -import { basename } from 'node:path'; +import { basename, isAbsolute } from 'node:path'; import type { RepositoryOpenResult, @@ -10,9 +10,24 @@ import { type RepositorySnapshotResult, } from '@codex-git/protocol'; +export interface HostNavigationContext { + readonly canonicalProjectPath: string | null; + readonly openCodexContext: boolean; + readonly openFileInCodex: boolean; + readonly taskId: string | null; +} + +const noHostNavigation: HostNavigationContext = { + canonicalProjectPath: null, + openCodexContext: false, + openFileInCodex: false, + taskId: null, +}; + export function toProtocolRepositorySnapshot( result: RepositoryOpenResult, projectPath: string, + hostNavigation: HostNavigationContext = noHostNavigation, ): RepositorySnapshotResult { if (result.kind === 'not_repository') { return { @@ -56,6 +71,7 @@ export function toProtocolRepositorySnapshot( candidate.upstream.kind === 'tracking' ? (remoteFetches.get(candidate.upstream.remoteId) ?? null) : lastSuccessfulFetchAt(source.fetch), + hostNavigation, ), ), remotes: source.remotes.map(({ remoteId, displayName, host }) => ({ @@ -72,7 +88,11 @@ export function toProtocolRepositorySnapshot( }); } -function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) { +function worktree( + source: PublishedWorktreeSnapshot, + fetchedAt: string | null, + hostNavigation: HostNavigationContext, +) { const path = source.canonicalPath ?? source.displayPath; return { worktreeId: source.worktreeId, @@ -101,6 +121,14 @@ function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) { }, indexTree: null, status: worktreeStatus(source), + provenance: + source.provenance.kind === 'codex_task' + ? { + kind: source.provenance.kind, + title: source.provenance.task.title, + status: source.provenance.task.status, + } + : source.provenance, upstream: upstream(source, fetchedAt), changes: source.changes.map( ({ @@ -116,10 +144,20 @@ function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) { baseline, displayPath, previousDisplayPath, - nativeTargets: [nativeFileTarget(source, nativeTargetId, fileId)], + nativeTargets: [ + nativeFileTarget(source, nativeTargetId, fileId, hostNavigation), + ], }), ), - nativeTargets: [], + nativeTargets: + source.nativeTargetId === null + ? [] + : [ + { + targetId: source.nativeTargetId, + actions: worktreeNativeActions(source, hostNavigation), + }, + ], }; } @@ -127,6 +165,7 @@ function nativeFileTarget( worktree: PublishedWorktreeSnapshot, targetId: PublishedWorktreeSnapshot['changes'][number]['nativeTargetId'], fileId: PublishedWorktreeSnapshot['changes'][number]['fileId'], + hostNavigation: HostNavigationContext, ) { const change = worktree.changes.find( (candidate) => candidate.fileId === fileId, @@ -139,13 +178,78 @@ function nativeFileTarget( } return { targetId, - actions: - change.workingFilePresent && pathIsUtf8 - ? (['open_default_app', 'copy_relative_path'] as const) - : (['copy_relative_path'] as const), + actions: fileNativeActions( + worktree, + pathIsUtf8, + change.workingFilePresent, + hostNavigation, + ), }; } +function worktreeNativeActions( + worktree: PublishedWorktreeSnapshot, + hostNavigation: HostNavigationContext, +) { + const path = worktree.canonicalPath ?? worktree.displayPath; + const copyActions = [ + ...(isAbsolute(path) ? (['copy_absolute_path'] as const) : []), + 'copy_branch_or_sha' as const, + ]; + const hostActions = + hostNavigation.openCodexContext && + hostContextMatches(worktree, hostNavigation) + ? (['open_codex_context'] as const) + : []; + return worktree.canonicalPath !== null && + worktree.availability.kind === 'available' + ? ([ + 'open_terminal', + 'reveal_in_finder', + ...hostActions, + ...copyActions, + ] as const) + : [...hostActions, ...copyActions]; +} + +function fileNativeActions( + worktree: PublishedWorktreeSnapshot, + pathIsUtf8: boolean, + workingFilePresent: boolean, + hostNavigation: HostNavigationContext, +) { + if (!pathIsUtf8) return ['copy_relative_path'] as const; + const copyActions = [ + 'copy_relative_path' as const, + ...(worktree.canonicalPath === null + ? [] + : (['copy_absolute_path'] as const)), + ]; + return workingFilePresent && worktree.canonicalPath !== null + ? ([ + 'open_default_app', + 'reveal_in_finder', + ...(hostNavigation.openFileInCodex && + hostContextMatches(worktree, hostNavigation) + ? (['open_file_in_codex'] as const) + : []), + ...copyActions, + ] as const) + : copyActions; +} + +function hostContextMatches( + worktree: PublishedWorktreeSnapshot, + hostNavigation: HostNavigationContext, +): boolean { + return ( + (worktree.provenance.kind === 'codex_task' && + worktree.provenance.task.id === hostNavigation.taskId) || + (worktree.canonicalPath !== null && + worktree.canonicalPath === hostNavigation.canonicalProjectPath) + ); +} + function refresh(source: RefreshState) { if (source.kind === 'fresh') return { kind: 'current' as const }; return { kind: source.kind, message: source.error.message } as const; diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index 441d289..4cff2bd 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -8,6 +8,7 @@ import { promisify } from 'node:util'; import type { HostConnection } from '@codex-git/host-adapter'; import { createRepositoryEngine, + type CodexMetadataAdapter, type RepositorySession, } from '@codex-git/repository-engine'; import type { @@ -32,6 +33,8 @@ const uiConfigPath = fileURLToPath( ); export interface StandaloneRuntimeOptions { + readonly metadata?: CodexMetadataAdapter; + readonly nativeHostConnection?: () => HostConnection | null; readonly projectPath?: string; readonly surfacePort?: number; } @@ -65,9 +68,9 @@ export async function startStandaloneRuntime( try { if (options.projectPath !== undefined) { - repositorySession = await createRepositoryEngine().open( - options.projectPath as AbsolutePath, - ); + repositorySession = await createRepositoryEngine({ + metadata: options.metadata, + }).open(options.projectPath as AbsolutePath); const opened = await repositorySession.requestRefresh(); if (opened.kind === 'repository') { openedRepositoryId = opened.repository.repositoryId; @@ -81,14 +84,23 @@ export async function startStandaloneRuntime( : { diff: ({ fileId }) => repositorySession!.diff(fileId), nativeActions: (request) => - performFileNativeAction(repositorySession!, request), + performNativeAction( + repositorySession!, + request, + options.nativeHostConnection, + ), branchSearch: (request) => repositorySession!.searchBranches(request), - snapshot: async () => - toProtocolRepositorySnapshot( - await repositorySession!.requestRefresh(), + snapshot: async () => { + const result = await repositorySession!.requestRefresh(); + return toProtocolRepositorySnapshot( + result, options.projectPath!, - ), + await hostNavigationContext( + options.nativeHostConnection?.() ?? null, + ), + ); + }, commands: (request) => dispatchRepositoryCommand(repositorySession!, request), operationRecovery: (operationId) => @@ -184,26 +196,86 @@ async function dispatchRepositoryCommand( }; } -async function performFileNativeAction( +async function performNativeAction( session: RepositorySession, request: NativeActionRequest, + nativeHostConnection?: () => HostConnection | null, ): Promise { try { - const target = await session.resolveFileNativeTarget(request.targetId); if (request.kind === 'copy_relative_path') { + const target = await session.resolveFileNativeTarget(request.targetId); return { kind: 'copy_text', text: target.relativePath }; } - if (request.kind !== 'open_default_app') { - return { - kind: 'unavailable', - message: 'This file action is not available yet.', - }; + if (request.kind === 'copy_branch_or_sha') { + const target = await session.resolveWorktreeNativeTarget( + request.targetId, + ); + return { kind: 'copy_text', text: target.branchOrSha }; + } + if (request.kind === 'copy_absolute_path') { + const target = await resolvePathTarget(session, request.targetId); + return { kind: 'copy_text', text: target.absolutePath }; + } + if ( + request.kind === 'open_codex_context' || + request.kind === 'open_file_in_codex' + ) { + const host = nativeHostConnection?.() ?? null; + if (host === null) throw new Error('The Codex host is unavailable.'); + const capabilities = host.capabilities(); + const target = + request.kind === 'open_codex_context' + ? await session.resolveWorktreeNativeTarget(request.targetId) + : await session.resolveFileNativeTarget(request.targetId); + if ( + (request.kind === 'open_codex_context' + ? !capabilities.openCodexContext + : !capabilities.openFileInCodex) || + !(await hostContextMatches( + host, + target.worktreePath, + target.provenance, + )) + ) { + throw new Error('The Codex host cannot prove the exact target.'); + } + const hostResult = await host.perform({ + kind: + request.kind === 'open_codex_context' + ? 'open-codex-context' + : 'open-file-in-codex', + targetId: request.targetId, + }); + if (hostResult.status !== 'succeeded') { + throw new Error('The Codex host rejected the exact target.'); + } + return { kind: 'performed' }; + } + if (request.kind === 'open_terminal') { + const target = await session.resolveWorktreeNativeTarget( + request.targetId, + ); + const path = await revalidateWorktreePath(target); + await execFileAsync('/usr/bin/open', ['-a', 'Terminal', '--', path], { + timeout: 10_000, + windowsHide: true, + }); + return { kind: 'performed' }; + } + const target = await resolvePathTarget(session, request.targetId); + if (!target.canLaunch) { + throw new Error('The target cannot be opened from its current state.'); } - if (!target.canOpen || target.absolutePath === null) { - throw new Error('The file cannot be opened from this change state.'); + if (target.kind === 'worktree') { + const path = await revalidateWorktreePath(target); + await execFileAsync('/usr/bin/open', ['-R', '--', path], { + timeout: 10_000, + windowsHide: true, + }); + return { kind: 'performed' }; } const metadata = await lstat(target.absolutePath); - if (metadata.isSymbolicLink()) { + if (request.kind === 'open_default_app' && metadata.isSymbolicLink()) { throw new Error('Symbolic links cannot be opened from change review.'); } const [resolvedWorktree, resolvedFile] = await Promise.all([ @@ -221,19 +293,119 @@ async function performFileNativeAction( ) { throw new Error('The file resolves outside its Worktree.'); } - await execFileAsync('/usr/bin/open', ['--', resolvedFile], { - timeout: 10_000, - windowsHide: true, - }); + await execFileAsync( + '/usr/bin/open', + request.kind === 'reveal_in_finder' + ? ['-R', '--', target.absolutePath] + : ['--', resolvedFile], + { + timeout: 10_000, + windowsHide: true, + }, + ); return { kind: 'performed' }; } catch { return { kind: 'unavailable', - message: 'The file is no longer available. Refresh and try again.', + message: + 'The exact target is no longer available. Refresh or use a safe copy action.', }; } } +async function hostNavigationContext(host: HostConnection | null) { + if (host === null) { + return { + canonicalProjectPath: null, + openCodexContext: false, + openFileInCodex: false, + taskId: null, + } as const; + } + const context = host.currentContext(); + return { + ...host.capabilities(), + canonicalProjectPath: + context.projectPath === null + ? null + : await realpath(context.projectPath).catch(() => null), + taskId: context.task?.id ?? null, + }; +} + +async function hostContextMatches( + host: HostConnection, + worktreePath: string, + provenance: import('@codex-git/repository-engine').WorktreeProvenance, +): Promise { + const before = host.currentContext(); + const canonicalProjectPath = + before.projectPath === null + ? null + : await realpath(before.projectPath).catch(() => null); + const current = host.currentContext(); + if ( + current.projectPath !== before.projectPath || + current.task?.id !== before.task?.id + ) { + return false; + } + return ( + (provenance.kind === 'codex_task' && + provenance.task.id === current.task?.id) || + canonicalProjectPath === worktreePath + ); +} + +type ResolvedPathTarget = + | { + readonly kind: 'file'; + readonly absolutePath: string; + readonly canLaunch: boolean; + readonly worktreePath: string; + } + | { + readonly kind: 'worktree'; + readonly absolutePath: string; + readonly canLaunch: boolean; + readonly worktreePath: string; + }; + +async function resolvePathTarget( + session: RepositorySession, + targetId: NativeActionRequest['targetId'], +): Promise { + try { + const file = await session.resolveFileNativeTarget(targetId); + if (file.absolutePath === null) throw new Error('No file path.'); + return { + kind: 'file', + absolutePath: file.absolutePath, + canLaunch: file.canOpen, + worktreePath: file.worktreePath, + }; + } catch { + const worktree = await session.resolveWorktreeNativeTarget(targetId); + return { + kind: 'worktree', + absolutePath: worktree.absolutePath, + canLaunch: worktree.canLaunch, + worktreePath: worktree.worktreePath, + }; + } +} + +async function revalidateWorktreePath( + target: Pick, +): Promise { + if (!target.canLaunch) throw new Error('The Worktree is unavailable.'); + const resolved = await realpath(target.worktreePath); + if (resolved !== target.worktreePath) { + throw new Error('The Worktree moved before navigation.'); + } + return resolved; +} + async function forwardRepositoryInvalidations( session: RepositorySession, server: Pick, diff --git a/apps/server/src/protocol-dispatch.test.ts b/apps/server/src/protocol-dispatch.test.ts index a5c3b31..2119427 100644 --- a/apps/server/src/protocol-dispatch.test.ts +++ b/apps/server/src/protocol-dispatch.test.ts @@ -65,6 +65,7 @@ describe('protocol HTTP dispatch', () => { kind: 'unavailable', reason: 'token=fixture-unavailable-secret', }, + provenance: { kind: 'unclassified' }, upstream: { kind: 'not-applicable', reason: 'The branch has no configured Upstream.', @@ -839,6 +840,7 @@ function nativeSnapshot( head: { kind: 'initial' }, indexTree: null, status: { kind: 'clean' }, + provenance: { kind: 'unclassified' }, upstream: { kind: 'not-applicable', reason: 'The branch has no configured Upstream.', diff --git a/apps/ui/src/DiffReview.tsx b/apps/ui/src/DiffReview.tsx index b636110..c837362 100644 --- a/apps/ui/src/DiffReview.tsx +++ b/apps/ui/src/DiffReview.tsx @@ -9,6 +9,10 @@ import type { import type { WorktreeOverviewSnapshot } from './repository-overview-model.js'; import type { DiffLoadState } from './repository-store.js'; import { presentSideBySide } from './diff-presentation.js'; +import { + nativeActionLabel, + performPresentedNativeAction, +} from './native-action-presentation.js'; export function DiffReview({ worktree, @@ -96,15 +100,15 @@ export function DiffReview({ target.actions .filter( (kind) => - kind === 'open_default_app' || kind === 'copy_relative_path', + kind === 'open_default_app' || + kind === 'open_file_in_codex' || + kind === 'reveal_in_finder' || + kind === 'copy_relative_path' || + kind === 'copy_absolute_path', ) .map((kind) => ( )), )} @@ -135,25 +137,23 @@ async function performNativeAction( run: (request: NativeActionRequest) => Promise, publish: (message: string) => void, ): Promise { - try { - const result = await run(request); - if (result.kind === 'unavailable') { - publish(result.message); - return; - } - if (result.kind === 'performed') { - publish('Opened the current Changed File.'); - return; - } - if (globalThis.navigator.clipboard === undefined) { - publish(`Relative path: ${result.text}`); - return; - } - await globalThis.navigator.clipboard.writeText(result.text); - publish('Copied the relative path.'); - } catch { - publish('The file action could not be completed. Refresh and try again.'); - } + return performPresentedNativeAction(request, run, publish, { + performed: (current) => + current.kind === 'reveal_in_finder' + ? 'Revealed the current Changed File.' + : current.kind === 'open_file_in_codex' + ? 'Opened the current Changed File in Codex.' + : 'Opened the current Changed File.', + copyFallback: (current, text) => + current.kind === 'copy_absolute_path' + ? `Absolute path: ${text}` + : `Relative path: ${text}`, + copied: (current) => + current.kind === 'copy_absolute_path' + ? 'Copied the absolute path.' + : 'Copied the relative path.', + failed: 'The file action could not be completed. Refresh and try again.', + }); } function DiffContent({ diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx index f804c6c..0d01549 100644 --- a/apps/ui/src/RepositoryOverview.interactions.test.tsx +++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx @@ -81,10 +81,42 @@ describe('Repository overview interactions', () => { expect(container.textContent).toContain('Binary file · 4,096 bytes'); expect(container.querySelector('pre')).toBeNull(); expect(button('Open in Default App')).toBeDefined(); + expect(button('Reveal in Finder')).toBeDefined(); + expect(button('Copy Absolute Path')).toBeDefined(); await act(async () => button('Copy Relative Path').click()); expect(container.textContent).toContain('Relative path: README.md'); }); + it('reports exact Worktree copy results and safe navigation fallback', async () => { + const fixture = createOverviewFixture('one-worktree'); + const store = createRepositoryStore({ + ...fixture.source, + async requestNativeAction(request) { + return request.kind === 'copy_absolute_path' + ? { + kind: 'copy_text', + text: '/Users/leyoonafr/Projects/codex-git', + } + : { + kind: 'unavailable', + message: + 'The exact target is no longer available. Refresh or use a safe copy action.', + }; + }, + }); + act(() => root.render()); + + await act(async () => button('Copy Absolute Path for codex-git').click()); + expect(container.textContent).toContain( + 'Value: /Users/leyoonafr/Projects/codex-git', + ); + + await act(async () => button('Reveal codex-git in Finder').click()); + expect(container.textContent).toContain( + 'The exact target is no longer available. Refresh or use a safe copy action.', + ); + }); + it('shows Conflict index stages in the default side-by-side review', async () => { const fixture = createOverviewFixture('changed-worktree'); const store = createRepositoryStore({ diff --git a/apps/ui/src/RepositoryOverview.test.tsx b/apps/ui/src/RepositoryOverview.test.tsx index 5790165..942f950 100644 --- a/apps/ui/src/RepositoryOverview.test.tsx +++ b/apps/ui/src/RepositoryOverview.test.tsx @@ -21,11 +21,49 @@ describe('Repository overview', () => { expect(markup).toContain('
Upstream freshness
'); expect(markup).toContain('Cached from Fetch Aug 29, 2026, 2:03 PM'); expect(markup).toContain('Clean'); + expect(markup).toContain( + '
Provenance
Unclassified Worktree
', + ); + expect(markup).toContain('Open codex-git in Terminal'); + expect(markup).toContain('Reveal codex-git in Finder'); + expect(markup).toContain('Copy Absolute Path for codex-git'); + expect(markup).toContain('Copy Branch or SHA for codex-git'); expect(markup).toContain('Refresh codex-git locally'); expect(markup).toContain('Fetch origin for codex-git'); expect(markup).not.toContain('Search Worktrees'); }); + it.each([ + [ + { kind: 'codex_task', title: 'Exact task', status: 'active' } as const, + 'Codex Task Worktree — Exact task (active)', + ], + [{ kind: 'scheduled' } as const, 'Scheduled Worktree'], + [{ kind: 'permanent' } as const, 'Permanent Worktree'], + [{ kind: 'external' } as const, 'External Worktree'], + [{ kind: 'unclassified' } as const, 'Unclassified Worktree'], + ])('uses the canonical provenance term for %s', (provenance, label) => { + const fixture = createOverviewFixture('one-worktree'); + const source = fixture.source.getSnapshot(); + if (source.kind !== 'repository') throw new Error('Expected Repository'); + fixture.publish({ + kind: 'repository', + snapshot: { + ...source.snapshot, + worktrees: source.snapshot.worktrees.map((worktree) => ({ + ...worktree, + provenance, + })), + }, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(`
Provenance
${label}
`); + }); + it('keeps Main first and the remaining Worktrees stable when status changes', () => { const fixture = createOverviewFixture('many-worktrees'); const store = createRepositoryStore(fixture.source); diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index 1a5ff2e..1667417 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -1,4 +1,9 @@ -import { useEffect, useRef, useSyncExternalStore } from 'react'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; + +import type { + NativeActionRequest, + NativeActionResult, +} from '@codex-git/protocol'; import type { RepositoryOverviewSnapshot, @@ -7,6 +12,11 @@ import type { import type { RepositoryStore } from './repository-store.js'; import { ChangeGroups } from './ChangeGroups.js'; import { DiffReview } from './DiffReview.js'; +import { + nativeActionLabel, + performPresentedNativeAction, + worktreeNativeActionLabel, +} from './native-action-presentation.js'; export function RepositoryOverview({ store, @@ -23,6 +33,10 @@ export function RepositoryOverview({ const searchInput = useRef(null); const worktreeTitle = useRef(null); const handledFocusRecovery = useRef(0); + const [nativeActionStatus, setNativeActionStatus] = useState<{ + readonly worktreeId: string; + readonly message: string; + } | null>(null); const orderedWorktrees = state.source.kind === 'repository' ? [...state.source.snapshot.worktrees].sort(compareWorktrees) @@ -340,6 +354,10 @@ export function RepositoryOverview({
Status
{statusLabel(selected.status)}
+
+
Provenance
+
{provenanceLabel(selected.provenance)}
+
Worktree observation
{refreshLabel(selected.freshness)}
@@ -354,6 +372,31 @@ export function RepositoryOverview({ )}
+ {selected.nativeTargets.flatMap((target) => + target.actions.map((kind) => ( + + )), + )}
+ {nativeActionStatus?.worktreeId !== selected.worktreeId ? null : ( +

+ {nativeActionStatus.message} +

+ )} {branchPicker.kind === 'closed' ? null : (

Switch Branch

@@ -571,13 +619,43 @@ function matchesSearch( worktree.displayName, worktree.path, branch, - worktree.codexTitle ?? '', + worktree.provenance.kind === 'codex_task' ? worktree.provenance.title : '', ] .join('\n') .toLocaleLowerCase() .includes(normalizedQuery); } +function provenanceLabel( + provenance: WorktreeOverviewSnapshot['provenance'], +): string { + switch (provenance.kind) { + case 'codex_task': + return `Codex Task Worktree — ${provenance.title} (${provenance.status})`; + case 'scheduled': + return 'Scheduled Worktree'; + case 'permanent': + return 'Permanent Worktree'; + case 'external': + return 'External Worktree'; + case 'unclassified': + return 'Unclassified Worktree'; + } +} + +async function performWorktreeNativeAction( + request: NativeActionRequest, + run: (request: NativeActionRequest) => Promise, + publish: (message: string) => void, +): Promise { + return performPresentedNativeAction(request, run, publish, { + performed: () => 'Opened the exact Worktree target.', + copyFallback: (_current, text) => `Value: ${text}`, + copied: () => 'Copied the exact Worktree value.', + failed: 'The exact target is unavailable. Refresh and try again.', + }); +} + function headLabel(head: WorktreeOverviewSnapshot['head']): string { if (head.kind === 'initial') return 'Initial Repository State'; if (head.kind === 'detached') diff --git a/apps/ui/src/native-action-presentation.ts b/apps/ui/src/native-action-presentation.ts new file mode 100644 index 0000000..2411ad5 --- /dev/null +++ b/apps/ui/src/native-action-presentation.ts @@ -0,0 +1,68 @@ +import type { + NativeActionRequest, + NativeActionResult, +} from '@codex-git/protocol'; + +export function nativeActionLabel(kind: NativeActionRequest['kind']): string { + switch (kind) { + case 'open_terminal': + return 'Open in Terminal'; + case 'reveal_in_finder': + return 'Reveal in Finder'; + case 'copy_absolute_path': + return 'Copy Absolute Path'; + case 'copy_branch_or_sha': + return 'Copy Branch or SHA'; + case 'open_codex_context': + return 'Open Codex Context'; + case 'open_file_in_codex': + return 'Open File in Codex'; + case 'copy_relative_path': + return 'Copy Relative Path'; + case 'open_default_app': + return 'Open in Default App'; + } +} + +export function worktreeNativeActionLabel( + kind: NativeActionRequest['kind'], + worktreeName: string, +): string { + if (kind === 'open_terminal') return `Open ${worktreeName} in Terminal`; + if (kind === 'reveal_in_finder') return `Reveal ${worktreeName} in Finder`; + return `${nativeActionLabel(kind)} for ${worktreeName}`; +} + +export interface NativeActionPresentation { + copied(request: NativeActionRequest): string; + copyFallback(request: NativeActionRequest, text: string): string; + failed: string; + performed(request: NativeActionRequest): string; +} + +export async function performPresentedNativeAction( + request: NativeActionRequest, + run: (request: NativeActionRequest) => Promise, + publish: (message: string) => void, + presentation: NativeActionPresentation, +): Promise { + try { + const result = await run(request); + if (result.kind === 'unavailable') { + publish(result.message); + return; + } + if (result.kind === 'performed') { + publish(presentation.performed(request)); + return; + } + if (globalThis.navigator.clipboard === undefined) { + publish(presentation.copyFallback(request, result.text)); + return; + } + await globalThis.navigator.clipboard.writeText(result.text); + publish(presentation.copied(request)); + } catch { + publish(presentation.failed); + } +} diff --git a/apps/ui/src/overview-fixtures.ts b/apps/ui/src/overview-fixtures.ts index 96b10f9..5868f2b 100644 --- a/apps/ui/src/overview-fixtures.ts +++ b/apps/ui/src/overview-fixtures.ts @@ -141,6 +141,9 @@ const changedFileIds = [1, 2, 3, 4].map((index) => const changedNativeTargetIds = [1, 2, 3, 4].map((index) => nativeTargetIdSchema.parse(`native_${index.toString(16).padStart(32, '0')}`), ); +const mainNativeTargetId = nativeTargetIdSchema.parse( + 'native_00000000000000000000000000000010', +); export const oneWorktree: RepositoryOverviewSnapshot = { repositoryId, @@ -168,7 +171,19 @@ export const oneWorktree: RepositoryOverviewSnapshot = { objectId: '0123456789abcdef0123456789abcdef01234567', }, status: { kind: 'clean' }, + provenance: { kind: 'unclassified' }, changes: [], + nativeTargets: [ + { + targetId: mainNativeTargetId, + actions: [ + 'open_terminal', + 'reveal_in_finder', + 'copy_absolute_path', + 'copy_branch_or_sha', + ], + }, + ], upstream: { kind: 'tracking', displayName: 'origin/main', @@ -235,7 +250,12 @@ export const changedWorktree: RepositoryOverviewSnapshot = { function nativeFileTarget(index: number) { return { targetId: changedNativeTargetIds[index]!, - actions: ['open_default_app', 'copy_relative_path'] as const, + actions: [ + 'open_default_app', + 'reveal_in_finder', + 'copy_relative_path', + 'copy_absolute_path', + ] as const, }; } @@ -260,7 +280,14 @@ const linkedWorktrees: RepositoryOverviewSnapshot['worktrees'] = Array.from( role: 'linked' as const, displayName, path: `/private/tmp/codex-git-${displayName}`, - codexTitle: index === 2 ? 'Build the adaptive overview' : undefined, + provenance: + index === 2 + ? { + kind: 'codex_task' as const, + title: 'Build the adaptive overview', + status: 'active', + } + : { kind: 'unclassified' as const }, freshness: { kind: 'current' as const }, head: { kind: 'local_branch' as const, @@ -278,6 +305,7 @@ const linkedWorktrees: RepositoryOverviewSnapshot['worktrees'] = Array.from( } : { kind: 'clean' as const }, changes: [], + nativeTargets: [], upstream: { kind: 'tracking' as const, displayName: `origin/feat/${displayName}`, diff --git a/apps/ui/src/protocol-repository-source.test.ts b/apps/ui/src/protocol-repository-source.test.ts index 70ab647..b0ef928 100644 --- a/apps/ui/src/protocol-repository-source.test.ts +++ b/apps/ui/src/protocol-repository-source.test.ts @@ -461,6 +461,7 @@ const repositorySnapshot = { }, indexTree: null, status: { kind: 'clean' }, + provenance: { kind: 'unclassified' }, upstream: { kind: 'unpublished', remoteName: null, fetchedAt: null }, changes: [], nativeTargets: [], diff --git a/apps/ui/src/repository-overview-model.ts b/apps/ui/src/repository-overview-model.ts index 11ed3e4..9036ec6 100644 --- a/apps/ui/src/repository-overview-model.ts +++ b/apps/ui/src/repository-overview-model.ts @@ -51,12 +51,13 @@ export interface WorktreeOverviewSnapshot { readonly displayName: string; readonly path: string; readonly availability?: ProtocolWorktree['availability']; - readonly codexTitle?: string; + readonly provenance: ProtocolWorktree['provenance']; readonly freshness: ProtocolWorktree['freshness']; readonly head: ProtocolWorktree['head']; readonly status: ProtocolWorktree['status']; readonly changes: ProtocolWorktree['changes']; readonly upstream: UpstreamOverview; + readonly nativeTargets: ProtocolWorktree['nativeTargets']; readonly transition?: { readonly label: string; readonly progress: number | null; diff --git a/packages/host-adapter/codex-cdp/src/adapter.test.ts b/packages/host-adapter/codex-cdp/src/adapter.test.ts index dbfa5e9..6994a3c 100644 --- a/packages/host-adapter/codex-cdp/src/adapter.test.ts +++ b/packages/host-adapter/codex-cdp/src/adapter.test.ts @@ -199,6 +199,42 @@ describe('CodexCdpHostAdapter', () => { await result.connection.close(); }); + it('restores the proven current Codex context but does not claim file navigation', async () => { + const dom = compatibleDom(); + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource( + fixtureRenderer(dom, '26.820.60940'), + ), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + if (result.kind !== 'attached') { + throw new Error('Expected the compatible Codex renderer to attach'); + } + documentEntry(dom)?.click(); + + expect(result.connection.capabilities()).toEqual({ + openCodexContext: true, + openFileInCodex: false, + }); + await expect( + result.connection.perform({ + kind: 'open-codex-context', + targetId: 'native_0123456789abcdef0123456789abcdef', + }), + ).resolves.toEqual({ status: 'succeeded' }); + expect(documentFrame(dom)).toBeNull(); + await expect( + result.connection.perform({ + kind: 'open-file-in-codex', + targetId: 'native_0123456789abcdef0123456789abcdef', + }), + ).resolves.toEqual({ status: 'unsupported' }); + + await result.connection.close(); + }); + it('forwards Current Project, theme, and current task context changes', async () => { const dom = compatibleDom(); const renderer = new FixtureRenderer(dom, '26.820.60940', { diff --git a/packages/host-adapter/codex-cdp/src/connection.ts b/packages/host-adapter/codex-cdp/src/connection.ts index 0951d8e..0ebef2d 100644 --- a/packages/host-adapter/codex-cdp/src/connection.ts +++ b/packages/host-adapter/codex-cdp/src/connection.ts @@ -5,6 +5,7 @@ import type { NativeHostAction, SurfaceDescriptor, } from '@codex-git/host-adapter'; +import { isNativeHostAction } from '@codex-git/host-adapter'; import type { CompatibleCodexAnchors } from './compatibility.js'; import type { CodexRenderer, CspBypassLease } from './renderer.js'; @@ -93,6 +94,10 @@ export class CodexHostConnection implements HostConnection { return this.context; } + capabilities() { + return { openCodexContext: true, openFileInCodex: false } as const; + } + async *contexts(): AsyncIterable { const queue = [this.currentContext()]; let closed = this.closed; @@ -138,6 +143,11 @@ export class CodexHostConnection implements HostConnection { case 'restore-native-surface': this.restoreNativeSurface(); return { status: 'succeeded' }; + case 'open-codex-context': + this.restoreNativeSurface(); + return { status: 'succeeded' }; + case 'open-file-in-codex': + return { status: 'unsupported' }; } } @@ -327,8 +337,7 @@ function isHostActionMessage(value: unknown): value is { typeof candidate.capability === 'string' && typeof candidate.challenge === 'string' && Number.isSafeInteger(candidate.generation) && - typeof action === 'object' && - action !== null && - (action as Record).kind === 'restore-native-surface' + isNativeHostAction(action) && + action.kind === 'restore-native-surface' ); } diff --git a/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts index 52576ab..5b9a469 100644 --- a/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts +++ b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts @@ -133,6 +133,10 @@ class ManagedDedicatedConnection implements HostConnection { return this.context; } + capabilities() { + return { openCodexContext: true, openFileInCodex: false } as const; + } + contexts(): AsyncIterable { return this.contextStream.read(this.context); } diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.ts index 8e414f7..af642fa 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.ts @@ -152,7 +152,10 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { if (this.closed) { return { status: 'rejected' }; } - if (action.kind === 'restore-native-surface') { + if ( + action.kind === 'restore-native-surface' || + action.kind === 'open-codex-context' + ) { await evaluate(this.session, 'globalThis.__codexGitBridge?.restore()'); this.open = false; return { status: 'succeeded' }; diff --git a/packages/host-adapter/package.json b/packages/host-adapter/package.json index e947acb..a3245bd 100644 --- a/packages/host-adapter/package.json +++ b/packages/host-adapter/package.json @@ -3,5 +3,6 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": "./src/index.ts", "types": "./src/index.ts" } diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index 95c8a7e..fa36df4 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -14,12 +14,39 @@ export interface HostTaskContext { readonly title: string; } -export type NativeHostAction = { readonly kind: 'restore-native-surface' }; +export type NativeHostAction = + | { readonly kind: 'restore-native-surface' } + | { + readonly kind: 'open-codex-context' | 'open-file-in-codex'; + readonly targetId: string; + }; + +const nativeTargetPattern = /^native_[0-9a-f]{32}$/u; + +export function isNativeHostAction(value: unknown): value is NativeHostAction { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + if (candidate.kind === 'restore-native-surface') { + return Object.keys(candidate).length === 1; + } + return ( + (candidate.kind === 'open-codex-context' || + candidate.kind === 'open-file-in-codex') && + typeof candidate.targetId === 'string' && + nativeTargetPattern.test(candidate.targetId) && + Object.keys(candidate).length === 2 + ); +} export interface NativeActionResult { readonly status: 'succeeded' | 'rejected' | 'unsupported'; } +export interface HostCapabilities { + readonly openCodexContext: boolean; + readonly openFileInCodex: boolean; +} + export interface SanitizedDiagnostic { readonly code: 'attach-failed' | 'host-unavailable' | 'incompatible-host'; readonly message: string; @@ -37,6 +64,7 @@ export type HostAttachResult = }; export interface HostConnection { + capabilities(): HostCapabilities; currentContext(): HostContext; contexts(): AsyncIterable; transitions(): AsyncIterable; diff --git a/packages/host-adapter/standalone/src/adapter.ts b/packages/host-adapter/standalone/src/adapter.ts index 8d19dd1..e67addd 100644 --- a/packages/host-adapter/standalone/src/adapter.ts +++ b/packages/host-adapter/standalone/src/adapter.ts @@ -13,6 +13,10 @@ const standaloneContext = { } satisfies HostContext; class StandaloneHostConnection implements HostConnection { + capabilities() { + return { openCodexContext: false, openFileInCodex: false } as const; + } + currentContext(): HostContext { return standaloneContext; } diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 632599e..023d9a4 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -191,6 +191,18 @@ export const worktreeStatusSchema = z.discriminatedUnion('kind', [ }), ]); +export const worktreeProvenanceSchema = z.discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('codex_task'), + title: z.string().min(1).max(1_024), + status: z.string().min(1).max(128), + }), + z.strictObject({ kind: z.literal('scheduled') }), + z.strictObject({ kind: z.literal('permanent') }), + z.strictObject({ kind: z.literal('external') }), + z.strictObject({ kind: z.literal('unclassified') }), +]); + export const operationSummarySchema = z.strictObject({ operationId: operationIdSchema, category: z.enum([ @@ -263,6 +275,7 @@ export const worktreeSnapshotSchema = z.strictObject({ head: headStateSchema, indexTree: objectIdSchema.nullable(), status: worktreeStatusSchema, + provenance: worktreeProvenanceSchema, upstream: upstreamOverviewSchema, changes: z.array(changedFileSchema).max(2_000).readonly(), nativeTargets: z.array(nativeTargetDescriptorSchema).readonly(), diff --git a/packages/repository-engine/src/index.ts b/packages/repository-engine/src/index.ts index 4980c9f..68f908b 100644 --- a/packages/repository-engine/src/index.ts +++ b/packages/repository-engine/src/index.ts @@ -5,8 +5,16 @@ export { type GitLockState, type RepositoryDiscovery, type RepositoryEngine, + type RepositoryEngineOptions, type WorktreeAvailability, } from './repository-engine.js'; +export { + resolveWorktreeProvenance, + type CodexMetadataAdapter, + type CodexTaskMetadata, + type CodexWorktreeMetadata, + type WorktreeProvenance, +} from './worktree-provenance.js'; export { type ChangedFileObservation, type InProgressGitOperation, @@ -30,6 +38,7 @@ export { } from './repository-publication.js'; export { type FileNativeTarget, + type WorktreeNativeTarget, type RemoteFetchResult, type RepositoryFetchRequest, type RepositorySession, diff --git a/packages/repository-engine/src/observation-publication.ts b/packages/repository-engine/src/observation-publication.ts index 4045a14..9f91054 100644 --- a/packages/repository-engine/src/observation-publication.ts +++ b/packages/repository-engine/src/observation-publication.ts @@ -14,6 +14,7 @@ import type { } from './repository-observation.js'; import type { FileId, NativeTargetId } from '@codex-git/protocol'; import type { RemoteSnapshot } from './remote-observation.js'; +import type { WorktreeProvenance } from './worktree-provenance.js'; export interface PublishedRepositoryObservation { readonly refs: readonly RefSnapshot[]; @@ -23,14 +24,16 @@ export interface PublishedRepositoryObservation { export interface PublishedObservationWorktree extends Omit< DiscoveredWorktree, - 'canonicalPathBytes' + 'canonicalPathBytes' | 'provenance' > { readonly worktreeRevision: number; + readonly nativeTargetId: NativeTargetId | null; readonly freshness: WorktreeFreshness; readonly index: IndexSnapshot | null; readonly status: WorktreeStatusSummary | null; readonly changes: readonly PublishedChangedFile[]; readonly upstream: UpstreamSnapshot; + readonly provenance: WorktreeProvenance; } export type PublishedChangedFile = ChangedFileObservation & { @@ -109,6 +112,10 @@ export function publishObservedFacts( 'worktreeRevision' | 'changes' > & { readonly changes: readonly ChangedFileObservation[] } = { worktreeId: worktree.worktreeId, + nativeTargetId: + prior?.generation === worktree.generation + ? prior.nativeTargetId + : (issueNativeTargetId?.() ?? null), generation: worktree.generation, displayPath: worktree.displayPath, canonicalPath: worktree.canonicalPath, @@ -116,18 +123,23 @@ export function publishObservedFacts( head: observedFacts.head, gitLock: worktree.gitLock, availability: worktree.availability, + provenance: worktree.provenance ?? { kind: 'unclassified' }, freshness: observedFacts.freshness, index: observedFacts.index, status: observedFacts.status, changes: observedFacts.changes, upstream: observedFacts.upstream, }; - const changed = + const gitFactsChanged = prior === undefined || worktreeEvidence(candidate) !== worktreeEvidence(prior); + const provenanceChanged = + prior === undefined || + JSON.stringify(candidate.provenance) !== JSON.stringify(prior.provenance); + const changed = gitFactsChanged || provenanceChanged; worktreeChanged ||= changed; const changes = - !changed && prior !== undefined + !gitFactsChanged && prior !== undefined ? prior.changes : observedFacts.changes.map((change) => ({ ...change, diff --git a/packages/repository-engine/src/repository-engine.ts b/packages/repository-engine/src/repository-engine.ts index ff1b1a4..a6d0386 100644 --- a/packages/repository-engine/src/repository-engine.ts +++ b/packages/repository-engine/src/repository-engine.ts @@ -36,6 +36,11 @@ import { createRemoteIdentityState, type RemoteIdentityState, } from './remote-observation.js'; +import { + resolveWorktreeProvenance, + type CodexMetadataAdapter, + type WorktreeProvenance, +} from './worktree-provenance.js'; const GIT_OUTPUT_LIMIT_BYTES = 4 * 1_024 * 1_024; const GIT_TIMEOUT_MILLISECONDS = 10_000; @@ -59,6 +64,7 @@ export interface DiscoveredWorktree { readonly head: DiscoveredHead; readonly gitLock: GitLockState; readonly availability: WorktreeAvailability; + readonly provenance?: WorktreeProvenance; } export type DiscoveredHead = @@ -120,7 +126,13 @@ interface CanonicalRegistration { readonly unavailableReason: string | null; } -export function createRepositoryEngine(): RepositoryEngine { +export interface RepositoryEngineOptions { + readonly metadata?: CodexMetadataAdapter; +} + +export function createRepositoryEngine( + options: RepositoryEngineOptions = {}, +): RepositoryEngine { return { async open(anchor) { const resolved = await resolveAnchor(anchor); @@ -196,6 +208,11 @@ export function createRepositoryEngine(): RepositoryEngine { : { kind: 'all' }; const worktreeIds = scope.kind === 'all' ? undefined : new Set(scope.worktreeIds); + discovery = await attachProvenance( + discovery, + options.metadata, + signal, + ); const observation = await createRepositoryObserver( runGit, ids, @@ -241,6 +258,29 @@ export function createRepositoryEngine(): RepositoryEngine { }; } +async function attachProvenance( + discovery: RepositoryDiscovery, + adapter: CodexMetadataAdapter | undefined, + signal: AbortSignal, +): Promise { + let metadata: Awaited> = []; + try { + metadata = (await adapter?.read(signal)) ?? []; + } catch { + // Optional host metadata must never make Git inventory unavailable. + } + return { + ...discovery, + worktrees: discovery.worktrees.map((worktree) => ({ + ...worktree, + provenance: + worktree.canonicalPath === null + ? { kind: 'unclassified' as const } + : resolveWorktreeProvenance(worktree.canonicalPath, metadata), + })), + }; +} + async function discoverRepository( resolved: ResolvedAnchor, identity: RepositoryIdentityState, diff --git a/packages/repository-engine/src/repository-refresh.ts b/packages/repository-engine/src/repository-refresh.ts index 7a72544..82f83db 100644 --- a/packages/repository-engine/src/repository-refresh.ts +++ b/packages/repository-engine/src/repository-refresh.ts @@ -214,6 +214,8 @@ export function createRefreshingRepositorySession( diff: (fileId) => delegate.diff(fileId), resolveFileNativeTarget: (targetId) => delegate.resolveFileNativeTarget(targetId), + resolveWorktreeNativeTarget: (targetId) => + delegate.resolveWorktreeNativeTarget(targetId), searchBranches: (request) => delegate.searchBranches(request), dispatch, cancelOperation: (operationId) => delegate.cancelOperation(operationId), diff --git a/packages/repository-engine/src/repository-session.test.ts b/packages/repository-engine/src/repository-session.test.ts index 5b7863e..1018164 100644 --- a/packages/repository-engine/src/repository-session.test.ts +++ b/packages/repository-engine/src/repository-session.test.ts @@ -22,6 +22,7 @@ describe('Repository File native targets', () => { worktrees: [ { canonicalPath: '/projects/repository', + provenance: { kind: 'unclassified' }, changes: [ { nativeTargetId: targetId, @@ -45,9 +46,66 @@ describe('Repository File native targets', () => { absolutePath: null, canOpen: false, relativePath: '\\xff.txt', + provenance: { kind: 'unclassified' }, worktreePath: '/projects/repository', }); await session.close(); }); }); + +describe('Repository Worktree native targets', () => { + it('resolves an exact opaque Worktree target with current generation facts', async () => { + const targetId = + 'native_00000000000000000000000000000002' as NativeTargetId; + const repository = { + kind: 'repository' as const, + repository: { + repositoryId: 'repository_fixture', + repositoryRevision: 1, + topologyRevision: 1, + refsRevision: 1, + refresh: { kind: 'fresh' }, + remotes: [], + operations: [], + worktrees: [ + { + nativeTargetId: targetId, + worktreeId: 'worktree_fixture', + generation: 'generation_fixture', + canonicalPath: '/projects/exact-worktree', + displayPath: '/projects/exact-worktree', + availability: { kind: 'available' }, + provenance: { kind: 'unclassified' }, + head: { + kind: 'local_branch', + displayName: 'feat/exact-target', + fullName: 'refs/heads/feat/exact-target', + objectId: '1'.repeat(40), + }, + changes: [], + }, + ], + }, + }; + const delegate = { + snapshot: async () => repository, + requestRefresh: async () => repository, + requestScopedRefresh: async () => repository, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; + const session = createRepositorySession(delegate); + + await expect( + session.resolveWorktreeNativeTarget(targetId), + ).resolves.toEqual({ + absolutePath: '/projects/exact-worktree', + branchOrSha: 'feat/exact-target', + canLaunch: true, + provenance: { kind: 'unclassified' }, + worktreePath: '/projects/exact-worktree', + }); + + await session.close(); + }); +}); diff --git a/packages/repository-engine/src/repository-session.ts b/packages/repository-engine/src/repository-session.ts index 1b9f19b..789f560 100644 --- a/packages/repository-engine/src/repository-session.ts +++ b/packages/repository-engine/src/repository-session.ts @@ -31,6 +31,7 @@ import type { RepositorySnapshot, ScopedRepositoryPublicationSession, } from './repository-publication.js'; +import type { WorktreeProvenance } from './worktree-provenance.js'; const OPERATION_TIMEOUT_MILLISECONDS = 30_000; @@ -38,6 +39,9 @@ export interface RepositorySession extends RepositoryPublicationSession { fetch(request: RepositoryFetchRequest): Promise; diff(fileId: FileId): Promise; resolveFileNativeTarget(targetId: NativeTargetId): Promise; + resolveWorktreeNativeTarget( + targetId: NativeTargetId, + ): Promise; searchBranches(request: BranchSearchRequest): Promise; dispatch(request: CommandEnvelope): Promise; cancelOperation(operationId: OperationId): Promise; @@ -99,6 +103,15 @@ export interface FileNativeTarget { readonly absolutePath: string | null; readonly canOpen: boolean; readonly relativePath: string; + readonly provenance: WorktreeProvenance; + readonly worktreePath: string; +} + +export interface WorktreeNativeTarget { + readonly absolutePath: string; + readonly branchOrSha: string; + readonly canLaunch: boolean; + readonly provenance: WorktreeProvenance; readonly worktreePath: string; } @@ -520,6 +533,7 @@ export function createRepositorySession( absolutePath: null, canOpen: false, relativePath: escapedBytePath(change.pathBytes), + provenance: worktree.provenance, worktreePath: worktree.canonicalPath, }; } @@ -532,6 +546,7 @@ export function createRepositorySession( absolutePath, canOpen: change.workingFilePresent, relativePath, + provenance: worktree.provenance, worktreePath: worktree.canonicalPath, }; } @@ -539,6 +554,28 @@ export function createRepositorySession( } throw new RepositoryTargetFailure(); }, + async resolveWorktreeNativeTarget(targetId) { + const result = await observe(() => delegate.requestRefresh()); + if (result.kind !== 'repository') throw new RepositoryTargetFailure(); + const worktree = result.repository.worktrees.find( + (candidate) => candidate.nativeTargetId === targetId, + ); + if (worktree === undefined) throw new RepositoryTargetFailure(); + const branchOrSha = + worktree.head.kind === 'local_branch' + ? worktree.head.displayName + : worktree.head.objectId; + const worktreePath = worktree.canonicalPath ?? worktree.displayPath; + return { + absolutePath: worktreePath, + branchOrSha, + canLaunch: + worktree.canonicalPath !== null && + worktree.availability.kind === 'available', + provenance: worktree.provenance, + worktreePath, + }; + }, async searchBranches(request) { const observed = await observe(() => delegate.requestRefresh()); if (observed.kind !== 'repository') { diff --git a/packages/repository-engine/src/worktree-provenance.test.ts b/packages/repository-engine/src/worktree-provenance.test.ts new file mode 100644 index 0000000..175bfad --- /dev/null +++ b/packages/repository-engine/src/worktree-provenance.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveWorktreeProvenance } from './worktree-provenance.js'; + +describe('Worktree provenance resolver', () => { + it('classifies conflicting stable Codex evidence as Unclassified', () => { + const result = resolveWorktreeProvenance('/private/tmp/exact-worktree', [ + { + canonicalCwd: '/private/tmp/exact-worktree', + kind: 'codex_task', + stable: true, + task: { + id: 'task-17', + status: 'active', + title: 'Implement exact navigation', + }, + }, + { + canonicalCwd: '/private/tmp/exact-worktree', + kind: 'scheduled', + stable: true, + }, + ]); + + expect(result).toEqual({ kind: 'unclassified' }); + }); + + it('fails closed on malformed or unstable metadata', () => { + expect( + resolveWorktreeProvenance('/private/tmp/exact-worktree', [ + { + canonicalCwd: '/private/tmp/exact-worktree', + kind: 'codex_task', + stable: true, + task: { + id: 'task-unsafe', + status: 'active', + title: 'x'.repeat(1_025), + }, + }, + { + canonicalCwd: '/private/tmp/exact-worktree', + kind: 'scheduled', + stable: false, + }, + ]), + ).toEqual({ kind: 'unclassified' }); + }); + + it.each([ + [ + 'unstable conflicting evidence', + { + canonicalCwd: '/private/tmp/exact-worktree', + kind: 'scheduled', + stable: false, + }, + ], + [ + 'malformed conflicting evidence', + { + canonicalCwd: '/private/tmp/exact-worktree', + kind: 'codex_task', + stable: true, + }, + ], + ] as const)( + 'fails closed on %s beside stable evidence', + (_label, conflict) => { + const stable = { + canonicalCwd: '/private/tmp/exact-worktree', + kind: 'codex_task' as const, + stable: true, + task: { + id: 'task-stable', + status: 'active', + title: 'Stable task', + }, + }; + + expect( + resolveWorktreeProvenance('/private/tmp/exact-worktree', [ + stable, + conflict as typeof stable, + ]), + ).toEqual({ kind: 'unclassified' }); + }, + ); +}); diff --git a/packages/repository-engine/src/worktree-provenance.ts b/packages/repository-engine/src/worktree-provenance.ts new file mode 100644 index 0000000..8aa8be2 --- /dev/null +++ b/packages/repository-engine/src/worktree-provenance.ts @@ -0,0 +1,95 @@ +import type { AbsolutePath } from '@codex-git/protocol'; + +export type WorktreeProvenance = + | { + readonly kind: 'codex_task'; + readonly task: CodexTaskMetadata; + } + | { readonly kind: 'scheduled' } + | { readonly kind: 'permanent' } + | { readonly kind: 'external' } + | { readonly kind: 'unclassified' }; + +export interface CodexTaskMetadata { + readonly id: string; + readonly status: string; + readonly title: string; +} + +export type CodexWorktreeMetadata = + | { + readonly canonicalCwd: AbsolutePath | string; + readonly kind: 'codex_task'; + readonly stable: boolean; + readonly task: CodexTaskMetadata; + } + | { + readonly canonicalCwd: AbsolutePath | string; + readonly kind: 'scheduled' | 'permanent' | 'external'; + readonly stable: boolean; + }; + +export interface CodexMetadataAdapter { + read(signal?: AbortSignal): Promise; +} + +export function resolveWorktreeProvenance( + canonicalCwd: AbsolutePath | string, + metadata: readonly CodexWorktreeMetadata[], +): WorktreeProvenance { + const exactCwdCandidates = metadata.filter( + (candidate) => candidate.canonicalCwd === canonicalCwd, + ); + if ( + exactCwdCandidates.length === 0 || + exactCwdCandidates.some((candidate) => !isStableMetadata(candidate)) + ) { + return { kind: 'unclassified' }; + } + const stableCandidates = + exactCwdCandidates as readonly (CodexWorktreeMetadata & { + readonly stable: true; + })[]; + const first = stableCandidates[0]!; + if ( + stableCandidates.some( + (candidate) => JSON.stringify(candidate) !== JSON.stringify(first), + ) + ) { + return { kind: 'unclassified' }; + } + return first.kind === 'codex_task' + ? { kind: first.kind, task: first.task } + : { kind: first.kind }; +} + +function isStableMetadata( + candidate: CodexWorktreeMetadata, +): candidate is CodexWorktreeMetadata & { readonly stable: true } { + if (!candidate.stable) return false; + if ( + candidate.kind !== 'codex_task' && + candidate.kind !== 'scheduled' && + candidate.kind !== 'permanent' && + candidate.kind !== 'external' + ) { + return false; + } + if (candidate.kind !== 'codex_task') return true; + const task: unknown = candidate.task; + if (typeof task !== 'object' || task === null) return false; + const fields = task as Record; + return ( + boundedText(fields.id, 1_024) && + boundedText(fields.status, 128) && + boundedText(fields.title, 1_024) + ); +} + +function boundedText(value: unknown, maximumLength: number): value is string { + return ( + typeof value === 'string' && + value.trim().length > 0 && + value.length <= maximumLength + ); +} diff --git a/tests/contract/host-adapter.contract.test.ts b/tests/contract/host-adapter.contract.test.ts index 9fedb39..e4ab86c 100644 --- a/tests/contract/host-adapter.contract.test.ts +++ b/tests/contract/host-adapter.contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; +import { isNativeHostAction } from '@codex-git/host-adapter'; describe('HostAdapter contract', () => { it('attaches the standalone surface with a current typed Host Context', async () => { @@ -19,7 +20,34 @@ describe('HostAdapter contract', () => { task: null, theme: 'system', }); + expect(result.connection.capabilities()).toEqual({ + openCodexContext: false, + openFileInCodex: false, + }); await result.connection.close(); }); }); + +describe('Host native action contract', () => { + it('accepts only named actions with exact opaque targets', () => { + expect( + isNativeHostAction({ + kind: 'open-file-in-codex', + targetId: 'native_0123456789abcdef0123456789abcdef', + }), + ).toBe(true); + expect( + isNativeHostAction({ + kind: 'open-file-in-codex', + absolutePath: '/tmp/user-supplied.ts', + }), + ).toBe(false); + expect( + isNativeHostAction({ + kind: 'run-host-command', + targetId: 'native_0123456789abcdef0123456789abcdef', + }), + ).toBe(false); + }); +}); diff --git a/tests/contract/protocol.contract.test.ts b/tests/contract/protocol.contract.test.ts index e07f3e8..6285b19 100644 --- a/tests/contract/protocol.contract.test.ts +++ b/tests/contract/protocol.contract.test.ts @@ -117,6 +117,11 @@ describe('protocol runtime schemas', () => { head: { kind: 'initial' }, indexTree: null, status: { kind: 'clean' }, + provenance: { + kind: 'codex_task', + title: 'Implement exact navigation', + status: 'active', + }, upstream: { kind: 'not-applicable', reason: 'The branch has no configured Upstream.', @@ -136,7 +141,17 @@ describe('protocol runtime schemas', () => { ], }, ], - nativeTargets: [], + nativeTargets: [ + { + targetId: 'native_1123456789abcdef0123456789abcdef', + actions: [ + 'open_terminal', + 'reveal_in_finder', + 'copy_absolute_path', + 'copy_branch_or_sha', + ], + }, + ], }, ], remotes: [ diff --git a/tests/e2e/codex-runtime.e2e.test.ts b/tests/e2e/codex-runtime.e2e.test.ts index 16f58a9..e411d2b 100644 --- a/tests/e2e/codex-runtime.e2e.test.ts +++ b/tests/e2e/codex-runtime.e2e.test.ts @@ -1,3 +1,6 @@ +import { realpath, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { afterEach, describe, expect, it, vi } from 'vitest'; import type { @@ -5,15 +8,28 @@ import type { DedicatedCodexTarget, DedicatedRendererConnection, } from '@codex-git/host-adapter-codex-cdp'; -import type { HostContext } from '@codex-git/host-adapter'; +import type { HostContext, NativeHostAction } from '@codex-git/host-adapter'; import { startCodexRuntime, type CodexRuntime } from '@codex-git/launcher'; +import { + PROTOCOL_VERSION_HEADER, + repositorySnapshotSchema, +} from '@codex-git/protocol'; + +import { + createTemporaryGitRepository, + type TemporaryGitRepository, +} from '../fixtures/temporary-git-repository.js'; const runtimes: CodexRuntime[] = []; +const repositories: TemporaryGitRepository[] = []; afterEach(async () => { await Promise.all( runtimes.splice(0).map((runtime) => runtime.close().catch(() => undefined)), ); + await Promise.all( + repositories.splice(0).map((repository) => repository.dispose()), + ); }); describe('Codex runtime composition', () => { @@ -45,8 +61,122 @@ describe('Codex runtime composition', () => { await vi.waitFor(() => expect(instance.closed).toBe(true)); expect(runtime.currentHost()).toBe('standalone'); }); + + it('advertises and routes only an exact proven current Codex context', async () => { + 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'); + await writeFile(join(repository.path, 'README.md'), 'changed\n'); + const canonicalCwd = await realpath(repository.path); + const renderer = new RoutableRenderer({ + projectPath: canonicalCwd, + task: { id: 'task-15', title: 'Exact navigation' }, + theme: 'dark', + }); + const runtime = await startCodexRuntime({ + connectRenderer: async () => renderer, + launchInstance: async () => new FixtureInstance(ownedTarget), + metadata: { + async read() { + return [ + { + canonicalCwd, + kind: 'codex_task', + stable: true, + task: { + id: 'task-15', + status: 'active', + title: 'Exact navigation', + }, + }, + ]; + }, + }, + projectPath: repository.path, + surfacePort: 0, + }); + runtimes.push(runtime); + const snapshot = repositorySnapshotSchema.parse( + await (await protocolRequest(runtime, 'snapshot')).json(), + ); + const target = snapshot.worktrees[0]?.nativeTargets.find(({ actions }) => + actions.includes('open_codex_context'), + ); + if (target === undefined) { + throw new Error('Expected a proven Codex context target.'); + } + + const response = await protocolRequest(runtime, 'native-actions', { + kind: 'open_codex_context', + targetId: target.targetId, + }); + + expect(await response.json()).toEqual({ kind: 'performed' }); + expect(renderer.actions).toEqual([ + { kind: 'open-codex-context', targetId: target.targetId }, + ]); + expect( + snapshot.worktrees[0]?.changes.some(({ nativeTargets }) => + nativeTargets.some(({ actions }) => + actions.includes('open_file_in_codex'), + ), + ), + ).toBe(false); + + renderer.setContext({ + projectPath: '/private/tmp/another-project', + task: { id: 'task-other', title: 'Another task' }, + theme: 'dark', + }); + const staleContext = await protocolRequest(runtime, 'native-actions', { + kind: 'open_codex_context', + targetId: target.targetId, + }); + expect(await staleContext.json()).toMatchObject({ kind: 'unavailable' }); + expect(renderer.actions).toHaveLength(1); + }); }); +class RoutableRenderer implements DedicatedRendererConnection { + readonly actions: NativeHostAction[] = []; + private listener: Parameters[0] = + () => undefined; + + constructor(private context: HostContext) {} + + setContext(context: HostContext): void { + this.context = context; + this.listener({ kind: 'context', context }); + } + + currentContext(): HostContext { + return this.context; + } + isSurfaceOpen(): boolean { + return false; + } + projectIdentity(): { readonly id: string; readonly label: string } { + return { id: 'project-routable', label: 'codex-git' }; + } + subscribe(listener: typeof this.listener): () => void { + this.listener = listener; + return () => { + this.listener = () => undefined; + }; + } + async perform(action: NativeHostAction) { + this.actions.push(action); + return action.kind === 'open-codex-context' + ? ({ status: 'succeeded' } as const) + : ({ status: 'unsupported' } as const); + } + async close(): Promise {} +} + const ownedTarget = { id: 'renderer-42', webSocketUrl: 'ws://127.0.0.1:43117/devtools/page/renderer-42', @@ -109,3 +239,23 @@ class FailingRenderer implements DedicatedRendererConnection { throw new Error('Closed CDP socket cannot restore CSP'); } } + +function protocolRequest( + runtime: CodexRuntime, + endpoint: 'native-actions' | 'snapshot', + body?: unknown, +): Promise { + const url = new URL( + runtime.sessionUrl.pathname.replace(/\/session$/u, `/${endpoint}`), + runtime.sessionUrl, + ); + return fetch(url, { + method: body === undefined ? 'GET' : 'POST', + headers: { + origin: runtime.surfaceUrl.origin, + [PROTOCOL_VERSION_HEADER]: '1', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} diff --git a/tests/e2e/protocol-runtime.e2e.test.ts b/tests/e2e/protocol-runtime.e2e.test.ts index d240e07..bbc4a4e 100644 --- a/tests/e2e/protocol-runtime.e2e.test.ts +++ b/tests/e2e/protocol-runtime.e2e.test.ts @@ -1,5 +1,12 @@ -import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { + mkdtemp, + realpath, + rm, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from 'vitest'; @@ -156,12 +163,91 @@ describe('protocol runtime composition', () => { freshness: { kind: 'current' }, head: { kind: 'local_branch', displayName: branchName }, status: { kind: 'clean' }, + provenance: { kind: 'unclassified' }, upstream: { kind: 'unpublished' }, }, ], }); }); + it('copies exact Worktree path and Branch from an opaque target', async () => { + const repository = await createRepositoryWithCommit(); + const runtime = await startStandaloneRuntime({ + projectPath: repository.path, + surfacePort: 0, + }); + runtimes.push(runtime); + const snapshot = repositorySnapshotSchema.parse( + await (await protocolRequest(runtime, 'snapshot')).json(), + ); + const worktree = snapshot.worktrees[0]!; + const target = worktree.nativeTargets[0]!; + const branchName = ( + await repository.git('branch', '--show-current') + ).stdout.trim(); + + const [pathResponse, branchResponse] = await Promise.all([ + protocolRequest(runtime, 'native-actions', { + kind: 'copy_absolute_path', + targetId: target.targetId, + }), + protocolRequest(runtime, 'native-actions', { + kind: 'copy_branch_or_sha', + targetId: target.targetId, + }), + ]); + + expect(await pathResponse.json()).toEqual({ + kind: 'copy_text', + text: await realpath(repository.path), + }); + expect(await branchResponse.json()).toEqual({ + kind: 'copy_text', + text: branchName, + }); + }); + + it('rejects an opaque Worktree target after its generation disappears', async () => { + const repository = await createRepositoryWithCommit(); + const linkedPath = join( + dirname(repository.path), + `${basename(repository.path)}-navigation-linked`, + ); + temporaryDirectories.push(linkedPath); + await repository.git( + 'worktree', + 'add', + '--quiet', + '-b', + 'navigation-linked', + linkedPath, + ); + const runtime = await startStandaloneRuntime({ + projectPath: repository.path, + surfacePort: 0, + }); + runtimes.push(runtime); + const snapshot = repositorySnapshotSchema.parse( + await (await protocolRequest(runtime, 'snapshot')).json(), + ); + const linked = snapshot.worktrees.find(({ role }) => role === 'linked'); + if (linked?.nativeTargets[0] === undefined) { + throw new Error('Expected an exact Linked Worktree target.'); + } + await repository.git('worktree', 'remove', '--force', linkedPath); + + const response = await protocolRequest(runtime, 'native-actions', { + kind: 'copy_absolute_path', + targetId: linked.nativeTargets[0].targetId, + }); + + expect(await response.json()).toEqual({ + kind: 'unavailable', + message: + 'The exact target is no longer available. Refresh or use a safe copy action.', + }); + }); + it('dispatches Fetch through command and operation endpoints', async () => { const repository = await createRepositoryWithCommit(); const remotePath = await mkdtemp(join(tmpdir(), 'codex-git-remote-')); @@ -379,7 +465,10 @@ describe('protocol runtime composition', () => { ({ displayPath }) => displayPath === 'outside-link', ); - expect(deletion?.nativeTargets[0]?.actions).toEqual(['copy_relative_path']); + expect(deletion?.nativeTargets[0]?.actions).toEqual([ + 'copy_relative_path', + 'copy_absolute_path', + ]); expect(link?.nativeTargets[0]?.actions).toContain('open_default_app'); if (link?.nativeTargets[0] === undefined) { throw new Error('The symbolic-link target is absent.'); @@ -406,6 +495,56 @@ describe('protocol runtime composition', () => { expect(await actionResponse.json()).toMatchObject({ kind: 'unavailable' }); }); + it('targets the new path for renames and rejects a file that disappears before launch', async () => { + const repository = await createRepositoryWithCommit(); + await writeFile(join(repository.path, 'old-name.txt'), 'rename me\n'); + await writeFile(join(repository.path, 'disappearing.txt'), 'temporary\n'); + await repository.git('add', '--', 'old-name.txt', 'disappearing.txt'); + await repository.git('commit', '--quiet', '-m', 'Add navigation fixtures'); + await repository.git('mv', 'old-name.txt', 'new-name.txt'); + await writeFile(join(repository.path, 'disappearing.txt'), 'changed\n'); + const runtime = await startStandaloneRuntime({ + projectPath: repository.path, + surfacePort: 0, + }); + runtimes.push(runtime); + const snapshot = repositorySnapshotSchema.parse( + await (await protocolRequest(runtime, 'snapshot')).json(), + ); + const renamed = snapshot.worktrees[0]?.changes.find( + ({ previousDisplayPath }) => previousDisplayPath === 'old-name.txt', + ); + const disappearing = snapshot.worktrees[0]?.changes.find( + ({ displayPath }) => displayPath === 'disappearing.txt', + ); + if (renamed?.nativeTargets[0] === undefined) { + throw new Error('Expected a renamed Changed File target.'); + } + expect(renamed.displayPath).toBe('new-name.txt'); + const renameCopy = await protocolRequest(runtime, 'native-actions', { + kind: 'copy_relative_path', + targetId: renamed.nativeTargets[0].targetId, + }); + expect(await renameCopy.json()).toEqual({ + kind: 'copy_text', + text: 'new-name.txt', + }); + + if (disappearing?.nativeTargets[0] === undefined) { + throw new Error('Expected a disappearing Changed File target.'); + } + await unlink(join(repository.path, 'disappearing.txt')); + const staleOpen = await protocolRequest(runtime, 'native-actions', { + kind: 'open_default_app', + targetId: disappearing.nativeTargets[0].targetId, + }); + expect(await staleOpen.json()).toEqual({ + kind: 'unavailable', + message: + 'The exact target is no longer available. Refresh or use a safe copy action.', + }); + }); + it('serves a typed non-Repository result for the Current Project', async () => { const projectPath = await mkdtemp(join(tmpdir(), 'codex-git-project-')); temporaryDirectories.push(projectPath); @@ -505,7 +644,8 @@ describe('protocol runtime composition', () => { function protocolRequest( runtime: StandaloneRuntime, - endpoint: 'branches' | 'commands' | 'operations' | 'snapshot', + endpoint: + 'branches' | 'commands' | 'native-actions' | 'operations' | 'snapshot', body?: unknown, ): Promise { const url = new URL( diff --git a/tests/integration/worktree-provenance.integration.test.ts b/tests/integration/worktree-provenance.integration.test.ts new file mode 100644 index 0000000..9a1917c --- /dev/null +++ b/tests/integration/worktree-provenance.integration.test.ts @@ -0,0 +1,144 @@ +import { realpath, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createRepositoryEngine, + type CodexMetadataAdapter, + type RepositorySession, +} from '@codex-git/repository-engine'; +import type { AbsolutePath } from '@codex-git/protocol'; + +import { + createTemporaryGitRepository, + type TemporaryGitRepository, +} from '../fixtures/temporary-git-repository.js'; + +const repositories: TemporaryGitRepository[] = []; + +afterEach(async () => { + await Promise.all( + repositories.splice(0).map((repository) => repository.dispose()), + ); +}); + +describe('optional Codex Worktree provenance', () => { + it('joins stable Codex metadata only to its exact canonical cwd', async () => { + const repository = await createRepository(); + const canonicalCwd = await realpath(repository.path); + const metadata: CodexMetadataAdapter = { + async read() { + return [ + { + canonicalCwd, + kind: 'codex_task', + stable: true, + task: { + id: 'task-15', + status: 'active', + title: 'Add exact-target navigation', + }, + }, + { + canonicalCwd: `${canonicalCwd}-same-name`, + kind: 'external', + stable: true, + }, + ]; + }, + }; + const session = await createRepositoryEngine({ metadata }).open( + repository.path as AbsolutePath, + ); + + const worktree = await onlyWorktree(session); + + expect(worktree.provenance).toEqual({ + kind: 'codex_task', + task: { + id: 'task-15', + status: 'active', + title: 'Add exact-target navigation', + }, + }); + }); + + it('keeps every Git Worktree Unclassified when metadata is unavailable', async () => { + const repository = await createRepository(); + const session = await createRepositoryEngine({ + metadata: { + async read() { + throw new Error('Codex metadata is unavailable.'); + }, + }, + }).open(repository.path as AbsolutePath); + + const result = await session.snapshot(); + + expect(result.kind).toBe('repository'); + if (result.kind !== 'repository') return; + expect(result.repository.worktrees).toHaveLength(1); + expect(result.repository.worktrees[0]?.provenance).toEqual({ + kind: 'unclassified', + }); + }); + + it('does not invalidate Git file targets when optional metadata disappears', async () => { + const repository = await createRepository(); + await writeFile(join(repository.path, 'README.md'), 'changed\n'); + const canonicalCwd = await realpath(repository.path); + let metadata = [ + { + canonicalCwd, + kind: 'codex_task' as const, + stable: true, + task: { + id: 'task-optional', + status: 'active', + title: 'Optional metadata', + }, + }, + ]; + const session = await createRepositoryEngine({ + metadata: { + async read() { + return metadata; + }, + }, + }).open(repository.path as AbsolutePath); + const initial = await onlyWorktree(session); + metadata = []; + + const withoutMetadata = await onlyWorktree(session); + + expect(withoutMetadata.provenance).toEqual({ kind: 'unclassified' }); + expect(withoutMetadata.worktreeRevision).toBe(initial.worktreeRevision + 1); + expect(withoutMetadata.changes[0]?.fileId).toBe(initial.changes[0]?.fileId); + expect(withoutMetadata.changes[0]?.nativeTargetId).toBe( + initial.changes[0]?.nativeTargetId, + ); + }); +}); + +async function createRepository(): 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 onlyWorktree(session: RepositorySession) { + const result = await session.snapshot(); + if ( + result.kind !== 'repository' || + result.repository.worktrees[0] === undefined + ) { + throw new Error('Expected one Repository Worktree.'); + } + return result.repository.worktrees[0]; +}