406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
import { realpathSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import type { ProjectConfig } from '../../shared/project-config';
|
|
import type { OpencodeAgentInfo } from './client';
|
|
import {
|
|
buildProjectAgentManifest,
|
|
readProjectConfig,
|
|
} from './project-config';
|
|
|
|
interface ProjectAgentRuntimeManager {
|
|
getRuntimeGeneration?: () => number;
|
|
getRuntimeGenerationProvenance?: () => 'unknown' | 'starting' | 'fresh' | 'attached';
|
|
}
|
|
|
|
interface ProjectAgentRegistryClient {
|
|
listAgents: (options?: { signal?: AbortSignal }) => Promise<OpencodeAgentInfo[]>;
|
|
}
|
|
|
|
type RuntimeGenerationProvenance = 'unknown' | 'starting' | 'fresh' | 'attached';
|
|
|
|
interface ProjectAgentRuntimeState {
|
|
runtimeGeneration: number;
|
|
runtimeGenerationProvenance: RuntimeGenerationProvenance;
|
|
desiredFingerprint: string;
|
|
appliedFingerprint: string | null;
|
|
bootstrapCandidateFingerprint: string | null;
|
|
desiredAgentHashes: Map<string, string>;
|
|
desiredAgentDefinitions: Map<string, string>;
|
|
appliedAgentHashes: Map<string, string>;
|
|
bootstrapCandidateAgentHashes: Map<string, string> | null;
|
|
hotAddCandidateAgentIds: Set<string>;
|
|
knownAgentIds: Set<string>;
|
|
}
|
|
|
|
export interface ProjectAgentRuntimeSnapshot extends ProjectAgentRuntimeState {
|
|
projectPath: string;
|
|
}
|
|
|
|
export interface ProjectAgentPreflightResult {
|
|
ready: boolean;
|
|
runtimeGeneration: number;
|
|
}
|
|
|
|
export type ProjectAgentAcceptanceResult<T> =
|
|
| { ready: false; runtimeGeneration: number }
|
|
| { ready: true; runtimeGeneration: number; value: T };
|
|
|
|
export interface ProjectAgentRuntimeMutation<T> {
|
|
previousConfig: ProjectConfig | null;
|
|
config: ProjectConfig;
|
|
value: T;
|
|
}
|
|
|
|
const runtimeStates = new WeakMap<object, Map<string, ProjectAgentRuntimeState>>();
|
|
const runtimeLocks = new WeakMap<object, Map<string, Promise<void>>>();
|
|
|
|
function canonicalProjectPath(projectPath: string): string {
|
|
try {
|
|
return realpathSync.native(projectPath);
|
|
} catch {
|
|
return path.resolve(projectPath);
|
|
}
|
|
}
|
|
|
|
function buildProjectAgentHashes(config: ProjectConfig): Map<string, string> {
|
|
return new Map(buildProjectAgentManifest(config).entries.map((entry) => [
|
|
path.posix.basename(entry.relativePath, '.md'),
|
|
entry.contentHash,
|
|
]));
|
|
}
|
|
|
|
function buildProjectAgentDefinitions(config: ProjectConfig): Map<string, string> {
|
|
return new Map(config.agents.map((agent) => [agent.id, JSON.stringify(agent)]));
|
|
}
|
|
|
|
function appliedManifestMatchesDesired(state: ProjectAgentRuntimeState): boolean {
|
|
return [...state.desiredAgentHashes].every(
|
|
([agentId, desiredHash]) => state.appliedAgentHashes.get(agentId) === desiredHash,
|
|
);
|
|
}
|
|
|
|
function refreshAppliedFingerprint(state: ProjectAgentRuntimeState): void {
|
|
state.appliedFingerprint = appliedManifestMatchesDesired(state)
|
|
? state.desiredFingerprint
|
|
: null;
|
|
}
|
|
|
|
function createRuntimeState(
|
|
manager: ProjectAgentRuntimeManager,
|
|
config: ProjectConfig,
|
|
options: { forcePending?: boolean } = {},
|
|
): ProjectAgentRuntimeState {
|
|
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
|
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
|
const manifest = buildProjectAgentManifest(config);
|
|
const desiredAgentHashes = buildProjectAgentHashes(config);
|
|
const desiredAgentDefinitions = buildProjectAgentDefinitions(config);
|
|
const authoritativeFresh = provenance === 'fresh' && !options.forcePending;
|
|
return {
|
|
runtimeGeneration,
|
|
runtimeGenerationProvenance: provenance,
|
|
desiredFingerprint: manifest.fingerprint,
|
|
appliedFingerprint: authoritativeFresh ? manifest.fingerprint : null,
|
|
bootstrapCandidateFingerprint: provenance === 'starting' && !options.forcePending
|
|
? manifest.fingerprint
|
|
: null,
|
|
desiredAgentHashes,
|
|
desiredAgentDefinitions,
|
|
appliedAgentHashes: authoritativeFresh ? new Map(desiredAgentHashes) : new Map(),
|
|
bootstrapCandidateAgentHashes: provenance === 'starting' && !options.forcePending
|
|
? new Map(desiredAgentHashes)
|
|
: null,
|
|
hotAddCandidateAgentIds: new Set(),
|
|
knownAgentIds: new Set(desiredAgentHashes.keys()),
|
|
};
|
|
}
|
|
|
|
function getManagerMap<T>(registry: WeakMap<object, Map<string, T>>, manager: object): Map<string, T> {
|
|
let entries = registry.get(manager);
|
|
if (!entries) {
|
|
entries = new Map<string, T>();
|
|
registry.set(manager, entries);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
async function withProjectRuntimeLock<T>(
|
|
manager: object,
|
|
projectPath: string,
|
|
operation: () => Promise<T>,
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
const locks = getManagerMap(runtimeLocks, manager);
|
|
const previous = locks.get(projectPath) ?? Promise.resolve();
|
|
let release!: () => void;
|
|
const current = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const queued = previous.then(() => current);
|
|
locks.set(projectPath, queued);
|
|
try {
|
|
await awaitAbortable(previous, signal);
|
|
signal?.throwIfAborted();
|
|
return await operation();
|
|
} finally {
|
|
release();
|
|
void queued.then(() => {
|
|
if (locks.get(projectPath) === queued) {
|
|
locks.delete(projectPath);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadValidProjectConfig(projectPath: string): Promise<ProjectConfig> {
|
|
const result = await readProjectConfig(projectPath);
|
|
if (result.status !== 'valid') {
|
|
throw new Error('Project configuration is missing or invalid');
|
|
}
|
|
return result.config;
|
|
}
|
|
|
|
function observeState(
|
|
manager: ProjectAgentRuntimeManager,
|
|
canonicalPath: string,
|
|
config: ProjectConfig,
|
|
): ProjectAgentRuntimeState {
|
|
const states = getManagerMap(runtimeStates, manager as object);
|
|
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
|
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
|
const desiredManifest = buildProjectAgentManifest(config);
|
|
const desiredAgentHashes = buildProjectAgentHashes(config);
|
|
const desiredAgentDefinitions = buildProjectAgentDefinitions(config);
|
|
const current = states.get(canonicalPath);
|
|
if (!current || current.runtimeGeneration !== runtimeGeneration) {
|
|
const next = createRuntimeState(manager, config);
|
|
states.set(canonicalPath, next);
|
|
return next;
|
|
}
|
|
for (const [agentId, desiredHash] of desiredAgentHashes) {
|
|
const previousDesiredHash = current.desiredAgentHashes.get(agentId);
|
|
if (
|
|
current.desiredAgentDefinitions.get(agentId) === desiredAgentDefinitions.get(agentId)
|
|
&& previousDesiredHash
|
|
&& current.appliedAgentHashes.get(agentId) === previousDesiredHash
|
|
) {
|
|
current.appliedAgentHashes.set(agentId, desiredHash);
|
|
}
|
|
if (
|
|
!current.knownAgentIds.has(agentId)
|
|
&& (provenance === 'fresh' || provenance === 'starting')
|
|
) {
|
|
current.hotAddCandidateAgentIds.add(agentId);
|
|
}
|
|
current.knownAgentIds.add(agentId);
|
|
}
|
|
for (const agentId of current.hotAddCandidateAgentIds) {
|
|
if (!desiredAgentHashes.has(agentId)) current.hotAddCandidateAgentIds.delete(agentId);
|
|
}
|
|
for (const agentId of current.desiredAgentHashes.keys()) {
|
|
if (desiredAgentHashes.has(agentId)) continue;
|
|
current.appliedAgentHashes.delete(agentId);
|
|
current.bootstrapCandidateAgentHashes?.delete(agentId);
|
|
}
|
|
current.desiredFingerprint = desiredManifest.fingerprint;
|
|
current.desiredAgentHashes = desiredAgentHashes;
|
|
current.desiredAgentDefinitions = desiredAgentDefinitions;
|
|
if (provenance !== 'fresh') {
|
|
current.appliedFingerprint = null;
|
|
} else if (current.runtimeGenerationProvenance === 'starting') {
|
|
const bootstrapHashes = current.bootstrapCandidateAgentHashes ?? new Map();
|
|
current.appliedAgentHashes = new Map([...bootstrapHashes].filter(
|
|
([agentId, hash]) => desiredAgentHashes.get(agentId) === hash,
|
|
));
|
|
refreshAppliedFingerprint(current);
|
|
} else if (current.runtimeGenerationProvenance !== 'fresh') {
|
|
current.appliedFingerprint = null;
|
|
} else {
|
|
refreshAppliedFingerprint(current);
|
|
}
|
|
current.runtimeGenerationProvenance = provenance;
|
|
return current;
|
|
}
|
|
|
|
export async function observeProjectAgentRuntime(
|
|
manager: ProjectAgentRuntimeManager,
|
|
projectPath: string,
|
|
knownConfig?: ProjectConfig,
|
|
signal?: AbortSignal,
|
|
): Promise<ProjectAgentRuntimeSnapshot> {
|
|
const canonicalPath = canonicalProjectPath(projectPath);
|
|
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
|
signal?.throwIfAborted();
|
|
const config = knownConfig ?? await loadValidProjectConfig(canonicalPath);
|
|
signal?.throwIfAborted();
|
|
const state = observeState(manager, canonicalPath, config);
|
|
return { projectPath: canonicalPath, ...state };
|
|
}, signal);
|
|
}
|
|
|
|
export async function observeProjectAgentRuntimeGeneration(
|
|
manager: ProjectAgentRuntimeManager,
|
|
projectPath: string,
|
|
signal?: AbortSignal,
|
|
): Promise<ProjectAgentRuntimeSnapshot> {
|
|
const canonicalPath = canonicalProjectPath(projectPath);
|
|
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
|
signal?.throwIfAborted();
|
|
const states = getManagerMap(runtimeStates, manager as object);
|
|
const current = states.get(canonicalPath);
|
|
if (!current) {
|
|
const config = await loadValidProjectConfig(canonicalPath);
|
|
signal?.throwIfAborted();
|
|
const state = observeState(manager, canonicalPath, config);
|
|
return { projectPath: canonicalPath, ...state };
|
|
}
|
|
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
|
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
|
if (current.runtimeGeneration !== runtimeGeneration) {
|
|
const config = await loadValidProjectConfig(canonicalPath);
|
|
const next = createRuntimeState(manager, config);
|
|
states.set(canonicalPath, next);
|
|
return { projectPath: canonicalPath, ...next };
|
|
} else if (provenance !== 'fresh') {
|
|
current.appliedFingerprint = null;
|
|
current.runtimeGenerationProvenance = provenance;
|
|
} else if (current.runtimeGenerationProvenance === 'starting') {
|
|
const bootstrapHashes = current.bootstrapCandidateAgentHashes ?? new Map();
|
|
current.appliedAgentHashes = new Map([...bootstrapHashes].filter(
|
|
([agentId, hash]) => current.desiredAgentHashes.get(agentId) === hash,
|
|
));
|
|
refreshAppliedFingerprint(current);
|
|
current.runtimeGenerationProvenance = provenance;
|
|
} else if (current.runtimeGenerationProvenance !== 'fresh') {
|
|
current.appliedFingerprint = null;
|
|
current.runtimeGenerationProvenance = provenance;
|
|
}
|
|
return { projectPath: canonicalPath, ...current };
|
|
}, signal);
|
|
}
|
|
|
|
export async function markProjectAgentRuntimePending(
|
|
manager: ProjectAgentRuntimeManager,
|
|
projectPath: string,
|
|
config: ProjectConfig,
|
|
): Promise<ProjectAgentRuntimeSnapshot> {
|
|
const canonicalPath = canonicalProjectPath(projectPath);
|
|
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
|
const state = createRuntimeState(manager, config, { forcePending: true });
|
|
getManagerMap(runtimeStates, manager as object).set(canonicalPath, state);
|
|
return { projectPath: canonicalPath, ...state };
|
|
});
|
|
}
|
|
|
|
export async function mutateProjectAgentRuntime<T>(
|
|
manager: ProjectAgentRuntimeManager,
|
|
projectPath: string,
|
|
mutation: () => Promise<ProjectAgentRuntimeMutation<T>>,
|
|
): Promise<T> {
|
|
const canonicalPath = canonicalProjectPath(projectPath);
|
|
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
|
const result = await mutation();
|
|
if (result.previousConfig) {
|
|
observeState(manager, canonicalPath, result.previousConfig);
|
|
observeState(manager, canonicalPath, result.config);
|
|
} else {
|
|
const state = createRuntimeState(manager, result.config, { forcePending: true });
|
|
getManagerMap(runtimeStates, manager as object).set(canonicalPath, state);
|
|
}
|
|
return result.value;
|
|
});
|
|
}
|
|
|
|
function liveAgentIds(agents: OpencodeAgentInfo[]): Set<string> {
|
|
return new Set(agents.flatMap((agent) => {
|
|
const name = typeof agent.name === 'string' ? agent.name.trim() : '';
|
|
const id = typeof agent.id === 'string' ? agent.id.trim() : '';
|
|
return [name, id].filter(Boolean);
|
|
}));
|
|
}
|
|
|
|
async function awaitAbortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
if (!signal) return await promise;
|
|
signal.throwIfAborted();
|
|
return await new Promise<T>((resolve, reject) => {
|
|
const onAbort = () => reject(signal.reason);
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
promise.then(
|
|
(value) => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
resolve(value);
|
|
},
|
|
(error) => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function acceptProjectAgentRuntime<T>(
|
|
manager: ProjectAgentRuntimeManager,
|
|
projectPath: string,
|
|
client: ProjectAgentRegistryClient,
|
|
selectedAgentId: string,
|
|
accept: () => Promise<T>,
|
|
signal?: AbortSignal,
|
|
): Promise<ProjectAgentAcceptanceResult<T>> {
|
|
const canonicalPath = canonicalProjectPath(projectPath);
|
|
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
|
signal?.throwIfAborted();
|
|
const config = await loadValidProjectConfig(canonicalPath);
|
|
signal?.throwIfAborted();
|
|
const state = observeState(manager, canonicalPath, config);
|
|
const desiredHash = state.desiredAgentHashes.get(selectedAgentId);
|
|
if (!desiredHash) {
|
|
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
|
}
|
|
const alreadyApplied = state.runtimeGenerationProvenance === 'fresh'
|
|
&& state.appliedAgentHashes.get(selectedAgentId) === desiredHash;
|
|
const hotAddCandidate = state.runtimeGenerationProvenance === 'fresh'
|
|
&& state.hotAddCandidateAgentIds.has(selectedAgentId);
|
|
if (!alreadyApplied && !hotAddCandidate) {
|
|
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
|
}
|
|
|
|
signal?.throwIfAborted();
|
|
const liveIds = liveAgentIds(await awaitAbortable(client.listAgents({ signal }), signal));
|
|
if (!liveIds.has(selectedAgentId)) {
|
|
if (alreadyApplied) {
|
|
state.appliedAgentHashes.delete(selectedAgentId);
|
|
refreshAppliedFingerprint(state);
|
|
}
|
|
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
|
}
|
|
if (hotAddCandidate) {
|
|
state.appliedAgentHashes.set(selectedAgentId, desiredHash);
|
|
state.hotAddCandidateAgentIds.delete(selectedAgentId);
|
|
refreshAppliedFingerprint(state);
|
|
}
|
|
signal?.throwIfAborted();
|
|
const value = await awaitAbortable(accept(), signal);
|
|
return {
|
|
ready: true,
|
|
runtimeGeneration: state.runtimeGeneration,
|
|
value,
|
|
};
|
|
}, signal);
|
|
}
|
|
|
|
export async function preflightProjectAgentRuntime(
|
|
manager: ProjectAgentRuntimeManager,
|
|
projectPath: string,
|
|
client: ProjectAgentRegistryClient,
|
|
selectedAgentId: string,
|
|
): Promise<ProjectAgentPreflightResult> {
|
|
const result = await acceptProjectAgentRuntime(
|
|
manager,
|
|
projectPath,
|
|
client,
|
|
selectedAgentId,
|
|
async () => undefined,
|
|
);
|
|
return { ready: result.ready, runtimeGeneration: result.runtimeGeneration };
|
|
}
|