feat(web): add intelligence + realtime lib modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Clément SAILLANT
2026-03-26 22:10:25 +01:00
parent 1dde0b9e9f
commit 781b278b0d
3 changed files with 350 additions and 0 deletions
+106
View File
@@ -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<T>(url: string, timeoutMs = 1500): Promise<T | null> {
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<QueueHealth> {
const data = await fetchJson<Record<string, unknown>>(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<WorkerHealth> {
const data = await fetchJson<Record<string, unknown>>(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<RealtimeHealth> {
const data = await fetchJson<Record<string, unknown>>(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<WebPlatformHealth> {
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,
};
}
+169
View File
@@ -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<T = unknown> {
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<McpToolResult<EdaJobResult>> {
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<McpToolResult<PartResult[]>> {
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<McpToolResult<CiRunRef>> {
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<McpToolResult<{ url: string; contentType: string }>> {
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<McpToolResult<ReviewHint[]>> {
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) };
}
}
+75
View File
@@ -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;
}