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
1 change: 1 addition & 0 deletions apps/launcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"@codex-git/host-adapter": "*",
"@codex-git/host-adapter-codex-cdp": "*",
"@codex-git/host-adapter-standalone": "*",
"@codex-git/repository-engine": "*",
"@codex-git/server": "*"
},
"devDependencies": {
Expand Down
46 changes: 46 additions & 0 deletions apps/launcher/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type { Server } from 'node:http';
import { fileURLToPath } from 'node:url';

import type { HostConnection } from '@codex-git/host-adapter';
import {
createRepositoryEngine,
type RepositorySession,
} from '@codex-git/repository-engine';
import type { AbsolutePath, 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 All @@ -14,6 +19,7 @@ const uiConfigPath = fileURLToPath(
);

export interface StandaloneRuntimeOptions {
readonly projectPath?: string;
readonly surfacePort?: number;
}

Expand All @@ -30,17 +36,34 @@ export async function startStandaloneRuntime(
let protocolServer: LoopbackServer | undefined;
let surfaceServer: ViteDevServer | undefined;
let hostConnection: HostConnection | null = null;
let repositorySession: RepositorySession | undefined;
let invalidationPump = Promise.resolve();

async function closeResources(): Promise<void> {
await Promise.all([
hostConnection?.close(),
repositorySession?.close(),
surfaceServer?.close(),
protocolServer?.close(),
]);
await invalidationPump;
}

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,
);
}
}
surfaceServer = await createViteServer({
configFile: uiConfigPath,
plugins: [protocolBootstrapPlugin(protocolServer.sessionUrl)],
Expand Down Expand Up @@ -81,6 +104,29 @@ export async function startStandaloneRuntime(
}
}

async function forwardRepositoryInvalidations(
session: RepositorySession,
server: Pick<LoopbackServer, 'publish'>,
repositoryId: RepositoryId,
): Promise<void> {
for await (const invalidation of session.subscribe()) {
server.publish(
invalidation.kind === 'operation'
? {
kind: 'operation_progress',
operationId: invalidation.operation.operationId,
phase: invalidation.operation.phase,
progress: invalidation.operation.progress,
}
: {
kind: 'repository_revision',
repositoryId,
repositoryRevision: invalidation.repositoryRevision,
},
);
}
}

function serverUrl(
server: Pick<Server, 'address'> | null,
pathname: string,
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/repository-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
export {
type IndexSnapshot,
type RefSnapshot,
type UpstreamSnapshot,
type WorktreeObservationError,
type WorktreeStatusSummary,
} from './repository-observation.js';
Expand All @@ -20,8 +21,8 @@ export {
type RefreshState,
type RepositoryInvalidation,
type RepositoryOpenResult,
type RepositorySession,
RepositorySessionFailure,
type RepositorySnapshot,
type WorktreeFreshness,
} from './repository-publication.js';
export { type RepositorySession } from './repository-session.js';
88 changes: 79 additions & 9 deletions packages/repository-engine/src/observation-publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
IndexSnapshot,
RefSnapshot,
RepositoryObservation,
UpstreamSnapshot,
WorktreeObservation,
WorktreeObservationError,
WorktreeStatusSummary,
Expand All @@ -26,6 +27,7 @@ export interface PublishedObservationWorktree extends Omit<
readonly freshness: WorktreeFreshness;
readonly index: IndexSnapshot | null;
readonly status: WorktreeStatusSummary | null;
readonly upstream: UpstreamSnapshot;
}

export type WorktreeFreshness =
Expand All @@ -35,36 +37,53 @@ export type WorktreeFreshness =
| { readonly kind: 'failed'; readonly error: WorktreeObservationError };

export interface PublishedObservationResult extends PublishedRepositoryObservation {
readonly privateRefsEvidence: string;
readonly privateRefsEvidence: PrivateRefsEvidence;
readonly refsChanged: boolean;
readonly worktreeChanged: boolean;
}

export interface PrivateRefsEvidence {
readonly shared: string;
readonly upstreams: string;
}

export function publishObservedFacts(
discovery: RepositoryDiscovery,
previous?: PublishedRepositoryObservation,
observation?: RepositoryObservation,
previousPrivateRefsEvidence?: string,
previousPrivateRefsEvidence?: PrivateRefsEvidence,
): PublishedObservationResult {
const shared = observation?.shared ?? {
refs: previous?.refs ?? [],
remotes: previous?.remotes ?? [],
privateRefsEvidence: previousPrivateRefsEvidence ?? '',
privateRefsEvidence: previousPrivateRefsEvidence?.shared ?? '',
};
const previousWorktrees = new Map(
previous?.worktrees.map((worktree) => [worktree.worktreeId, worktree]),
);
const observations = new Map(
observation?.worktrees.map((worktree) => [worktree.worktreeId, worktree]),
);
const sharedRefsChanged =
observation !== undefined &&
shared.privateRefsEvidence !== previousPrivateRefsEvidence?.shared;
let worktreeChanged = previous === undefined;
const worktrees = discovery.worktrees.map((worktree) => {
const prior = previousWorktrees.get(worktree.worktreeId);
const observed = observations.get(worktree.worktreeId);
if (observation !== undefined && observed === undefined) {
if (
observation !== undefined &&
observation.complete !== false &&
observed === undefined
) {
throw new Error('Repository observation omitted a registered Worktree.');
}
const observedFacts = publishWorktreeObservation(worktree, observed, prior);
const observedFacts =
observation?.complete === false &&
observed === undefined &&
prior !== undefined
? retainWorktreeObservation(prior, sharedRefsChanged)
: publishWorktreeObservation(worktree, observed, prior);
const published: Omit<PublishedObservationWorktree, 'worktreeRevision'> = {
worktreeId: worktree.worktreeId,
generation: worktree.generation,
Expand All @@ -77,6 +96,7 @@ export function publishObservedFacts(
freshness: observedFacts.freshness,
index: observedFacts.index,
status: observedFacts.status,
upstream: observedFacts.upstream,
};
const changed =
prior === undefined ||
Expand All @@ -89,33 +109,76 @@ export function publishObservedFacts(
};
});
worktreeChanged ||= previousWorktrees.size !== worktrees.length;
const privateRefsEvidence: PrivateRefsEvidence =
observation === undefined
? (previousPrivateRefsEvidence ?? { shared: '', upstreams: '[]' })
: {
shared: shared.privateRefsEvidence,
upstreams: JSON.stringify(
worktrees.map(({ worktreeId, upstream }) => ({
worktreeId,
upstream,
})),
),
};
const upstreamChanged = worktrees.some((worktree) => {
const prior = previousWorktrees.get(worktree.worktreeId);
return (
prior !== undefined &&
JSON.stringify(worktree.upstream) !== JSON.stringify(prior.upstream)
);
});
return {
refs: shared.refs,
remotes: shared.remotes,
worktrees,
privateRefsEvidence: shared.privateRefsEvidence,
privateRefsEvidence,
refsChanged:
previous === undefined ||
(observation !== undefined &&
shared.privateRefsEvidence !== previousPrivateRefsEvidence),
(observation !== undefined && (sharedRefsChanged || upstreamChanged)),
worktreeChanged,
};
}

function retainWorktreeObservation(
previous: PublishedObservationWorktree,
sharedRefsChanged: boolean,
): Pick<
PublishedObservationWorktree,
'freshness' | 'head' | 'index' | 'status' | 'upstream'
> {
return {
freshness: sharedRefsChanged
? {
kind: 'stale',
error: {
code: 'not_observed',
message: 'Shared refs changed before this Worktree was observed.',
},
}
: previous.freshness,
head: previous.head,
index: previous.index,
status: previous.status,
upstream: previous.upstream,
};
}

function publishWorktreeObservation(
worktree: DiscoveredWorktree,
observed: WorktreeObservation | undefined,
previous: PublishedObservationWorktree | undefined,
): Pick<
PublishedObservationWorktree,
'freshness' | 'head' | 'index' | 'status'
'freshness' | 'head' | 'index' | 'status' | 'upstream'
> {
if (observed?.kind === 'fresh') {
return {
freshness: { kind: 'fresh' },
head: observed.head,
index: observed.index,
status: observed.status,
upstream: observed.upstream,
};
}
if (
Expand All @@ -127,6 +190,7 @@ function publishWorktreeObservation(
head: worktree.head,
index: null,
status: null,
upstream: previous?.upstream ?? { kind: 'unavailable' },
};
}
if (observed?.kind === 'failed') {
Expand All @@ -140,20 +204,26 @@ function publishWorktreeObservation(
head: previous.head,
index: previous.index,
status: previous.status,
upstream: previous.upstream,
};
}
return {
freshness: { kind: 'failed', error: observed.error },
head: worktree.head,
index: null,
status: null,
upstream: { kind: 'unavailable' },
};
}
return {
freshness: { kind: 'fresh' },
head: worktree.head,
index: null,
status: null,
upstream:
worktree.head.kind === 'detached'
? { kind: 'not_applicable', reason: 'detached_head' }
: { kind: 'unavailable' },
};
}

Expand Down
Loading