/** * On-disk rendezvous between `spectral serve` (the relay daemon) and whoever * launched it — today `spectral desktop`, tomorrow any supervisor. * * Two files, both under `getConfigDir()` (`~/.spectral`, or `SPECTRAL_CONFIG_DIR` * in tests/CI): * * serve.pid the daemon's own pid (single-instance lock) * serve-ready.json { pid, ts, version } written once the relay socket is open * * Why both: the pid file alone says "a process exists", not "the relay is * connected" — and the web app at aexol.ai/agent subscribes to the relay the * moment it loads, so opening the browser before the socket is up produces a * `machine_disconnected` on every start (the exact bug the removed Electron app * papered over with a 3-second `setTimeout`). `serve-ready.json` plus the * `SPECTRAL_SERVE_READY` stdout marker give the launcher a real signal. * * A stale pid file (SIGKILL, power loss, crash) is expected: every reader * sanity-checks liveness with `kill(pid, 0)` and removes dead entries, so a * crashed daemon never blocks the next start. * * Ownership is explicit: the pid file is a *claim*, taken atomically with * `O_EXCL` ({@link claimServePidFile}) and only ever rewritten or removed by * whoever holds it ({@link ownsServeState}). A launcher that merely *read* the * file must never treat it as its own, and a process that lost the claim must * not overwrite the state of the daemon that actually owns it. * * All helpers are synchronous and never throw — a daemon must not fail because * a bookkeeping file could not be written. */ /** Line `spectral serve` prints to stdout once the relay socket is open. */ export declare const SERVE_READY_MARKER = "SPECTRAL_SERVE_READY"; /** Serve log size threshold past which `spectral desktop` rotates the log. */ export declare const DEFAULT_SERVE_LOG_MAX_BYTES: number; /** Contents of `serve-ready.json`. */ export interface ServeReadyInfo { pid: number; /** Unix epoch millis at which the relay became ready. */ ts: number; /** CLI version that produced the ready signal. */ version: string; } export declare function getServePidFile(): string; export declare function getServeReadyFile(): string; /** * @param env env to read `SPECTRAL_SERVE_LOG_PATH` from. Defaults to * `process.env`, but a caller that holds a custom env (the desktop spawn * plan) must pass it — otherwise the wrapper watches one file while the * daemon logs into another. */ export declare function getServeLogFile(env?: NodeJS.ProcessEnv): string; /** * Is `pid` a live process? Uses signal 0, which performs error checking * without delivering a signal. `EPERM` counts as alive — the process exists, * it just belongs to somebody else. */ export declare function isProcessAlive(pid: number): boolean; /** * Genealogy verdicts for a live pid we did not spawn: * * - `ours` — provably a descendant of one of the given ancestors; * - `not-ours` — the parent chain was readable and reached init without * passing through an ancestor; * - `unknown` — parentage could not be read (Windows, no `ps`, transient * probe failure under load). * * The distinction matters: `unknown` is NOT "ours". A live pid nobody can * vouch for must never be classified as our daemon. */ export type ServeGenealogy = "ours" | "not-ours" | "unknown"; /** * Is `pid` a descendant of `ancestor`? Used to confirm that the pid file handed * over after a spawn really belongs to the process we launched: under `tsx` the * CLI re-execs, so the daemon is the *child* of the spawned pid. * * Best-effort: when parentage cannot be read (Windows, no `ps`, process gone) * it answers true, i.e. "unknown" is treated as ours — a launcher must never * block a legitimate start over an unverifiable genealogy. Callers that must * keep "ours" apart from "unreadable" (see {@link confirmServePidChild}) use the * strict tri-state verdict instead. */ export declare function isDescendantOf(pid: number, ancestor: number, maxDepth?: number): boolean; /** Write the pid file for the current (or given) daemon process. */ export declare function writeServePid(pid?: number): void; /** * Read a pid file WITHOUT a liveness check. Returns null when the file is * missing or is not a plain pid. The whole payload must be digits: `parseInt` * turns `"12abc"` into 12, and a launcher would then signal a process nobody * claimed. * * @param file defaults to {@link getServePidFile}; tests and alternative * layouts may point elsewhere. */ export declare function readServePidFile(file?: string): number | null; /** * Read the pid file WITH a liveness check. A stale entry (process gone) is * deleted and reported as "not running". */ export declare function getLiveServePid(): number | null; /** * Does `pid` hold the pid-file claim? Every cleanup and every ready-signal * write is gated on this, so a second daemon (or a wrapper that merely read * the file) can never delete or overwrite the state of the process that owns * it. */ export declare function ownsServeState(pid?: number): boolean; export type ServePidClaim = /** We hold the claim; the file currently records `placeholderPid`. */ { status: "claimed"; placeholderPid: number; } /** A live process holds it — reuse that daemon instead of spawning one. */ | { status: "busy"; pid: number; } /** The claim could not be taken (e.g. unwritable config dir). */ | { status: "failed"; error: string; }; /** * Atomically claim `serve.pid` BEFORE spawning anything. * * The naive version — read the file, ask "is it alive?", then spawn — races two * concurrent `spectral desktop` invocations into starting two daemons, because * both see "not running" before either writes. `open(O_EXCL)` folds the test * and the claim into one syscall: exactly one winner, everyone else gets * `busy`. * * The winner writes its *own* pid as a placeholder — a live pid, so a racer * that arrives microseconds later also sees `busy` — and hands it over with * {@link confirmServePidChild} once the spawn succeeded. */ export declare function claimServePidFile(placeholderPid?: number): ServePidClaim; export interface ServePidChildConfirmation { status: "child" | "daemon" | "foreign" | "unknown"; pid: number | null; } export interface ConfirmServePidChildOptions { /** * Injectable genealogy verdict for tests. The real probe walks `ps -o ppid=` * and can transiently fail under load, so tests pin the verdict to keep the * `daemon`/`foreign`/`unknown` classification deterministic. */ genealogy?: (pid: number, ancestors: number[]) => ServeGenealogy; } /** * After a successful spawn, confirm `serve.pid` records the daemon we launched. * * - `child` — it holds the spawned pid (the normal standalone/node case). * - `daemon` — the daemon already rewrote it with its own pid, which happens * when the CLI re-execs (`tsx` in dev). Accepted only when that pid is * provably our descendant. * - `foreign` — a live, unrelated process owns it: the caller must warn and * stop touching the state instead of hijacking it. * - `unknown` — the hand-off write did not land (read-only dir), or a live pid * this command did not spawn could not be verified as ours (unreadable * parentage); the caller must warn (unverified holder) but must never signal * it — readiness still works via the log marker and the ready file. */ export declare function confirmServePidChild(childPid: number, placeholderPid?: number, opts?: ConfirmServePidChildOptions): ServePidChildConfirmation; /** * Drop a claim taken by {@link claimServePidFile} when the start failed. Only * removes the files while they still record the placeholder or the child we * launched — never a foreign daemon's state. */ export declare function releaseServePidClaim(placeholderPid?: number, childPid?: number): void; /** Write the readiness marker next to the pid file. */ export declare function writeServeReady(version: string, pid?: number): ServeReadyInfo; /** Read `serve-ready.json`, or null when missing/corrupt. */ export declare function readServeReadyFile(file?: string): ServeReadyInfo | null; /** Remove both the pid file and the ready file. Idempotent. */ export declare function clearServeState(): void; /** Current size of a file, 0 when it does not exist yet. */ export declare function fileSizeOf(file: string): number; /** * Truncate a daemon log that outgrew {@link DEFAULT_SERVE_LOG_MAX_BYTES} by * moving it aside as `.old` (one generation kept — enough to debug the * previous start, without an unbounded log growing forever). * * Must run BEFORE the new daemon is spawned and before the caller snapshots the * log offset: after a rotate the fresh log starts at byte 0. * * @returns true when a rotation happened. */ export declare function rotateServeLogFile(file?: string, maxBytes?: number): boolean; /** * Append-mode read: return the bytes written to `file` since `offset` and the * new offset. The desktop launcher snapshots the log size before spawning so * the `SPECTRAL_SERVE_READY` marker of a *previous* run can never be mistaken * for the current one. * * The returned offset is always the offset to read from next time — including * when there is nothing new, and when the file shrank under us (a rotation). * A caller that only advanced on new text would stay pinned past end-of-file * and never see the next marker. */ export declare function readFromOffset(file: string, offset: number): { text: string; offset: number; }; export interface WaitForServeReadyOptions { /** Pid of the spawned serve process (used for the fast-fail path). */ pid: number; /** Log file to scan for {@link SERVE_READY_MARKER}. */ logFile?: string; /** Ready file to poll as fallback. Defaults to `serve-ready.json`. */ readyFile?: string; /** Pid file consulted to recognise a re-exec'd daemon as the signal owner. */ pidFile?: string; /** Byte offset in `logFile` to start scanning from. */ logOffset?: number; /** * Wall-clock millis captured BEFORE the daemon was spawned. A ready signal * older than this belongs to an earlier run and is rejected. Defaults to 0 * (no freshness requirement) for callers that cannot know the spawn time. */ sinceMs?: number; /** * True when the spawned child re-execs into the daemon (`tsx`/`ts-node` in * dev). This pid is then only the wrapper, so the `process-exited` quick-fail * is skipped and only the ready signal or the timeout decides. */ expectReExec?: boolean; /** Extra pids considered ours (tests, alternative supervisors). */ expectedPids?: number[]; timeoutMs?: number; intervalMs?: number; /** Injectable for tests. */ alive?: (pid: number) => boolean; } export type ServeReadyOutcome = "marker" | "ready-file" | "process-exited" | "timeout"; export interface ServeReadyResult { ok: boolean; reason: ServeReadyOutcome; waitedMs: number; /** Ready-file payload when a ready signal was observed. */ info?: ServeReadyInfo; /** Last chunk of daemon output, surfaced in error messages. */ logTail?: string; } /** * Wait for the daemon to announce readiness, by either signal: * - `SPECTRAL_SERVE_READY` appearing in the serve log after `logOffset`, or * - a live `serve-ready.json`. * * A signal only counts when it belongs to THIS launch: * - the pid it reports is the spawned pid, or the pid `serve.pid` currently * records (the daemon's own pid once it re-exec'd), and * - its timestamp is not older than `sinceMs`, and * - that pid is alive. * * The log marker carries no pid of its own, so whenever a ready file exists it * must pass the same check — a marker can never be trusted on its own while the * ready file proves the signal came from a foreign or stale daemon. * * Gives up early when the child dies (registration/auth/sqlite failures) so the * user sees the real error instead of waiting out the timeout, and always * returns (never throws) with the tail of the log for diagnostics. */ export declare function waitForServeReady(opts: WaitForServeReadyOptions): Promise; //# sourceMappingURL=serve-state.d.ts.map