Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/launcher/src/protocol-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
179 changes: 179 additions & 0 deletions apps/launcher/src/repository-protocol-adapter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 26 additions & 7 deletions apps/launcher/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<void> {
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/protocol-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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),
Expand Down Expand Up @@ -770,20 +783,33 @@ function nativeSnapshot(
duplicate?: readonly string[],
): ReturnType<typeof repositorySnapshotSchema.parse> {
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 },
Expand Down
12 changes: 8 additions & 4 deletions apps/server/src/protocol-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ import {
type CommitDraftUpdate,
type CommandEnvelope,
type RepositorySnapshot,
type RepositorySnapshotResult,
type OperationReceipt,
type OperationId,
type OperationResult,
type NativeActionKind,
type NativeActionRequest,
type NativeActionResult,
type NativeTargetId,
repositorySnapshotSchema,
repositorySnapshotResultSchema,
type SessionMetadata,
type WorktreeId,
type DiagnosticRedactor,
Expand All @@ -57,7 +58,7 @@ export interface ProtocolHandlers {
) => Awaitable<OperationResult>;
readonly nativeActions?: NativeActionHandler;
readonly diff?: (request: DiffRequest) => Awaitable<DiffResult>;
readonly snapshot?: () => Awaitable<RepositorySnapshot>;
readonly snapshot?: () => Awaitable<RepositorySnapshotResult>;
}

export interface ProtocolDispatchResponse {
Expand Down Expand Up @@ -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<NativeTargetId, Set<NativeActionKind>>();
if (nativeActions === undefined) return invalidHandlerResponse();
issuedNativeActions = nativeActions;
return { status: 200, value: snapshot.data };
Expand Down
Loading