// M68: the daemon's watches as a module: who asked to be told once when a job is next idle or // has ended, their file, their expiry, and the notice. Ports are narrow; time is the Clock's. import { type Clock, systemClock } from "./clock.ts"; import { envelope, MAX_WATCHES, noticeBody, type Relation, WATCH_TTL_MS } from "./comms.ts"; import { OpError, str } from "./op.ts"; import type { RoostEvent } from "./events.ts"; import type { JobStateName } from "./protocol.ts"; /** A watch: tell `watcher` (a session key) once when job `id` is next idle, or has ended. */ export interface Watch { watcher: string; watcherName: string; /** The watching job, when a job watches (null: a foreground session). */ watcherId?: string | null; id: string; when: "idle" | "ended"; at: number; } /** The parts of a job's row a notice is made of. */ export interface WatchedRow { id: string; name: string; state: JobStateName; waitingFor: string | null; question: string | null; summary: string | null; error: string | null; } /** The sessions: the peer that asks (`P`: whatever the daemon calls a connected session) and its tree. */ export interface WatchesSessionsPort

{ /** The peer takes no agent items (a watch is told as one). */ refusesItems(peer: P): boolean; /** The peer's session key (throws what the daemon throws for a session-less peer). */ keyOf(peer: P): string; name(peer: P): string; /** The peer's job, when a job asks (null: a foreground session). */ jobId(peer: P): string | null; /** The session `to` (an id or a name) names, among those the peer reaches (throws when none, or many). */ find(peer: P, to: string): { key: string; id: string; role: string }; } export interface WatchesJobsPort { /** The job still has a record. */ exists(id: string): boolean; row(id: string): WatchedRow | undefined; /** What the watched job's session is to the watcher's. */ relation(watcherKey: string, jobId: string): Relation; /** Hand a notice to the session `key`; `reached`: it got it (a gone watcher is not told). */ deliver(key: string, text: string, meta: { from: string; relation: Relation }): Promise<{ outcome: string; reached: boolean }>; } /** The file the watches live in (the daemon's writes atomically under the roost's home). */ export interface WatchesStore { read(): unknown; write(v: unknown): void; } export interface WatchesDeps

{ sessions: WatchesSessionsPort

; jobs: WatchesJobsPort; store: WatchesStore; record(e: Omit): void; log(level: "info" | "warn" | "error", msg: string, extra?: Record): void; clock?: Clock; /** A watch that never fires expires after this (default WATCH_TTL_MS). */ ttlMs?: number; /** Watches one session keeps at a time (default MAX_WATCHES). */ maxPerWatcher?: number; } export interface WatchResult { id: string; name: string; when: Watch["when"]; fired: boolean; } export interface Watches

{ /** Loads the file: expired watches and ones on jobs that are gone are dropped. Call once the jobs are known. */ load(): void; /** The `watch` op of `peer`. */ add(peer: P, args: { id?: unknown; when?: unknown } | undefined): Promise; /** A job's row went out: if it is in a state watches fire on, tell each watcher once. */ stateChanged(row: WatchedRow): Promise; /** A job is being removed (call before its record goes): its watchers are told it ended. */ removed(id: string, name: string): Promise; /** The session `key` is held by nothing any more: its own watches go. */ watcherGone(key: string): void; /** Expires watches that never fired and drops ones on jobs that are gone (the daemon's reaper calls it). */ sweep(): void; } /** idle: it stopped working (done, waiting, failed, stopped); ended: it failed, was stopped, or is gone. */ export function watchFires(when: Watch["when"], state: JobStateName | "removed"): boolean { if (state === "removed") return true; if (when === "ended") return state === "failed" || state === "stopped"; return state !== "working"; } type Subject = WatchedRow | { id: string; name: string; state: "removed" }; export function createWatches

(deps: WatchesDeps

): Watches

