/** * ProjectionStore - Domain projections for TUI consumption * * Implements contracts §17; DD §13.1. * * @module packages/runtime/src/projection/ProjectionStore */ import type { RuntimeEvent, SessionID } from '@aircoding/contracts' export interface SessionProjection { session_id: string project_id: string status: string title?: string tasks: TaskProjection[] agents: AgentProjection[] } export interface TaskProjection { id: string type: string status: string title: string retry_count: number attempts: number created_at: string } export interface AgentProjection { id: string type: string status: string task_id?: string last_heartbeat?: string } export type ProjectionSubscriber = (projection: SessionProjection) => void export class ProjectionStore { private snapshot: Map = new Map() private subscribers: ProjectionSubscriber[] = [] /** * Hydrate projection from repositories. */ hydrate(session_id: string, data: { session: { id: string; project_id: string; status: string; title?: string } tasks: TaskProjection[] agents: AgentProjection[] }): void { this.snapshot.set(session_id, { session_id: data.session.id, project_id: data.session.project_id, status: data.session.status, title: data.session.title, tasks: data.tasks, agents: data.agents }) } /** * Apply an event to the projection (incrementally update). */ apply(event: RuntimeEvent): void { const session_id = event.session_id const proj = this.snapshot.get(session_id) if (!proj) return switch (event.type) { case 'task.created': { const p = event.payload as unknown as TaskProjection proj.tasks.push(p) break } case 'task.status.changed': { const p = event.payload as { task_id: string; status: string } const task = proj.tasks.find(t => t.id === p.task_id) if (task) task.status = p.status break } case 'agent.created': { const p = event.payload as unknown as AgentProjection proj.agents.push(p) break } case 'agent.status.changed': { const p = event.payload as { agent_id: string; status: string } const agent = proj.agents.find(a => a.id === p.agent_id) if (agent) agent.status = p.status break } case 'session.status.changed': { const p = event.payload as { status: string } proj.status = p.status break } } this.notify(proj) } /** * Get current snapshot for a session. */ get_snapshot(session_id: string): SessionProjection | undefined { return this.snapshot.get(session_id) } /** * Subscribe to projection updates. */ subscribe(subscriber: ProjectionSubscriber): () => void { this.subscribers.push(subscriber) return () => { this.subscribers = this.subscribers.filter(s => s !== subscriber) } } /** * Full rebuild from DB (INV-5: from SQLite, not EventBus). * TODO(P6): Query all repositories to rebuild projection from database state. */ rebuild(session_id: string): void { // STUB: Would query SessionRepository, TaskRepository, AgentRepository etc. } private notify(projection: SessionProjection): void { for (const sub of this.subscribers) { sub(projection) } } }