import { randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { userInfo } from "node:os"; import { join } from "node:path"; import { type Metadata, type ModelRef, validateName } from "@ace/channel"; import { config } from "./config"; import { login } from "./tailnet"; export type Listing = { version: 2; id: string; name: string; /** The original name remains the prefix for lane branches after a rename. */ prefix: string; /** Whether the initial name was chosen deliberately rather than randomly. */ named: boolean; /** A listing projection; pi's metadata document is authoritative once the channel opens. */ summary?: string; revision?: number; owner: string; project: string; model?: ModelRef; created: number; /** When the transcript last grew; channels from older builds have none until they next do. */ active?: number; /** Rebuildable live-run projection, not history; valid only while its source is connected. */ busy?: boolean; archived?: boolean; /** Base URL of the service hosting the channel; this host serves its workspace. */ hosted?: string; /** * A move in progress fences the channel: no worker starts and no client connects until the * move finishes or is resolved. `digest` names the snapshot an outbound move tried to activate. */ moving?: { to: string; digest?: string }; }; export const home = config.home; const root = join(home, "channels"); /** * The local human, named by their Tailscale login so they are the same participant on every * host. Without Tailscale the host is single-user and the OS account name stands in. */ export const user = config.user || login() || userInfo().username; export function dir(id: string): string { return join(root, id); } export function paths(id: string) { const base = dir(id); return { base, record: join(base, "channel.json"), storage: join(base, "channel.sqlite"), socket: join(base, "channel.sock"), pid: join(base, "worker.pid"), log: join(base, "worker.log"), lanes: join(base, "lanes"), }; } const WORDS = [ [ "amber", "brisk", "calm", "deft", "eager", "fair", "glad", "hazy", "keen", "lucid", "mellow", "nimble", ], [ "anchor", "beacon", "cedar", "delta", "ember", "fjord", "grove", "harbor", "ladder", "meadow", "orbit", "ridge", ], ]; function pick(words: string[]): string { return words[randomBytes(1)[0] % words.length]; } /** A new channel's record, not yet saved: a hosted channel saves it once the service has it. */ export function draft(project: string, model?: ModelRef, name?: string, hosted?: string): Listing { const names = new Set(list().flatMap((record) => [record.name, record.prefix])); const random = () => `${pick(WORDS[0])}-${pick(WORDS[1])}`; let chosen = name ? validateName(name) : random(); if (name && names.has(chosen)) throw new Error(`A channel named ${chosen} already exists`); while (names.has(chosen)) chosen = random(); const record: Listing = { version: 2, id: randomBytes(8).toString("hex"), name: chosen, prefix: chosen, named: !!name, owner: user, project, model, created: Date.now(), ...(hosted ? { hosted } : {}), }; return record; } export function create(project: string, model?: ModelRef, name?: string): Listing { return save(draft(project, model, name)); } export function save(record: Listing): Listing { const { base, record: file } = paths(record.id); mkdirSync(base, { recursive: true }); writeFileSync(file, JSON.stringify(record, null, "\t")); return record; } /** Whether this host runs the channel. Ids arrive from peers, so only well-formed ones reach the disk. */ export function owns(id: string): boolean { return /^[0-9a-f]{16}$/.test(id) && existsSync(paths(id).record); } export function read(id: string): Listing { const record = JSON.parse(readFileSync(paths(id).record, "utf8")) as & Omit