Merge remote-tracking branch 'origin/main'

Integrate 6 remote commits: platform health endpoint, web/intelligence modules,
PR API, Redis compose, KiCad CI exports, README docs update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kxkm
2026-03-27 03:44:34 +01:00
12 changed files with 1411 additions and 26 deletions
+4
View File
@@ -3,3 +3,7 @@ EDA_QUEUE_NAME=yiacad-eda
KICANVAS_BUNDLE_URL=https://kicanvas.org/kicanvas/kicanvas.js
KIBOT_CONFIG=/absolute/path/to/kibot.yaml
KIAUTO_BIN=kiauto
# GitHub PR integration
GITHUB_TOKEN=ghp_...
GITHUB_REPO=electron-rare/Kill_LIFE
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { getEdaQueue } from "@/lib/eda-queue";
export const runtime = "nodejs";
type ProbeResult = {
status: "up" | "degraded" | "down";
latency_ms?: number;
detail?: string | number;
};
async function probeUrl(url: string, timeoutMs = 1500): Promise<ProbeResult> {
const t0 = Date.now();
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
return { status: res.ok ? "up" : "degraded", latency_ms: Date.now() - t0 };
} catch {
return { status: "down", latency_ms: Date.now() - t0 };
}
}
async function probeQueue(): Promise<ProbeResult> {
try {
const queue = getEdaQueue();
const counts = await queue.getJobCounts("wait", "active", "failed");
const depth = counts.wait ?? 0;
const active = counts.active ?? 0;
const failed = counts.failed ?? 0;
const status = failed > 10 ? "degraded" : "up";
return { status, detail: depth, ...(active ? { active } : {}), ...(failed ? { failed } : {}) } as ProbeResult & Record<string, unknown>;
} catch (err) {
return { status: "down", detail: String(err) };
}
}
export async function GET() {
const yjsUrl =
process.env.YJS_WS_HTTP_URL ??
`http://localhost:${process.env.YJS_WS_PORT ?? "1234"}/`;
const nextUrl = `http://localhost:${process.env.PORT ?? "3000"}/`;
const [nextProbe, yjsProbe, queueProbe] = await Promise.all([
probeUrl(nextUrl),
probeUrl(yjsUrl),
probeQueue(),
]);
const probes = {
"next-js": nextProbe,
"yjs-realtime": yjsProbe,
"eda-queue": queueProbe,
};
const upCount = Object.values(probes).filter((p) => p.status === "up").length;
const total = Object.keys(probes).length;
const status =
upCount === total ? "up" : upCount === 0 ? "down" : "degraded";
return NextResponse.json({ status, up_count: upCount, total, probes });
}
+1 -1
View File
@@ -3,7 +3,7 @@
import dynamic from "next/dynamic";
import { startTransition, useEffect, useRef, useState } from "react";
import { useYjsExcalidraw } from "@/lib/use-yjs-excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
const Excalidraw = dynamic(
async () => {
+12
View File
@@ -0,0 +1,12 @@
services:
redis:
image: redis:7-alpine
container_name: yiacad-redis-dev
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
volumes:
- yiacad-redis-data:/data
volumes:
yiacad-redis-data:
+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) };
}
}
+65 -9
View File
@@ -269,6 +269,60 @@ function buildReviewSummary(
return `${branch ?? "detached-head"} · ${changeLabel} · latest CI ${latestRun} · ${artifacts.length} artifact(s)`;
}
// ---------------------------------------------------------------------------
// GitHub PR API
// ---------------------------------------------------------------------------
const GITHUB_REPO =
process.env.GITHUB_REPO ?? "electron-rare/Kill_LIFE";
const GITHUB_API = "https://api.github.com";
async function fetchGitHubPRs(artifacts: ArtifactRecord[]): Promise<PullRequestRecord[] | null> {
const token = process.env.GITHUB_TOKEN;
if (!token) return null;
try {
const resp = await fetch(`${GITHUB_API}/repos/${GITHUB_REPO}/pulls?state=open&per_page=20`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) return null;
const prs = (await resp.json()) as Array<Record<string, unknown>>;
const artifactIds = artifacts.map((a) => a.id);
return prs.map((pr) => {
const files: string[] = [];
const title = typeof pr.title === "string" ? pr.title : "";
const sourceBranch = (pr.head as Record<string, unknown>)?.ref as string ?? "";
const targetBranch = (pr.base as Record<string, unknown>)?.ref as string ?? "main";
const author = ((pr.user as Record<string, unknown>)?.login as string) ?? "unknown";
const state = typeof pr.state === "string" ? pr.state : "open";
const number = typeof pr.number === "number" ? pr.number : 0;
return {
id: String(number),
title,
status: state,
author,
hasPcbDiff: false,
hasDiagramDiff: false,
hasArtifactPreview: artifactIds.length > 0,
sourceBranch,
targetBranch,
changedFiles: files,
artifactIds,
} satisfies PullRequestRecord;
});
} catch {
return null;
}
}
function derivePullRequests(
branch: string | null,
head: string | null,
@@ -321,20 +375,22 @@ export async function getProjectSnapshot() {
loadArtifacts(),
getGitProjectState(REPO_ROOT, PROJECT_ROOT)
]);
const pullRequests = derivePullRequests(
gitState.branch,
gitState.head,
gitState.author,
gitState.changedFiles,
ciRuns,
artifacts
);
const pullRequests =
(await fetchGitHubPRs(artifacts)) ??
derivePullRequests(
gitState.branch,
gitState.head,
gitState.author,
gitState.changedFiles,
ciRuns,
artifacts
);
return {
id: `yiacad-${gitState.branch ?? "local"}`,
name: "YiACAD Local Workspace",
rootPath: toPosixPath(relative(REPO_ROOT, PROJECT_ROOT)),
repoProvider: "local git read model (ready for gitea/gitlab adapter)",
repoProvider: `github:${GITHUB_REPO}`,
repoVisibility: "repo-backed working tree",
repoBranch: gitState.branch,
repoHead: gitState.head,
+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;
}
+1 -1
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import type { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types";
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
const WS_URL =
typeof window !== "undefined"
+911 -14
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -13,7 +13,12 @@
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/ws": "^8.18.1",
"bullmq": "5.71.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"graphql": "16.13.1",
"graphql-yoga": "5.18.1",
"next": "14.2.35",
+2 -1
View File
@@ -36,6 +36,7 @@
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"aperant"
]
}