import { type Orchestrator, type OrchestratorCreateResult, type OrchestratorStats } from '@serfab/cadre-provider'; import { type HostProcessConfig, type HostedSpawnRequest, type ManagedNodeInfo, type NodePorts, type NodeStateListener } from './types.js'; /** * The libp2p listen addresses a managed child binds — its `CADRE_LISTEN_ADDRS`, one * entry per element: TCP on the `p2p` port and WebSocket on the `ws` port, both on * every interface. * * The WebSocket entry is what lets a phone reach the child. A phone's node carries * WebSocket, circuit-relay and WebRTC transports but no TCP * (`reference-app-rn/src/phone-node-config.ts`), and it listens on nothing, so the * phone has to be the one that dials. Naming a `/ws` address is all the child needs: * cadre-core adds the WebSocket transport for any listen entry that names one * (`resolveTransportOptions` in `cadre-core/src/relay-addrs.ts`). */ export declare function childListenAddrs(ports: Pick): string[]; /** * Runs cadre nodes as plain Node child processes on the host. * * Each container gets its own workdir under `rootDir`. Handles are mirrored * to `/state.json` so cadre-host can re-attach after a restart. * * ## Child stdio: direct-fd inheritance * * Spawned children inherit a file descriptor opened on `/node.log` * directly as their stdout and stderr. The orchestrator closes its own copy * of the fd immediately after spawn, so the child's write path no longer * touches any handle owned by this process. This is what lets children * survive — and keep logging through — an orchestrator restart. Were the * orchestrator instead piping stdout/stderr through an in-process Writable, * a parent exit would deliver SIGPIPE to the child (POSIX) or fail its * writes with EPIPE (Windows), silently breaking the documented "init() * re-attach" flow. * * ## Log rotation: spawn-time only * * Because the child holds a stable fd open against the inode, the * orchestrator cannot safely rename `node.log` while the child is running. * Rotation therefore happens only at spawn time, when no live child is * attached to the workdir (`createContainer` is the single producer per * workdir). If `node.log` already exceeds `logMaxBytes`, the rotation * cascade runs against the on-disk paths before the fresh fd is opened. * A child whose log grows past `logMaxBytes` mid-run will keep appending * to the active file until it (or its successor) is restarted; v1 traffic * is low enough that this is acceptable. */ export declare class HostProcessOrchestrator implements Orchestrator { private readonly cfg; private readonly rootDir; private readonly portAllocator; private readonly stateStore; private readonly stopTimeoutMs; private readonly logMaxBytes; private readonly logMaxFiles; private readonly defaultMemoryLimit?; private readonly handles; private readonly cliEntrypoint; /** Resolves push credentials fresh on every spawn (see HostProcessConfig). */ private readonly pushResolver?; /** The public addresses a node announces, asked at every spawn (see HostProcessConfig). */ private readonly announceAddrs?; /** State-change listeners — invoked when a handle's alive state changes. */ private readonly stateListeners; constructor(cfg: HostProcessConfig); /** * Read persisted state and re-attach to surviving children. Not part of * `Orchestrator` — call once before handing the instance to a container * service. */ init(): Promise; /** * Snapshot of currently-known managed nodes. Used by the local UI's * `/api/nodes` route. */ listNodes(): ManagedNodeInfo[]; /** * Look up a single managed node by either containerId (friendly id) or * dockerId (opaque `pid:token`). Returns undefined when neither matches. */ getNode(idOrDockerId: string): ManagedNodeInfo | undefined; /** * Resolve the opaque dockerId for a friendly containerId so callers can * pass it into stopContainer / getLogs / getStats. */ resolveDockerId(idOrDockerId: string): string | undefined; /** * Register a state-change listener. Invoked when a handle's `alive` * transitions (create, child exit, manual stop) and once more, as `stopped`, * when a handle is removed. Returns an unsubscribe fn. */ onStateChange(listener: NodeStateListener): () => void; /** * Spawn a managed node — a hosted node for some cadre, started waiting to be * claimed when the request carries a `claimSecret`, or redeeming the cadre * invitation it carries as `invitation`. The node is given its own * protobuf identity key inside its workdir (`ensureNodeIdentity`, reused across * re-spawns of the same containerId), which is what makes its peer id stable * across restarts AND what makes its node-local stores durable: `cadre-cli * start` opens the file-backed bootstrap-peer, trusted-owner and strand * network-state stores in the node's state directory, which defaults to the * directory holding the `cadre.json` written here — the same workdir. * Removing the node (`removeContainer`) deletes the workdir, so the key and * every store go with it. */ createContainer(request: HostedSpawnRequest): Promise; /** * Remove the working directory a spawn created for `containerId` when no * handle owns it — the cleanup of last resort for a hosted-node record that * never got a `dockerId` (the host died between `ensureNodeIdentity` and the * handle write, so `createContainer`'s own unwind never ran). Returns whether * anything was removed. * * Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs * to await. */ reclaimWorkdir(containerId: string): boolean; /** * Best-effort removal of a workdir no handle owns. Logs, never throws: every * caller is either unwinding a failure it must report instead of this one, or * tearing a container down along a path where a surviving directory is a * nuisance rather than a fault. * * `retries` sizes the wait for the OS to release a cwd handle — Windows drops * it asynchronously after a child exits. `createContainer`'s unwind needs none * of that (its throw surface ends before any child is spawned), but * `reclaimWorkdir` can still meet an orphan process the previous host left * running with this directory as its cwd, so it is not a *guaranteed* * no-process path either — it simply logs and leaves the directory when the * removal fails. */ private discardWorkdir; /** * Resolve the push credentials to inject into the node about to spawn. Called * on every spawn so a re-spawn re-reads the secret store — raw private keys are * never persisted. A resolver failure (e.g. a partial credential set) is * logged and degrades to "no push" rather than failing the spawn: the node * stays up and reachable, just without platform push-wake. */ private resolvePush; /** * The public addresses the node about to spawn announces. A hook that throws costs * the node its public addresses, never its start: it still binds and is reachable * on its LAN addresses, and `NatService` restarts it once the addresses it should * announce differ from these. */ private announceAddrsFor; /** * Core spawn mechanics behind `createContainer`. * * The caller has already allocated `ports` and **owns their lifetime**: this * method never releases them, so the caller must release on any throw out * of here (it has to unwind its handle drop on the same path anyway — see * `restoreDroppedHandles`). * * That contract holds because this method throws only *before* the child is * spawned and its handle stored — everything after that point is * best-effort. Anything new added at the tail must keep it that way. * * Entirely synchronous, which is what makes that unwind sound. Do not add an * `await` here. */ private launchChild; /** * Refuse to re-spawn `containerId` while a previous child of it may still be * running. `createContainer` calls this as its last `await` before the handle * drop, so a refusal happens while nothing has been released, no token rotated * and no child launched — the caller sees an ordinary failed spawn. * * Why it exists: the drop hands the old handle's ports back and the launch takes * those same ports (`reusedNodePorts`), so a child started over a live one dies * on a port clash — but only after `launchChild` has returned success. The * caller then records a dead child, and the live one keeps running with no * handle naming it. * * - A handle this process spawned is checked exactly ({@link isHandleLive}); a * live one refuses outright. * - A handle re-attached from `state.json` whose pid is alive but whose token * does not match is either our child still starting or an unrelated process * that inherited the pid. The ports decide: if any the re-spawn would take back * is already bound, refuse. The pid is never killed on that evidence — it may * not be ours. * * NOTE: the probe cannot see a re-attached child still loading its modules — * `cadre-cli start` writes its token and only then binds, so a child in that * window shows neither, and a re-spawn launched then races it for the ports. * Reachable only when the host restarts within a child's first seconds. If it * is ever seen, treat a re-attached handle with a live pid as starting for a * grace period after its `spawnedAt`. * * NOTE: the probe holds each port for an instant, so a child binding that port * in the same instant fails. It would have had to write its token between the * token check above and the probe, against the several awaited steps * `cadre-cli start` takes between writing its token and binding; if a re-attached * child is ever seen dying on its own port right after a host restart, probe by * connecting instead of binding. */ private refuseRespawnOverLiveChild; /** Every handle for `containerId`, in map order — the order `dropStaleHandle` drops them in. */ private handlesFor; /** * Drop any handle left over from a previous spawn of the same `containerId` * and release its ports. Handles are keyed by the per-spawn `dockerId`, so * without this a re-spawn (a hosted node's respawn) would strand the prior * handle in the map forever, leaking its ports from a bounded range each * time. * * DO NOT delete the workdir: the identity key and node-local stores * (trusted-owner anchor, retained cold-start dial targets) live there and are * the whole reason a respawn comes back as the same node. `launchChild` * reuses the same workdir. * * Releasing the ports hands them straight back to the allocator, and the * re-spawn then takes those same ports as overrides (`reusedNodePorts`), so a * re-spawn while the *previous* child is still listening would bind-clash. * The caller must therefore never drop a live child, and does so only after * {@link refuseRespawnOverLiveChild} has passed. A caller that needs to replace * a live child must stop it first. * * **The drop is reversible.** It happens before the launch can fail, so the * dropped handles are returned and the caller restores them via * {@link restoreDroppedHandles} when the launch throws — otherwise the caller * would be left holding a dockerId this orchestrator no longer knows, and the * node's workdir could never be reclaimed. On a *successful* spawn the drop * stands: `HostedNodeService.abandonRespawn` depends on the old handle being * gone by then. */ private dropStaleHandle; /** * Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each * handle back in the map and re-reserve its ports, leaving host state * exactly as the failed attempt found it. * * **Precondition: the drop → launch window is synchronous.** Restoring is * sound only because nothing can have taken those ports or run cleanup * against the handle in between — `launchChild` contains no `await` and the * caller resolves push credentials *before* its drop. `launchChild`'s half * is enforced: its declared return type is not a Promise, so adding an * `await` there is a compile error. The caller's half is not — an `await` * slipped between a drop and its launch silently breaks this. * * No `persist()` here: `state.json` is rewritten only by `launchChild` and * only *after* the new handle is stored, so throughout the failure window the * on-disk state still lists the dropped handle — this restore realigns memory * to disk rather than diverging from it. * * NOTE: a restored handle keeps `alive: true` even if its child exited while * it was out of the map (`launchChild`'s exit listener looks the handle up by * dockerId and finds nothing). Only reachable by dropping a handle whose child * is still live, which {@link refuseRespawnOverLiveChild} refuses before any * drop. (Its start-up blind spot concerns re-attached handles only, and those * have no exit listener to miss.) */ private restoreDroppedHandles; /** A managed node's own directory under `rootDir` — config, log, storage, identity. */ private workdirFor; /** * Inverse of {@link releasePorts} — hold a handle's whole port set against * the allocator. Used both to rehydrate on `init` and to undo a drop whose * launch then failed. */ private reservePorts; private releasePorts; stopContainer(dockerId: string): Promise; removeContainer(dockerId: string): Promise; /** * Whether the child `dockerId` names is running — see {@link isHandleLive}. An * id with no handle is not running: without the handle's workdir there is no * token to verify a live pid against. */ isRunning(dockerId: string): Promise; getStats(dockerId: string): Promise; getLogs(dockerId: string, tail?: number): Promise; private requireHandle; private buildChildConfig; private persist; private emitStateChange; private toPersisted; }