import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { homedir, hostname } from "node:os"; import { resolve } from "node:path"; import type { ChannelInfo, ModelRef } from "@ace/channel/protocol"; import { available, choose } from "@ace/channel/models"; import * as catalog from "./catalog"; import { Connection, hostedAuth, request } from "./client"; import { config } from "./config"; import { models as providers } from "./keys"; import { preference } from "./preferences"; import { checkout } from "./projects"; export function parseModel(value: string): ModelRef { const split = value.indexOf("/"); if (split < 1) throw new Error("A model is written provider/id, such as openai/gpt-6-astra"); return { provider: value.slice(0, split), modelId: value.slice(split + 1) }; } /** Models with credentials on this machine. */ export async function models(): Promise { return available(providers()); } export async function defaultModel(): Promise { if (config.model) return parseModel(config.model); return preference() || choose(providers()); } /** The Git top level containing `dir`. */ export function project(dir: string): string { dir = resolve(dir === "~" ? homedir() : dir.startsWith("~/") ? homedir() + dir.slice(1) : dir); const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", }); if (result.error) throw new Error("Git is unavailable. Check This Mac in Settings."); if (result.status !== 0) throw new Error(`${dir} is not inside a Git repository`); return result.stdout.trim(); } /** Hosted channels are always reachable; local ones run while their worker does. */ export function isRunning(id: string): boolean { if (catalog.read(id).hosted) return true; return existsSync(catalog.paths(id).socket) && isWorkerAlive(id); } /** PID liveness also covers startup, before the worker has opened its listener. */ export function isWorkerAlive(id: string): boolean { try { const owner = Number(readFileSync(catalog.paths(id).pid, "utf8")); if (!Number.isSafeInteger(owner) || owner <= 0) return false; process.kill(owner, 0); return true; } catch { return false; } } /** What the hosting service stores for a channel whose workspace is this host. */ export function hostedConfig(record: catalog.Listing) { return { version: 2, id: record.id, name: record.name, prefix: record.prefix, named: record.named, owner: record.owner, project: record.project, lanes: catalog.paths(record.id).lanes, model: record.model, workspace: `host:${hostname()}`, }; } /** Create a channel on a hosting service, with this host as its workspace. */ export async function host( dir: string, model: ModelRef | undefined, name: string | undefined, url: string, ) { // Saving the record starts the workspace link, so the service must have the channel first. const record = catalog.draft(dir, model, name, url.replace(/\/$/, "")); const response = await fetch(`${record.hosted}/channels/${record.id}`, { method: "POST", headers: await hostedAuth(), body: JSON.stringify(hostedConfig(record)), }).catch((error: Error) => new Response(error.message, { status: 502 })); if (!response.ok) { throw new Error(`The hosting service refused the channel: ${await response.text()}`); } return catalog.save(record); } export async function kill(id: string): Promise { if (catalog.read(id).hosted) return request(id, { op: "kill" }); const connection = await Connection.running(id); if (!connection) return; let timer: ReturnType | undefined; try { await Promise.race([ (async () => { await connection.request({ op: "kill" }); // The reply only confirms the abort; the worker closes sockets after storage and tools. await connection.closed; })(), new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`Channel ${id} did not close within 10 seconds`)), 10_000, ); }), ]); } finally { clearTimeout(timer); connection.close(); } } export async function archive(id: string, archived: boolean): Promise { if (archived) await kill(id); const record = { ...catalog.read(id), archived }; catalog.write(record); return record; } /** Dormant workers retire only once idle, so only a live worker can have work in progress. */ async function busy(id: string): Promise { const { hosted } = catalog.read(id); const connection = hosted ? await Connection.hosted(hosted, id) : await Connection.running(id); if (!connection) return false; try { const { chats } = await connection.request({ op: "info" }); return chats.some((chat) => chat.busy); } finally { connection.close(); } } /** Archives a project's channels on this host that have no work in progress; returns their IDs. */ export async function archiveInactive(root: string): Promise { const open = catalog.list().filter((record) => !record.archived && checkout(record.project).root === root ); const idle = await Promise.all(open.map(async ({ id }) => (await busy(id)) ? undefined : id)); const ids = idle.filter((id) => id !== undefined); await Promise.all(ids.map((id) => archive(id, true))); return ids; } /** * Kills the worker and keeps its lock, so a client still watching the channel cannot start another * worker over storage being removed. A worker that started before the lock is killed in turn. */ async function hold(id: string): Promise { for (let wait = 50; wait < 5000; wait *= 2) { await kill(id); if (catalog.lock(id)) return; await Bun.sleep(wait); } throw new Error("The channel's worker kept restarting; try deleting it again"); } /** Removes the channel and its lane worktrees; lane branches stay in the project. */ export async function remove(id: string): Promise { const { project, hosted } = catalog.read(id); if (hosted) { await kill(id); const response = await fetch(`${hosted}/channels/${id}`, { method: "DELETE", headers: await hostedAuth(), }); if (!response.ok && response.status !== 404) { throw new Error(`The hosting service did not delete the channel: ${response.status}`); } } else await hold(id); const { lanes, pid } = catalog.paths(id); try { for (const lane of existsSync(lanes) ? readdirSync(lanes) : []) { spawnSync("git", ["-C", project, "worktree", "remove", "--force", `${lanes}/${lane}`]); } catalog.remove(id); } catch (error) { if (!hosted) rmSync(pid, { force: true }); throw error; } }