diff --git a/web/lib/intelligence/health-aggregator.ts b/web/lib/intelligence/health-aggregator.ts new file mode 100644 index 0000000..2358ef3 --- /dev/null +++ b/web/lib/intelligence/health-aggregator.ts @@ -0,0 +1,106 @@ +/** + * T-AI-323 — Intelligence health aggregator for Lot 23 (YiACAD Git-EDA Platform). + * + * Surfaces queue / worker / realtime status into a single structured payload + * consumable by intelligence_tui and runtime_ai_gateway. + */ + +export type ServiceStatus = "ok" | "degraded" | "unavailable"; + +export interface QueueHealth { + status: ServiceStatus; + waiting: number; + active: number; + failed: number; + latencyMs?: number; +} + +export interface WorkerHealth { + status: ServiceStatus; + running: boolean; + lastJobId?: string; + lastJobMs?: number; +} + +export interface RealtimeHealth { + status: ServiceStatus; + connected: boolean; + clients: number; + roomCount: number; +} + +export interface WebPlatformHealth { + timestamp: string; + overall: ServiceStatus; + queue: QueueHealth; + worker: WorkerHealth; + realtime: RealtimeHealth; +} + +const QUEUE_API = process.env.EDA_QUEUE_URL ?? "http://localhost:3000/api/ops/queue"; +const WORKER_API = process.env.EDA_WORKER_URL ?? "http://localhost:3000/api/ops/worker"; +const REALTIME_API = process.env.YJS_HEALTH_URL ?? "http://localhost:1234/health"; + +async function fetchJson(url: string, timeoutMs = 1500): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +async function getQueueHealth(): Promise { + const data = await fetchJson>(QUEUE_API); + if (!data) return { status: "unavailable", waiting: 0, active: 0, failed: 0 }; + const waiting = typeof data.waiting === "number" ? data.waiting : 0; + const active = typeof data.active === "number" ? data.active : 0; + const failed = typeof data.failed === "number" ? data.failed : 0; + const latencyMs = typeof data.latencyMs === "number" ? data.latencyMs : undefined; + const status: ServiceStatus = failed > 10 ? "degraded" : "ok"; + return { status, waiting, active, failed, latencyMs }; +} + +async function getWorkerHealth(): Promise { + const data = await fetchJson>(WORKER_API); + if (!data) return { status: "unavailable", running: false }; + const running = data.running === true; + const lastJobId = typeof data.lastJobId === "string" ? data.lastJobId : undefined; + const lastJobMs = typeof data.lastJobMs === "number" ? data.lastJobMs : undefined; + return { status: running ? "ok" : "degraded", running, lastJobId, lastJobMs }; +} + +async function getRealtimeHealth(): Promise { + const data = await fetchJson>(REALTIME_API); + if (!data) return { status: "unavailable", connected: false, clients: 0, roomCount: 0 }; + const clients = typeof data.clients === "number" ? data.clients : 0; + const roomCount = typeof data.rooms === "number" ? data.rooms : 0; + return { status: "ok", connected: true, clients, roomCount }; +} + +function roll(statuses: ServiceStatus[]): ServiceStatus { + if (statuses.includes("unavailable")) return "degraded"; + if (statuses.includes("degraded")) return "degraded"; + return "ok"; +} + +/** + * Aggregate health from queue, worker, and realtime services. + * Safe to call from Next.js API routes (server-side only). + */ +export async function getWebPlatformHealth(): Promise { + const [queue, worker, realtime] = await Promise.all([ + getQueueHealth(), + getWorkerHealth(), + getRealtimeHealth(), + ]); + + return { + timestamp: new Date().toISOString(), + overall: roll([queue.status, worker.status, realtime.status]), + queue, + worker, + realtime, + }; +} diff --git a/web/lib/intelligence/review-assist-mcp.ts b/web/lib/intelligence/review-assist-mcp.ts new file mode 100644 index 0000000..414d615 --- /dev/null +++ b/web/lib/intelligence/review-assist-mcp.ts @@ -0,0 +1,169 @@ +/** + * T-AI-326 — MCP / service-first boundary for YiACAD review-assist surface. + * + * Defines tool contracts for: + * - EDA worker invocation + * - Parts search + * - CI trigger + * - Artifact fetch + * - Review hints (read-only) + * + * All tools are READ-ONLY from the agent perspective. Write operations + * (commit, merge, ERC fix) remain exclusively in the human operator path. + */ + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export interface McpToolResult { + ok: boolean; + data?: T; + error?: string; +} + +// --------------------------------------------------------------------------- +// EDA Worker tool +// --------------------------------------------------------------------------- + +export type EdaJobKind = "erc" | "drc" | "bom" | "gerber" | "step"; + +export interface EdaJobRequest { + kind: EdaJobKind; + schematicPath: string; + /** Optional ref (branch/commit) — defaults to HEAD */ + gitRef?: string; +} + +export interface EdaJobResult { + jobId: string; + status: "queued" | "running" | "done" | "failed"; + violationCount?: number; + artifactUrl?: string; +} + +export async function enqueueEdaJob( + req: EdaJobRequest, +): Promise> { + try { + const res = await fetch("/api/eda/enqueue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + return { ok: true, data: (await res.json()) as EdaJobResult }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +// --------------------------------------------------------------------------- +// Parts search tool (read-only) +// --------------------------------------------------------------------------- + +export interface PartsSearchQuery { + query: string; + category?: string; + maxResults?: number; +} + +export interface PartResult { + mpn: string; + manufacturer: string; + description: string; + datasheet?: string; + stock?: number; +} + +export async function searchParts( + q: PartsSearchQuery, +): Promise> { + const params = new URLSearchParams({ q: q.query }); + if (q.category) params.set("category", q.category); + if (q.maxResults) params.set("limit", String(q.maxResults)); + try { + const res = await fetch(`/api/parts/search?${params}`); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + return { ok: true, data: (await res.json()) as PartResult[] }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +// --------------------------------------------------------------------------- +// CI trigger tool +// --------------------------------------------------------------------------- + +export interface CiTriggerRequest { + workflow: "firmware" | "erc" | "drc" | "full"; + gitRef?: string; +} + +export interface CiRunRef { + runId: string; + url: string; + status: "pending" | "running" | "success" | "failure"; +} + +export async function triggerCi( + req: CiTriggerRequest, +): Promise> { + try { + const res = await fetch("/api/ci/trigger", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + return { ok: true, data: (await res.json()) as CiRunRef }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +// --------------------------------------------------------------------------- +// Artifact fetch tool (read-only) +// --------------------------------------------------------------------------- + +export interface ArtifactRef { + kind: "gerber" | "bom" | "step" | "report"; + jobId: string; +} + +export async function fetchArtifactUrl( + ref: ArtifactRef, +): Promise> { + try { + const res = await fetch(`/api/artifacts/${ref.kind}/${ref.jobId}`); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + return { ok: true, data: (await res.json()) as { url: string; contentType: string } }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +// --------------------------------------------------------------------------- +// Review hints (read-only, generated by intelligence overlay) +// --------------------------------------------------------------------------- + +export interface ReviewHint { + severity: "error" | "warning" | "info"; + component?: string; + message: string; + source: "erc" | "drc" | "bom" | "agent"; +} + +export async function getReviewHints( + schematicPath: string, +): Promise> { + try { + const res = await fetch( + `/api/review/hints?path=${encodeURIComponent(schematicPath)}`, + ); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + return { ok: true, data: (await res.json()) as ReviewHint[] }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} diff --git a/web/lib/realtime/git-policy.ts b/web/lib/realtime/git-policy.ts new file mode 100644 index 0000000..c2156d0 --- /dev/null +++ b/web/lib/realtime/git-policy.ts @@ -0,0 +1,75 @@ +/** + * T-AI-325 — Git snapshot policy for Excalidraw-Yjs collaborative editing. + * + * Defines when Yjs realtime state is promoted to a Git commit, and how + * conflicts between concurrent editors are resolved. + * + * Design decisions: + * - Realtime sync (Yjs) is ephemeral: it keeps clients in sync but does NOT + * automatically write to Git. + * - Git commits are always explicit, triggered by a deliberate user action + * ("Save to Git") or by a CI/agent event. + * - Conflict resolution: last-write-wins within the Yjs CRDT (automatic), + * Git-level conflicts are resolved by the snapshot author. + */ + +export type SnapshotTrigger = + | "manual_save" // user clicked "Save to Git" + | "ci_trigger" // CI pipeline requests a snapshot before running ERC/DRC + | "agent_request"; // intelligence agent captures state for review + +export interface GitSnapshotPolicy { + /** Who may trigger a Git snapshot. */ + allowedTriggers: SnapshotTrigger[]; + + /** + * Minimum time (ms) between automatic snapshots from the same session. + * Manual saves are not rate-limited. + */ + minIntervalMs: number; + + /** + * If true, the snapshot includes the full Yjs document state as a + * base64 blob in the commit message (for forensics). + */ + embedYjsState: boolean; + + /** + * Commit message template. Tokens: {trigger}, {author}, {timestamp}, {room}. + */ + commitMessageTemplate: string; +} + +/** Default policy for YiACAD collaborative sessions. */ +export const DEFAULT_POLICY: GitSnapshotPolicy = { + allowedTriggers: ["manual_save", "ci_trigger", "agent_request"], + minIntervalMs: 30_000, // 30s floor on non-manual snapshots + embedYjsState: false, + commitMessageTemplate: "chore(yiacad): snapshot [{trigger}] by {author} at {timestamp} — room {room}", +}; + +export function buildCommitMessage( + policy: GitSnapshotPolicy, + ctx: { trigger: SnapshotTrigger; author: string; room: string }, +): string { + return policy.commitMessageTemplate + .replace("{trigger}", ctx.trigger) + .replace("{author}", ctx.author) + .replace("{timestamp}", new Date().toISOString()) + .replace("{room}", ctx.room); +} + +/** + * Returns true if a snapshot may be taken given the last snapshot timestamp. + * Manual saves always pass; other triggers are rate-limited. + */ +export function canSnapshot( + trigger: SnapshotTrigger, + policy: GitSnapshotPolicy, + lastSnapshotAt: number | null, +): boolean { + if (!policy.allowedTriggers.includes(trigger)) return false; + if (trigger === "manual_save") return true; + if (lastSnapshotAt === null) return true; + return Date.now() - lastSnapshotAt >= policy.minIntervalMs; +}