{ const { sessions, jobs, store, record, log } = deps; const clock = deps.clock ?? systemClock; const ttl = deps.ttlMs ?? WATCH_TTL_MS; const maxPer = deps.maxPerWatcher ?? MAX_WATCHES; let watches: Watch[] = []; function load(): void { const saved = store.read(); if (!Array.isArray(saved)) return; const now = clock.now(); watches = (saved as Watch[]).filter((w) => w && str(w.watcher) && str(w.id) && (w.when === "idle" || w.when === "ended") && typeof w.at === "number" && now - w.at < ttl && jobs.exists(w.id)); } function save(): void { try { store.write(watches); } catch (err) { log("error", "watches not saved", { error: String(err) }); } } function sweep(): void { const now = clock.now(); const before = watches.length; watches = watches.filter((w) => now - w.at < ttl && jobs.exists(w.id)); if (watches.length !== before) save(); } /** Tells the watcher; the watch itself is already forgotten (or never kept). */ async function fire(w: Watch, job: Subject): Promise { const removed = job.state === "removed"; const row = removed ? undefined : (job as WatchedRow); const when: Watch["when"] = removed || row?.state === "failed" || row?.state === "stopped" ? "ended" : "idle"; const body = noticeBody({ when, state: job.state, waitingFor: row?.waitingFor ?? null, question: row?.question ?? null, summary: row?.summary ?? null, error: row?.error ?? null, }); const relation = jobs.relation(w.watcher, job.id); const text = envelope({ from: job.name, id: job.id, relation, kind: "notice", body }); try { const r = await jobs.deliver(w.watcher, text, { from: job.name, relation }); log("info", "watch fired", { id: job.id, watcher: w.watcher, when: w.when, outcome: r.outcome }); // a watcher that is gone (a closed pi, a stopped job) is not told: the notice is lost, as a message would be if (r.reached) record({ kind: "notice", id: job.id, name: job.name, who: w.watcherName, whoId: w.watcherId ?? null, text: when === "ended" ? "ended" : "is idle" }); } catch (err) { log("warn", "watch notice failed", { id: job.id, watcher: w.watcher, error: String(err) }); } } /** The job is in a state watches fire on: tell each watcher once, and forget the watch. */ async function fireAll(job: Subject): Promise { const due = watches.filter((w) => w.id === job.id && watchFires(w.when, job.state)); if (due.length === 0) return; watches = watches.filter((w) => !due.includes(w)); save(); for (const w of due) await fire(w, job); } async function add(peer: P, args: { id?: unknown; when?: unknown } | undefined): Promise { // M50: the notice is an agent item: an agent that takes none would never hear it if (sessions.refusesItems(peer)) throw new OpError("unsupported", "a watch is told as a notice: say agent-items in your hello to take one"); const to = typeof args?.id === "string" ? args.id.trim() : ""; const when = args?.when; if (!to) throw new OpError("bad_request", "id required"); if (when !== "idle" && when !== "ended") throw new OpError("bad_request", "when must be idle or ended"); const me = sessions.keyOf(peer); const target = sessions.find(peer, to); if (target.key === me) throw new OpError("bad_request", "a session cannot watch itself"); if (target.role !== "job") throw new OpError("unsupported", "a foreground session is not watched (it has no job state)"); const row = jobs.row(target.id); if (!row) throw new OpError("not_found", `no such job: ${target.id}`); const w: Watch = { watcher: me, watcherName: sessions.name(peer), watcherId: sessions.jobId(peer), id: row.id, when, at: clock.now() }; // already so: said at once, nothing kept if (watchFires(when, row.state)) { await fire(w, row); return { id: row.id, name: row.name, when, fired: true }; } const mine = watches.filter((x) => x.watcher === me); const same = mine.find((x) => x.id === row.id && x.when === when); if (same) watches = watches.filter((x) => x !== same); else if (mine.length >= maxPer) throw new OpError("full", `at most ${maxPer} watches at a time`); watches.push(w); save(); log("info", "watch", { id: row.id, watcher: me, when }); return { id: row.id, name: row.name, when, fired: false }; } return { load, add, stateChanged: (row) => fireAll(row), removed: (id, name) => fireAll({ id, name, state: "removed" }), watcherGone(key) { if (!watches.some((w) => w.watcher === key)) return; watches = watches.filter((w) => w.watcher !== key); save(); }, sweep, }; }