feat(web): add intelligence + realtime lib modules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user