223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
/**
|
|
* Live-instance registry.
|
|
*
|
|
* Each VS Code window that enables AI Agent access writes **its own** file
|
|
* under `instances/`. Two properties follow from that:
|
|
*
|
|
* - **No locking and no merging.** Writers never touch each other's files, so
|
|
* there is no read-modify-write race to guard. An earlier design that
|
|
* shared a single JSON file would have needed a lock file (itself another
|
|
* thing a crash can leave behind) plus a merge step.
|
|
* - **Crash recovery does not wait for the same workspace.** Any instance can
|
|
* prune entries whose recorded PID is dead, so a crashed window is cleaned
|
|
* up the next time *any* VS Code window with the extension activates.
|
|
*
|
|
* A merged, read-only `index.json` is derived from the instance files purely
|
|
* for discovery (agents/humans looking at the directory), so the machine
|
|
* coordination files and the human-readable view stay separate.
|
|
*
|
|
* Pure TypeScript: no VS Code dependency.
|
|
*/
|
|
|
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { isProcessAlive, type AgentEndpoint } from "./endpoint";
|
|
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
|
|
|
|
/** One live extension-host instance. */
|
|
export interface AgentInstance extends AgentEndpoint {
|
|
/** Unique per window; also the file name stem. */
|
|
instanceId: string;
|
|
}
|
|
|
|
/** Discovery manifest derived from all live instances. */
|
|
export interface AgentIndexManifest {
|
|
schemaVersion: number;
|
|
generatedAt: string;
|
|
instances: Array<{
|
|
instanceId: string;
|
|
processId?: number;
|
|
url: string;
|
|
/** Tokens are intentionally omitted: the manifest is for discovery. */
|
|
projects: string[];
|
|
}>;
|
|
projects: string[];
|
|
}
|
|
|
|
export const INSTANCE_SCHEMA_VERSION = 1;
|
|
|
|
/** Directory holding one file per live extension host. */
|
|
export function instancesDir(agentHome = defaultAgentHome()): string {
|
|
return join(agentHome, "instances");
|
|
}
|
|
|
|
/** Path to the merged discovery manifest. */
|
|
export function manifestPath(agentHome = defaultAgentHome()): string {
|
|
return join(agentHome, "index.json");
|
|
}
|
|
|
|
/** File name for one instance. */
|
|
export function instanceFileName(instanceId: string): string {
|
|
return `vscode-${instanceId}.json`;
|
|
}
|
|
|
|
/**
|
|
* Builds a process-unique instance id. Combining the PID with a random suffix
|
|
* keeps two windows of the same process id from colliding across restarts.
|
|
*/
|
|
export function makeInstanceId(pid = process.pid): string {
|
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
return `${pid}-${rand}`;
|
|
}
|
|
|
|
/** Writes this instance's own file. */
|
|
export async function writeInstance(
|
|
instance: AgentInstance,
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<string> {
|
|
const file = join(instancesDir(agentHome), instanceFileName(instance.instanceId));
|
|
await mkdir(dirname(file), { recursive: true });
|
|
await writeFile(file, `${JSON.stringify(instance, null, 2)}\n`, "utf8");
|
|
return file;
|
|
}
|
|
|
|
/** Reads every instance file, skipping malformed ones. */
|
|
export async function readInstances(
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<AgentInstance[]> {
|
|
const dir = instancesDir(agentHome);
|
|
let names: string[];
|
|
try {
|
|
names = await readdir(dir);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const out: AgentInstance[] = [];
|
|
for (const name of names) {
|
|
if (!name.endsWith(".json")) continue;
|
|
try {
|
|
const parsed = JSON.parse(
|
|
await readFile(join(dir, name), "utf8"),
|
|
) as AgentInstance;
|
|
if (parsed?.url && parsed?.token) {
|
|
// Fall back to the file name when an older file lacks the field.
|
|
parsed.instanceId ??= name.replace(/^vscode-/, "").replace(/\.json$/, "");
|
|
out.push(parsed);
|
|
}
|
|
} catch {
|
|
// Skip unreadable/corrupt entries.
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Removes this instance's own file. */
|
|
export async function clearInstance(
|
|
instanceId: string,
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<void> {
|
|
await rm(join(instancesDir(agentHome), instanceFileName(instanceId)), {
|
|
force: true,
|
|
});
|
|
}
|
|
|
|
export interface PruneResult {
|
|
removed: string[];
|
|
kept: AgentInstance[];
|
|
}
|
|
|
|
/**
|
|
* Removes instance files whose recorded PID is no longer alive.
|
|
*
|
|
* This is how crashes are cleaned up without waiting for the same workspace to
|
|
* be reopened. Only clearly-dead PIDs are pruned: `isProcessAlive` treats an
|
|
* unknown or unparseable PID as alive, so an older file that predates the
|
|
* `processId` field is never deleted by mistake.
|
|
*/
|
|
export async function pruneInstances(
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<PruneResult> {
|
|
const instances = await readInstances(agentHome);
|
|
const removed: string[] = [];
|
|
const kept: AgentInstance[] = [];
|
|
for (const instance of instances) {
|
|
if (isProcessAlive(instance.processId)) {
|
|
kept.push(instance);
|
|
} else {
|
|
removed.push(instance.instanceId);
|
|
await clearInstance(instance.instanceId, agentHome).catch(() => undefined);
|
|
}
|
|
}
|
|
return { removed, kept };
|
|
}
|
|
|
|
/** Collects every project root across live instances. */
|
|
export function projectsOf(instances: readonly AgentInstance[]): string[] {
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const instance of instances) {
|
|
const candidates = [
|
|
...(instance.projects ?? []),
|
|
...(instance.projectDir ? [instance.projectDir] : []),
|
|
];
|
|
for (const project of candidates) {
|
|
const key = project.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(project);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Regenerates the merged read-only manifest from the live instances.
|
|
* Best-effort: failures are swallowed because the manifest is a convenience,
|
|
* not a correctness requirement (readers can always scan `instances/`).
|
|
*/
|
|
export async function writeManifest(
|
|
instances: readonly AgentInstance[],
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<AgentIndexManifest> {
|
|
const manifest: AgentIndexManifest = {
|
|
schemaVersion: INSTANCE_SCHEMA_VERSION,
|
|
generatedAt: new Date().toISOString(),
|
|
instances: instances.map((instance) => ({
|
|
instanceId: instance.instanceId,
|
|
processId: instance.processId,
|
|
url: instance.url,
|
|
projects: [
|
|
...(instance.projects ?? []),
|
|
...(instance.projectDir ? [instance.projectDir] : []),
|
|
],
|
|
})),
|
|
projects: projectsOf(instances),
|
|
};
|
|
try {
|
|
const file = manifestPath(agentHome);
|
|
await mkdir(dirname(file), { recursive: true });
|
|
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
} catch {
|
|
// Discovery only: never fail the caller over this.
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
/** Reads the discovery manifest, or null when absent/malformed. */
|
|
export async function readManifest(
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<AgentIndexManifest | null> {
|
|
try {
|
|
return JSON.parse(
|
|
await readFile(manifestPath(agentHome), "utf8"),
|
|
) as AgentIndexManifest;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Snapshot path for a project, re-exported for discovery convenience. */
|
|
export function projectKey(projectDir: string): string {
|
|
return snapshotBaseName(projectDir);
|
|
}
|