Skip to content
Closed
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
41 changes: 32 additions & 9 deletions apps/launcher/src/repository-protocol-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export function toProtocolRepositorySnapshot(
source.worktrees.find(({ role }) => role === 'main') ?? source.worktrees[0];
const repositoryPath =
main?.canonicalPath ?? main?.displayPath ?? projectPath;
const remoteFetches = new Map(
source.remoteFetches?.map(({ remoteId, fetchedAt }) => [
remoteId,
fetchedAt,
]),
);
return repositorySnapshotSchema.parse({
kind: 'repository',
repositoryId: source.repositoryId,
Expand All @@ -42,10 +48,21 @@ export function toProtocolRepositorySnapshot(
displayName: displayName(repositoryPath),
path: repositoryPath,
refresh: refresh(source.refresh),
fetch: { kind: 'never' },
fetchAvailable: false,
worktrees: source.worktrees.map(worktree),
remotes: source.remotes,
fetch: source.fetch,
fetchAvailable: source.remotes.length > 0,
worktrees: source.worktrees.map((candidate) =>
worktree(
candidate,
candidate.upstream.kind === 'tracking'
? (remoteFetches.get(candidate.upstream.remoteId) ?? null)
: lastSuccessfulFetchAt(source.fetch),
),
),
remotes: source.remotes.map(({ remoteId, displayName, host }) => ({
remoteId,
displayName,
host,
})),
operations: source.operations.map((operation) => ({
operationId: operation.operationId,
category: operation.category,
Expand All @@ -55,7 +72,7 @@ export function toProtocolRepositorySnapshot(
});
}

function worktree(source: PublishedWorktreeSnapshot) {
function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) {
const path = source.canonicalPath ?? source.displayPath;
return {
worktreeId: source.worktreeId,
Expand Down Expand Up @@ -84,7 +101,7 @@ function worktree(source: PublishedWorktreeSnapshot) {
},
indexTree: null,
status: worktreeStatus(source),
upstream: upstream(source),
upstream: upstream(source, fetchedAt),
changes: [],
nativeTargets: [],
};
Expand Down Expand Up @@ -136,7 +153,7 @@ function worktreeStatus(source: PublishedWorktreeSnapshot) {
};
}

function upstream(source: PublishedWorktreeSnapshot) {
function upstream(source: PublishedWorktreeSnapshot, fetchedAt: string | null) {
switch (source.upstream.kind) {
case 'tracking':
return {
Expand All @@ -150,13 +167,13 @@ function upstream(source: PublishedWorktreeSnapshot) {
source.upstream.aheadBehind.kind === 'cached'
? source.upstream.aheadBehind.behind
: null,
fetchedAt: null,
fetchedAt,
};
case 'unpublished':
return {
kind: 'unpublished' as const,
remoteName: null,
fetchedAt: null,
fetchedAt,
};
case 'not_applicable':
return {
Expand All @@ -174,6 +191,12 @@ function upstream(source: PublishedWorktreeSnapshot) {
}
}

function lastSuccessfulFetchAt(
fetch: import('@codex-git/repository-engine').FetchState,
): string | null {
return fetch.kind === 'never' ? null : fetch.fetchedAt;
}

function displayName(path: string): string {
return basename(path) || path;
}
40 changes: 39 additions & 1 deletion apps/launcher/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
createRepositoryEngine,
type RepositorySession,
} from '@codex-git/repository-engine';
import type { AbsolutePath, RepositoryId } from '@codex-git/protocol';
import type {
AbsolutePath,
CommandEnvelope,
OperationReceipt,
RepositoryId,
} from '@codex-git/protocol';
import { startLoopbackServer, type LoopbackServer } from '@codex-git/server';
import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone';
import { createServer as createViteServer, type ViteDevServer } from 'vite';
Expand Down Expand Up @@ -72,6 +77,10 @@ export async function startStandaloneRuntime(
await repositorySession!.requestRefresh(),
options.projectPath!,
),
commands: (request) =>
dispatchRepositoryCommand(repositorySession!, request),
operationRecovery: (operationId) =>
repositorySession!.recoverOperation(operationId),
},
});
if (repositorySession !== undefined && openedRepositoryId !== undefined) {
Expand Down Expand Up @@ -123,6 +132,35 @@ export async function startStandaloneRuntime(
}
}

async function dispatchRepositoryCommand(
session: RepositorySession,
request: CommandEnvelope,
): Promise<OperationReceipt> {
if (
request.command.kind !== 'fetch_remote' &&
request.command.kind !== 'fetch_all'
) {
throw new Error('The Product Command is not implemented.');
}
const admission = await session.fetch({
repositoryId: request.command.repositoryId,
remoteId:
request.command.kind === 'fetch_remote' ? request.command.remoteId : null,
expectedRefsRevision: request.command.expectedRefsRevision,
});
if (admission.kind === 'closed') {
throw new Error('The Repository Session is closed.');
}
return {
clientCommandId: request.clientCommandId,
operationId:
admission.kind === 'accepted'
? admission.operation.operationId
: admission.result.operationId,
disposition: 'accepted',
};
}

async function forwardRepositoryInvalidations(
session: RepositorySession,
server: Pick<LoopbackServer, 'publish'>,
Expand Down
42 changes: 41 additions & 1 deletion apps/ui/src/RepositoryOverview.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { fileIdSchema } from '@codex-git/protocol';
import { fileIdSchema, operationIdSchema } from '@codex-git/protocol';

import { App } from './overview.js';
import { createOverviewFixture } from './overview-fixtures.js';
Expand Down Expand Up @@ -132,6 +132,46 @@ describe('Repository overview', () => {
);
});

it('lists every Remote result after a Partial Success Fetch-all', () => {
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,
fetchResult: {
kind: 'partial_success',
operationId: operationIdSchema.parse(
'operation_00000000000000000000000000000001',
),
message: 'Some Remotes were fetched.',
effects: [
{ kind: 'succeeded', label: 'origin' },
{
kind: 'failed_known',
label: 'backup',
code: 'offline',
message: 'The Remote could not be reached.',
},
],
},
},
});

const markup = renderToStaticMarkup(
<App store={createRepositoryStore(fixture.source)} />,
);

expect(markup).toContain('Fetch-all result');
expect(markup).toContain('origin — Succeeded');
expect(markup).toContain(
'backup — Failed: The Remote could not be reached.',
);
});

it('counts Worktree availability independently from status freshness', () => {
const fixture = createOverviewFixture('one-worktree');
const source = fixture.source.getSnapshot();
Expand Down
50 changes: 50 additions & 0 deletions apps/ui/src/RepositoryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export function RepositoryOverview({
worktree.status.kind === 'unavailable'),
).length;
const fetchAvailable = snapshot.fetchAvailable !== false;
const fetchResultEffects =
snapshot.fetchResult?.kind === 'partial_success'
? snapshot.fetchResult.effects
: snapshot.fetchResult?.kind === 'failed_known'
? snapshot.fetchResult.effects
: undefined;

return (
<main className="repository-overview">
Expand Down Expand Up @@ -191,6 +197,33 @@ export function RepositoryOverview({
</div>
</header>

{snapshot.fetchResult === undefined ? null : (
<section aria-live="polite" className="fetch-result" role="status">
<h2>
{fetchResultEffects !== undefined
? 'Fetch-all result'
: 'Fetch result'}
</h2>
{fetchResultEffects !== undefined ? (
<>
<p>{fetchResultLabel(snapshot.fetchResult)}</p>
<ul>
{fetchResultEffects.map((effect) => (
<li key={effect.label}>
{effect.label} —{' '}
{effect.kind === 'succeeded'
? 'Succeeded'
: `Failed: ${effect.message}`}
</li>
))}
</ul>
</>
) : (
<p>{fetchResultLabel(snapshot.fetchResult)}</p>
)}
</section>
)}

<div
className={
snapshot.worktrees.length === 1
Expand Down Expand Up @@ -444,6 +477,23 @@ function fetchLabel(
return `${fetch.kind === 'stale' ? 'Stale' : 'Fetch failed'} — ${time}. ${fetch.message}`;
}

function fetchResultLabel(
result: import('@codex-git/protocol').OperationResult,
): string {
switch (result.kind) {
case 'succeeded':
return result.result.kind === 'remote'
? result.result.summary
: 'Fetch succeeded.';
case 'rejected':
case 'failed_known':
case 'unknown_outcome':
return result.message;
case 'partial_success':
return result.message;
}
}

function upstreamLabel(
upstream: import('./repository-overview-model.js').UpstreamOverview,
): string {
Expand Down
102 changes: 101 additions & 1 deletion apps/ui/src/protocol-repository-source.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';

import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '@codex-git/protocol';
import {
PROTOCOL_VERSION,
PROTOCOL_VERSION_HEADER,
remoteIdSchema,
} from '@codex-git/protocol';

import { createProtocolRepositorySource } from './protocol-repository-source.js';

Expand Down Expand Up @@ -150,6 +154,102 @@ describe('ProtocolRepositorySource', () => {
});
});

it('submits an explicit Fetch intent with opaque snapshot authority', async () => {
const commands: unknown[] = [];
const operations: unknown[] = [];
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, init) => {
const url = String(input);
if (url.endsWith('/session')) {
return jsonResponse({
...sessionMetadata,
capabilities: {
...sessionMetadata.capabilities,
commands: true,
operationRecovery: true,
},
});
}
if (url.endsWith('/snapshot')) {
return jsonResponse({
...repositorySnapshot,
fetchAvailable: true,
remotes: [
{
remoteId: 'remote_0123456789abcdef0123456789abcdef',
displayName: 'origin',
host: 'example.test',
},
],
});
}
if (url.endsWith('/operations')) {
operations.push(JSON.parse(String(init?.body)));
return jsonResponse({
kind: 'succeeded',
operationId: 'operation_0123456789abcdef0123456789abcdef',
result: { kind: 'remote', summary: 'Fetched origin.' },
});
}
const command = JSON.parse(String(init?.body)) as {
readonly clientCommandId: string;
};
commands.push({
body: command,
contentType: new Headers(init?.headers).get('content-type'),
method: init?.method,
url,
version: new Headers(init?.headers).get(PROTOCOL_VERSION_HEADER),
});
return jsonResponse({
operationId: 'operation_0123456789abcdef0123456789abcdef',
clientCommandId: command.clientCommandId,
disposition: 'accepted',
});
},
});
await until(() => source.getSnapshot().kind === 'repository');

source.requestFetch(
remoteIdSchema.parse('remote_0123456789abcdef0123456789abcdef'),
);
await until(() => commands.length === 1);
await until(() => operations.length === 1);

expect(commands).toEqual([
{
body: {
clientCommandId: expect.stringMatching(/^command_[0-9a-f]{32}$/u),
command: {
kind: 'fetch_remote',
repositoryId: repositorySnapshot.repositoryId,
remoteId: 'remote_0123456789abcdef0123456789abcdef',
expectedRefsRevision: repositorySnapshot.refsRevision,
},
},
contentType: 'application/json',
method: 'POST',
url: 'http://127.0.0.1:4173/instance/fixture-token/v1/commands',
version: String(PROTOCOL_VERSION),
},
]);
expect(operations).toEqual([
{ operationId: 'operation_0123456789abcdef0123456789abcdef' },
]);
expect(source.getSnapshot()).toMatchObject({
kind: 'repository',
snapshot: {
fetchResult: {
kind: 'succeeded',
result: { kind: 'remote', summary: 'Fetched origin.' },
},
},
});
});

it('refetches when a newer invalidation arrives during an in-flight snapshot', async () => {
const events = new FakeEventSource();
const inFlight = deferred<Response>();
Expand Down
Loading