/** Scoped runtime context exposed to each Source kind, isolating Sources from direct Store or Engine access. */ import type { EngineForSources, ReceiveResult, Trigger } from '../core/index.ts' import type { Deployment } from '../deployment.ts' import type { Commands } from '../infra/commands.ts' import type { SetupProblem } from '../setup.ts' import type { SourceContext } from '../sources/host.ts' import type { SourceRegistry } from '../sources/registry.ts' import type { Outbox } from './outbox.ts' import type { Store } from './store.ts' export interface SourceContextDeps { readonly store: Pick readonly outbox: Pick readonly engine: Pick readonly commands: Commands readonly sources: SourceRegistry /** Throws when accessed before initial setup completes. */ readonly deployment: Deployment readonly ready: Promise configured(): boolean /** Setup problem whose `place` matches the Source kind receives it on `context.problem`. */ readonly setupProblem?: SetupProblem | undefined /** Why setup found no Feishu bot even though the Feishu Source names a CLI. */ readonly feishuProblem?: SetupProblem | undefined /** Hands a Trigger to the Engine and schedules the pass that assesses it. */ receive(trigger: Trigger): ReceiveResult | Promise } /** * Builds the context for one Source kind. Health reports are restricted to entries declared by the kind and * bound to the active registration, becoming no-ops once unregistered. */ export function sourceContextOf(deps: SourceContextDeps, kind: { kind: string; health: readonly string[] }): SourceContext { const { store } = deps const health = deps.sources.healthEntries(kind.health) return { get deployment() { return deps.deployment }, commands: deps.commands, ready: deps.ready, configured: () => deps.configured(), get problem() { return [deps.feishuProblem, deps.setupProblem].find(problem => problem?.place === kind.kind) }, config: >(id: string) => { const instance = deps.sources.instance(id) return instance?.enabled ? instance.config : undefined }, receive: trigger => deps.receive(trigger), atomic: work => store.atomic(work), state: { get: name => store.get(name), set: (name, value) => { if (value === undefined) store.delete(name); else store.set(name, value) }, keys: prefix => store.keys(prefix), }, threads: { get: key => store.threads.get(key), subscribed: source => store.read.subscribedThreads(source), describe: (key, description) => store.threads.describe(key, description), patch: (key, meta) => { store.threads.patchMeta(key, meta) }, }, tickets: { get: id => store.tickets.get(id), open: () => store.tickets.open(), bindThread: (id, thread, own) => { deps.engine.bindThread(id, thread, own) }, threadClosed: (thread, outcome, at) => { deps.engine.threadClosed(thread, outcome, at) }, }, triggers: { get: id => store.triggers.get(id), latest: thread => store.read.threadTriggers(thread, 1)[0] }, notify: message => { deps.outbox.send(message) }, report: (entry, value) => { if (!kind.health.includes(entry)) throw new Error(`The ${kind.kind} Source has no health entry ${entry}`) health.get(entry)?.set(value) }, } }