/** * All calls of one app run (docs/DESIGN.md sections 1.5, 4.1, 4.5 and 6.2). * * - **One live call at a time.** A start while a call is starting, recording or paused answers * `409 already_recording`, naming that call (`already_recording`: id, title, workspace, start * and state); with `attach` it answers with that call instead, so an agent follows it. A call * that is stopping does not block a new start: its helper is killed within the stop budget * whatever happens, and two helpers may hold a tap at once. * - **Crash recovery at the next start.** `init()` closes what a previous run left open (see * `recovery.ts`) and indexes every call on disk. `start()` runs it first if nobody did. * - **`live` and `last`.** Every call reference is a call id, `live` or `last`. `live` with * nothing recording is `404 no_live_call` carrying the last call. `last` is refused with `400` * on the live controls (`stop`, `pause`, `resume`, `mute`, `unmute`), so a control can never land * on a finished call (TRAPS T3.14). `last` never names a failed start. * - **Move, trash, restore (PROGRAMMABILITY PG-A4).** A finished call's folder moves to another * workspace's folder, or to the trash, `/.trash//` with a * `.json` beside it saying when; `init()` purges what has been there 30 days, and * `emptyTrash()` purges all of it at once. A restore moves the folder back, untouched. A live or * busy call never moves. */ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { basename, join } from "node:path"; import type { LogEvent } from "../../core/log/events.ts"; import { type CallView, fold } from "../../core/log/fold.ts"; import { readLog } from "../../core/log/reader.ts"; import { EVENTS_FILE, type WriterOptions } from "../../core/log/writer.ts"; import { legacyLive } from "../asr/live-setups.ts"; import { type CaptureEngine, type Clock, realClock } from "../capture/engine.ts"; import type { IngestOptions, PartIngest } from "../capture/ingest.ts"; import type { Packet } from "../capture/protocol.ts"; import { renameReplacing } from "../vocab/files.ts"; import { CallController, type CaptureChoice, type StartOk } from "./call.ts"; import { checkWorkspace, createCallFolderAsync, ulid } from "./folder.ts"; import { type CallSummary, CHANGES, listCallDirs, type RecoveryAction, recoverCall, summarize, } from "./recovery.ts"; import { type CallBudgets, DEFAULT_BUDGETS, fail, type LiveBrief, type Outcome } from "./state.ts"; export interface CallManagerOptions { /** `~/Recordings/akou`, or a temporary folder in tests. */ root: string; engine: CaptureEngine; clock?: Clock; budgets?: Partial; /** The user's name, written into `call.created`. */ user?: string; /** IANA zone for folder names and `call.created.tz`. Defaults to the system zone. */ tz?: string; akouVersion?: string; /** Default capture choice. */ capture?: Partial; ingest?: Omit; writer?: WriterOptions; onEvent?(callId: string, e: LogEvent): void; onPacket?(callId: string, part: number, p: Packet, ingest: PartIngest): void; /** See `ControllerDeps.beforeEnd`: the live recognizer's flush before `call.ended`. */ beforeEnd?(callId: string): Promise; /** * Every controller once it exists, created or read from disk, before anyone reads its view: the * app sets the view's read-time vocabulary here. */ onOpen?(c: CallController): void; /** Tests: awaited inside a start's folder stage, where a slow disk makes it wait. */ folderStall?(): Promise | void; } export interface StartRequest { workspace?: string; title?: string; template?: string; mic?: string; call?: string; /** * Call-scoped words (attendees, title terms), written as `vocab.add` right after `call.created` * and before capture opens, so the first segment is already decoded with them (DESIGN 6.1). */ vocab?: readonly { term: string; heard?: readonly string[] }[]; /** Author of the `vocab.add` events. */ by?: string; /** * Record even though the speech models are not downloaded yet: audio only, nothing is transcribed * live. Without it a start answers `503 models_missing` (the app checks, not the manager). */ withoutModels?: boolean; /** * This call's live model (`asr.live`'s values, or `upgrade`, the old spelling of `nemotron` with * Qwen's second pass), instead of the setting. Kept on the call's controller (`liveAsked`) before * the helper spawns; the app reads it there when the call's audio first reaches the recognizer. */ live?: string; /** This call's second pass (`asr.review.model`'s values) and its interval, instead of the settings. */ review?: string; reviewEvery?: number; /** * This call's final-pass model (`asr.final.model`'s values: `auto`, a model id or `fusion`), * instead of the setting. Written into `call.created`, so a pass after a restart runs it too. */ final?: string; /** * An agent's start: when a call is already starting, recording or paused, start nothing and * answer with that call (`attached`) instead of `409 already_recording`. A call still starting * is answered once its capture opened, or with its failure. */ attach?: boolean; } /** A start's answer: the new call, or with `attach` the live call it found (`attached`). */ export type StartAnswer = StartOk & { attached?: LiveBrief }; export type CallRef = string; /** Controls that must never land on a finished call. */ export const LIVE_CONTROLS = ["stop", "pause", "resume", "mute", "unmute"] as const; /** The trash under the recordings root: not a workspace name, so no list ever shows it. */ export const TRASH_DIR = ".trash"; /** How long a trashed call is kept before `init()` deletes it. */ export const TRASH_DAYS = 30; /** What the trash keeps beside a trashed call's folder. */ export interface Trashed { id: string; workspace: string; trashedAt: number; } export class CallManager { private readonly clock: Clock; private readonly budgets: CallBudgets; private readonly controllers = new Map(); /** Calls being read from disk for a restart, so two restarts of one call share one controller. */ private readonly loading = new Map>(); private readonly index = new Map(); /** The start or reopen of a call still `starting`, so an attach waits for it to open. */ private readonly opening = new Map>(); /** * A start's folder stage while it waits on the disk. Every check of the live call waits for it * first (`folderSettled`), so the call it is making counts as live, as it did when the stage held * the event loop (docs/TRAPS.md #491). */ private folderStage: Promise | null = null; private warm = false; private initP: Promise | null = null; /** Folders recovery could not read. They are skipped, never fatal. */ readonly recoveryErrors: { dir: string; error: string }[] = []; constructor(private readonly o: CallManagerOptions) { this.clock = o.clock ?? realClock; this.budgets = { ...DEFAULT_BUDGETS, ...o.budgets }; } /** Recovers crashed calls and indexes the root. Runs once; later calls share the result. */ init(): Promise { this.initP ??= this.runInit(); return this.initP; } private async runInit(): Promise { this.purgeTrash(); const actions: RecoveryAction[] = []; for (const { dir, workspace } of listCallDirs(this.o.root)) { if ([...this.controllers.values()].some((c) => c.dir === dir)) continue; // One unreadable folder must never stop a start: skip it and say so. try { // Each log is read and folded once: recovery and the index share it. A folder recovery // appended to is read again, so its summary is what the writer committed. const { events } = await readLog(join(dir, EVENTS_FILE)); const view = fold(events); const a = await recoverCall( dir, { now: () => this.clock.now(), abandonAfterMs: this.budgets.abandonAfterMs, writerOptions: this.o.writer, }, { events, view }, ); if (a) { actions.push(a); await this.reindex(dir, workspace); } else { const s = summarize(dir, workspace, events, view); if (s) this.index.set(s.id, s); } } catch (err) { this.recoveryErrors.push({ dir, error: (err as Error).message }); } } return actions; } private async reindex(dir: string, workspace: string): Promise { const { events } = await readLog(join(dir, EVENTS_FILE)); const s = summarize(dir, workspace, events); if (s) this.index.set(s.id, s); } private deps(workspace: string) { return { engine: this.o.engine, clock: this.clock, budgets: this.budgets, ingest: this.o.ingest ?? {}, writer: this.o.writer, isWarm: () => this.warm, markWarm: () => { this.warm = true; }, onEvent: (id: string, e: LogEvent) => { this.onEvent(id, workspace, e); this.o.onEvent?.(id, e); }, onPacket: this.o.onPacket, beforeEnd: this.o.beforeEnd, }; } private onEvent(id: string, workspace: string, e: LogEvent): void { const c = this.controllers.get(id); if (!c) return; const prev = this.index.get(id); const live = c.view.live || c.live; // The end time moves only with an event that ends audio, as `summarize` computes it, so a name // or a final-pass line written later never changes it. const ends = e.type === "call.ended" || e.type === "call.failed" || e.type === "part.ended"; this.index.set(id, { id, dir: c.dir, workspace, title: c.view.call?.title ?? prev?.title ?? "", createdAt: c.view.call?.t ?? prev?.createdAt ?? e.t, state: c.view.state, endedAt: live ? null : ends ? e.t : (prev?.endedAt ?? e.t), parts: c.view.parts().length, updatedAt: CHANGES.has(e.type) ? Math.max(e.t, prev?.updatedAt ?? 0) : (prev?.updatedAt ?? c.view.call?.t ?? e.t), }); } /** * Waits until no start is in its folder stage. Callers await it only while one is, so a control * with no start under way still acts before it returns its promise, as it always did. */ private async folderSettled(): Promise { while (this.folderStage) await this.folderStage; } /** The live call: starting, recording or paused. */ live(): CallController | null { for (const c of this.controllers.values()) if (c.live) return c; return null; } /** Every known call, newest first. */ calls(opts: { failed?: boolean } = {}): CallSummary[] { const all = [...this.index.values()].sort((a, b) => b.createdAt - a.createdAt); return opts.failed ? all.filter((c) => c.state === "failed") : all.filter((c) => c.state !== "failed"); } /** * Every workspace: each folder under the root with a workspace's name, empty ones included, and * each workspace a known call is in. By name, with how many calls each holds (failed starts left * out, as `calls()` leaves them out). */ workspaces(): { name: string; calls: number }[] { const folders = this.folders(); const count = new Map(folders.map((f) => [f, 0])); for (const c of this.calls()) { const name = this.spelling(c.workspace, folders); count.set(name, (count.get(name) ?? 0) + 1); } return [...count] .map(([name, calls]) => ({ name, calls })) .sort((a, b) => a.name.localeCompare(b.name)); } /** * Makes a workspace's folder, so an empty workspace survives a restart. One that exists already * is left as it is (`created: false`), under the spelling its folder has; the name follows * `checkWorkspace`. */ addWorkspace(name: string): Outcome<{ workspace: string; created: boolean }> { const bad = checkWorkspace(name); if (bad) return fail(400, "bad_workspace", bad); const dir = join(this.o.root, name); const had = existsSync(dir); if (had && !statSync(dir).isDirectory()) { return fail( 409, "workspace_not_folder", `"${name}" exists in the recordings folder and is not a folder`, ); } mkdirSync(dir, { recursive: true }); return { ok: true, workspace: had ? this.spelling(name, this.folders()) : name, created: !had }; } /** The folders under the root that are workspaces. */ private folders(): string[] { if (!existsSync(this.o.root) || !statSync(this.o.root).isDirectory()) return []; return readdirSync(this.o.root, { withFileTypes: true }) .filter((d) => d.isDirectory() && checkWorkspace(d.name) === null) .map((d) => d.name); } /** * The folder a workspace name lands in. A disk that ignores case (a Mac's, by default) puts * `work` into an existing `Work`: that is one workspace, under the folder's spelling. A disk * that keeps case finds no `work` there, so the two stay apart. */ private spelling(name: string, folders: readonly string[]): string { if (folders.includes(name) || !existsSync(join(this.o.root, name))) return name; return folders.find((f) => f.toLowerCase() === name.toLowerCase()) ?? name; } private lastSummary(): CallSummary | null { return this.calls()[0] ?? null; } /** * Resolves `live`, `last` or an id. `control` is true for the live controls, which refuse * `last`. */ resolve(ref: CallRef, opts: { control?: boolean } = {}): Outcome<{ id: string }> { if (ref === "live") { const c = this.live(); if (c) return { ok: true, id: c.id }; const last = this.lastSummary(); return { ...fail(404, "no_live_call", "nothing is recording"), last: last ? { id: last.id, title: last.title, endedAt: last.endedAt } : null, }; } if (ref === "last") { if (opts.control) { return fail( 400, "last_refused", "`last` is refused on live controls; name the call or use `live`", ); } const last = this.lastSummary(); return last ? { ok: true, id: last.id } : fail(404, "no_calls", "there are no calls yet"); } if (this.index.has(ref) || this.controllers.has(ref)) return { ok: true, id: ref }; return fail(404, "not_found", `no call ${ref}`, { call: ref }); } view(ref: CallRef): CallView | null { const r = this.resolve(ref); if (!r.ok) return null; return this.controllers.get(r.id)?.view ?? null; } /** Every controller in memory: the live call and the calls opened this run. */ opened(): CallController[] { return [...this.controllers.values()]; } controller(id: string): CallController | undefined { return this.controllers.get(id); } // ------------------------------------------------------------------------- // Start /** * `POST /calls`. Creates the folder and the log, spawns the helper, and answers once it * reports `capturing`. */ async start(req: StartRequest = {}): Promise> { await this.init(); // Inline, not `folderSettled()`: from the last look at the stage to its reservation below // nothing awaits, so two starts cannot both pass the check. A later start waits for the stage, // then finds this call live. while (this.folderStage) await this.folderStage; const live = this.live(); if (live) { if (req.attach) return this.attachTo(live); return fail(409, "already_recording", "a call is already recording", { call: live.id, already_recording: liveBrief(live), }); } const workspace = req.workspace ?? "default"; const bad = checkWorkspace(workspace); if (bad) return fail(400, "bad_workspace", bad); const title = req.title?.trim() || "Call"; const tz = this.o.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone; const now = this.clock.now(); const id = ulid(now); const capture: CaptureChoice = { mic: req.mic ?? this.o.capture?.mic ?? "default", call: req.call ?? this.o.capture?.call ?? "system", excludeResponsible: this.o.capture?.excludeResponsible, }; let release = () => {}; this.folderStage = new Promise((r) => { release = r; }); try { let c: CallController; try { // Stage "folder": the folder, the lock and call.created, off the event loop. Nothing is // spawned yet. await this.o.folderStall?.(); const dir = await createCallFolderAsync(this.o.root, workspace, now, tz, title); c = await CallController.create( dir, { type: "call.created", id, schema: 1, workspace, title, tz, user: this.o.user ?? "", akou: this.o.akouVersion ?? "0.0.0", ...(req.template ? { template: req.template } : {}), ...(req.final ? { final: req.final } : {}), }, this.deps(workspace), capture, ); } catch (err) { return fail( 503, "capture_failed", `cannot create the call folder: ${(err as Error).message}`, { stage: "folder", }, ); } const asked = legacyLive(req.live, req.review); c.liveAsked = asked.live; c.reviewAsked = asked.review === undefined && req.reviewEvery === undefined ? undefined : { ...(asked.review === undefined ? {} : { model: asked.review }), ...(req.reviewEvery === undefined ? {} : { everySeconds: req.reviewEvery }), }; this.controllers.set(id, c); this.o.onOpen?.(c); // `call.created`, sent now that the call is known, in the same run as `begin` makes it // `starting`: the live recognizer starts loading on a microtask after it (#378). const created = c.view.call as LogEvent; this.onEvent(id, workspace, created); this.o.onEvent?.(id, created); (req.vocab ?? []).forEach((v, i) => { c.record({ type: "vocab.add", id: `v${String(i + 1).padStart(4, "0")}`, rev: 1, term: v.term, heard: [...(v.heard ?? [])], by: req.by ?? "user", }); }); // `begin` makes the call `starting` before its first await, so it is live once the stage // ends. return this.track(id, c.begin()); } finally { this.folderStage = null; release(); } } /** * The live call for an attach, or null when none is live: a start with `attach` that finds one * answers before any start check (models, vocabulary), since it starts nothing. */ async attachLive(): Promise | null> { await this.init(); if (this.folderStage) await this.folderSettled(); const live = this.live(); return live ? this.attachTo(live) : null; } /** * The live call, handed back to an attach. A call still starting is answered once its capture * opened, so "attached" means audio is being written, as it does for a start; a start that then * fails answers with that failure. */ private async attachTo(live: CallController): Promise> { const opening = live.status === "starting" ? this.opening.get(live.id) : undefined; if (opening) { const r = await opening; if (!r.ok) return r; } return { ok: true, call: live.id, folder: live.dir, part: live.view.parts().at(-1)?.part ?? 1, startMs: 0, attached: liveBrief(live), }; } /** Remembers a call's start or reopen while it runs, for `attachTo`. */ private track(id: string, p: Promise): Promise { this.opening.set(id, p); const clear = () => { if (this.opening.get(id) === p) this.opening.delete(id); }; p.then(clear, clear); return p; } // ------------------------------------------------------------------------- // Controls private async control(ref: CallRef, name: (typeof LIVE_CONTROLS)[number]): Promise { if (this.folderStage) await this.folderSettled(); const r = this.resolve(ref, { control: true }); if (!r.ok) return r; const c = this.controllers.get(r.id); if (!c?.live) return fail(409, "not_live", "that call is not recording", { call: r.id }); switch (name) { case "stop": return c.stop(); case "pause": return c.pause(); case "resume": return c.resume(); case "mute": return c.mute(true); case "unmute": return c.mute(false); } } stop(ref: CallRef = "live"): Promise { return this.control(ref, "stop"); } pause(ref: CallRef = "live"): Promise { return this.control(ref, "pause"); } resume(ref: CallRef = "live"): Promise { return this.control(ref, "resume"); } mute(ref: CallRef = "live"): Promise { return this.control(ref, "mute"); } unmute(ref: CallRef = "live"): Promise { return this.control(ref, "unmute"); } /** A new part in the same call. `last` is accepted here (DESIGN 6.2). */ async restart( ref: CallRef = "live", opts: { force?: boolean } = {}, ): Promise> { await this.init(); if (this.folderStage) await this.folderSettled(); const r = this.resolve(ref); if (!r.ok) return r; let c = this.controllers.get(r.id); // An interrupt still flushing shows `interrupted`, which is not live, so a start can take over // while it flushes; wait for it here, before the look for another live call (LG-3). A start // that reached its folder stage meanwhile counts as live once that stage settles. if (c) { await c.settled(); if (this.folderStage) await this.folderSettled(); } if (!c?.live) { const other = this.live(); if (other && other.id !== r.id) { return fail(409, "already_recording", "another call is recording", { call: other.id, already_recording: liveBrief(other), }); } } if (!c) { // Two restarts at once (the window and an agent) share one load and one controller; the // second then finds the call starting and is refused, never a second helper. const loaded = await this.open(r.id); if (!loaded) return fail(404, "not_found", `no call ${r.id}`, { call: r.id }); c = loaded; const other = this.live(); if (other && other !== c) { return fail(409, "already_recording", "another call is recording", { call: other.id, already_recording: liveBrief(other), }); } } return this.track(c.id, c.restart(opts)); } /** * The controller of a call, loading a finished call from disk if this run has not touched it, so * a name, a note or a restart on an older call goes through that call's one writer. Concurrent * opens of one call share one load. Null for an unknown id. */ async open(id: string): Promise { await this.init(); const known = this.controllers.get(id); if (known) return known; const s = this.index.get(id); if (!s) return null; let p = this.loading.get(id); if (!p) { const capture: CaptureChoice = { mic: this.o.capture?.mic ?? "default", call: this.o.capture?.call ?? "system", excludeResponsible: this.o.capture?.excludeResponsible, }; p = CallController.load(s.dir, this.deps(s.workspace), capture).then((loaded) => { const existing = this.controllers.get(loaded.id); if (existing) return existing; this.controllers.set(loaded.id, loaded); this.o.onOpen?.(loaded); return loaded; }); const done = () => this.loading.delete(id); p.then(done, done); this.loading.set(id, p); } return p; } /** Indexes a call folder written while the app runs (an import), so every route finds it. */ async adopt(dir: string, workspace: string): Promise { await this.init(); await this.reindex(dir, workspace); } // ------------------------------------------------------------------------- // Move, trash, restore (PG-A4) /** * Moves a finished call's folder out of the way of every reader: to `to` (another workspace), * or to the trash (`null`). The controller in memory is dropped, so the next open reads the * folder where it now is. Refuses a live call (409 `live_call`) and one still busy (409 `busy`). */ private async relocate( id: string, to: { workspace: string } | null, now: number, ): Promise> { await this.init(); if (this.folderStage) await this.folderSettled(); const s = this.index.get(id); if (!s) return fail(404, "not_found", `no call ${id}`); const c = this.controllers.get(id); if (c?.live || this.live()?.id === id) { return fail(409, "live_call", "the call is recording; stop it first", { call: id }); } if (c?.busy() || this.loading.has(id)) { return fail(409, "busy", "the call is still being written; try again in a moment", { call: id, }); } const parent = to ? join(this.o.root, to.workspace) : join(this.o.root, TRASH_DIR, s.workspace); const dir = join(parent, basename(s.dir)); // Already in that workspace: nothing moves. if (dir === s.dir) return { ok: true, dir, from: s.dir }; if (existsSync(dir)) { return fail(409, "folder_taken", `${dir} already exists`, { call: id }); } mkdirSync(parent, { recursive: true }); try { // On Windows a folder does not move while a reader (the start's final catch-up, a GET) has // a file in it open; that lasts a moment, so the rename is retried as a file replace is. await renameReplacing(s.dir, dir); } catch (err) { return fail(409, "busy", `the call's folder cannot move now: ${(err as Error).message}`, { call: id, }); } this.controllers.delete(id); if (to) this.index.set(id, { ...s, dir, workspace: to.workspace }); else { this.index.delete(id); const t: Trashed = { id, workspace: s.workspace, trashedAt: now }; writeFileSync(`${dir}.json`, `${JSON.stringify(t)}\n`); } return { ok: true, dir, from: s.dir }; } /** Moves a finished call's folder into another workspace's folder. */ move(id: string, workspace: string): Promise> { const bad = checkWorkspace(workspace); if (bad) return Promise.resolve(fail(400, "bad_workspace", bad)); return this.relocate(id, { workspace: this.spelling(workspace, this.folders()) }, 0); } /** Moves a finished call to the trash: no list shows it until it is restored. */ trash(id: string): Promise> { return this.relocate(id, null, this.clock.now()); } /** The trashed calls, each with its folder in the trash. */ private trashed(): (Trashed & { dir: string })[] { const root = join(this.o.root, TRASH_DIR); if (!existsSync(root)) return []; const out: (Trashed & { dir: string })[] = []; for (const ws of readdirSync(root, { withFileTypes: true })) { if (!ws.isDirectory()) continue; for (const f of readdirSync(join(root, ws.name))) { if (!f.endsWith(".json")) continue; const dir = join(root, ws.name, f.slice(0, -".json".length)); try { const t = JSON.parse(readFileSync(`${dir}.json`, "utf8")) as Trashed; if (typeof t.id === "string" && existsSync(dir)) out.push({ ...t, dir }); } catch {} } } return out; } /** Brings a trashed call back to the workspace it was in, exactly as it was. */ async restore(id: string): Promise> { await this.init(); if (this.index.has(id)) return fail(409, "not_trashed", `call ${id} is not in the trash`); const t = this.trashed().find((x) => x.id === id); if (!t) return fail(404, "not_found", `no call ${id} in the trash`); const dir = join(this.o.root, t.workspace, basename(t.dir)); if (existsSync(dir)) return fail(409, "folder_taken", `${dir} already exists`, { call: id }); mkdirSync(join(this.o.root, t.workspace), { recursive: true }); await renameReplacing(t.dir, dir); rmSync(`${t.dir}.json`, { force: true }); await this.reindex(dir, t.workspace); return { ok: true, dir, workspace: t.workspace }; } /** What is in the trash now, newest first: each call's id, its workspace and when it went. */ async trashList(): Promise { await this.init(); return this.trashed() .map(({ id, workspace, trashedAt }) => ({ id, workspace, trashedAt })) .sort((a, b) => b.trashedAt - a.trashedAt); } /** * Empty trash: deletes every call in the trash for good, now, through the same purge the start * runs after 30 days. With `upTo`, only calls trashed at that time or before go: the user was * asked about those, and a call trashed since then stays. Only folders under `.trash/` are * touched. Returns how many went. */ async emptyTrash(upTo = Number.POSITIVE_INFINITY): Promise { await this.init(); return this.purgeTrash(upTo); } /** Deletes what has been in the trash since `cutoff` or before (`TRASH_DAYS` days by default). */ private purgeTrash(cutoff = this.clock.now() - TRASH_DAYS * 24 * 3600_000): number { let n = 0; for (const t of this.trashed()) { if (t.trashedAt > cutoff) continue; try { rmSync(t.dir, { recursive: true, force: true }); rmSync(`${t.dir}.json`, { force: true }); n++; } catch {} } return n; } /** The folder and workspace of a known call, loaded or not. */ summary(id: string): CallSummary | undefined { return this.index.get(id); } /** The quit path: stop the live call within the stop budget, then settle everything. */ async quit(): Promise { if (this.folderStage) await this.folderSettled(); const c = this.live(); if (c) await c.stop(); await Promise.all([...this.controllers.values()].map((x) => x.idle())); } } /** The live call as a refused start names it, and as an attach hands it back. */ export function liveBrief(c: CallController): LiveBrief { const call = c.view.call; return { id: c.id, title: call?.title ?? "", workspace: call?.workspace ?? "", startedAt: c.view.parts()[0]?.wallStart ?? call?.t ?? 0, state: c.status, callMode: c.view.parts().at(-1)?.call.mode ?? c.capture.call, }; }