+
+
+
+ {documentTitle}
+
+
+ Skip to content
+
+ {/*
+ Chrome hydrates lazily: these islands render their interactive shell
+ server-side and hydrate when the browser is idle. Page content islands
+ keep client:load so their data loads without waiting on idle.
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/lib/api/config.test.ts b/frontend/src/lib/api/config.test.ts
new file mode 100644
index 0000000..c64979c
--- /dev/null
+++ b/frontend/src/lib/api/config.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test";
+import { getApiOrigin, getWsOrigin, wsOriginFrom } from "./config.js";
+
+describe("api origin helpers", () => {
+ test("ws origin derives from an http origin", () => {
+ expect(wsOriginFrom("http://127.0.0.1:4317")).toBe("ws://127.0.0.1:4317");
+ });
+
+ test("https origins map to wss", () => {
+ expect(wsOriginFrom("https://models.example.com")).toBe("wss://models.example.com");
+ });
+
+ test("getApiOrigin returns an http url", () => {
+ expect(getApiOrigin()).toMatch(/^https?:\/\//);
+ });
+
+ test("getWsOrigin returns a ws url", () => {
+ expect(getWsOrigin()).toMatch(/^wss?:\/\//);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/api/config.ts b/frontend/src/lib/api/config.ts
new file mode 100644
index 0000000..d03fe34
--- /dev/null
+++ b/frontend/src/lib/api/config.ts
@@ -0,0 +1,31 @@
+/**
+ * API origin resolution for the console shell.
+ *
+ * The backend listens on 127.0.0.1:4317 in development. The origin can be
+ * overridden at build/dev time with `PUBLIC_WEAVELLM_API_ORIGIN` (Astro
+ * exposes PUBLIC_* environment variables to client code through
+ * `import.meta.env`). This is the only API-layer module in phase 1; typed
+ * clients for /api, /v1 and /ws arrive in phase 2.
+ */
+
+const DEFAULT_API_ORIGIN = "http://127.0.0.1:4317";
+
+/** Base origin for backend HTTP calls (defaults to the local runtime). */
+export function getApiOrigin(): string {
+ const override: string | undefined = import.meta.env.PUBLIC_WEAVELLM_API_ORIGIN as
+ | string
+ | undefined;
+ return typeof override === "string" && override.length > 0
+ ? override
+ : DEFAULT_API_ORIGIN;
+}
+
+/** Derive a websocket origin from an http(s) origin (http→ws, https→wss). */
+export function wsOriginFrom(apiOrigin: string): string {
+ return apiOrigin.replace(/^http/, "ws");
+}
+
+/** Base origin for backend websocket calls (WS /ws). */
+export function getWsOrigin(): string {
+ return wsOriginFrom(getApiOrigin());
+}
\ No newline at end of file
diff --git a/frontend/src/lib/api/health.test.ts b/frontend/src/lib/api/health.test.ts
new file mode 100644
index 0000000..7352df0
--- /dev/null
+++ b/frontend/src/lib/api/health.test.ts
@@ -0,0 +1,43 @@
+/**
+ * bun:test suite for the /api/health client (getHealth). NO auth gate on the
+ * backend — the health branch is open. fetchImpl injected; no network.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { ApiError, type FetchLike } from "./http.js";
+import { getHealth } from "./health.js";
+
+function jsonBody(
+ body: unknown,
+ status = 200,
+): FetchLike {
+ return async () =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+describe("getHealth()", () => {
+ test("returns {status:\"ok\"} for a live backend", async () => {
+ const fetchImpl = jsonBody({ status: "ok" });
+ const result = await getHealth({ origin: "http://test", fetchImpl });
+ expect(result).toEqual({ status: "ok" });
+ });
+
+ test("propagates localModels when a hub is wired", async () => {
+ const fetchImpl = jsonBody({ status: "ok", localModels: ["llama-3.1-8b"] });
+ const result = await getHealth({ origin: "http://test", fetchImpl });
+ expect(result.localModels).toEqual(["llama-3.1-8b"]);
+ });
+
+ test("throws ApiError on a non-ok backend status", async () => {
+ const fetchImpl = jsonBody({ error: "service_unavailable" }, 503);
+ const caught = await getHealth({ origin: "http://test", fetchImpl }).catch(
+ (err: unknown) => err,
+ );
+ expect(caught).toBeInstanceOf(ApiError);
+ expect((caught as ApiError).status).toBe(503);
+ expect((caught as ApiError).code).toBe("service_unavailable");
+ });
+});
diff --git a/frontend/src/lib/api/health.ts b/frontend/src/lib/api/health.ts
new file mode 100644
index 0000000..ea6673c
--- /dev/null
+++ b/frontend/src/lib/api/health.ts
@@ -0,0 +1,21 @@
+/**
+ * Typed client for the health surface.
+ *
+ * GET /api/health → HealthStatus (liveness; NO auth gate — the probe the
+ * console polls freely at boot and on the status bar).
+ */
+
+import { request, type FetchLike } from "./http.js";
+import type { HealthStatus } from "./types.js";
+
+export interface HealthOptions {
+ origin?: string;
+ fetchImpl?: FetchLike;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+}
+
+/** GET /api/health → {status:"ok", localModels?}. */
+export async function getHealth(options: HealthOptions = {}): Promise {
+ return request("/api/health", { method: "GET", ...options });
+}
diff --git a/frontend/src/lib/api/http.test.ts b/frontend/src/lib/api/http.test.ts
new file mode 100644
index 0000000..335692c
--- /dev/null
+++ b/frontend/src/lib/api/http.test.ts
@@ -0,0 +1,91 @@
+/**
+ * bun:test suite for the http transport (request + ApiError). Mirrors the
+ * config.test.ts style (U01) — bun:test, describe/test, imports "./x.js" —
+ * and drives request() with an injected fake fetch so no real network or
+ * backend process is touched.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { ApiError, request, type FetchLike } from "./http.js";
+
+/** Fake fetch returning a canned Response for a single call. */
+function jsonBody(
+ body: unknown,
+ status = 200,
+ headers: Record = { "content-type": "application/json" },
+): FetchLike {
+ return async () => new Response(JSON.stringify(body), { status, headers });
+}
+
+describe("request()", () => {
+ test("parses a 200 JSON response body", async () => {
+ const fetchImpl = jsonBody({ name: "file", version: 3 });
+ const result = await request<{ name: string; version: number }>("/api/workflows", {
+ origin: "http://test",
+ fetchImpl,
+ });
+ expect(result).toEqual({ name: "file", version: 3 });
+ });
+
+ test("returns undefined for a 204", async () => {
+ const fetchImpl = jsonBody(undefined, 204);
+ const result = await request("/api/workflows/del", {
+ method: "DELETE",
+ origin: "http://test",
+ fetchImpl,
+ });
+ expect(result).toBeUndefined();
+ });
+
+ test("throws ApiError wrapping the {error} envelope on non-2xx", async () => {
+ const fetchImpl = jsonBody(
+ { error: "workflow_not_found" },
+ 404,
+ { "content-type": "application/json" },
+ );
+ const caught = await request("/api/workflows/missing", {
+ origin: "http://test",
+ fetchImpl,
+ }).catch((err: unknown) => err);
+
+ expect(caught).toBeInstanceOf(ApiError);
+ const apiError = caught as ApiError;
+ expect(apiError.status).toBe(404);
+ expect(apiError.code).toBe("workflow_not_found");
+ expect(apiError.message).toBe("workflow_not_found");
+ });
+
+ test("throws ApiError(0, network_error) on transport failure", async () => {
+ const fetchImpl: FetchLike = async () => {
+ throw new TypeError("fetch failed");
+ };
+
+ const caught = await request("/api/health", {
+ origin: "http://test",
+ fetchImpl,
+ }).catch((err: unknown) => err);
+
+ expect(caught).toBeInstanceOf(ApiError);
+ const apiError = caught as ApiError;
+ expect(apiError.status).toBe(0);
+ expect(apiError.code).toBe("network_error");
+ });
+
+ test("carries backend envelope errors when present", async () => {
+ const fetchImpl = jsonBody(
+ { error: "invalid_yaml", errors: [{ path: "nodes", message: "missing start" }] },
+ 400,
+ { "content-type": "application/json" },
+ );
+ const caught = await request("/api/workflows/x", {
+ method: "PUT",
+ origin: "http://test",
+ fetchImpl,
+ }).catch((err: unknown) => err);
+
+ const apiError = caught as ApiError;
+ expect(apiError.status).toBe(400);
+ expect(apiError.code).toBe("invalid_yaml");
+ expect(apiError.errors).toEqual([{ path: "nodes", message: "missing start" }]);
+ });
+});
diff --git a/frontend/src/lib/api/http.ts b/frontend/src/lib/api/http.ts
new file mode 100644
index 0000000..a5572a0
--- /dev/null
+++ b/frontend/src/lib/api/http.ts
@@ -0,0 +1,164 @@
+/**
+ * Minimal HTTP transport for the console API surface.
+ *
+ * Funnels every typed call in this layer through `request()`, which:
+ * - resolves the base origin via getApiOrigin() (config.ts, phase 1);
+ * - sends JSON (jsonBody) or raw text (rawBody — the workflows PUT sends
+ * YAML text, not JSON);
+ * - treats 204 as undefined, everything else 2xx as parsed JSON;
+ * - maps non-2xx onto the backend envelope `{error, errors?}` throwing an
+ * `ApiError(status, message, errors?)`, and transport failures onto
+ * `ApiError(0, "network_error"|"request_timeout")`;
+ * - lets tests inject a fake fetch and/or a base origin.
+ *
+ * No React/Astro imports — stays importable under bun:test in any runtime.
+ */
+
+import { getApiOrigin } from "./config.js";
+import type { ApiErrorEnvelope } from "./types.js";
+
+export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
+
+/**
+ * Minimal fetch contract the transport actually uses: input + init →
+ * Response promise. Deliberately narrower than `typeof fetch` (which also
+ * carries `preconnect` and stricter RequestInfo typing) so tests can inject
+ * plain async functions without casts; the global `fetch` satisfies it.
+ */
+export type FetchLike = (
+ input: string | URL | Request,
+ init?: RequestInit,
+) => Promise;
+
+export interface RequestOptions {
+ method?: HttpMethod;
+ /** JSON body (mutually exclusive with rawBody). */
+ jsonBody?: unknown;
+ /** Raw text body — the workflows PUT sends YAML text. */
+ rawBody?: string;
+ /** Content type for rawBody (defaults to application/x-yaml). */
+ rawBodyType?: string;
+ /** Override the API origin (defaults to getApiOrigin()). */
+ origin?: string;
+ /** Inject a fetch implementation (tests). Defaults to global fetch. */
+ fetchImpl?: FetchLike;
+ /** Abort the request. */
+ signal?: AbortSignal;
+ /** Timeout in ms (default 10_000). */
+ timeoutMs?: number;
+}
+
+export interface ApiErrorOptions {
+ status: number;
+ message: string;
+ code: string;
+ errors?: unknown;
+}
+
+/** Error thrown on non-2xx responses (status>0) or transport failures (0). */
+export class ApiError extends Error {
+ readonly status: number;
+ readonly code: string;
+ readonly errors?: unknown;
+
+ constructor(options: ApiErrorOptions) {
+ super(options.message);
+ this.name = "ApiError";
+ this.status = options.status;
+ this.code = options.code;
+ this.errors = options.errors;
+ }
+}
+
+async function parseBody(res: Response): Promise {
+ return res.text();
+}
+
+function toEnvelope(value: unknown): ApiErrorEnvelope | null {
+ if (typeof value !== "object" || value === null) return null;
+ const e = value as ApiErrorEnvelope;
+ return typeof e.error === "string" ? e : null;
+}
+
+/**
+ * Execute a request against the /api surface.
+ *
+ * @param path URL path, e.g. "/api/health".
+ * @param options
+ * @returns the parsed JSON body (T), or undefined for 204 / empty body.
+ * @throws ApiError for non-2xx and transport failures.
+ */
+export async function request(
+ path: string,
+ options: RequestOptions = {},
+): Promise {
+ const origin = options.origin ?? getApiOrigin();
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const method = options.method ?? "GET";
+ const headers = new Headers({ accept: "application/json" });
+
+ let body: string | undefined;
+ if (options.rawBody !== undefined) {
+ body = options.rawBody;
+ headers.set("content-type", options.rawBodyType ?? "application/x-yaml");
+ } else if (options.jsonBody !== undefined) {
+ body = JSON.stringify(options.jsonBody);
+ headers.set("content-type", "application/json");
+ }
+
+ const timeoutMs = options.timeoutMs ?? 10_000;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ if (options.signal?.aborted) controller.abort();
+ else options.signal?.addEventListener("abort", () => controller.abort(), { once: true });
+
+ let res: Response;
+ try {
+ res = await fetchImpl(`${origin}${path}`, {
+ method,
+ headers,
+ body,
+ signal: controller.signal,
+ });
+ } catch (err) {
+ clearTimeout(timer);
+ const name = (err as Error)?.name;
+ if (name === "AbortError" && controller.signal.aborted) {
+ throw new ApiError({ status: 0, message: "request_timeout", code: "request_timeout" });
+ }
+ throw new ApiError({
+ status: 0,
+ message: (err as Error)?.message ?? "network_error",
+ code: "network_error",
+ });
+ }
+ clearTimeout(timer);
+
+ const isOk = res.ok || res.status === 204;
+ if (!isOk) {
+ const text = await parseBody(res).catch(() => "");
+ let envelope: ApiErrorEnvelope | null = null;
+ if (text.length > 0) {
+ try {
+ envelope = toEnvelope(JSON.parse(text));
+ } catch {
+ // non-JSON error body — fall through to the raw status
+ }
+ }
+ throw new ApiError({
+ status: res.status,
+ message: envelope?.error ?? `http_${res.status}`,
+ code: envelope?.error ?? `http_${res.status}`,
+ errors: envelope?.errors,
+ });
+ }
+
+ if (res.status === 204) return undefined as T ;
+ const text = await parseBody(res);
+ if (text.length === 0) return undefined as T;
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ throw new ApiError({ status: 0, message: "invalid_json", code: "invalid_json" });
+ }
+}
diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts
new file mode 100644
index 0000000..03ffc51
--- /dev/null
+++ b/frontend/src/lib/api/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Public barrel for the console API layer.
+ *
+ * config.ts — origin resolution (phase 1, U01) — re-exported so the
+ * console consumes ONE api surface from one import root.
+ * http.ts — request() + ApiError (typed transport funnel).
+ * types.ts — shared wire types (verbatim backend shapes).
+ * health.ts — /api/health.
+ * models.ts — /api/models hub surface.
+ * workflows.ts — /api/workflows CRUD + run + logs.
+ * runtime.ts — combined runtime status probe.
+ * v1.ts — /v1/chat/completions (JSON + OpenAI-wire SSE).
+ */
+
+export * from "./config.js";
+export * from "./http.js";
+export * from "./types.js";
+export * from "./health.js";
+export * from "./models.js";
+export * from "./workflows.js";
+export * from "./runtime.js";
+export * from "./v1.js";
diff --git a/frontend/src/lib/api/models.test.ts b/frontend/src/lib/api/models.test.ts
new file mode 100644
index 0000000..d0e7d6a
--- /dev/null
+++ b/frontend/src/lib/api/models.test.ts
@@ -0,0 +1,69 @@
+/**
+ * bun:test suite for the models surface (models.ts). Mirrors config.test.ts
+ * (U01) style — describe/test, imports "./x.js" — and injects a fake fetch
+ * via ModelOptions.fetchImpl so no real network / backend process is touched.
+ */
+
+import { expect, test } from "bun:test";
+import {
+ activateModel,
+ deactivateModel,
+ getModel,
+ listModels,
+} from "./models.js";
+import { ApiError, type FetchLike } from "./http.js";
+import type { ModelStatus } from "./types.js";
+
+function jsonBody(
+ body: unknown,
+ status = 200,
+ headers: Record = { "content-type": "application/json" },
+): FetchLike {
+ return async () =>
+ new Response(JSON.stringify(body), { status, headers });
+}
+
+const opts = { origin: "http://test", } as const;
+
+test("listModels() GETs /api/models and returns the registry rows", async () => {
+ const rows: ModelStatus[] = [
+ { id: "llama-3.1-8b", state: "active", pid: 44, port: 11180 },
+ { id: "qwen2.5-coder", state: "disabled" },
+ ];
+ const result = await listModels({ fetchImpl: jsonBody(rows), ...opts });
+ expect(result).toEqual(rows);
+});
+
+test("getModel() fetches /api/models/:id/status", async () => {
+ const row: ModelStatus = { id: "llama-3.1-8b", state: "active", pid: 44, port: 11180 };
+ const result = await getModel("llama-3.1-8b", { fetchImpl: jsonBody(row), ...opts });
+ expect(result).toEqual(row);
+});
+
+test("activateModel() POSTs /activate and returns the pid/port", async () => {
+ const result = await activateModel("llama-3.1-8b", {
+ fetchImpl: jsonBody({ state: "active", pid: 44, port: 11180 }),
+ ...opts,
+ });
+ expect(result).toEqual({ state: "active", pid: 44, port: 11180 });
+});
+
+test("deactivateModel() POSTs /deactivate and returns {state:disabled}", async () => {
+ const result = await deactivateModel("llama-3.1-8b", {
+ fetchImpl: jsonBody({ state: "disabled" }),
+ ...opts,
+ });
+ expect(result).toEqual({ state: "disabled" });
+});
+
+test("returns backend error envelope via ApiError on 404", async () => {
+ const caught = await getModel("missing", {
+ fetchImpl: jsonBody({ error: "model_not_found" }, 404),
+ ...opts,
+ }).catch((err: unknown) => err); ;
+
+ expect(caught).toBeInstanceOf(ApiError);
+ expect((caught as ApiError).status).toBe(404);
+ expect((caught as ApiError).code).toBe("model_not_found");
+ expect((caught as ApiError).message).toBe("model_not_found");
+});
diff --git a/frontend/src/lib/api/models.ts b/frontend/src/lib/api/models.ts
new file mode 100644
index 0000000..c9a0164
--- /dev/null
+++ b/frontend/src/lib/api/models.ts
@@ -0,0 +1,60 @@
+/**
+ * Typed client for the model-management surface (auth-gated on the backend
+ * through the optional `auth` gate — 401 {error:"unauthorized"} when the
+ * console has no valid bearer token).
+ *
+ * Real backend surface (src/routes/api.ts, models branch, hub-gated):
+ * GET /api/models → ModelStatus[] (registry snapshot)
+ * GET /api/models/:id/status → ModelStatus | 404 model_not_found
+ * POST /api/models/:id/activate → {state,pid,port} | 400/404/503
+ * POST /api/models/:id/deactivate → {state:"disabled"} | 404
+ */
+
+import { request, type FetchLike } from "./http.js";
+import type {
+ ActivateModelResponse,
+ DeactivateModelResponse,
+ ModelStatus,
+} from "./types.js";
+
+export interface ModelOptions {
+ origin?: string;
+ fetchImpl?: FetchLike;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+}
+
+/** GET /api/models → full registry snapshot. */
+export async function listModels(options: ModelOptions = {}): Promise {
+ return request("/api/models", { method: "GET", ...options });
+}
+
+/** GET /api/models/:id/status → one row; throws ApiError(404, "model_not_found"). */
+export async function getModel(id: string, options: ModelOptions = {}): Promise {
+ return request(`/api/models/${encodeURIComponent(id)}/status`, {
+ method: "GET",
+ ...options,
+ });
+}
+
+/** POST /api/models/:id/activate → {state:"active",pid,port}. */
+export async function activateModel(
+ id: string,
+ options: ModelOptions = {},
+): Promise {
+ return request(`/api/models/${encodeURIComponent(id)}/activate`, {
+ method: "POST",
+ ...options,
+ });
+}
+
+/** POST /api/models/:id/deactivate → {state:"disabled"}. */
+export async function deactivateModel(
+ id: string,
+ options: ModelOptions = {},
+): Promise {
+ return request(`/api/models/${encodeURIComponent(id)}/deactivate`, {
+ method: "POST",
+ ...options,
+ });
+}
diff --git a/frontend/src/lib/api/runtime.test.ts b/frontend/src/lib/api/runtime.test.ts
new file mode 100644
index 0000000..605e67c
--- /dev/null
+++ b/frontend/src/lib/api/runtime.test.ts
@@ -0,0 +1,67 @@
+/**
+ * bun:test suite for the runtime status probe (runtime.ts). Mirrors
+ * config.test.ts (U01) style — describe/test, imports "./x.js" — and injects
+ * fake fetch implementations so no real network/backend is touched.
+ *
+ * runtimeStatus() is deliberately non-throwing; its two key behaviours:
+ * - transport failure ⇒ { reachable:false, authEnabled:false };
+ * - reachable + 401 on the models branch ⇒ authEnabled:true.
+ */
+
+import { expect, test } from "bun:test";
+import { runtimeStatus } from "./runtime.js";
+import type { FetchLike } from "./http.js";
+
+function jsonBody(
+ body: unknown,
+ status = 200,
+): FetchLike {
+ return async () => new Response(JSON.stringify(body), { status });
+}
+
+function failingFetch(): FetchLike {
+ return async () => {
+ throw new TypeError("fetch failed");
+ };
+}
+
+const opts = { origin: "http://test" } as const;
+
+test("returns reachable=true plus health when the backend answers", async () => {
+ const result = await runtimeStatus({
+ ...opts,
+ fetchImpl: jsonBody({ status: "ok" }),
+ });
+ expect(result.reachable).toBe(true);
+ expect(result.health).toEqual({ status: "ok" });
+ expect(result.authEnabled).toBe(false);
+});
+
+test("sets authEnabled=true when the models branch is 401-gated", async () => {
+ // Real backend behaviour: /api/health is open (200), only /api/models
+ // is auth-gated (401) — so the probe must route by URL.
+ const routedFetch: FetchLike = async (url: string | URL | Request) => {
+ const path = String(url);
+ if (path.endsWith("/api/health")) {
+ return new Response(JSON.stringify({ status: "ok" }), { status: 200 });
+ }
+ return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
+ };
+
+ const result = await runtimeStatus({
+ ...opts,
+ fetchImpl: routedFetch,
+ });
+ expect(result.reachable).toBe(true);
+ expect(result.authEnabled).toBe(true);
+});
+
+test("returns reachable=false on transport failure, never throwing", async () => {
+ const result = await runtimeStatus({
+ ...opts,
+ fetchImpl: failingFetch(),
+ });
+ expect(result.reachable).toBe(false);
+ expect(result.authEnabled).toBe(false);
+ expect(result.health).toBeUndefined();
+});
diff --git a/frontend/src/lib/api/runtime.ts b/frontend/src/lib/api/runtime.ts
new file mode 100644
index 0000000..78574b1
--- /dev/null
+++ b/frontend/src/lib/api/runtime.ts
@@ -0,0 +1,58 @@
+/**
+ * Combined runtime status probe — the "is the console actually usable right
+ * now?" affordance the status bar polls.
+ *
+ * Real backend surface (NO auth, like the whole /api surface):
+ * GET /api/health → HealthStatus (liveness; localModels? when hub wired)
+ * GET /api/models → ModelStatus[] (registry snapshot)
+ *
+ * runtimeStatus() is deliberately **non-throwing**: it runs the liveness probe
+ * and, when reachable, a second probe of the models branch. A 401 (no bearer
+ * token configured) is not a failure — it means `authEnabled: true` and the
+ * console should prompt for a token. Transport failure ⇒ reachable:false.
+ */
+
+import { getHealth } from "./health.js";
+import { request, type FetchLike } from "./http.js";
+import { ApiError } from "./http.js";
+import type { HealthStatus } from "./types.js";
+
+export interface RuntimeStatus {
+ /** Liveness probe answered (any HTTP status, incl. 401). */
+ reachable: boolean;
+ /** GET /api/health body, present when reachable. */
+ health?: HealthStatus;
+ /** True when a 401 gate guards the /api/models branch (token required). */
+ authEnabled: boolean;
+}
+
+/** Forwarded transport options (DI seam: origin/fetch/signal/timeout). */
+export interface RuntimeStatusOptions {
+ origin?: string;
+ fetchImpl?: FetchLike;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+}
+
+/**
+ * Probe the runtime: health first, then (when reachable) the auth gate via
+ * the models branch. Never throws — always resolves to a status object.
+ */
+export async function runtimeStatus(options: RuntimeStatusOptions = {}): Promise {
+ let health: HealthStatus | undefined;
+ try {
+ health = await getHealth(options);
+ } catch {
+ return { reachable: false, authEnabled: false };
+ }
+
+ let authEnabled = false;
+ try {
+ await request("/api/models", { method: "GET", ...options });
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ authEnabled = true;
+ }
+ }
+ return { reachable: true, health, authEnabled };
+}
diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
new file mode 100644
index 0000000..fb3caaf
--- /dev/null
+++ b/frontend/src/lib/api/types.ts
@@ -0,0 +1,83 @@
+/**
+ * Shared wire types for the console API, mirroring the REAL backend shapes
+ * (verbatim field-by-field from src/backend/hub.ts, src/orchestrator/store.ts
+ * and src/routes/api.ts). No invented fields — this layer types exactly the
+ * /api surface the server serves.
+ */
+
+/** GET /api/health → backend health (liveness; no auth). */
+export interface HealthStatus {
+ status: "ok";
+ /** ids of locally-managed models, only when a hub is wired. */
+ localModels?: string[];
+}
+
+/** GET /api/models / GET /api/models/:id (hub registry; auth-gated). */
+export interface ModelStatus {
+ id: string;
+ state: "active" | "disabled" | "error";
+ pid?: number;
+ port?: number;
+ error?: string;
+}
+
+/** POST /api/models/:id/activate → activation affordance. */
+export interface ActivateModelResponse {
+ state: "active";
+ pid: number;
+ port: number;
+}
+
+/** POST /api/models/:id/deactivate → {state:"disabled"}. */
+export interface DeactivateModelResponse {
+ state: "disabled";
+}
+
+/** GET /api/workflows → workflow metadata rows. */
+export interface WorkflowRecord {
+ name: string;
+ version: number;
+ updatedAt: string;
+}
+
+/** GET /api/workflows/:name → record + canonical YAML. */
+export interface WorkflowWithYaml extends WorkflowRecord {
+ yaml: string;
+}
+
+/** PUT /api/workflows/:name → accepted envelope. */
+export interface SaveWorkflowResult {
+ ok: true;
+ name: string;
+ version: number;
+}
+
+/** POST /api/workflows/:name/run → the runner's completion output. */
+export interface RunWorkflowResult {
+ id: string;
+ object: "chat.completion";
+ created: number;
+ model: string;
+ choices: Array<{
+ index: number;
+ message: { role: "assistant"; content: string };
+ finish_reason: string;
+ }>;
+ usage: null;
+}
+
+/** GET /api/workflows/:name/logs → execution history rows. */
+export interface ExecutionLogRow {
+ id: string;
+ workflowId: string;
+ status: "ok" | "error";
+ error: string | null;
+ startedAt: string;
+ ms: number | null;
+}
+
+/** Backend error envelope → thrown as ApiError(status, message, errors?). */
+export interface ApiErrorEnvelope {
+ error: string;
+ errors?: unknown;
+}
diff --git a/frontend/src/lib/api/v1.test.ts b/frontend/src/lib/api/v1.test.ts
new file mode 100644
index 0000000..6cc9d2e
--- /dev/null
+++ b/frontend/src/lib/api/v1.test.ts
@@ -0,0 +1,363 @@
+/**
+ * bun:test suite for the /v1 client (v1.ts, U11). Drives both wire paths
+ * with an injected fake fetch — JSON completion, OpenAI-wire SSE streams,
+ * the 401 Bearer envelope, the 404 unknown-model envelope, stream split
+ * across chunk boundaries, truncation, and mid-stream abort.
+ *
+ * Mirrors the config.test.ts / http.test.ts conventions: bun:test,
+ * describe/test, imports with explicit .js extensions, no real network.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ chatCompletion,
+ chatCompletionStream,
+ splitSSEFrames,
+ v1ErrorFrom,
+} from "./v1.js";
+import type { ChatStreamHandlers, V1ChatRequest, V1Completion, V1StreamEvent } from "./v1.js";
+import { ApiError, type FetchLike } from "./http.js";
+
+const encoder = new TextEncoder();
+
+const REQUEST: V1ChatRequest = {
+ model: "gateway/summary",
+ messages: [{ role: "user", content: "hi" }],
+ stream: false,
+};
+
+function jsonResponse(
+ body: unknown,
+ status = 200,
+ headers: Record = { "content-type": "application/json" },
+): FetchLike {
+ return async () => new Response(JSON.stringify(body), { status, headers });
+}
+
+/** Fake fetch returning an SSE body built from raw text chunks. */
+function sseResponse(chunks: string[]): FetchLike {
+ const stream = new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
+ controller.close();
+ },
+ });
+ return async () =>
+ new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } });
+}
+
+/** Capture the RequestInit a fetch impl receives (header/body assertions). */
+function captureFetch(): { fetchImpl: FetchLike; calls: Array<{ url: string; init: RequestInit }> } {
+ const calls: Array<{ url: string; init: RequestInit }> = [];
+ const fetchImpl: FetchLike = async (url: string | URL | Request, init?: RequestInit) => {
+ calls.push({ url: String(url), init: init ?? {} });
+ return jsonResponse({ ok: true })("http://test");
+ };
+ return { fetchImpl, calls };
+}
+
+async function collectStream(
+ body: V1ChatRequest,
+ handlers: ChatStreamHandlers,
+ fetchImpl: FetchLike,
+): Promise {
+ const events: V1StreamEvent[] = [];
+ for await (const event of chatCompletionStream(body, handlers, {
+ origin: "http://test",
+ fetchImpl,
+ })) {
+ events.push(event);
+ }
+ return events;
+}
+
+const COMPLETION: V1Completion = {
+ id: "cmpl-1",
+ object: "chat.completion",
+ created: 1710000000,
+ model: "gateway/summary",
+ choices: [
+ { index: 0, message: { role: "assistant", content: "Hello!" }, finish_reason: "stop" },
+ ],
+ usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
+};
+
+describe("splitSSEFrames()", () => {
+ test("splits one frame per chunk and keeps an unterminated tail", () => {
+ const state = { rest: "" };
+ expect(splitSSEFrames(state, "data: a\n\n")).toEqual(["data: a"]);
+ expect(state.rest).toBe("");
+ expect(splitSSEFrames(state, "data: b")).toEqual([]);
+ expect(state.rest).toBe("data: b");
+ expect(splitSSEFrames(state, "\n\ndata: c\n\n")).toEqual(["data: b", "data: c"]);
+ expect(state.rest).toBe("");
+ });
+
+ test("completes a frame split across ANY chunk boundary, LF or CRLF", () => {
+ const state = { rest: "" };
+ // LF separator, separator itself split across chunks
+ expect(splitSSEFrames(state, "data: o")).toEqual([]);
+ expect(splitSSEFrames(state, "ne\n")).toEqual([]);
+ expect(splitSSEFrames(state, "\n")).toEqual(["data: one"]);
+ // CRLF separator
+ expect(splitSSEFrames(state, "data: two\r\n\r")).toEqual([]);
+ expect(splitSSEFrames(state, "\n")).toEqual(["data: two"]);
+ expect(state.rest).toBe("");
+ });
+
+ test("handles multiple complete frames in one chunk", () => {
+ const state = { rest: "" };
+ expect(splitSSEFrames(state, "data: 1\n\ndata: 2\n\ndata: 3\n\n")).toEqual([
+ "data: 1",
+ "data: 2",
+ "data: 3",
+ ]);
+ });
+});
+
+describe("v1ErrorFrom()", () => {
+ test("extracts the OpenAI envelope verbatim", () => {
+ const err = v1ErrorFrom(
+ 404,
+ JSON.stringify({
+ error: { message: "The model 'nope' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" },
+ }),
+ );
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(404);
+ expect(err.message).toBe("The model 'nope' does not exist");
+ expect(err.code).toBe("model_not_found");
+ expect(err.errors).toEqual({
+ error: { message: "The model 'nope' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" },
+ });
+ });
+
+ test("keeps non-JSON bodies as raw text verbatim", () => {
+ const err = v1ErrorFrom(502, "upstream exploded");
+ expect(err.status).toBe(502);
+ expect(err.message).toBe("http_502");
+ expect(err.errors).toBe("upstream exploded");
+ });
+});
+
+describe("chatCompletion()", () => {
+ test("parses a JSON completion body", async () => {
+ const result = await chatCompletion(REQUEST, {
+ origin: "http://test",
+ fetchImpl: jsonResponse(COMPLETION),
+ });
+ expect(result).toEqual(COMPLETION);
+ });
+
+ test("posts the typed body to /v1/chat/completions with stream:false and NO Authorization header", async () => {
+ const { fetchImpl, calls } = captureFetch();
+ await chatCompletion(
+ { ...REQUEST, temperature: 0.4 },
+ { origin: "http://test", fetchImpl },
+ );
+ expect(calls).toHaveLength(1);
+ expect(calls[0].url).toBe("http://test/v1/chat/completions");
+ const init = calls[0].init;
+ expect(init.method).toBe("POST");
+ const headers = new Headers(init.headers as HeadersInit);
+ expect(headers.get("authorization")).toBeNull();
+ expect(headers.get("content-type")).toBe("application/json");
+ const sent = JSON.parse(String(init.body)) as Record;
+ expect(sent.model).toBe("gateway/summary");
+ expect(sent.stream).toBe(false);
+ expect(sent.temperature).toBe(0.4);
+ });
+
+ test("omits temperature when not provided", async () => {
+ const { fetchImpl, calls } = captureFetch();
+ await chatCompletion(REQUEST, { origin: "http://test", fetchImpl });
+ const sent = JSON.parse(String(calls[0].init.body)) as Record;
+ expect("temperature" in sent).toBe(false);
+ });
+
+ test("throws ApiError with the 401 Bearer envelope verbatim", async () => {
+ const err = await chatCompletion(REQUEST, {
+ origin: "http://test",
+ fetchImpl: jsonResponse(
+ { error: { message: "Unauthorized", type: "authentication_error", param: null, code: null } },
+ 401,
+ ),
+ }).catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ const apiError = err as ApiError;
+ expect(apiError.status).toBe(401);
+ expect(apiError.message).toBe("Unauthorized");
+ expect(apiError.errors).toMatchObject({ error: { message: "Unauthorized" } });
+ });
+
+ test("throws ApiError with the unknown-model 404 envelope verbatim", async () => {
+ const err = await chatCompletion(
+ { ...REQUEST, model: "qwen-local" },
+ {
+ origin: "http://test",
+ fetchImpl: jsonResponse(
+ { error: { message: "The model 'qwen-local' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" } },
+ 404,
+ ),
+ },
+ ).catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ const apiError = err as ApiError;
+ expect(apiError.status).toBe(404);
+ expect(apiError.message).toBe("The model 'qwen-local' does not exist");
+ expect(apiError.code).toBe("model_not_found");
+ });
+
+ test("rejects as aborted when the signal fires before the request", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ // Realistic browser fetch: an already-aborted signal rejects immediately.
+ const signalAwareFetch: FetchLike = async (_url: string | URL | Request, init?: RequestInit) => {
+ if (init?.signal?.aborted === true) {
+ throw new DOMException("The operation was aborted.", "AbortError");
+ }
+ return new Response("", { status: 200 });
+ };
+ const err = await chatCompletion(REQUEST, {
+ origin: "http://test",
+ fetchImpl: signalAwareFetch,
+ signal: controller.signal,
+ }).catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect((err as ApiError).code).toBe("aborted");
+ });
+});
+
+describe("chatCompletionStream()", () => {
+ const CHUNK_1 = JSON.stringify({
+ id: "cmpl-1",
+ object: "chat.completion.chunk",
+ created: 1710000000,
+ model: "gateway/summary",
+ choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }],
+ });
+ const CHUNK_2 = JSON.stringify({
+ id: "cmpl-1",
+ object: "chat.completion.chunk",
+ created: 1710000000,
+ model: "gateway/summary",
+ choices: [{ index: 0, delta: { content: "!" }, finish_reason: "stop" }],
+ });
+
+ test("yields parsed frames, then done, with raw text and rolled-up content", async () => {
+ const rawParts: string[] = [];
+ let summary: unknown = null;
+ const events = await collectStream(
+ { ...REQUEST, stream: true },
+ {
+ onRaw: (chunk) => rawParts.push(chunk),
+ onDone: (s) => {
+ summary = s;
+ },
+ },
+ sseResponse([`data: ${CHUNK_1}\n\n`, `data: ${CHUNK_2}\n\n`, "data: [DONE]\n\n"]),
+ );
+ expect(events.map((e) => e.type)).toEqual(["frame", "frame", "done"]);
+ expect(rawParts.join("")).toBe(`data: ${CHUNK_1}\n\ndata: ${CHUNK_2}\n\ndata: [DONE]\n\n`);
+ const first = events[0];
+ if (first.type !== "frame") throw new Error("expected first frame");
+ expect(first.payload.choices[0].delta.content).toBe("Hello");
+ expect(summary).toEqual({
+ done: true,
+ raw: `data: ${CHUNK_1}\n\ndata: ${CHUNK_2}\n\ndata: [DONE]\n\n`,
+ content: "Hello!",
+ frames: 2,
+ });
+ });
+
+ test("parses frames split across chunk boundaries (mid-JSON cut)", async () => {
+ const events = await collectStream(
+ { ...REQUEST, stream: true },
+ { onRaw: () => {} },
+ sseResponse([`data: ${CHUNK_1.slice(0, 24)}`, `${CHUNK_1.slice(24)}\n\ndata: [DONE]\n\n`]),
+ );
+ expect(events.map((e) => e.type)).toEqual(["frame", "done"]);
+ });
+
+ test("throws ApiError(404) with the envelope verbatim, before any event", async () => {
+ const events: V1StreamEvent[] = [];
+ let caught: unknown = null;
+ try {
+ for await (const event of chatCompletionStream(
+ { ...REQUEST, stream: true },
+ { onRaw: () => {} },
+ {
+ origin: "http://test",
+ fetchImpl: jsonResponse(
+ { error: { message: "The model 'x' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" } },
+ 404,
+ ),
+ },
+ )) {
+ events.push(event);
+ }
+ } catch (err) {
+ caught = err;
+ }
+ expect(events).toEqual([]);
+ expect(caught).toBeInstanceOf(ApiError);
+ expect((caught as ApiError).status).toBe(404);
+ expect((caught as ApiError).message).toBe("The model 'x' does not exist");
+ });
+
+ test("yields truncated when the body ends without the terminal [DONE]", async () => {
+ let summary: unknown = null;
+ const events = await collectStream(
+ { ...REQUEST, stream: true },
+ {
+ onRaw: () => {},
+ onDone: (s) => {
+ summary = s;
+ },
+ },
+ sseResponse([`data: ${CHUNK_1}\n\n`]),
+ );
+ expect(events.map((e) => e.type)).toEqual(["frame", "truncated"]);
+ expect(summary).toMatchObject({ done: false, frames: 1 });
+ });
+
+ test("yields aborted mid-stream and skips onDone", async () => {
+ const controller = new AbortController();
+ const pendingRead = new Promise((_resolve, reject) => {
+ controller.signal.addEventListener("abort", () => {
+ reject(new DOMException("The operation was aborted.", "AbortError"));
+ }, { once: true });
+ });
+ pendingRead.catch(() => {});
+ const stream = new ReadableStream({
+ start(streamController) {
+ streamController.enqueue(encoder.encode(`data: ${CHUNK_1}\n\n`));
+ },
+ async pull() {
+ await pendingRead;
+ },
+ });
+ const fetchImpl: FetchLike = async () =>
+ new Response(stream, { headers: { "content-type": "text/event-stream" } });
+
+ let doneCalled = false;
+ const events: V1StreamEvent[] = [];
+ const iterator = chatCompletionStream(
+ { ...REQUEST, stream: true },
+ {
+ onRaw: () => {},
+ onDone: () => {
+ doneCalled = true;
+ },
+ signal: controller.signal,
+ },
+ { origin: "http://test", fetchImpl },
+ );
+ for await (const event of iterator) {
+ events.push(event);
+ if (event.type === "frame") controller.abort();
+ }
+ expect(events.map((e) => e.type)).toEqual(["frame", "aborted"]);
+ expect(doneCalled).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/api/v1.ts b/frontend/src/lib/api/v1.ts
new file mode 100644
index 0000000..4535a5b
--- /dev/null
+++ b/frontend/src/lib/api/v1.ts
@@ -0,0 +1,401 @@
+/**
+ * Typed client for the OpenAI-compatible /v1 surface (real backend:
+ * src/routes/v1.ts). The console exercises exactly the wire paths that
+ * exist server-side:
+ *
+ * POST /v1/chat/completions — JSON completion when `stream: false`;
+ * the same endpoint with `stream: true` returns OpenAI-wire SSE: one
+ * `data: {JSON}\n\n` frame per chunk, terminated by EXACTLY one
+ * `data: [DONE]\n\n` (src/routes/relay.ts). Client disconnect makes the
+ * server abort the upstream call (v1.ts streamResponse).
+ *
+ * Error mapping keeps the backend envelope VERBATIM. /v1 errors use the
+ * OpenAI shape `{error:{message,type,param,code}}` — 401 from the Bearer
+ * gate (src/routes/v1.ts auth branch), 404 model_not_found, 4xx/5xx from
+ * the provider gate. They are thrown as ApiError with the parsed envelope
+ * carried on `errors`, so the UI renders what the server actually said.
+ *
+ * This client NEVER sets an Authorization header. Auth lives server-side
+ * (WEAVELLM_AUTH gate, src/routes/auth.ts); if the server demands a Bearer
+ * key it answers 401 and the playground shows that envelope verbatim.
+ *
+ * No Svelte/Astro imports — importable under bun:test with an injected
+ * fetch and/or origin.
+ */
+
+import { getApiOrigin } from "./config.js";
+import { ApiError, type FetchLike } from "./http.js";
+import { dataField, decodeTokenData } from "../sse.js";
+
+export type V1Role = "system" | "user" | "assistant";
+
+export interface V1ChatMessage {
+ role: V1Role;
+ content: string;
+}
+
+/** Request body sent to POST /v1/chat/completions. */
+export interface V1ChatRequest {
+ model: string;
+ messages: V1ChatMessage[];
+ temperature?: number;
+ stream: boolean;
+}
+
+/** usage on the completed wire body — verbatim, only when the server sent it. */
+export interface V1Usage {
+ prompt_tokens: number;
+ completion_tokens: number;
+ total_tokens: number;
+}
+
+export interface V1CompletionChoice {
+ index: number;
+ message: { role: string; content: string };
+ finish_reason: string | null;
+}
+
+/** Non-stream completion body (object: "chat.completion"). */
+export interface V1Completion {
+ id: string;
+ object: "chat.completion";
+ created: number;
+ model: string;
+ choices: V1CompletionChoice[];
+ usage: V1Usage | null;
+}
+
+/**
+ * One parsed SSE data payload on the streaming wire. External adapters can
+ * add extra fields (usage in the final chunk, tool calls, ...) — the index
+ * signatures keep the frame VERBATIM; the UI renders the raw wire text and
+ * never fabricates fields from these.
+ */
+export interface V1ChatChunk {
+ id: string;
+ object: "chat.completion.chunk";
+ created: number;
+ model: string;
+ choices: Array<{
+ index: number;
+ delta: { role?: string; content?: string; [field: string]: unknown };
+ finish_reason?: string | null;
+ }>;
+ [field: string]: unknown;
+}
+
+/** Events yielded by `chatCompletionStream`, in wire order. */
+export type V1StreamEvent =
+ | { readonly type: "frame"; readonly payload: V1ChatChunk }
+ | { readonly type: "done" }
+ | { readonly type: "truncated" }
+ | { readonly type: "aborted" };
+
+/** Rolled-up stream summary handed to `onDone`. */
+export interface V1StreamSummary {
+ /** True when the terminal `data: [DONE]` frame was received. */
+ done: boolean;
+ /** Raw SSE text as received, verbatim (frames + separators). */
+ raw: string;
+ /** Concatenated assistant text via decodeTokenData semantics. */
+ content: string;
+ /** Number of parsed JSON data frames (excludes [DONE]). */
+ frames: number;
+}
+
+export interface V1ClientOptions {
+ /** Override the API origin (defaults to getApiOrigin()). */
+ origin?: string;
+ /** Inject a fetch implementation (tests). Defaults to global fetch. */
+ fetchImpl?: FetchLike;
+ /** Abort the request. */
+ signal?: AbortSignal;
+ /**
+ * Non-stream timeout in ms (default 60_000 — a local completion can take
+ * minutes). Streams have NO timeout: they run until [DONE] or abort.
+ */
+ timeoutMs?: number;
+}
+
+/** Incremental SSE frame state — mutated by splitSSEFrames across chunks. */
+export interface SSEFrameState {
+ rest: string;
+}
+
+/** SSE event separator: blank line between events, LF or CRLF (relay: \n\n). */
+const FRAME_SEPARATOR = /\r?\n\r?\n/;
+
+/**
+ * Feed one decoded text chunk into `state`, returning every COMPLETE SSE
+ * frame it closes (frame body only, separator stripped). An unterminated
+ * tail stays in `state.rest` until a later chunk completes it. Safe across
+ * ANY chunk boundary, LF/CRLF separators, and multiple frames per chunk.
+ */
+export function splitSSEFrames(state: SSEFrameState, chunk: string): string[] {
+ state.rest += chunk;
+ const frames: string[] = [];
+ for (;;) {
+ const match = FRAME_SEPARATOR.exec(state.rest);
+ if (match === null) break;
+ frames.push(state.rest.slice(0, match.index));
+ state.rest = state.rest.slice(match.index + match[0].length);
+ }
+ return frames;
+}
+
+/** OpenAI /v1 error body: {error:{message,...}} or a bare {error:string}. */
+interface V1ErrorEnvelope {
+ error:
+ | { message: string; type?: string | null; param?: unknown; code?: string | null }
+ | string;
+}
+
+function isV1ErrorEnvelope(value: unknown): value is V1ErrorEnvelope {
+ if (typeof value !== "object" || value === null) return false;
+ const error = (value as { error?: unknown }).error;
+ if (typeof error === "string") return true;
+ return (
+ typeof error === "object" &&
+ error !== null &&
+ typeof (error as { message?: unknown }).message === "string"
+ );
+}
+
+/**
+ * Map a non-2xx /v1 response onto ApiError with the envelope VERBATIM in
+ * `errors`: the parsed OpenAI body when JSON, the raw text otherwise.
+ */
+export function v1ErrorFrom(status: number, rawText: string): ApiError {
+ let envelope: unknown = null;
+ let message = "";
+ let code = "";
+ if (rawText.length > 0) {
+ try {
+ const parsed: unknown = JSON.parse(rawText) as unknown;
+ envelope = parsed;
+ if (isV1ErrorEnvelope(parsed)) {
+ if (typeof parsed.error === "string") {
+ message = parsed.error;
+ } else {
+ message = parsed.error.message;
+ // The envelope's own code wins when present (e.g. model_not_found);
+ // ApiError.code stays a stable discriminator for the UI hints.
+ if (typeof parsed.error.code === "string" && parsed.error.code.length > 0) {
+ code = parsed.error.code;
+ }
+ }
+ }
+ } catch {
+ envelope = rawText; // non-JSON error body — keep it verbatim
+ }
+ }
+ const fallback = `http_${status}`;
+ return new ApiError({
+ status,
+ code: code !== "" ? code : message !== "" ? message : fallback,
+ message: message !== "" ? message : fallback,
+ errors: envelope,
+ });
+}
+
+/** Shared /v1 payload builder — only fields the user actually set. */
+function payloadOf(body: V1ChatRequest): Record {
+ const payload: Record = {
+ model: body.model,
+ messages: body.messages,
+ stream: body.stream,
+ };
+ if (body.temperature !== undefined) payload.temperature = body.temperature;
+ return payload;
+}
+
+/**
+ * One-shot (non-streaming) chat completion. Resolves with the parsed
+ * `chat.completion` body, or throws ApiError carrying the backend envelope
+ * verbatim (401 Bearer-required, 404 unknown-model, provider 4xx/5xx).
+ */
+export async function chatCompletion(
+ body: V1ChatRequest,
+ options: V1ClientOptions = {},
+): Promise {
+ const origin = options.origin ?? getApiOrigin();
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const externalSignal = options.signal;
+
+ const timeoutMs = options.timeoutMs ?? 60_000;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ if (externalSignal !== undefined) {
+ if (externalSignal.aborted) controller.abort();
+ else externalSignal.addEventListener("abort", () => controller.abort(), { once: true });
+ }
+
+ let res: Response;
+ try {
+ res = await fetchImpl(`${origin}/v1/chat/completions`, {
+ method: "POST",
+ headers: { accept: "application/json", "content-type": "application/json" },
+ body: JSON.stringify(payloadOf(body)),
+ signal: controller.signal,
+ });
+ } catch (err) {
+ clearTimeout(timer);
+ if (externalSignal?.aborted === true) {
+ throw new ApiError({ status: 0, code: "aborted", message: "request aborted" });
+ }
+ if (controller.signal.aborted) {
+ throw new ApiError({ status: 0, code: "request_timeout", message: "request_timeout" });
+ }
+ throw new ApiError({
+ status: 0,
+ code: "network_error",
+ message: (err as Error)?.message ?? "network_error",
+ });
+ }
+ clearTimeout(timer);
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw v1ErrorFrom(res.status, text);
+ }
+ const text = await res.text();
+ if (text.length === 0) {
+ throw new ApiError({ status: 0, code: "invalid_json", message: "empty response body" });
+ }
+ try {
+ return JSON.parse(text) as V1Completion;
+ } catch {
+ throw new ApiError({ status: 0, code: "invalid_json", message: "response body is not JSON" });
+ }
+}
+
+export interface ChatStreamHandlers {
+ /** Every raw decoded text chunk of the SSE body, verbatim. */
+ onRaw: (chunk: string) => void;
+ /**
+ * Terminal summary, called exactly once after [DONE] or on truncation.
+ * Never called on abort — the consumer observes the `aborted` event.
+ */
+ onDone?: (summary: V1StreamSummary) => void;
+ /** Aborts the fetch and closes the reader; the server aborts upstream. */
+ signal?: AbortSignal;
+}
+
+function streamSummary(raw: string, frames: number, done: boolean): V1StreamSummary {
+ return { done, raw, frames, content: decodeTokenData(raw).content };
+}
+
+/**
+ * Streaming chat completion. Async generator over wire events in order
+ * (`frame` → ... → `done`; `truncated` when the body ended without the
+ * terminal [DONE]; `aborted` when `signal` fired). Raw text goes to
+ * `handlers.onRaw` as it arrives so the UI renders the live transcript;
+ * JSON frames are yielded parsed; the rolled-up content reuses
+ * decodeTokenData semantics on the aggregated buffer.
+ *
+ * Non-2xx responses throw ApiError with the envelope verbatim BEFORE any
+ * event is yielded. Aborting the signal makes the generator yield `aborted`
+ * and close the reader — the browser closes the socket, so the server's
+ * relay unwinds and aborts the upstream call (v1.ts contract).
+ */
+export async function* chatCompletionStream(
+ body: V1ChatRequest,
+ handlers: ChatStreamHandlers,
+ options: V1ClientOptions = {},
+): AsyncGenerator {
+ const origin = options.origin ?? getApiOrigin();
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const signal = handlers.signal;
+
+ let res: Response;
+ try {
+ res = await fetchImpl(`${origin}/v1/chat/completions`, {
+ method: "POST",
+ headers: { accept: "text/event-stream", "content-type": "application/json" },
+ body: JSON.stringify(payloadOf(body)),
+ signal,
+ });
+ } catch (err) {
+ if (signal?.aborted === true) {
+ yield { type: "aborted" };
+ return;
+ }
+ throw new ApiError({
+ status: 0,
+ code: "network_error",
+ message: (err as Error)?.message ?? "network_error",
+ });
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw v1ErrorFrom(res.status, text);
+ }
+ if (res.body === null) {
+ throw new ApiError({ status: 0, code: "network_error", message: "empty stream body" });
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ const state: SSEFrameState = { rest: "" };
+ let readError: unknown = null;
+ let raw = "";
+ let frames = 0;
+ let finished = false;
+
+ try {
+ readerLoop: for (;;) {
+ // read() never rejects here: an errored read resolves done and sets
+ // readError, so abandoning the generator mid-read can never leave an
+ // unhandled rejection behind.
+ const result = await reader.read().catch((err: unknown): ReadableStreamReadResult => {
+ readError = err;
+ return { done: true, value: new Uint8Array(0) };
+ });
+ if (result.done) break;
+
+ const text = decoder.decode(result.value, { stream: true });
+ raw += text;
+ handlers.onRaw(text);
+
+ for (const frame of splitSSEFrames(state, text)) {
+ const payloadText = dataField(frame);
+ if (payloadText === null) continue;
+ if (payloadText === "[DONE]") {
+ finished = true;
+ yield { type: "done" };
+ break readerLoop;
+ }
+ try {
+ frames += 1;
+ yield { type: "frame", payload: JSON.parse(payloadText) as V1ChatChunk };
+ } catch {
+ // Non-JSON data frame — kept in the raw transcript, not thrown.
+ }
+ }
+ }
+ } finally {
+ try {
+ reader.releaseLock();
+ } catch {
+ // A read is still pending (consumer abandoned the generator without
+ // aborting): cancelling the body makes the server see the disconnect.
+ void reader.cancel().catch(() => {});
+ }
+ }
+
+ if (readError !== null) {
+ if (signal?.aborted === true) {
+ yield { type: "aborted" };
+ return;
+ }
+ throw new ApiError({
+ status: 0,
+ code: "network_error",
+ message: (readError as Error)?.message ?? "stream read failed",
+ });
+ }
+
+ if (!finished) yield { type: "truncated" };
+ handlers.onDone?.(streamSummary(raw, frames, finished));
+}
\ No newline at end of file
diff --git a/frontend/src/lib/api/workflows.test.ts b/frontend/src/lib/api/workflows.test.ts
new file mode 100644
index 0000000..0fa173d
--- /dev/null
+++ b/frontend/src/lib/api/workflows.test.ts
@@ -0,0 +1,105 @@
+/**
+ * bun:test suite for the workflows surface (workflows.ts). Mirrors
+ * config.test.ts (U01) style — describe/test, imports "./x.js" — and injects
+ * a fake fetch via WorkflowOptions.fetchImpl, so no real network/backend.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ deleteWorkflow,
+ getWorkflow,
+ listWorkflows,
+ runWorkflow,
+ saveWorkflow,
+ workflowLogs,
+} from "./workflows.js";
+import type { ExecutionLogRow, WorkflowWithYaml } from "./types.js";
+import type { FetchLike } from "./http.js";
+
+function jsonBody(
+ body: unknown,
+ status = 200,
+ headers: Record = { "content-type": "application/json" },
+): FetchLike {
+ return async () =>
+ new Response(JSON.stringify(body), { status, headers });
+}
+
+describe("workflows surface", () => {
+ test("listWorkflows() returns metadata rows", async () => {
+ const rows = [{ name: "summary", version: 3, updatedAt: "2024-01-01T00:00:00Z" }];
+ const result = await listWorkflows({
+ origin: "http://test",
+ fetchImpl: jsonBody(rows),
+ });
+ expect(result).toEqual(rows);
+ });
+
+ test("getWorkflow() returns the record with yaml", async () => {
+ const row: WorkflowWithYaml = {
+ name: "summary",
+ version: 3,
+ updatedAt: "2024-01-01T00:00:00Z",
+ yaml: "name: summary\n",
+ };
+ const result = await getWorkflow("summary", {
+ origin: "http://test",
+ fetchImpl: jsonBody(row),
+ });
+ expect(result.yaml).toContain("name: summary");
+ });
+
+ test("saveWorkflow() PUTs raw YAML text and returns the envelope", async () => {
+ const yaml = "name: summary\nversion: 1\n";
+ const result = await saveWorkflow("summary", yaml, {
+ origin: "http://test",
+ fetchImpl: jsonBody({ ok: true, name: "summary", version: 4 }),
+ });
+ expect(result).toEqual({ ok: true, name: "summary", version: 4 });
+ });
+
+ test("deleteWorkflow() resolves on 204", async () => {
+ const result = await deleteWorkflow("summary", {
+ origin: "http://test",
+ fetchImpl: jsonBody(undefined, 204),
+ });
+ expect(result).toBeUndefined();
+ });
+
+ test("runWorkflow() POSTs messages and returns the completion", async () => {
+ const result = await runWorkflow(
+ "summary",
+ [{ role: "user", content: "hi" }],
+ {
+ origin: "http://test",
+ fetchImpl: jsonBody({
+ id: "cmpl-1",
+ object: "chat.completion",
+ created: 1,
+ model: "gateway/summary",
+ choices: [
+ {
+ index: 0,
+ message: { role: "assistant", content: "Hello!" },
+ finish_reason: "stop",
+ },
+ ],
+ usage: null,
+ }),
+ },
+ );
+ expect(result.model).toBe("gateway/summary");
+ expect(result.choices[0].message.content).toBe("Hello!");
+ });
+
+ test("workflowLogs() returns execution history rows", async () => {
+ const rows: ExecutionLogRow[] = [
+ { id: "e1", workflowId: "summary", status: "ok", error: null, startedAt: "2024-01-01T00:00:00Z", ms: 12 },
+ ];
+ const result = await workflowLogs("summary", {
+ origin: "http://test",
+ fetchImpl: jsonBody(rows),
+ });
+ expect(result).toEqual(rows);
+ });
+});
diff --git a/frontend/src/lib/api/workflows.ts b/frontend/src/lib/api/workflows.ts
new file mode 100644
index 0000000..b0398d7
--- /dev/null
+++ b/frontend/src/lib/api/workflows.ts
@@ -0,0 +1,87 @@
+/**
+ * Typed client for the workflow CRUD + run + logs surface (NO auth gate —
+ * the workflows branch is intentionally open on the backend).
+ *
+ * Real backend surface (src/routes/api.ts, workflows branch):
+ * GET /api/workflows → WorkflowRecord[]
+ * GET /api/workflows/:name → WorkflowWithYaml | 404 not_found
+ * PUT /api/workflows/:name → YAML text body → SaveWorkflowResult | 400
+ * DELETE /api/workflows/:name → 204 | 404
+ * POST /api/workflows/:name/run → OpenAI chat body → ChainCompletion | 400/404/502
+ * GET /api/workflows/:name/logs → ExecutionLogRow[] | 404
+ *
+ * The PUT body is RAW YAML TEXT (not JSON) — `saveWorkflow` sends YAML via
+ * the http layer's rawBody path, exactly like the backend reads req.text().
+ */
+
+import { request, type FetchLike } from "./http.js";
+import type {
+ ExecutionLogRow,
+ RunWorkflowResult,
+ SaveWorkflowResult,
+ WorkflowRecord,
+ WorkflowWithYaml,
+} from "./types.js";
+
+export interface WorkflowOptions {
+ origin?: string;
+ fetchImpl?: FetchLike;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+}
+
+/** GET /api/workflows → metadata rows. */
+export async function listWorkflows(options: WorkflowOptions = {}): Promise {
+ return request("/api/workflows", { method: "GET", ...options });
+}
+
+/** GET /api/workflows/:name → {name,version,updatedAt,yaml} | throws 404. */
+export async function getWorkflow(name: string, options: WorkflowOptions = {}): Promise {
+ return request(`/api/workflows/${encodeURIComponent(name)}`, {
+ method: "GET",
+ ...options,
+ });
+}
+
+/** PUT /api/workflows/:name — raw YAML text body → {ok,name,version}. */
+export async function saveWorkflow(
+ name: string,
+ yaml: string,
+ options: WorkflowOptions = {},
+): Promise {
+ return request(`/api/workflows/${encodeURIComponent(name)}`, {
+ method: "PUT",
+ rawBody: yaml,
+ rawBodyType: "application/x-yaml",
+ ...options,
+ });
+}
+
+/** DELETE /api/workflows/:name → 204; throws 404. */
+export async function deleteWorkflow(name: string, options: WorkflowOptions = {}): Promise {
+ return request(`/api/workflows/${encodeURIComponent(name)}`, {
+ method: "DELETE",
+ ...options,
+ });
+}
+
+/** POST /api/workflows/:name/run — OpenAI chat messages → completion. */
+export async function runWorkflow(
+ name: string,
+ messages: { role: string; content: string }[],
+ options: WorkflowOptions = {},
+): Promise {
+ return request(`/api/workflows/${encodeURIComponent(name)}/run`, {
+ method: "POST",
+ jsonBody: { messages },
+ ...options,
+ });
+}
+
+/** GET /api/workflows/:name/logs → execution history rows. */
+export async function workflowLogs(name: string, options: WorkflowOptions = {}): Promise {
+ return request(`/api/workflows/${encodeURIComponent(name)}/logs`, {
+ method: "GET",
+ ...options,
+ });
+}
diff --git a/frontend/src/lib/executions-ui.test.ts b/frontend/src/lib/executions-ui.test.ts
new file mode 100644
index 0000000..c2c7d47
--- /dev/null
+++ b/frontend/src/lib/executions-ui.test.ts
@@ -0,0 +1,155 @@
+/**
+ * bun:test suite for executions-ui.ts (U10) — pure, deterministic helpers.
+ * Mirrors the sibling suites (models-ui.test.ts / workflows-ui.test.ts):
+ * describe/test, imports "./x.js", no network, no Svelte imports.
+ */
+
+import { describe, expect, test } from "bun:test";
+import type { ExecutionLogRow } from "./api/types.js";
+import {
+ EXECUTION_STATUS_CHIPS,
+ executionRowView,
+ latestRun,
+ summarizeExecutions,
+} from "./executions-ui.js";
+import { formatLocalDateTime, formatMs } from "./workflows-ui.js";
+
+const OK_ROW: ExecutionLogRow = {
+ id: "r-ok-1",
+ workflowId: "demo",
+ status: "ok",
+ error: null,
+ startedAt: "2026-09-20T14:30:00.000Z",
+ ms: 1240,
+};
+
+const ERR_ROW: ExecutionLogRow = {
+ id: "r-err-1",
+ workflowId: "demo",
+ status: "error",
+ error: "llama-server exited with code 1",
+ startedAt: "2026-09-20T15:00:00.000Z",
+ ms: null,
+};
+
+const ERR_EMPTY_MSG: ExecutionLogRow = {
+ id: "r-err-2",
+ workflowId: "demo",
+ status: "error",
+ error: "",
+ startedAt: "2026-09-20T15:10:00.000Z",
+ ms: 500,
+};
+
+const OK_BAD_DATE: ExecutionLogRow = {
+ id: "r-ok-2",
+ workflowId: "demo",
+ status: "ok",
+ error: null,
+ startedAt: "not-a-date",
+ ms: 12,
+};
+
+describe("summarizeExecutions", () => {
+ test("counts ok and error over a mixed array", () => {
+ expect(summarizeExecutions([OK_ROW, ERR_ROW, OK_ROW])).toEqual({
+ total: 3,
+ ok: 2,
+ error: 1,
+ });
+ });
+
+ test("an empty array is all-zero, never invented numbers", () => {
+ expect(summarizeExecutions([])).toEqual({ total: 0, ok: 0, error: 0 });
+ });
+
+ test("an all-error array counts zero ok", () => {
+ expect(summarizeExecutions([ERR_ROW, ERR_ROW])).toEqual({
+ total: 2,
+ ok: 0,
+ error: 2,
+ });
+ });
+});
+
+describe("latestRun", () => {
+ test("empty rows have no latest run", () => {
+ expect(latestRun([])).toBeNull();
+ });
+
+ test("newest-first input (the backend contract) returns the first row", () => {
+ const rows = [ERR_ROW, OK_ROW];
+ expect(latestRun(rows)).toBe(ERR_ROW);
+ });
+
+ test("is defensive against unsorted input — returns the actual newest", () => {
+ const rows = [OK_ROW, ERR_ROW, OK_BAD_DATE];
+ const latest = latestRun(rows);
+ expect(latest).toBe(ERR_ROW);
+ });
+
+ test("invalid timestamps sort last, never as the newest run", () => {
+ expect(latestRun([OK_BAD_DATE, OK_ROW])).toBe(OK_ROW);
+ });
+
+ test("ties keep their input order (stable sort)", () => {
+ const same = [
+ { ...OK_ROW, id: "a" },
+ { ...OK_ROW, id: "b" },
+ { ...OK_ROW, id: "c" },
+ ];
+ expect(latestRun(same)?.id).toBe("a");
+ });
+});
+
+describe("EXECUTION_STATUS_CHIPS", () => {
+ test("the two real statuses map to stable chips", () => {
+ expect(EXECUTION_STATUS_CHIPS.ok).toEqual({ tone: "ok", label: "OK" });
+ expect(EXECUTION_STATUS_CHIPS.error).toEqual({ tone: "error", label: "Error" });
+ });
+});
+
+describe("executionRowView", () => {
+ test("ok rows carry the ok chip, formatted time and duration, no error", () => {
+ const view = executionRowView(OK_ROW);
+ expect(view.key).toBe("r-ok-1");
+ expect(view.status).toBe("ok");
+ expect(view.chip).toEqual(EXECUTION_STATUS_CHIPS.ok);
+ expect(view.startedAt).toBe(formatLocalDateTime(new Date(OK_ROW.startedAt)));
+ expect(view.startedAt).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
+ expect(view.duration).toBe(formatMs(1240));
+ expect(view.duration).toBe("1.2s");
+ expect(view.error).toBeNull();
+ expect(view.errorCell).toBe("—");
+ });
+
+ test("error rows expose the verbatim message and an error chip", () => {
+ const view = executionRowView(ERR_ROW);
+ expect(view.status).toBe("error");
+ expect(view.chip).toEqual(EXECUTION_STATUS_CHIPS.error);
+ expect(view.error).toBe("llama-server exited with code 1");
+ expect(view.errorCell).toBe("llama-server exited with code 1");
+ });
+
+ test("null ms renders a dash duration", () => {
+ expect(executionRowView(ERR_ROW).duration).toBe("—");
+ });
+
+ test("an empty error string on an error row renders as no error", () => {
+ const view = executionRowView(ERR_EMPTY_MSG);
+ expect(view.error).toBeNull();
+ expect(view.errorCell).toBe("—");
+ });
+
+ test("invalid startedAt renders a dash, never NaN", () => {
+ expect(executionRowView(OK_BAD_DATE).startedAt).toBe("—");
+ });
+
+ test("long error messages truncate with an ellipsis in the cell", () => {
+ const long = "x".repeat(200);
+ const view = executionRowView({ ...ERR_ROW, error: long });
+ expect(view.errorCell.length).toBe(97); // 96 chars + the ellipsis
+ expect(view.errorCell.endsWith("…")).toBe(true);
+ expect(view.error).toBe(long); // the full text survives for titles
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/executions-ui.ts b/frontend/src/lib/executions-ui.ts
new file mode 100644
index 0000000..69b85b5
--- /dev/null
+++ b/frontend/src/lib/executions-ui.ts
@@ -0,0 +1,117 @@
+/**
+ * Pure display helpers for the executions surface (U10).
+ *
+ * Deterministic and unit-tested in executions-ui.test.ts. No IO, no Svelte
+ * imports: importable under bun:test. Every value derives from the
+ * ExecutionLogRow[] arrays the backend actually serves (GET
+ * /api/workflows/:name/logs — src/orchestrator/store.ts selects
+ * `ORDER BY started_at DESC`, so rows arrive newest first).
+ *
+ * Honest-aggregate discipline: there is NO global executions endpoint, so a
+ * page summary can only sum the per-workflow log rows it actually fetched.
+ * These helpers count exactly the rows they are given — nothing more, and
+ * never "all-time" numbers.
+ */
+
+import type { ExecutionLogRow } from "./api/types.js";
+import { errorCell } from "./models-ui.js";
+import { formatLocalDateTime, formatMs } from "./workflows-ui.js";
+
+/** The two real log statuses the backend writes (store.ts recordExecution). */
+export type ExecutionStatus = ExecutionLogRow["status"];
+
+/** Counts over one workflow's log rows — total and per-status buckets. */
+export interface ExecutionSummary {
+ total: number;
+ ok: number;
+ error: number;
+}
+
+/**
+ * Count a log array by status. Every bucket derives from the rows verbatim;
+ * an empty array maps to all-zero counts (the honest "No runs yet" case).
+ */
+export function summarizeExecutions(rows: readonly ExecutionLogRow[]): ExecutionSummary {
+ const summary: ExecutionSummary = { total: rows.length, ok: 0, error: 0 };
+ for (const row of rows) {
+ if (row.status === "ok") {
+ summary.ok += 1;
+ } else {
+ summary.error += 1;
+ }
+ }
+ return summary;
+}
+
+/**
+ * Parse an ISO timestamp; invalid input sorts last so broken rows never
+ * masquerade as the newest run.
+ */
+function timestampMs(iso: string): number {
+ const ms = Date.parse(iso);
+ return Number.isFinite(ms) ? ms : -Infinity;
+}
+
+/**
+ * The most recent run: rows sorted by startedAt descending, first element.
+ * The backend already orders newest-first; the defensive sort keeps the
+ * helper correct against any input order, with stable ties (equal
+ * timestamps keep their input order).
+ */
+export function latestRun(rows: readonly ExecutionLogRow[]): ExecutionLogRow | null {
+ if (rows.length === 0) return null;
+ const sorted = [...rows].sort((a, b) => timestampMs(b.startedAt) - timestampMs(a.startedAt));
+ return sorted[0] ?? null;
+}
+
+/** Chip mapping per status. `tone` is a StatusDot tone; `label` the chip text. */
+export const EXECUTION_STATUS_CHIPS = {
+ ok: { tone: "ok", label: "OK" },
+ error: { tone: "error", label: "Error" },
+} as const;
+
+export type ExecutionStatusChip = (typeof EXECUTION_STATUS_CHIPS)[ExecutionStatus];
+
+/** Absolute local "YYYY-MM-DD HH:mm"; invalid timestamps render "—". */
+function formatStartedAt(iso: string): string {
+ const ms = timestampMs(iso);
+ if (!Number.isFinite(ms)) return "—";
+ return formatLocalDateTime(new Date(ms));
+}
+
+/** How far error text is truncated in the summary line (title keeps the full text). */
+const ERROR_CELL_MAX = 96;
+
+/**
+ * Display view for one log row: stable {#each} key, chip mapping, formatted
+ * timestamps/duration, and the error surface (full + truncated). The caller
+ * renders exactly this — no row-level formatting logic in the component.
+ */
+export interface ExecutionRowView {
+ /** Stable identity for {#each} keys — the backend log row id. */
+ key: string;
+ status: ExecutionStatus;
+ chip: ExecutionStatusChip;
+ /** Absolute local "YYYY-MM-DD HH:mm". */
+ startedAt: string;
+ /** "1.2s" / "384ms" / "—". */
+ duration: string;
+ /** Verbatim backend message on error rows, null otherwise. */
+ error: string | null;
+ /** Ellipsis-truncated error text for compact rows. */
+ errorCell: string;
+}
+
+export function executionRowView(row: ExecutionLogRow): ExecutionRowView {
+ const isError = row.status === "error" && row.error !== null && row.error !== "";
+ const error: string | null = isError ? row.error : null;
+ return {
+ key: row.id,
+ status: row.status,
+ chip: EXECUTION_STATUS_CHIPS[row.status],
+ startedAt: formatStartedAt(row.startedAt),
+ duration: formatMs(row.ms),
+ error,
+ errorCell: errorCell(error ?? undefined, ERROR_CELL_MAX),
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/lib/focus-trap.ts b/frontend/src/lib/focus-trap.ts
new file mode 100644
index 0000000..ab39572
--- /dev/null
+++ b/frontend/src/lib/focus-trap.ts
@@ -0,0 +1,56 @@
+/**
+ * Keyboard focus containment for modal dialogs (WAI-ARIA dialog pattern).
+ *
+ * On activation it moves focus to the first interactive element inside
+ * `container` unless focus already landed there, then traps Tab / Shift+Tab
+ * within it (cycles at both ends). Returns a cleanup that detaches the key
+ * listener.
+ *
+ * DOM glue (querySelectorAll + focus) — exercised through the console's
+ * dialog components, not unit-tested (no DOM harness in this repo).
+ */
+
+const FOCUSABLE_SELECTOR = [
+ "a[href]",
+ "button:not([disabled])",
+ "input:not([disabled])",
+ "select:not([disabled])",
+ "textarea:not([disabled])",
+ '[tabindex]:not([tabindex="-1"])',
+].join(",");
+
+function focusables(container: HTMLElement): HTMLElement[] {
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
+ (el) => el.getClientRects().length > 0,
+ );
+}
+
+export function trapFocus(container: HTMLElement): () => void {
+ function onKeydown(event: KeyboardEvent): void {
+ if (event.key !== "Tab") return;
+ const els = focusables(container);
+ const first = els.at(0);
+ const last = els.at(-1);
+ if (first === undefined || last === undefined) return;
+ const active = document.activeElement;
+ const focusInside = active instanceof HTMLElement && container.contains(active);
+ if (event.shiftKey) {
+ if (!focusInside || active === first) {
+ event.preventDefault();
+ last.focus();
+ }
+ } else if (!focusInside || active === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ // Move focus in unless it already landed inside (some dialogs focus a
+ // specific field themselves).
+ const active = document.activeElement;
+ const focusInside = active instanceof HTMLElement && container.contains(active);
+ if (!focusInside) focusables(container).at(0)?.focus();
+
+ container.addEventListener("keydown", onKeydown);
+ return () => container.removeEventListener("keydown", onKeydown);
+}
\ No newline at end of file
diff --git a/frontend/src/lib/models-ui.test.ts b/frontend/src/lib/models-ui.test.ts
new file mode 100644
index 0000000..9920009
--- /dev/null
+++ b/frontend/src/lib/models-ui.test.ts
@@ -0,0 +1,143 @@
+/**
+ * bun:test suite for models-ui.ts (U08) — pure, deterministic helpers.
+ * Mirrors the sibling suites (overview.test.ts / workflows-ui.test.ts):
+ * describe/test, imports "./x.js", no network, no Svelte imports.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ MODEL_STATE_CHIPS,
+ errorCell,
+ formatError,
+ formatPid,
+ formatPort,
+ modelRows,
+ stateChip,
+ summarizeModels,
+} from "./models-ui.js";
+import type { ModelStatus } from "./api/types.js";
+
+const ACTIVE: ModelStatus = { id: "zebra-7b", state: "active", pid: 4242, port: 11180 };
+const DISABLED: ModelStatus = { id: "alpha-1b", state: "disabled" };
+const ERROR: ModelStatus = {
+ id: "mid-3b",
+ state: "error",
+ error: "GGUF file not found: '(none)'",
+};
+
+describe("summarizeModels", () => {
+ test("counts each state bucket over a mixed registry", () => {
+ expect(summarizeModels([ACTIVE, DISABLED, ERROR, ACTIVE])).toEqual({
+ total: 4,
+ active: 2,
+ disabled: 1,
+ error: 1,
+ });
+ });
+
+ test("an empty registry is all-zero, never invented numbers", () => {
+ expect(summarizeModels([])).toEqual({ total: 0, active: 0, disabled: 0, error: 0 });
+ });
+
+ test("error bucket counts only state error", () => {
+ expect(summarizeModels([ERROR, DISABLED])).toEqual({
+ total: 2,
+ active: 0,
+ disabled: 1,
+ error: 1,
+ });
+ });
+});
+
+describe("stateChip / MODEL_STATE_CHIPS", () => {
+ test("the three real states map to stable chips", () => {
+ expect(stateChip("active")).toEqual(MODEL_STATE_CHIPS.active);
+ expect(stateChip("disabled")).toEqual(MODEL_STATE_CHIPS.disabled);
+ expect(stateChip("error")).toEqual(MODEL_STATE_CHIPS.error);
+ });
+
+ test("chip contract: ok/idle/error tones with Active/Disabled/Error labels", () => {
+ expect(MODEL_STATE_CHIPS.active).toEqual({ tone: "ok", label: "Active" });
+ expect(MODEL_STATE_CHIPS.disabled).toEqual({ tone: "idle", label: "Disabled" });
+ expect(MODEL_STATE_CHIPS.error).toEqual({ tone: "error", label: "Error" });
+ });
+});
+
+describe("modelRows", () => {
+ const registry = [ACTIVE, DISABLED, ERROR];
+
+ test("no hints → every model sorted by id ascending", () => {
+ expect(modelRows(registry).map((m) => m.id)).toEqual(["alpha-1b", "mid-3b", "zebra-7b"]);
+ });
+
+ test("hint ids pin first, in the given order, rest sorted after", () => {
+ const rows = modelRows(registry, ["zebra-7b", "alpha-1b"]);
+ expect(rows.map((m) => m.id)).toEqual(["zebra-7b", "alpha-1b", "mid-3b"]);
+ expect(rows[0]).toEqual(ACTIVE);
+ expect(rows[1]).toEqual(DISABLED);
+ });
+
+ test("duplicate hint ids pin once", () => {
+ expect(modelRows(registry, ["zebra-7b", "zebra-7b"]).map((m) => m.id)).toEqual([
+ "zebra-7b",
+ "alpha-1b",
+ "mid-3b",
+ ]);
+ });
+
+ test("hint ids absent from the registry are ignored", () => {
+ expect(modelRows(registry, ["ghost"]).map((m) => m.id)).toEqual([
+ "alpha-1b",
+ "mid-3b",
+ "zebra-7b",
+ ]);
+ });
+
+ test("empty registry stays empty", () => {
+ expect(modelRows([])).toEqual([]);
+ });
+});
+
+describe("formatPid / formatPort", () => {
+ test("numbers render verbatim", () => {
+ expect(formatPid(4242)).toBe("4242");
+ expect(formatPort(11180)).toBe("11180");
+ });
+
+ test("absent values render a dash", () => {
+ expect(formatPid(undefined)).toBe("—");
+ expect(formatPort(undefined)).toBe("—");
+ });
+
+ test("non-finite numbers render a dash, never NaN", () => {
+ expect(formatPid(Number.NaN)).toBe("—");
+ expect(formatPort(Number.POSITIVE_INFINITY)).toBe("—");
+ });
+});
+
+describe("formatError / errorCell", () => {
+ test("the backend message renders verbatim", () => {
+ expect(formatError("spawn failed: boom")).toBe("spawn failed: boom");
+ });
+
+ test("absent errors render a dash", () => {
+ expect(formatError(undefined)).toBe("—");
+ expect(formatError("")).toBe("—");
+ });
+
+ test("short messages pass through untruncated", () => {
+ expect(errorCell("spawn failed")).toBe("spawn failed");
+ });
+
+ test("long messages truncate with an ellipsis at the max length", () => {
+ const long = "x".repeat(200);
+ const cell = errorCell(long);
+ expect(cell.length).toBe(97); // 96 chars + the ellipsis
+ expect(cell.endsWith("…")).toBe(true);
+ expect(cell.startsWith(long.slice(0, 96))).toBe(true);
+ });
+
+ test("absent errors stay a dash even under truncation", () => {
+ expect(errorCell(undefined)).toBe("—");
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/models-ui.ts b/frontend/src/lib/models-ui.ts
new file mode 100644
index 0000000..342e5e7
--- /dev/null
+++ b/frontend/src/lib/models-ui.ts
@@ -0,0 +1,109 @@
+/**
+ * Pure display helpers for the models registry surface (U08).
+ *
+ * Deterministic and unit-tested in models-ui.test.ts. No IO, no Svelte
+ * imports: importable under bun:test. Every value derives from the
+ * ModelStatus[] rows the backend actually serves (src/backend/hub.ts —
+ * state is exactly "active" | "disabled" | "error", plus optional
+ * pid/port/error). Nothing here invents telemetry: there is no GPU/VRAM/
+ * throughput data anywhere in the real surface, so none is rendered.
+ */
+
+import type { ModelStatus } from "./api/types.js";
+
+/** Registry state means exactly the three real hub states. */
+export type ModelState = ModelStatus["state"];
+
+/** Counts over a registry snapshot — total and per-state buckets. */
+export interface ModelCounts {
+ total: number;
+ active: number;
+ disabled: number;
+ error: number;
+}
+
+/**
+ * Count the registry by state. Every bucket derives from the rows verbatim;
+ * an empty registry maps to all-zero counts (the honest "No models" case).
+ */
+export function summarizeModels(models: readonly ModelStatus[]): ModelCounts {
+ const counts: ModelCounts = { total: models.length, active: 0, disabled: 0, error: 0 };
+ for (const model of models) {
+ if (model.state === "active") {
+ counts.active += 1;
+ } else if (model.state === "disabled") {
+ counts.disabled += 1;
+ } else {
+ counts.error += 1;
+ }
+ }
+ return counts;
+}
+
+/**
+ * Chip mapping per state. `tone` is a StatusDot tone; `label` the chip text.
+ * The literal values are the stable contract tests assert against.
+ */
+export const MODEL_STATE_CHIPS = {
+ active: { tone: "ok", label: "Active" },
+ disabled: { tone: "idle", label: "Disabled" },
+ error: { tone: "error", label: "Error" },
+} as const;
+
+export type ModelStateChip = (typeof MODEL_STATE_CHIPS)[ModelState];
+
+/** Resolve the chip mapping for one state. */
+export function stateChip(state: ModelState): ModelStateChip {
+ return MODEL_STATE_CHIPS[state];
+}
+
+/**
+ * Display order for the registry table: hint ids first (pinned, in the given
+ * order, deduped, only those actually present in the registry), then every
+ * remaining model sorted by id ascending. Deterministic, so tests and
+ * renders always agree.
+ */
+export function modelRows(
+ registry: readonly ModelStatus[],
+ hintIds: readonly string[] = [],
+): ModelStatus[] {
+ const pinnedIds = new Set();
+ const pinned: ModelStatus[] = [];
+ for (const id of hintIds) {
+ if (pinnedIds.has(id)) continue;
+ const match = registry.find((model) => model.id === id);
+ if (match === undefined) continue;
+ pinnedIds.add(id);
+ pinned.push(match);
+ }
+ const rest = registry
+ .filter((model) => !pinnedIds.has(model.id))
+ .sort((a, b) => a.id.localeCompare(b.id));
+ return [...pinned, ...rest];
+}
+
+/** pid display: the number verbatim, "—" when the backend sends none. */
+export function formatPid(pid: number | undefined): string {
+ return typeof pid === "number" && Number.isFinite(pid) ? String(pid) : "—";
+}
+
+/** port display: the number verbatim, "—" when the backend sends none. */
+export function formatPort(port: number | undefined): string {
+ return typeof port === "number" && Number.isFinite(port) ? String(port) : "—";
+}
+
+/** error display: the backend message verbatim, "—" when there is none. */
+export function formatError(error: string | undefined): string {
+ return typeof error === "string" && error.length > 0 ? error : "—";
+}
+
+/**
+ * Error cell copy: the full message when it fits, ellipsis-truncated beyond
+ * `max`. The row keeps the full text in its title attribute, so truncation
+ * never loses information.
+ */
+export function errorCell(error: string | undefined, max = 96): string {
+ const text = formatError(error);
+ if (text === "—" || text.length <= max) return text;
+ return `${text.slice(0, max)}…`;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts
new file mode 100644
index 0000000..aedfe37
--- /dev/null
+++ b/frontend/src/lib/navigation.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test";
+import { NAV_GROUPS, NAV_ITEMS, isRouteActive } from "./navigation.js";
+
+describe("navigation registry", () => {
+ test("every href is unique", () => {
+ const hrefs = NAV_ITEMS.map((item) => item.href);
+ expect(new Set(hrefs).size).toBe(hrefs.length);
+ });
+
+ test("every href is root-relative", () => {
+ for (const item of NAV_ITEMS) {
+ expect(item.href.startsWith("/")).toBe(true);
+ }
+ });
+
+ test("every group has at least one item", () => {
+ for (const group of NAV_GROUPS) {
+ expect(group.items.length).toBeGreaterThan(0);
+ }
+ });
+
+ test("exact path matches its item", () => {
+ expect(isRouteActive("/workflows", "/workflows")).toBe(true);
+ });
+
+ test("nested path highlights its parent item", () => {
+ expect(isRouteActive("/models", "/models/catalog")).toBe(true);
+ });
+
+ test("root never highlights nested paths", () => {
+ expect(isRouteActive("/", "/workflows")).toBe(false);
+ });
+
+ test("sibling section does not highlight", () => {
+ expect(isRouteActive("/workflows", "/models")).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts
new file mode 100644
index 0000000..3a42bed
--- /dev/null
+++ b/frontend/src/lib/navigation.ts
@@ -0,0 +1,177 @@
+/**
+ * Console navigation registry — single source of truth for sidebar links,
+ * command palette entries and active-route matching. Pure data + pure
+ * functions, so the same module serves Astro SSR, Svelte islands and tests.
+ */
+
+export const NAV_SECTIONS = {
+ overview: "Overview",
+ build: "Build",
+ manage: "Manage",
+ observe: "Observe",
+ system: "System",
+} as const;
+
+/**
+ * Stable section id (a key of NAV_SECTIONS) — display labels live in
+ * NAV_SECTIONS itself (`NAV_SECTIONS[section]`), so ids stay lowercase.
+ */
+export type NavSection = keyof typeof NAV_SECTIONS;
+
+/** Inline SVG icon names (geometry lives in svelte/common/Icon.svelte). */
+export const ICON_NAMES = {
+ overview: "overview",
+ workflows: "workflows",
+ models: "models",
+ catalog: "catalog",
+ downloads: "downloads",
+ providers: "providers",
+ executions: "executions",
+ api: "api",
+ runtime: "runtime",
+ settings: "settings",
+ about: "about",
+ search: "search",
+ github: "github",
+ menu: "menu",
+ close: "close",
+ back: "back",
+ chevron: "chevron",
+ plus: "plus",
+ play: "play",
+ check: "check",
+} as const;
+
+export type IconName = (typeof ICON_NAMES)[keyof typeof ICON_NAMES];
+
+export interface NavItem {
+ href: string;
+ label: string;
+ description: string;
+ icon: IconName;
+ section: NavSection;
+}
+
+export interface NavGroup {
+ section: NavSection;
+ items: readonly NavItem[];
+}
+
+export const NAV_GROUPS: readonly NavGroup[] = [
+ {
+ section: "overview",
+ items: [
+ {
+ href: "/",
+ label: "Overview",
+ description: "Runtime overview and quick actions",
+ icon: "overview",
+ section: "overview",
+ },
+ ],
+ },
+ {
+ section: "build",
+ items: [
+ {
+ href: "/workflows",
+ label: "Workflows",
+ description: "Graph workflow library and editor",
+ icon: "workflows",
+ section: "build",
+ },
+ ],
+ },
+ {
+ section: "manage",
+ items: [
+ {
+ href: "/models",
+ label: "Models",
+ description: "Local model registry",
+ icon: "models",
+ section: "manage",
+ },
+ {
+ href: "/models/catalog",
+ label: "Model catalog",
+ description: "Curated and community models",
+ icon: "catalog",
+ section: "manage",
+ },
+ {
+ href: "/models/downloads",
+ label: "Downloads",
+ description: "Checksum-verified GGUF downloads",
+ icon: "downloads",
+ section: "manage",
+ },
+ {
+ href: "/providers",
+ label: "Providers",
+ description: "External provider connections",
+ icon: "providers",
+ section: "manage",
+ },
+ ],
+ },
+ {
+ section: "observe",
+ items: [
+ {
+ href: "/executions",
+ label: "Executions",
+ description: "Workflow run logs",
+ icon: "executions",
+ section: "observe",
+ },
+ {
+ href: "/playground",
+ label: "API",
+ description: "OpenAI-compatible playground",
+ icon: "api",
+ section: "observe",
+ },
+ {
+ href: "/runtime",
+ label: "Runtime",
+ description: "Backend health and endpoint",
+ icon: "runtime",
+ section: "observe",
+ },
+ ],
+ },
+ {
+ section: "system",
+ items: [
+ {
+ href: "/settings",
+ label: "Settings",
+ description: "Application configuration",
+ icon: "settings",
+ section: "system",
+ },
+ {
+ href: "/about",
+ label: "About",
+ description: "Version and credits",
+ icon: "about",
+ section: "system",
+ },
+ ],
+ },
+];
+
+/** Flat list over every group (sidebar renders groups, palette renders flat). */
+export const NAV_ITEMS: readonly NavItem[] = NAV_GROUPS.flatMap(
+ (group) => group.items,
+);
+
+/**
+ * Active-route rule: exact match, or a path nested under the item. The root
+ * href ("/") never prefix-matches, so Overview only highlights on `/`.
+ */
+export function isRouteActive(href: string, current: string): boolean {
+ if (href === "/") return current === "/";
+ return current === href || current.startsWith(`${href}/`);
+}
\ No newline at end of file
diff --git a/frontend/src/lib/overview.test.ts b/frontend/src/lib/overview.test.ts
new file mode 100644
index 0000000..2351cd9
--- /dev/null
+++ b/frontend/src/lib/overview.test.ts
@@ -0,0 +1,142 @@
+/**
+ * Unit tests for the pure overview mapping (overview.ts). No IO: each test
+ * feeds a deterministic input object and asserts on the derived snapshot —
+ * empty inputs, state counts, newest-first truncation, unreachable runtime
+ * and allSettled-style partial inputs (missing pieces arrive as empty arrays
+ * / null / false and must map deterministically).
+ */
+
+import { describe, expect, test } from "bun:test";
+import { buildOverviewSnapshot, RECENT_WORKFLOWS_LIMIT } from "./overview.js";
+import type { OverviewInput } from "./overview.js";
+import type { HealthStatus, ModelStatus, WorkflowRecord } from "./api/types.js";
+
+const NOW = 1_700_000_000_000;
+
+function sampleHealth(): HealthStatus {
+ return { status: "ok" };
+}
+
+function sampleModel(id: string, state: ModelStatus["state"], error?: string): ModelStatus {
+ return error === undefined ? { id, state } : { id, state, error };
+}
+
+function sampleWorkflow(name: string, version = 1): WorkflowRecord {
+ return {
+ name,
+ version,
+ updatedAt: `2026-09-${String(version).padStart(2, "0")}T10:00:00Z`,
+ };
+}
+
+function baseInput(): OverviewInput {
+ return {
+ health: sampleHealth(),
+ authEnabled: false,
+ reachable: true,
+ models: [],
+ workflows: [],
+ now: NOW,
+ };
+}
+
+describe("buildOverviewSnapshot", () => {
+ test("empty inputs produce a deterministic zeroed snapshot", () => {
+ const snapshot = buildOverviewSnapshot({
+ health: null,
+ authEnabled: false,
+ reachable: false,
+ models: [],
+ workflows: [],
+ now: NOW,
+ });
+
+ expect(snapshot).toEqual({
+ runtime: { reachable: false, status: "unavailable", authEnabled: false },
+ models: { total: 0, active: 0, error: 0 },
+ workflows: { total: 0, recent: [] },
+ fetchedAt: NOW,
+ });
+ });
+
+ test("counts active and error states from the model rows", () => {
+ const snapshot = buildOverviewSnapshot({
+ ...baseInput(),
+ authEnabled: true,
+ models: [
+ sampleModel("a", "active"),
+ sampleModel("b", "active"),
+ sampleModel("c", "disabled"),
+ sampleModel("d", "error", "spawn failed"),
+ ],
+ });
+
+ expect(snapshot.runtime).toEqual({
+ reachable: true,
+ status: "ok",
+ authEnabled: true,
+ });
+ expect(snapshot.models).toEqual({ total: 4, active: 2, error: 1 });
+ });
+
+ test("truncates the newest-first workflow list to the limit", () => {
+ const workflows = Array.from({ length: RECENT_WORKFLOWS_LIMIT + 2 }, (_x, i) =>
+ sampleWorkflow(`wf-${i}`, i + 1),
+ );
+ const snapshot = buildOverviewSnapshot({
+ ...baseInput(),
+ workflows,
+ });
+
+ expect(snapshot.workflows.total).toBe(RECENT_WORKFLOWS_LIMIT + 2);
+ expect(snapshot.workflows.recent).toHaveLength(RECENT_WORKFLOWS_LIMIT);
+ expect(snapshot.workflows.recent[0].name).toBe("wf-0");
+ expect(snapshot.workflows.recent[RECENT_WORKFLOWS_LIMIT - 1].name).toBe(
+ `wf-${RECENT_WORKFLOWS_LIMIT - 1}`,
+ );
+ });
+
+ test("keeps the caller-given newest-first order untouched", () => {
+ const workflows = [sampleWorkflow("newest", 4), sampleWorkflow("older", 1)];
+ const snapshot = buildOverviewSnapshot({ ...baseInput(), workflows });
+
+ expect(snapshot.workflows.recent.map((wf) => wf.name)).toEqual(["newest", "older"]);
+ });
+
+ test("an unreachable runtime reads unavailable even when health data exists", () => {
+ const snapshot = buildOverviewSnapshot({
+ health: sampleHealth(),
+ authEnabled: false,
+ reachable: false,
+ models: [sampleModel("a", "active")],
+ workflows: [sampleWorkflow("w")],
+ now: NOW,
+ });
+
+ expect(snapshot.runtime).toEqual({
+ reachable: false,
+ status: "unavailable",
+ authEnabled: false,
+ });
+ // The rest of the dashboard still maps from whatever actually arrived.
+ expect(snapshot.models.total).toBe(1);
+ expect(snapshot.workflows.total).toBe(1);
+ });
+
+ test("allSettled-style partial inputs map deterministically", () => {
+ // Mirrors the island: a failed service call lands as an empty array.
+ const snapshot = buildOverviewSnapshot({
+ health: null,
+ authEnabled: true,
+ reachable: true,
+ models: [],
+ workflows: [],
+ now: NOW,
+ });
+
+ expect(snapshot.runtime.status).toBe("ok");
+ expect(snapshot.runtime.authEnabled).toBe(true);
+ expect(snapshot.models).toEqual({ total: 0, active: 0, error: 0 });
+ expect(snapshot.workflows).toEqual({ total: 0, recent: [] });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/overview.ts b/frontend/src/lib/overview.ts
new file mode 100644
index 0000000..27463ce
--- /dev/null
+++ b/frontend/src/lib/overview.ts
@@ -0,0 +1,76 @@
+/**
+ * Overview dashboard data mapping — PURE transform (no IO).
+ *
+ * The Overview island gathers live inputs through the typed API clients
+ * (./api) and funnels them through `buildOverviewSnapshot`, so every derived
+ * value is deterministic and fully unit-testable. Missing data is represented
+ * honestly: an unreachable backend maps to runtime.status "unavailable", and a
+ * failed service call arrives from the caller as an empty array / null — never
+ * an invented number.
+ */
+
+import type { HealthStatus, ModelStatus, WorkflowRecord } from "./api/types.js";
+
+/** Input gathered by the island (or a test) before mapping. */
+export interface OverviewInput {
+ /** GET /api/health body, or null when the backend is unreachable. */
+ health: HealthStatus | null;
+ /** True when a bearer-token gate guards the /api/models branch. */
+ authEnabled: boolean;
+ /** True when the /api/health probe answered (any status, incl. 401). */
+ reachable: boolean;
+ /** Registry snapshot; empty when the models branch failed. */
+ models: ModelStatus[];
+ /** Workflow metadata rows (newest first); empty when the call failed. */
+ workflows: WorkflowRecord[];
+ /** Epoch ms captured at fetch time — injected so the mapper stays pure. */
+ now: number;
+}
+
+/** How many of the newest-first workflow rows the dashboard lists. */
+export const RECENT_WORKFLOWS_LIMIT = 5;
+
+/** Derived dashboard state — every value comes from the input, honestly. */
+export interface OverviewSnapshot {
+ runtime: {
+ reachable: boolean;
+ /** "ok" when the runtime probe answered, "unavailable" otherwise. */
+ status: "ok" | "unavailable";
+ authEnabled: boolean;
+ };
+ models: {
+ total: number;
+ /** Rows in state "active". */
+ active: number;
+ /** Rows in state "error". */
+ error: number;
+ };
+ workflows: {
+ total: number;
+ /** First RECENT_WORKFLOWS_LIMIT rows of the newest-first list. */
+ recent: Array<{ name: string; version: number; updatedAt: string }>;
+ };
+ fetchedAt: number;
+}
+
+/** Map the raw gathered inputs onto the dashboard snapshot. */
+export function buildOverviewSnapshot(input: OverviewInput): OverviewSnapshot {
+ const active = input.models.filter((model) => model.state === "active").length;
+ const error = input.models.filter((model) => model.state === "error").length;
+ const recent = input.workflows.slice(0, RECENT_WORKFLOWS_LIMIT).map((workflow) => ({
+ name: workflow.name,
+ version: workflow.version,
+ updatedAt: workflow.updatedAt,
+ }));
+
+ return {
+ runtime: {
+ reachable: input.reachable,
+ status: input.reachable ? "ok" : "unavailable",
+ authEnabled: input.authEnabled,
+ },
+ models: { total: input.models.length, active, error },
+ workflows: { total: input.workflows.length, recent },
+ fetchedAt: input.now,
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/lib/run-transcript.test.ts b/frontend/src/lib/run-transcript.test.ts
new file mode 100644
index 0000000..a683bcf
--- /dev/null
+++ b/frontend/src/lib/run-transcript.test.ts
@@ -0,0 +1,219 @@
+/**
+ * bun:test suite for the run-view state machine (run-transcript.ts). All
+ * events are applied synchronously in arrival order; each test drives an
+ * explicit event sequence and asserts the resulting snapshot.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ RUN_STATUS,
+ createRunTranscript,
+ applyRunEvent,
+} from "./run-transcript.js";
+import type { RunTranscript, RunStep } from "./run-transcript.js";
+import type { WorkflowWsEvent } from "./ws.js";
+
+function status(
+ state: "bound" | "running" | "ok" | "error",
+ error?: string,
+): WorkflowWsEvent {
+ return { type: "status", workflow: "demo", state, error };
+}
+
+function started(nodeId: string, nodeType = "llm_call"): WorkflowWsEvent {
+ return { type: "step_started", workflow: "demo", nodeId, nodeType };
+}
+
+function completed(nodeId: string, ms: number): WorkflowWsEvent {
+ return { type: "step_completed", workflow: "demo", nodeId, ms };
+}
+
+function token(data: string): WorkflowWsEvent {
+ return { type: "token", workflow: "demo", data };
+}
+
+function runError(error: string, nodeId?: string): WorkflowWsEvent {
+ return { type: "error", workflow: "demo", error, nodeId };
+}
+
+function contentFrame(text: string): string {
+ return `data: {"choices":[{"message":{"content":${JSON.stringify(text)}}}]}\n\n`;
+}
+
+function doneFrame(): string {
+ return "data: [DONE]\n\n";
+}
+
+function run(fresh: RunTranscript, ...events: WorkflowWsEvent[]): RunTranscript {
+ return events.reduce(applyRunEvent, fresh);
+}
+
+describe("createRunTranscript", () => {
+ test("starts idle with no steps, output or error", () => {
+ expect(createRunTranscript()).toEqual({
+ status: "idle",
+ steps: [],
+ output: "",
+ done: false,
+ });
+ });
+
+ test("accepts an explicit initial status (connecting)", () => {
+ expect(createRunTranscript(RUN_STATUS.CONNECTING).status).toBe("connecting");
+ });
+});
+
+describe("applyRunEvent — lifecycle", () => {
+ test("status bound is a no-op for the run view", () => {
+ const t = run(createRunTranscript(), status("bound"));
+ expect(t.status).toBe("idle");
+ });
+
+ test("status running marks the run active", () => {
+ const t = run(createRunTranscript(), status("running"));
+ expect(t.status).toBe("running");
+ });
+
+ test("status ok is terminal", () => {
+ const fresh = createRunTranscript();
+ const t = run(fresh, status("running"), status("ok"));
+ expect(t.status).toBe("ok");
+ expect(t.done).toBe(true);
+ expect(t.error).toBeUndefined();
+ });
+
+ test("status error is terminal and carries the verbatim error", () => {
+ const t = run(createRunTranscript(), status("running"), status("error", "boom"));
+ expect(t.status).toBe("error");
+ expect(t.done).toBe(true);
+ expect(t.error).toBe("boom");
+ });
+});
+
+describe("applyRunEvent — steps", () => {
+ test("step_started appends a running step", () => {
+ const t = run(createRunTranscript(), started("llm-1", "llm_call"));
+ expect(t.steps).toEqual([{ nodeId: "llm-1", nodeType: "llm_call", state: "running" }]);
+ });
+
+ test("an earlier running step resolves to done when a new step starts", () => {
+ const t = run(createRunTranscript(), started("start"), started("llm-1"));
+ expect(t.steps.map((s) => s.state)).toEqual(["done", "running"]);
+ });
+
+ test("step_completed marks the running step done with its duration", () => {
+ const t = run(createRunTranscript(), started("llm-1"), completed("llm-1", 384));
+ const step = t.steps[0] as RunStep;
+ expect(step.state).toBe("done");
+ expect(step.ms).toBe(384);
+ });
+
+ test("step_completed for an unknown node leaves the trace untouched", () => {
+ const t = run(createRunTranscript(), started("llm-1"), completed("ghost", 5));
+ expect(t.steps[0]?.state).toBe("running");
+ });
+
+ test("the same node id across loop iterations keeps separate step entries", () => {
+ const t = run(
+ createRunTranscript(),
+ started("llm-1"),
+ completed("llm-1", 10),
+ started("llm-1"),
+ completed("llm-1", 20),
+ );
+ expect(t.steps).toHaveLength(2);
+ expect(t.steps.map((s) => s.ms)).toEqual([10, 20]);
+ expect(t.steps.every((s) => s.state === "done")).toBe(true);
+ });
+});
+
+describe("applyRunEvent — tokens", () => {
+ test("token frames append decoded content in order", () => {
+ const t = run(
+ createRunTranscript(),
+ token(contentFrame("The ")),
+ token(contentFrame("answer.")),
+ );
+ expect(t.output).toBe("The answer.");
+ expect(t.done).toBe(false);
+ });
+
+ test("a [DONE] token frame sets done without appending content", () => {
+ const t = run(createRunTranscript(), token(contentFrame("hi")), token(doneFrame()));
+ expect(t.output).toBe("hi");
+ expect(t.done).toBe(true);
+ });
+
+ test("empty token frames leave the output untouched", () => {
+ const t = run(createRunTranscript(), token(""));
+ expect(t.output).toBe("");
+ expect(t.done).toBe(false);
+ });
+});
+
+describe("applyRunEvent — errors", () => {
+ test("an error with a nodeId marks that running step error", () => {
+ const t = run(createRunTranscript(), started("llm-1"), runError("boom", "llm-1"));
+ expect(t.steps[0]?.state).toBe("error");
+ // Terminality is decided by the runner's status error, not the step error.
+ expect(t.status).toBe("idle");
+ expect(t.done).toBe(false);
+ });
+
+ test("an error without a nodeId (protocol-level) ends the run", () => {
+ const t = run(createRunTranscript(), runError("unknown workflow \"ghost\""));
+ expect(t.status).toBe("error");
+ expect(t.done).toBe(true);
+ expect(t.error).toBe('unknown workflow "ghost"');
+ });
+
+ test("a step error followed by status error stays terminal with the status text", () => {
+ const t = run(
+ createRunTranscript(),
+ started("llm-1"),
+ runError("boom", "llm-1"),
+ status("error", "boom"),
+ );
+ expect(t.status).toBe("error");
+ expect(t.done).toBe(true);
+ expect(t.error).toBe("boom");
+ expect(t.steps[0]?.state).toBe("error");
+ });
+});
+
+describe("applyRunEvent — full wire sequence", () => {
+ test("sequential happy path mirrors the real protocol", () => {
+ const fresh = createRunTranscript(RUN_STATUS.CONNECTING);
+ const t = run(
+ fresh,
+ status("bound"),
+ status("running"),
+ started("start"),
+ completed("start", 2),
+ started("llm-1"),
+ completed("llm-1", 384),
+ started("end"),
+ completed("end", 1),
+ token(contentFrame("Hello!")),
+ token(doneFrame()),
+ status("ok"),
+ );
+ expect(t.status).toBe("ok");
+ expect(t.done).toBe(true);
+ expect(t.output).toBe("Hello!");
+ expect(t.steps.map((s) => s.nodeId)).toEqual(["start", "llm-1", "end"]);
+ expect(t.steps.every((s) => s.state === "done")).toBe(true);
+ });
+
+ test("protocol error mid-run ends with the error visible", () => {
+ const t = run(
+ createRunTranscript(RUN_STATUS.CONNECTING),
+ status("running"),
+ started("llm-1"),
+ token("data: not-json\n\n"),
+ runError("received a message that is not valid JSON"),
+ );
+ expect(t.status).toBe("error");
+ expect(t.error).toBe("received a message that is not valid JSON");
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/run-transcript.ts b/frontend/src/lib/run-transcript.ts
new file mode 100644
index 0000000..0e9793f
--- /dev/null
+++ b/frontend/src/lib/run-transcript.ts
@@ -0,0 +1,150 @@
+/**
+ * Pure run-view state machine for the workflow RunPanel (frontend, U07).
+ *
+ * Turns the WS event stream (WorkflowWsEvent, ../lib/ws.ts) into the panel's
+ * render model: a status, a step trace, and aggregated output text. Events
+ * are applied synchronously in arrival order — the machine never reorders or
+ * batches them. No IO, no Svelte: importable under bun:test.
+ */
+
+import { decodeTokenData } from "./sse.js";
+import type { WorkflowWsEvent } from "./ws.js";
+
+export const RUN_STATUS = {
+ IDLE: "idle",
+ CONNECTING: "connecting",
+ RUNNING: "running",
+ OK: "ok",
+ ERROR: "error",
+} as const;
+
+export type RunStatus = (typeof RUN_STATUS)[keyof typeof RUN_STATUS];
+
+export const STEP_STATE = {
+ RUNNING: "running",
+ DONE: "done",
+ ERROR: "error",
+} as const;
+
+export type StepState = (typeof STEP_STATE)[keyof typeof STEP_STATE];
+
+export interface RunStep {
+ nodeId: string;
+ nodeType: string;
+ /** Duration in ms once the step completed. */
+ ms?: number;
+ state: StepState;
+}
+
+export interface RunTranscript {
+ status: RunStatus;
+ /** Step lifecycle events in arrival order (loops may repeat node ids). */
+ steps: RunStep[];
+ /** Aggregated assistant output decoded from token events. */
+ output: string;
+ /** True once the run finished (status ok/error or a [DONE] token frame). */
+ done: boolean;
+ /** Verbatim run error (status error / error events). */
+ error?: string;
+}
+
+/** Fresh run view; `connecting` is the status the panel sets while opening. */
+export function createRunTranscript(status: RunStatus = RUN_STATUS.IDLE): RunTranscript {
+ return { status, steps: [], output: "", done: false };
+}
+
+/** Apply one wire event to the transcript, returning the next snapshot. */
+export function applyRunEvent(
+ transcript: RunTranscript,
+ event: WorkflowWsEvent,
+): RunTranscript {
+ switch (event.type) {
+ case "status":
+ return applyStatus(transcript, event);
+ case "step_started":
+ return applyStepStarted(transcript, event);
+ case "step_completed":
+ return applyStepCompleted(transcript, event);
+ case "token":
+ return applyToken(transcript, event);
+ case "error":
+ return applyError(transcript, event);
+ }
+}
+
+function applyStatus(
+ transcript: RunTranscript,
+ event: Extract,
+): RunTranscript {
+ switch (event.state) {
+ case "running":
+ return { ...transcript, status: RUN_STATUS.RUNNING };
+ case "ok":
+ return { ...transcript, status: RUN_STATUS.OK, done: true, error: undefined };
+ case "error":
+ return { ...transcript, status: RUN_STATUS.ERROR, done: true, error: event.error };
+ default:
+ // "bound" carries no run-view transition.
+ return transcript;
+ }
+}
+
+function applyStepStarted(
+ transcript: RunTranscript,
+ event: Extract,
+): RunTranscript {
+ // A new step starting resolves any still-running predecessor(s) to done —
+ // the engine's events are sequential per path; any branch the runner left
+ // running converges to done once its own step_completed arrives.
+ const steps = transcript.steps.map((step) =>
+ step.state === STEP_STATE.RUNNING ? { ...step, state: STEP_STATE.DONE } : step,
+ );
+ steps.push({
+ nodeId: event.nodeId,
+ nodeType: event.nodeType,
+ state: STEP_STATE.RUNNING,
+ });
+ return { ...transcript, steps };
+}
+
+function applyStepCompleted(
+ transcript: RunTranscript,
+ event: Extract,
+): RunTranscript {
+ const steps = transcript.steps.map((step) =>
+ step.nodeId === event.nodeId && step.state === STEP_STATE.RUNNING
+ ? { ...step, state: STEP_STATE.DONE, ms: event.ms }
+ : step,
+ );
+ return { ...transcript, steps };
+}
+
+function applyToken(
+ transcript: RunTranscript,
+ event: Extract,
+): RunTranscript {
+ const decoded = decodeTokenData(event.data);
+ return {
+ ...transcript,
+ output:
+ decoded.content.length > 0 ? transcript.output + decoded.content : transcript.output,
+ done: transcript.done || decoded.done,
+ };
+}
+
+function applyError(
+ transcript: RunTranscript,
+ event: Extract,
+): RunTranscript {
+ if (event.nodeId !== undefined) {
+ // A step-level error marks that step; the runner's status error follows.
+ const steps = transcript.steps.map((step) =>
+ step.nodeId === event.nodeId && step.state === STEP_STATE.RUNNING
+ ? { ...step, state: STEP_STATE.ERROR }
+ : step,
+ );
+ return { ...transcript, steps };
+ }
+ // Protocol-level errors (bind failure, bad JSON, closed mid-run) end the run.
+ return { ...transcript, status: RUN_STATUS.ERROR, done: true, error: event.error };
+}
\ No newline at end of file
diff --git a/frontend/src/lib/runtime-ui.test.ts b/frontend/src/lib/runtime-ui.test.ts
new file mode 100644
index 0000000..e41a916
--- /dev/null
+++ b/frontend/src/lib/runtime-ui.test.ts
@@ -0,0 +1,163 @@
+/**
+ * Unit tests for the pure runtime display helpers (runtime-ui.ts). No IO:
+ * each test feeds deterministic inputs and asserts on the mapped views —
+ * health with/without latency and localModels, the websocket probe state
+ * machine (happy path, error path, retry reset, illegal-transition no-ops)
+ * and the package.json version line.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ WS_PROBE_INITIAL,
+ formatLatency,
+ formatLocalModels,
+ healthView,
+ versionLine,
+ wsProbeState,
+} from "./runtime-ui.js";
+import type { WsProbeState } from "./runtime-ui.js";
+
+describe("healthView", () => {
+ test("a null health maps to placeholders and a neutral tone", () => {
+ expect(healthView(null, null)).toEqual({
+ status: null,
+ statusText: "—",
+ latency: "—",
+ localModels: "—",
+ tone: "neutral",
+ });
+ });
+
+ test("an answered probe shows the backend status verbatim", () => {
+ const view = healthView({ status: "ok" }, 12.7);
+ expect(view.status).toBe("ok");
+ expect(view.statusText).toBe("ok");
+ expect(view.tone).toBe("ok");
+ });
+
+ test("localModels count renders with pluralization when present", () => {
+ expect(healthView({ status: "ok", localModels: ["a"] }, null).localModels).toBe(
+ "1 local model",
+ );
+ expect(healthView({ status: "ok", localModels: ["a", "b"] }, null).localModels).toBe(
+ "2 local models",
+ );
+ expect(healthView({ status: "ok", localModels: [] }, null).localModels).toBe(
+ "0 local models",
+ );
+ });
+
+ test("localModels is an honest dash when the backend sends none", () => {
+ expect(healthView({ status: "ok" }, null).localModels).toBe("—");
+ });
+});
+
+describe("formatLatency", () => {
+ test("rounds milliseconds below one second", () => {
+ expect(formatLatency(3.2)).toBe("3 ms");
+ expect(formatLatency(250.6)).toBe("251 ms");
+ });
+
+ test("switches to seconds at and past one second", () => {
+ expect(formatLatency(1000)).toBe("1.0 s");
+ expect(formatLatency(1234)).toBe("1.2 s");
+ });
+
+ test("dashes for absent or invalid measurements", () => {
+ expect(formatLatency(null)).toBe("—");
+ expect(formatLatency(Number.NaN)).toBe("—");
+ expect(formatLatency(-5)).toBe("—");
+ });
+});
+
+describe("formatLocalModels", () => {
+ test("dashes when the key is absent (hub not wired)", () => {
+ expect(formatLocalModels(undefined)).toBe("—");
+ });
+
+ test("counts the array when present, singular for one", () => {
+ expect(formatLocalModels([])).toBe("0 local models");
+ expect(formatLocalModels(["a"])).toBe("1 local model");
+ expect(formatLocalModels(["a", "b", "c"])).toBe("3 local models");
+ });
+});
+
+describe("wsProbeState", () => {
+ test("happy path: idle → connecting → connected → closed", () => {
+ const started = wsProbeState(WS_PROBE_INITIAL, { type: "start" });
+ expect(started).toEqual({ phase: "connecting", everConnected: false, error: null });
+
+ const opened = wsProbeState(started, { type: "open" });
+ expect(opened).toEqual({ phase: "connected", everConnected: true, error: null });
+
+ const closed = wsProbeState(opened, { type: "close" });
+ expect(closed.phase).toBe("closed");
+ // Closed after a successful handshake keeps the positive record.
+ expect(closed.everConnected).toBe(true);
+ });
+
+ test("error path: connecting → error keeps the message", () => {
+ const state = wsProbeState(
+ wsProbeState(WS_PROBE_INITIAL, { type: "start" }),
+ { type: "error", error: "websocket error" },
+ );
+ expect(state).toEqual({ phase: "error", everConnected: false, error: "websocket error" });
+ });
+
+ test("retry resets the probe from any phase", () => {
+ const errored: WsProbeState = {
+ phase: "error",
+ everConnected: false,
+ error: "websocket error",
+ };
+ expect(wsProbeState(errored, { type: "start" })).toEqual({
+ phase: "connecting",
+ everConnected: false,
+ error: null,
+ });
+
+ const closed: WsProbeState = { phase: "closed", everConnected: true, error: null };
+ expect(wsProbeState(closed, { type: "start" }).phase).toBe("connecting");
+ });
+
+ test("illegal transitions are no-ops (late browser events never corrupt)", () => {
+ // close / open / error before any probe: unchanged.
+ expect(wsProbeState(WS_PROBE_INITIAL, { type: "close" })).toBe(WS_PROBE_INITIAL);
+ expect(wsProbeState(WS_PROBE_INITIAL, { type: "open" })).toBe(WS_PROBE_INITIAL);
+ expect(wsProbeState(WS_PROBE_INITIAL, { type: "error", error: "x" })).toBe(
+ WS_PROBE_INITIAL,
+ );
+
+ // open from connected / closed: unchanged.
+ const connected: WsProbeState = { phase: "connected", everConnected: true, error: null };
+ expect(wsProbeState(connected, { type: "open" })).toBe(connected);
+
+ // close arriving after error (browser fires onclose after onerror): unchanged.
+ const errored: WsProbeState = {
+ phase: "error",
+ everConnected: true,
+ error: "websocket error",
+ };
+ expect(wsProbeState(errored, { type: "close" })).toBe(errored);
+
+ // error arriving after a clean close: unchanged.
+ const closed: WsProbeState = { phase: "closed", everConnected: true, error: null };
+ expect(wsProbeState(closed, { type: "error", error: "x" })).toBe(closed);
+ });
+});
+
+describe("versionLine", () => {
+ test("renders name and version from the package metadata", () => {
+ expect(versionLine({ name: "weavellm", version: "0.1.0" })).toBe("weavellm v0.1.0");
+ });
+
+ test("falls back to the bare version when the name is missing", () => {
+ expect(versionLine({ version: "0.1.0" })).toBe("v0.1.0");
+ });
+
+ test("dashes when there is no version to show", () => {
+ expect(versionLine({ name: "weavellm" })).toBe("—");
+ expect(versionLine({})).toBe("—");
+ expect(versionLine({ name: "weavellm", version: "" })).toBe("—");
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/runtime-ui.ts b/frontend/src/lib/runtime-ui.ts
new file mode 100644
index 0000000..836193d
--- /dev/null
+++ b/frontend/src/lib/runtime-ui.ts
@@ -0,0 +1,146 @@
+/**
+ * Pure display helpers for the Runtime page (U12).
+ *
+ * Deterministic and unit-tested in runtime-ui.test.ts. No IO, no Svelte
+ * imports: importable under bun:test. Everything derives from the REAL
+ * surface — GET /api/health (status + measured latency + localModels), the
+ * runtime auth probe outcome (runtime.ts) and the package.json version —
+ * with honest absence: values the probe did not observe render as "—",
+ * never as invented telemetry.
+ */
+
+import type { HealthStatus } from "./api/types.js";
+
+/** Tone choices the Runtime cards render (StatusCard / StatusDot set). */
+export type RuntimeTone = "ok" | "error" | "neutral";
+
+/** Display view for the Health card, from health + measured latency. */
+export interface HealthView {
+ /** The backend status verbatim ("ok"), or null when nothing answered. */
+ status: HealthStatus["status"] | null;
+ /** "ok" verbatim, or "—" when there is no answer. */
+ statusText: string;
+ /** Real round-trip latency line ("123 ms", "1.2 s"), or "—". */
+ latency: string;
+ /** localModels line ("3 local models"), or "—" when none was sent. */
+ localModels: string;
+ tone: Exclude;
+}
+
+/**
+ * Map the health probe onto its display view. A null health is the honest
+ * "no answer" case (backend unreachable, probe failed, or not probed yet) —
+ * it maps to placeholders, never to invented ok/error telemetry.
+ */
+export function healthView(
+ health: HealthStatus | null,
+ latencyMs: number | null,
+): HealthView {
+ if (health === null) {
+ return {
+ status: null,
+ statusText: "—",
+ latency: "—",
+ localModels: "—",
+ tone: "neutral",
+ };
+ }
+ return {
+ status: health.status,
+ statusText: health.status,
+ latency: formatLatency(latencyMs),
+ localModels: formatLocalModels(health.localModels),
+ tone: "ok",
+ };
+}
+
+/** Latency line: rounded ms, seconds past 1s, "—" when not measurable. */
+export function formatLatency(ms: number | null): string {
+ if (ms === null || !Number.isFinite(ms) || ms < 0) return "—";
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)} s`;
+ return `${Math.round(ms)} ms`;
+}
+
+/** localModels line: count of hub-served ids, "—" when the key is absent. */
+export function formatLocalModels(count: string[] | undefined): string {
+ if (count === undefined) return "—";
+ return count.length === 1 ? "1 local model" : `${count.length} local models`;
+}
+
+/** Phases of the one-shot WebSocket probe (not the run protocol). */
+export const WS_PROBE_PHASES = {
+ IDLE: "idle",
+ CONNECTING: "connecting",
+ CONNECTED: "connected",
+ CLOSED: "closed",
+ ERROR: "error",
+} as const;
+
+export type WsProbePhase = (typeof WS_PROBE_PHASES)[keyof typeof WS_PROBE_PHASES];
+
+/** Probe state machine state. */
+export interface WsProbeState {
+ phase: WsProbePhase;
+ /** True once the socket ever reached open — closed-after-ok reads positively. */
+ everConnected: boolean;
+ /** Last error message; present only in the error phase. */
+ error: string | null;
+}
+
+/** Initial state: no probe has run yet. */
+export const WS_PROBE_INITIAL: WsProbeState = {
+ phase: "idle",
+ everConnected: false,
+ error: null,
+};
+
+/** Probe lifecycle actions (a probe connects, waits, then closes). */
+export type WsProbeAction =
+ | { type: "start" }
+ | { type: "open" }
+ | { type: "close" }
+ | { type: "error"; error: string };
+
+/**
+ * Pure probe-state reducer. Transitions are deliberately narrow:
+ * start → connecting (any phase; a retry re-probes from scratch)
+ * open → connected (only from connecting)
+ * close → closed (only from connecting / connected)
+ * error → error (only from connecting / connected)
+ * Anything else is a no-op, so late browser events (e.g. close after error)
+ * never corrupt the honest state.
+ */
+export function wsProbeState(state: WsProbeState, action: WsProbeAction): WsProbeState {
+ switch (action.type) {
+ case "start":
+ return { phase: "connecting", everConnected: false, error: null };
+ case "open":
+ if (state.phase !== "connecting") return state;
+ return { ...state, phase: "connected", everConnected: true };
+ case "close":
+ if (state.phase !== "connecting" && state.phase !== "connected") return state;
+ return { ...state, phase: "closed" };
+ case "error":
+ if (state.phase !== "connecting" && state.phase !== "connected") return state;
+ return { phase: "error", everConnected: state.everConnected, error: action.error };
+ }
+}
+
+/** Minimum package metadata the version card renders (root package.json). */
+export interface PackageMeta {
+ name?: string;
+ version?: string;
+}
+
+/**
+ * Version line: "WeaveLLM v0.1.0" from the build-time package.json. Falls
+ * back to "v0.1.0" when the name is missing, and to "—" when there is no
+ * version to show — the version is whatever the package really declares.
+ */
+export function versionLine(pkg: PackageMeta): string {
+ const version =
+ typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : null;
+ if (version === null) return "—";
+ const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : "";
+ return name.length > 0 ? `${name} v${version}` : `v${version}`;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/settings-ui.test.ts b/frontend/src/lib/settings-ui.test.ts
new file mode 100644
index 0000000..a851c1c
--- /dev/null
+++ b/frontend/src/lib/settings-ui.test.ts
@@ -0,0 +1,98 @@
+/**
+ * Pure helper tests for the Settings page (U13). The honest-data discipline
+ * is enforced here: the server-environment rows may only carry the "Coming
+ * from backend" placeholder, and auth views derive strictly from the probe.
+ */
+
+import { describe, expect, test } from "bun:test";
+import type { RuntimeStatus } from "./api/runtime.js";
+import {
+ AUTH_NOTE,
+ COMING_FROM_BACKEND,
+ SERVER_ENV_NOTE,
+ SERVER_ENV_ROWS,
+ authProbe,
+ authView,
+} from "./settings-ui.js";
+
+describe("authProbe", () => {
+ test("reachable with the gate active reports enabled", () => {
+ const status: RuntimeStatus = { reachable: true, authEnabled: true };
+ expect(authProbe(status)).toEqual({ status: "loaded", authEnabled: true });
+ });
+
+ test("reachable with the gate inactive reports disabled", () => {
+ const status: RuntimeStatus = { reachable: true, authEnabled: false };
+ expect(authProbe(status)).toEqual({ status: "loaded", authEnabled: false });
+ });
+
+ test("unreachable backend is an honest error, never telemetry", () => {
+ const status: RuntimeStatus = { reachable: false, authEnabled: false };
+ expect(authProbe(status)).toEqual({
+ status: "error",
+ error: "backend unreachable",
+ });
+ });
+});
+
+describe("authView", () => {
+ test("loading renders the probing placeholder with no status dot", () => {
+ expect(authView({ status: "loading" })).toEqual({
+ value: "…",
+ hint: "Probing the auth gate…",
+ tone: "neutral",
+ statusLabel: "",
+ });
+ });
+
+ test("enabled renders Enabled with the server-owned note", () => {
+ expect(authView({ status: "loaded", authEnabled: true })).toEqual({
+ value: "Enabled",
+ hint: AUTH_NOTE,
+ tone: "ok",
+ statusLabel: "Enabled",
+ });
+ });
+
+ test("disabled renders Disabled with the same server-owned note", () => {
+ expect(authView({ status: "loaded", authEnabled: false })).toEqual({
+ value: "Disabled",
+ hint: AUTH_NOTE,
+ tone: "neutral",
+ statusLabel: "Disabled",
+ });
+ });
+
+ test("error renders Unavailable with the error verbatim", () => {
+ expect(authView({ status: "error", error: "backend unreachable" })).toEqual({
+ value: "—",
+ hint: "backend unreachable",
+ tone: "error",
+ statusLabel: "Unavailable",
+ });
+ });
+});
+
+describe("SERVER_ENV_ROWS", () => {
+ test("covers the four server-owned settings", () => {
+ expect(SERVER_ENV_ROWS.map((row) => row.label)).toEqual([
+ "Host",
+ "Port",
+ "App data directory",
+ "State store",
+ ]);
+ });
+
+ test("every row shows the honest placeholder, never a fake path", () => {
+ for (const row of SERVER_ENV_ROWS) {
+ expect(row.value).toBe(COMING_FROM_BACKEND);
+ }
+ });
+
+ test("the note is a single neutral line naming the real env keys", () => {
+ expect(SERVER_ENV_NOTE).toContain("WEAVELLM_HOST");
+ expect(SERVER_ENV_NOTE).toContain("WEAVELLM_PORT");
+ expect(SERVER_ENV_NOTE).toContain("WEAVELLM_APP_DATA");
+ expect(SERVER_ENV_NOTE).toContain("not exposed through the public API");
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/settings-ui.ts b/frontend/src/lib/settings-ui.ts
new file mode 100644
index 0000000..6f01aa1
--- /dev/null
+++ b/frontend/src/lib/settings-ui.ts
@@ -0,0 +1,88 @@
+/**
+ * Pure display helpers for the Settings page (U13).
+ *
+ * The settings surface is deliberately read-only: the backend exposes no
+ * settings API, and server-owned env (WEAVELLM_HOST / WEAVELLM_PORT /
+ * WEAVELLM_APP_DATA and the SQLite store path) is not observable from the
+ * browser. Everything here derives from the REAL surface — the runtime auth
+ * probe (runtime.ts) — plus an honest placeholder for what the browser
+ * cannot observe. The row table exists so a test can enforce that no fake
+ * path or invented value ever enters the server-environment card.
+ */
+
+import type { RuntimeStatus } from "./api/runtime.js";
+
+/** The only honest value the server-environment rows can render. */
+export const COMING_FROM_BACKEND = "Coming from backend";
+
+/** Note every Authentication card carries (server-owned, never the console). */
+export const AUTH_NOTE =
+ "Owned server-side via WEAVELLM_AUTH — this console never holds or changes the key.";
+
+/** Display tone choices the Settings cards render (StatusCard / StatusDot). */
+export type SettingsTone = "ok" | "error" | "neutral";
+
+/** Authentication probe state: loading → loaded (probe) or error. */
+export type AuthProbeState =
+ | { status: "loading" }
+ | { status: "loaded"; authEnabled: boolean }
+ | { status: "error"; error: string };
+
+/** Display view for the Authentication card. */
+export interface AuthCardView {
+ value: string;
+ hint: string;
+ tone: SettingsTone;
+ statusLabel: string;
+}
+
+/**
+ * Map the auth probe onto its display view. Placeholders (never guesses):
+ * loading renders a wait state, an unreachable backend renders the error
+ * verbatim, and the gate outcome renders Enabled/Disabled — all from the
+ * runtimeStatus() result, nothing invented.
+ */
+export function authView(state: AuthProbeState): AuthCardView {
+ switch (state.status) {
+ case "loading":
+ return { value: "…", hint: "Probing the auth gate…", tone: "neutral", statusLabel: "" };
+ case "error":
+ return { value: "—", hint: state.error, tone: "error", statusLabel: "Unavailable" };
+ case "loaded":
+ return state.authEnabled
+ ? { value: "Enabled", hint: AUTH_NOTE, tone: "ok", statusLabel: "Enabled" }
+ : { value: "Disabled", hint: AUTH_NOTE, tone: "neutral", statusLabel: "Disabled" };
+ }
+}
+
+/**
+ * Map a runtimeStatus() result onto the probe state. runtimeStatus never
+ * throws: an unreachable backend is the honest error case, never telemetry.
+ */
+export function authProbe(result: RuntimeStatus): AuthProbeState {
+ return result.reachable
+ ? { status: "loaded", authEnabled: result.authEnabled }
+ : { status: "error", error: "backend unreachable" };
+}
+
+/** A static server-environment row: label + the honest placeholder only. */
+export interface ServerEnvRow {
+ label: string;
+ value: string;
+}
+
+/**
+ * Server-owned configuration the public API never exposes. Values stay the
+ * honest placeholder — the browser cannot observe host/port/app-data/store,
+ * so any real-looking path here would be a lie.
+ */
+export const SERVER_ENV_ROWS: readonly ServerEnvRow[] = [
+ { label: "Host", value: COMING_FROM_BACKEND },
+ { label: "Port", value: COMING_FROM_BACKEND },
+ { label: "App data directory", value: COMING_FROM_BACKEND },
+ { label: "State store", value: COMING_FROM_BACKEND },
+];
+
+/** One-line neutral note for the Server environment card. */
+export const SERVER_ENV_NOTE =
+ "Server-owned configuration (WEAVELLM_HOST, WEAVELLM_PORT, WEAVELLM_APP_DATA, SQLite store) is not exposed through the public API.";
\ No newline at end of file
diff --git a/frontend/src/lib/sse.test.ts b/frontend/src/lib/sse.test.ts
new file mode 100644
index 0000000..8969a7f
--- /dev/null
+++ b/frontend/src/lib/sse.test.ts
@@ -0,0 +1,101 @@
+/**
+ * bun:test suite for the pure SSE token-frame decoder (sse.ts). No IO — the
+ * decoder only sees strings, so every frame shape the server (or the local
+ * one-shot encoder) can produce is covered directly.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { decodeTokenData, encodeTokenFrame } from "./sse.js";
+
+function completionFrame(completion: unknown): string {
+ return `data: ${JSON.stringify(completion)}\n\n`;
+}
+
+function completion(text: string): unknown {
+ return {
+ id: "cmpl-1",
+ object: "chat.completion",
+ created: 1,
+ model: "gateway/demo",
+ choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
+ usage: null,
+ };
+}
+
+function chunk(text: string): unknown {
+ return {
+ id: "cmpl-1",
+ object: "chat.completion.chunk",
+ choices: [{ index: 0, delta: { content: text } }],
+ };
+}
+
+describe("decodeTokenData", () => {
+ test("extracts content from a chat.completion frame", () => {
+ const result = decodeTokenData(completionFrame(completion("Hello!")));
+ expect(result).toEqual({ content: "Hello!", done: false });
+ });
+
+ test("extracts content from a chat.completion.chunk delta frame", () => {
+ const result = decodeTokenData(completionFrame(chunk("Hel")));
+ expect(result).toEqual({ content: "Hel", done: false });
+ });
+
+ test("a [DONE] frame sets the terminal flag", () => {
+ expect(decodeTokenData("data: [DONE]\n\n")).toEqual({ content: "", done: true });
+ });
+
+ test("content frame then [DONE] in one payload aggregates and terminates", () => {
+ const data = completionFrame(completion("Hello!")) + "data: [DONE]\n\n";
+ expect(decodeTokenData(data)).toEqual({ content: "Hello!", done: true });
+ });
+
+ test("multiple data frames in one event concatenate content in order", () => {
+ const data =
+ completionFrame(chunk("The ")) + completionFrame(chunk("answer ")) + completionFrame(chunk("is 42."));
+ expect(decodeTokenData(data)).toEqual({ content: "The answer is 42.", done: false });
+ });
+
+ test("CRLF frame separators are tolerated", () => {
+ const data = "data: {\"choices\":[{\"message\":{\"content\":\"hi\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n";
+ expect(decodeTokenData(data)).toEqual({ content: "hi", done: true });
+ });
+
+ test("multi-line data fields join into one JSON payload", () => {
+ const data = 'data: {"choices":[{"message":{"content":\ndata: "multi"}}]}\n\n';
+ expect(decodeTokenData(data)).toEqual({ content: "multi", done: false });
+ });
+
+ test("frames without a data: field are ignored", () => {
+ const data = ": comment\n\nevent: message\n\n";
+ expect(decodeTokenData(data)).toEqual({ content: "", done: false });
+ });
+
+ test("a non-JSON data frame is skipped, not thrown", () => {
+ expect(decodeTokenData("data: not json at all\n\n")).toEqual({ content: "", done: false });
+ });
+
+ test("an empty payload decodes to nothing", () => {
+ expect(decodeTokenData("")).toEqual({ content: "", done: false });
+ });
+
+ test("whitespace-only frames are ignored", () => {
+ expect(decodeTokenData(" \n\n")).toEqual({ content: "", done: false });
+ });
+});
+
+describe("encodeTokenFrame", () => {
+ test("round-trips through decodeTokenData", () => {
+ const decoded = decodeTokenData(encodeTokenFrame("one-shot output"));
+ expect(decoded).toEqual({ content: "one-shot output", done: false });
+ });
+
+ test("empty content round-trips as an empty (not done) frame", () => {
+ expect(decodeTokenData(encodeTokenFrame(""))).toEqual({ content: "", done: false });
+ });
+
+ test("frames that carry no completion text yield no content", () => {
+ const data = completionFrame({ id: "x", object: "chat.completion", choices: [] });
+ expect(decodeTokenData(data)).toEqual({ content: "", done: false });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts
new file mode 100644
index 0000000..c191e4c
--- /dev/null
+++ b/frontend/src/lib/sse.ts
@@ -0,0 +1,102 @@
+/**
+ * Pure SSE-frame decoding for workflow run token events (frontend, U07).
+ *
+ * The backend's WS token event carries OpenAI-wire SSE text in `data`
+ * (src/app/ws.ts): one `data: {JSON}\n\n` frame per token event, ended by
+ * exactly one `data: [DONE]\n\n`. This module decodes ANY number of frames
+ * defensively — the completed-run relay sends a chat.completion object, a
+ * live-streaming relay would send chat.completion.chunk deltas — plus the
+ * mirror encoder the local one-shot fallback uses so its output feeds the
+ * same transcript path as a live run. No IO, no Svelte: importable under
+ * bun:test.
+ */
+
+export interface DecodedTokenData {
+ /** Concatenated text content of all non-terminal data frames. */
+ content: string;
+ /** True once a `data: [DONE]` frame terminated the stream. */
+ done: boolean;
+}
+
+/** SSE event separator (blank line between events; CRLF tolerated). */
+const FRAME_SEPARATOR = /\r?\n\r?\n/;
+
+/**
+ * Extract the concatenated `data:` field payloads of a single SSE frame.
+ * One leading space after the colon is stripped (SSE field-value rule);
+ * frames with no `data:` field (comments, event/retry fields) are ignored.
+ * Exported for the /v1 stream client, which parses the same wire frames
+ * (U11) — the semantics stay in this single module.
+ */
+export function dataField(frame: string): string | null {
+ const lines: string[] = [];
+ for (const line of frame.split(/\r?\n/)) {
+ const trimmed = line.trimEnd();
+ if (trimmed.startsWith("data:")) {
+ const payload = trimmed.slice("data:".length);
+ lines.push(payload.startsWith(" ") ? payload.slice(1) : payload);
+ }
+ }
+ return lines.length > 0 ? lines.join("\n") : null;
+}
+
+/** Extract assistant text from one OpenAI-wire data payload, or null. */
+function completionText(payload: string): string | null {
+ let value: unknown;
+ try {
+ value = JSON.parse(payload);
+ } catch {
+ return null; // non-JSON data frame → nothing extractable, skip quietly
+ }
+ if (typeof value !== "object" || value === null) return null;
+ const choices = (value as { choices?: unknown }).choices;
+ if (!Array.isArray(choices) || choices.length === 0) return null;
+ const choice = choices[0];
+ if (typeof choice !== "object" || choice === null) return null;
+ const { message, delta } = choice as { message?: unknown; delta?: unknown };
+ for (const holder of [message, delta]) {
+ if (typeof holder !== "object" || holder === null) continue;
+ const content = (holder as { content?: unknown }).content;
+ if (typeof content === "string") return content;
+ }
+ return null;
+}
+
+/**
+ * Decode one `token.data` payload into aggregate content and the terminal
+ * flag. Handles multiple frames per event, CRLF separators, multi-line
+ * `data:` fields and both completion and chunk JSON shapes defensively —
+ * frames without extractable text are skipped, never thrown.
+ */
+export function decodeTokenData(data: string): DecodedTokenData {
+ let content = "";
+ let done = false;
+ for (const frame of data.split(FRAME_SEPARATOR)) {
+ const payload = dataField(frame);
+ if (payload === null) continue;
+ if (payload === "[DONE]") {
+ done = true;
+ continue;
+ }
+ const text = completionText(payload);
+ if (text !== null) content += text;
+ }
+ return { content, done };
+}
+
+/**
+ * OpenAI-wire token frame for one finished completion — the shape the
+ * backend relays after a run. Local one-shot runs encode their output with
+ * this so the transcript decodes it identically to a live run.
+ */
+export function encodeTokenFrame(content: string): string {
+ const completion = {
+ id: "cmpl-local",
+ object: "chat.completion",
+ created: 0,
+ model: "workflow",
+ choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
+ usage: null,
+ };
+ return `data: ${JSON.stringify(completion)}\n\n`;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/ui-state.test.ts b/frontend/src/lib/ui-state.test.ts
new file mode 100644
index 0000000..f15ec6c
--- /dev/null
+++ b/frontend/src/lib/ui-state.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from "bun:test";
+import { get } from "svelte/store";
+import { commandPaletteOpen, sidebarDrawerOpen } from "./ui-state.js";
+
+describe("ui-state stores", () => {
+ test("sidebar drawer defaults to closed", () => {
+ sidebarDrawerOpen.set(false);
+ expect(get(sidebarDrawerOpen)).toBe(false);
+ });
+
+ test("sidebar drawer toggles open and closed", () => {
+ sidebarDrawerOpen.set(true);
+ expect(get(sidebarDrawerOpen)).toBe(true);
+ sidebarDrawerOpen.set(false);
+ expect(get(sidebarDrawerOpen)).toBe(false);
+ });
+
+ test("command palette defaults to closed and opens", () => {
+ commandPaletteOpen.set(false);
+ expect(get(commandPaletteOpen)).toBe(false);
+ commandPaletteOpen.set(true);
+ expect(get(commandPaletteOpen)).toBe(true);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/ui-state.ts b/frontend/src/lib/ui-state.ts
new file mode 100644
index 0000000..d241586
--- /dev/null
+++ b/frontend/src/lib/ui-state.ts
@@ -0,0 +1,13 @@
+import { writable } from "svelte/store";
+
+/**
+ * Cross-island UI state. The shell renders Sidebar, Topbar, StatusBar and
+ * CommandPalette as separate Svelte islands; these writable stores let them
+ * coordinate without prop drilling (same module instance per page).
+ */
+
+/** Mobile drawer visibility — toggled from the Topbar hamburger. */
+export const sidebarDrawerOpen = writable(false);
+
+/** Command palette (Ctrl/Cmd+K quick navigation) visibility. */
+export const commandPaletteOpen = writable(false);
\ No newline at end of file
diff --git a/frontend/src/lib/v1-ui.test.ts b/frontend/src/lib/v1-ui.test.ts
new file mode 100644
index 0000000..40e88a5
--- /dev/null
+++ b/frontend/src/lib/v1-ui.test.ts
@@ -0,0 +1,74 @@
+/**
+ * bun:test suite for the playground's pure rendering helpers (v1-ui.ts).
+ * highlightJson must be byte-preserving (join(tokens) === source) and must
+ * classify keys/strings/numbers/bools/null correctly on a serialized body.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { highlightJson, type JsonToken } from "./v1-ui.js";
+
+const SAMPLE = JSON.stringify(
+ {
+ id: "cmpl-1",
+ object: "chat.completion",
+ created: 1710000000,
+ model: "gateway/summary",
+ choices: [
+ {
+ index: 0,
+ message: { role: "assistant", content: "Hello, \"world\"!" },
+ finish_reason: "stop",
+ },
+ ],
+ usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4, sorted: true, extra: null },
+ },
+ null,
+ 2,
+);
+
+function kindsOf(tokens: JsonToken[]): JsonToken["kind"][] {
+ return tokens.map((t) => t.kind);
+}
+
+describe("highlightJson()", () => {
+ test("round-trips byte-identically (join === source)", () => {
+ const tokens = highlightJson(SAMPLE);
+ expect(tokens.map((t) => t.value).join("")).toBe(SAMPLE);
+ });
+
+ test("classifies keys, strings, numbers, bools and null", () => {
+ const kinds = kindsOf(highlightJson(SAMPLE));
+ expect(kinds).toContain("key");
+ expect(kinds).toContain("string");
+ expect(kinds).toContain("number");
+ expect(kinds).toContain("bool");
+ expect(kinds).toContain("null");
+ // punctuation + whitespace are preserved verbatim as punct tokens
+ expect(kinds).toContain("punct");
+ });
+
+ test("'true' and 'null' become bool/null, not strings", () => {
+ const tokens = highlightJson('{"sorted": true, "extra": null}');
+ expect(tokens.filter((t) => t.value === "true")[0]?.kind).toBe("bool");
+ expect(tokens.filter((t) => t.value === "null")[0]?.kind).toBe("null");
+ });
+
+ test("non-key strings are never classified as keys", () => {
+ const tokens = highlightJson('{"a": "hello", "b": ["x", "y"]}');
+ const keys = tokens.filter((t) => t.kind === "key").map((t) => t.value);
+ expect(keys).toEqual(['"a"', '"b"']);
+ const strings = tokens.filter((t) => t.kind === "string").map((t) => t.value);
+ expect(strings).toEqual(['"hello"', '"x"', '"y"']);
+ });
+
+ test("escaped quotes stay inside one string token", () => {
+ const tokens = highlightJson('{"c": "a \\"b\\" c"}');
+ const stringTokens = tokens.filter((t) => t.kind === "string");
+ expect(stringTokens).toHaveLength(1);
+ expect(stringTokens[0]?.value).toBe('"a \\"b\\" c"');
+ });
+
+ test("empty input yields no tokens", () => {
+ expect(highlightJson("")).toEqual([]);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/v1-ui.ts b/frontend/src/lib/v1-ui.ts
new file mode 100644
index 0000000..fb89be4
--- /dev/null
+++ b/frontend/src/lib/v1-ui.ts
@@ -0,0 +1,108 @@
+/**
+ * Pure rendering helpers for the API playground (U11), kept UI-framework
+ * free so they stay importable under bun:test.
+ *
+ * highlightJson is an honest JSON syntax highlighter: it splits an already
+ * serialized JSON string into display tokens and NEVER alters or invents
+ * content — joining the token values always reproduces the source exactly.
+ * The playground renders the raw wire body through it; no derived metrics.
+ */
+
+export const JSON_TOKEN_KINDS = {
+ key: "key",
+ string: "string",
+ number: "number",
+ bool: "bool",
+ null: "null",
+ punct: "punct",
+} as const;
+
+export type JsonTokenKind = (typeof JSON_TOKEN_KINDS)[keyof typeof JSON_TOKEN_KINDS];
+
+export interface JsonToken {
+ kind: JsonTokenKind;
+ value: string;
+}
+
+/** Single-character JSON punctuation (plus whitespace, preserved verbatim). */
+const PUNCT_CHARS = new Set([" ", "\t", "\n", "\r", "{", "}", "[", "]", ":", ",", '"']);
+
+/**
+ * Tokenize a serialized JSON string for display. Keys are detected only in
+ * the exact `"key":` position; every other double-quoted span is a string.
+ * `true`/`false`/`null` become bool/null tokens; numbers are scanned as-is.
+ * Whitespace is preserved inside `punct` tokens so the rendered text is
+ * byte-identical to the input (join(tokens) === src).
+ */
+export function highlightJson(src: string): JsonToken[] {
+ const tokens: JsonToken[] = [];
+ const push = (kind: JsonTokenKind, value: string): void => {
+ if (value.length === 0) return;
+ tokens.push({ kind, value });
+ };
+
+ let i = 0;
+ let prevNonSpace = "";
+ while (i < src.length) {
+ const ch = src[i];
+
+ if (ch === '"') {
+ // Scan the full string literal, honoring backslash escapes.
+ let j = i + 1;
+ while (j < src.length) {
+ if (src[j] === "\\") {
+ j += 2;
+ continue;
+ }
+ if (src[j] === '"') break;
+ j += 1;
+ }
+ const literal = src.slice(i, Math.min(j + 1, src.length));
+ // A key sits right before ':' after the string — detect via the next
+ // non-space char (skip a possible second '"' for malformed input).
+ let k = j + 1;
+ while (k < src.length && /\s/.test(src[k])) k += 1;
+ const isKey = src[k] === ":" && prevNonSpace !== '"';
+ push(isKey ? "key" : "string", literal);
+ prevNonSpace = '"';
+ i = j + 1;
+ continue;
+ }
+
+ if (/\d/.test(ch) || ch === "-") {
+ let j = i + 1;
+ while (j < src.length && /[0-9.eE+\-]/.test(src[j])) j += 1;
+ push("number", src.slice(i, j));
+ prevNonSpace = "number";
+ i = j;
+ continue;
+ }
+
+ if (/[A-Za-z]/.test(ch)) {
+ let j = i + 1;
+ while (j < src.length && /[A-Za-z]/.test(src[j])) j += 1;
+ const word = src.slice(i, j);
+ if (word === "true" || word === "false") {
+ push("bool", word);
+ } else if (word === "null") {
+ push("null", word);
+ } else {
+ push("punct", word); // unknown word — preserved verbatim
+ }
+ prevNonSpace = word;
+ i = j;
+ continue;
+ }
+
+ // Punctuation and whitespace: emit one token per char (byte-identical).
+ if (PUNCT_CHARS.has(ch) || /\s/.test(ch)) {
+ push("punct", ch);
+ if (!/\s/.test(ch)) prevNonSpace = ch;
+ } else {
+ push("punct", ch); // unexpected byte (e.g. control char) — kept verbatim
+ }
+ i += 1;
+ }
+
+ return tokens;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-condition.test.ts b/frontend/src/lib/workflow-condition.test.ts
new file mode 100644
index 0000000..13c89c8
--- /dev/null
+++ b/frontend/src/lib/workflow-condition.test.ts
@@ -0,0 +1,179 @@
+/**
+ * U06 — condition source ⇄ SAFE AST converter tests.
+ *
+ * The grammar mirrors `sanitizeAst`'s admitted shapes; the last gate on top
+ * is the REAL backend `sanitizeAst`, so these tests pin the round-trip and
+ * the rejection surface (unsafe fields, missing values, trailing junk).
+ */
+import { describe, expect, test } from "bun:test";
+import { sanitizeAst, type AstExpr } from "../../../src/orchestrator/graph.js";
+import {
+ isConditionSourceValid,
+ parseConditionSource,
+ renderConditionSource,
+ type ConditionParseResult,
+} from "./workflow-condition.js";
+
+/** Narrow a parse result to its ok branch (throws with the failure reason). */
+function expectOk(result: ConditionParseResult): AstExpr {
+ if (!result.ok) throw new Error(`expected ok parse, got: ${result.error}`);
+ return result.expr;
+}
+
+/** Narrow a parse result to its failure branch (throws when the parse succeeded). */
+function expectFail(result: ConditionParseResult): string {
+ if (result.ok) throw new Error("expected a parse failure, but the source parsed");
+ return result.error;
+}
+
+function render(source: string): string {
+ const parsed = parseConditionSource(source);
+ expect(parsed.ok).toBe(true);
+ return renderConditionSource(expectOk(parsed));
+}
+
+describe("parseConditionSource", () => {
+ test("parses exists on a context field", () => {
+ const parsed = parseConditionSource("exists(lastResponse.content)");
+ expect(parsed.ok).toBe(true);
+ expect(expectOk(parsed)).toEqual({ op: "exists", field: "lastResponse.content" });
+ });
+
+ test("parses not(...) with nested expressions", () => {
+ const parsed = parseConditionSource("not(exists(error))");
+ expect(parsed.ok).toBe(true);
+ expect(expectOk(parsed)).toEqual({ op: "not", child: { op: "exists", field: "error" } });
+ });
+
+ test("parses all(...) and any(...) with multiple args", () => {
+ const all = parseConditionSource("all(lastResponse.status >= 200, exists(variables.step))");
+ expect(all.ok).toBe(true);
+ expect(expectOk(all)).toEqual({
+ op: "logical",
+ and: true,
+ args: [
+ { op: "compare", field: "lastResponse.status", op2: ">=", value: 200 },
+ { op: "exists", field: "variables.step" },
+ ],
+ });
+
+ const any = parseConditionSource("any(lastResponse.status == 200, lastResponse.status == 429)");
+ expect(any.ok).toBe(true);
+ expect(expectOk(any)).toEqual({
+ op: "logical",
+ and: false,
+ args: [
+ { op: "compare", field: "lastResponse.status", op2: "==", value: 200 },
+ { op: "compare", field: "lastResponse.status", op2: "==", value: 429 },
+ ],
+ });
+ });
+
+ test("parses compare on every operator", () => {
+ const ops = ["==", "!=", "<", "<=", ">", ">="] as const;
+ for (const op of ops) {
+ const parsed = parseConditionSource(`variables.count ${op} 3`);
+ expect(parsed.ok).toBe(true);
+ expect(expectOk(parsed)).toEqual({
+ op: "compare",
+ field: "variables.count",
+ op2: op,
+ value: 3,
+ });
+ }
+ });
+
+ test("parses string, negative, decimal and boolean literals", () => {
+ const strings = [
+ ['lastResponse.content == "ok"', { op: "compare", field: "lastResponse.content", op2: "==", value: "ok" }],
+ ['lastResponse.content == "a \\"quoted\\" pair"', { op: "compare", field: "lastResponse.content", op2: "==", value: 'a "quoted" pair' }],
+ ["variables.score < -1.5", { op: "compare", field: "variables.score", op2: "<", value: -1.5 }],
+ ["error != null", { op: "compare", field: "error", op2: "!=", value: null }],
+ ["variables.flag == true", { op: "compare", field: "variables.flag", op2: "==", value: true }],
+ ] as const;
+ for (const [source, expected] of strings) {
+ const parsed = parseConditionSource(source);
+ expect(parsed.ok).toBe(true);
+ expect(expectOk(parsed)).toEqual(expected);
+ }
+ });
+
+ test("tolerates whitespace and renders canonical form", () => {
+ expect(render(" all ( lastResponse.status >= 200 , exists( lastResponse.content ) ) ")).toBe(
+ "all(lastResponse.status >= 200, exists(lastResponse.content))",
+ );
+ });
+
+ test("round-trips every AST shape through render → parse → sanitizeAst", () => {
+ const corpus: AstExpr[] = [
+ { op: "exists", field: "lastResponse.content" },
+ { op: "not", child: { op: "exists", field: "error" } },
+ {
+ op: "logical",
+ and: true,
+ args: [
+ { op: "compare", field: "lastResponse.status", op2: "==", value: 200 },
+ { op: "not", child: { op: "exists", field: "lastResponse.content" } },
+ ],
+ },
+ { op: "compare", field: "variables.step", op2: ">=", value: 2 },
+ { op: "compare", field: "lastResponse.content", op2: "!=", value: "" },
+ { op: "compare", field: "error", op2: "==", value: null },
+ // Non-primitive compare value (engine admits anything in `value`).
+ { op: "compare", field: "variables.tags", op2: "==", value: ["a", "b"] },
+ { op: "compare", field: "variables.meta", op2: "==", value: { step: 1 } },
+ ];
+ for (const expr of corpus) {
+ const source = renderConditionSource(expr);
+ const parsed = parseConditionSource(source);
+ expect(parsed.ok, `parse "${source}"`).toBe(true);
+ const ok = expectOk(parsed);
+ expect(sanitizeAst(ok)).not.toBeNull();
+ expect(renderConditionSource(ok)).toBe(source);
+ }
+ });
+
+ test("rejects empty input", () => {
+ const parsed = parseConditionSource("");
+ expect(parsed.ok).toBe(false);
+ expect(expectFail(parsed)).toContain("expected an expression");
+ });
+
+ test("rejects unsafe fields", () => {
+ for (const source of [
+ "exists(https://evil.example/x)",
+ "exists(lastResponse)",
+ "file.read == 1",
+ "variables..x == 1",
+ ]) {
+ const parsed = parseConditionSource(source);
+ expect(parsed.ok, source).toBe(false);
+ }
+ });
+
+ test("rejects malformed expressions with position", () => {
+ const trailing = parseConditionSource("exists(lastResponse.content) garbage");
+ expect(trailing.ok).toBe(false);
+ expect(expectFail(trailing)).toContain("trailing input");
+
+ const unclosed = parseConditionSource("any( exists(lastResponse.content) ");
+ expect(unclosed.ok).toBe(false);
+ expect(expectFail(unclosed)).toContain("expected");
+
+ const missingValue = parseConditionSource("lastResponse.status >=");
+ expect(missingValue.ok).toBe(false);
+ expect(expectFail(missingValue)).toContain("value");
+ });
+});
+
+describe("isConditionSourceValid", () => {
+ test("is true exactly when parse + sanitizeAst admit the source", () => {
+ expect(isConditionSourceValid("lastResponse.status == 200")).toBe(true);
+ expect(isConditionSourceValid("variables.attempt < 3")).toBe(true);
+ // Parses but sanitizeAst rejects the op2? It cannot — the grammar admits
+ // only allowed op2 — so the two gates agree on shape; unsafe values still
+ // fail on the grammar side.
+ expect(isConditionSourceValid("exists(lastResponse.status) && true")).toBe(false);
+ expect(isConditionSourceValid("")).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-condition.ts b/frontend/src/lib/workflow-condition.ts
new file mode 100644
index 0000000..f4a88a7
--- /dev/null
+++ b/frontend/src/lib/workflow-condition.ts
@@ -0,0 +1,287 @@
+/**
+ * Canonical condition source ⇄ SAFE AST converters (U06).
+ *
+ * `condition`/`router` nodes carry an AST (`AstExpr`) that the engine treats
+ * as an opaque, non-editable blob. U06 makes the guarded expression editable
+ * through a canonical SOURCE STRING whose grammar is a strict mirror of the
+ * shapes `sanitizeAst` (src/orchestrator/graph.ts) admits — the backend SAFE
+ * AST is the authoritative contract and the ONLY interpreter:
+ *
+ * source := expr
+ * expr := "exists" "(" field ")"
+ * | "not" "(" expr ")"
+ * | ("all" | "any") "(" expr ("," expr)* ")"
+ * | field op2 literal
+ * field := "lastResponse.status" | "lastResponse.content" | "error"
+ * | "variables" | "variables" "." ident ("." ident)*
+ * ident := [A-Za-z_][A-Za-z0-9_]*
+ * op2 := "==" | "!=" | "<=" | ">=" | "<" | ">"
+ * literal := number | "string" | true | false | null | compact JSON value
+ *
+ * Values that are not primitives render as compact JSON and re-parse through
+ * the JSON-literal fallback, so any AST the engine ever admitted round-trips.
+ * Validation is two-gated: the grammar must parse AND the parsed AST must
+ * survive the real `sanitizeAst` — the editor can never store an expression
+ * the runtime would reject.
+ *
+ * Pure functions — no DOM, no Svelte, no stores — so this module runs under
+ * `bun test` and in the Astro build.
+ */
+import { sanitizeAst, type AstExpr, type CompareOp2 } from "../../../src/orchestrator/graph.js";
+
+// ── Rendering (AST → canonical source) ───────────────────────────────────────
+
+/** Render a validated SAFE AST as its canonical source string (never throws). */
+export function renderConditionSource(expr: AstExpr): string {
+ switch (expr.op) {
+ case "exists":
+ return `exists(${expr.field})`;
+ case "not":
+ return `not(${renderConditionSource(expr.child)})`;
+ case "logical":
+ return `${expr.and ? "all" : "any"}(${expr.args.map(renderConditionSource).join(", ")})`;
+ case "compare":
+ return `${expr.field} ${expr.op2} ${renderValue(expr.value)}`;
+ }
+}
+
+function renderValue(value: unknown): string {
+ if (typeof value === "string") return JSON.stringify(value);
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
+ return String(value);
+ }
+ return JSON.stringify(value);
+}
+
+// ── Parsing (source → AST) ────────────────────────────────────────────────────
+
+export type ConditionParseResult =
+ | { ok: true; expr: AstExpr }
+ | { ok: false; error: string };
+
+interface Cursor {
+ src: string;
+ pos: number;
+}
+
+function fail(cursor: Cursor, message: string): ConditionParseResult {
+ return { ok: false, error: `Invalid condition at offset ${cursor.pos}: ${message}` };
+}
+
+function peek(cursor: Cursor): string {
+ return cursor.src[cursor.pos] ?? "";
+}
+
+function skipWs(cursor: Cursor): void {
+ while (cursor.pos < cursor.src.length && /\s/.test(cursor.src[cursor.pos]!)) cursor.pos += 1;
+}
+
+/** Match a literal keyword or operator, advancing on success. */
+function eat(cursor: Cursor, token: string): boolean {
+ if (cursor.src.startsWith(token, cursor.pos)) {
+ cursor.pos += token.length;
+ return true;
+ }
+ return false;
+}
+
+function eatIdent(cursor: Cursor): string | null {
+ const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cursor.src.slice(cursor.pos));
+ if (match === null) return null;
+ cursor.pos += match[0].length;
+ return match[0];
+}
+
+/**
+ * Parse a dotted field reference into a SAFE context field. Strict mirror of
+ * `isSafeField()`: only the three context fields or `variables`-prefixed
+ * names are admitted.
+ */
+function parseField(cursor: Cursor): string | null {
+ const first = eatIdent(cursor);
+ if (first === null) return null;
+ let field = first;
+ while (eat(cursor, ".")) {
+ const part = eatIdent(cursor);
+ if (part === null) return null;
+ field = `${field}.${part}`;
+ }
+ if (field === "lastResponse.status" || field === "lastResponse.content" || field === "error") {
+ return field;
+ }
+ if (field === "variables" || field.startsWith("variables.")) return field;
+ return null;
+}
+
+/** Comparison operators, longest match first (<= / >= before < / >). */
+const OP2_TOKENS: readonly CompareOp2[] = ["==", "!=", "<=", ">=", "<", ">"];
+
+function parseCompareOp2(cursor: Cursor): CompareOp2 | null {
+ for (const op of OP2_TOKENS) {
+ if (eat(cursor, op)) return op;
+ }
+ return null;
+}
+
+function parseString(cursor: Cursor): string | undefined {
+ if (peek(cursor) !== '"') return undefined;
+ let out = "";
+ cursor.pos += 1;
+ while (cursor.pos < cursor.src.length) {
+ const ch = cursor.src[cursor.pos];
+ cursor.pos += 1;
+ if (ch === '"') return out;
+ if (ch !== "\\") {
+ out += ch;
+ continue;
+ }
+ const esc = cursor.src[cursor.pos];
+ if (esc === undefined) return undefined;
+ cursor.pos += 1;
+ switch (esc) {
+ case '"':
+ out += '"';
+ break;
+ case "\\":
+ out += "\\";
+ break;
+ case "n":
+ out += "\n";
+ break;
+ case "t":
+ out += "\t";
+ break;
+ case "r":
+ out += "\r";
+ break;
+ default:
+ out += esc;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Attempt a compact JSON value occupying the ENTIRE remaining source. Only
+ * canonical form qualifies (`JSON.stringify(parsed) === rest`), mirroring
+ * `renderValue` — re-parsing rendered output always succeeds.
+ */
+function tryParseJsonLiteral(cursor: Cursor): { ok: true; value: unknown } | { ok: false } {
+ const rest = cursor.src.slice(cursor.pos);
+ try {
+ const value: unknown = JSON.parse(rest);
+ if (JSON.stringify(value) === rest) {
+ cursor.pos = cursor.src.length;
+ return { ok: true, value };
+ }
+ return { ok: false };
+ } catch {
+ return { ok: false };
+ }
+}
+
+function parseLiteral(cursor: Cursor): { ok: true; value: unknown } | { ok: false } {
+ skipWs(cursor);
+ const ch = peek(cursor);
+ if (ch === '"') {
+ const value = parseString(cursor);
+ return value === undefined ? { ok: false } : { ok: true, value };
+ }
+ if (ch === "t" && eat(cursor, "true")) return { ok: true, value: true };
+ if (ch === "f" && eat(cursor, "false")) return { ok: true, value: false };
+ if (ch === "n" && eat(cursor, "null")) return { ok: true, value: null };
+ if (ch === "-" || (ch !== "" && /[0-9]/.test(ch))) {
+ const match = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(cursor.src.slice(cursor.pos));
+ if (match !== null) {
+ cursor.pos += match[0].length;
+ const value = Number(match[0]);
+ if (Number.isFinite(value)) return { ok: true, value };
+ }
+ return { ok: false };
+ }
+ // Fallback: any other literal is a compact JSON value (arrays/objects a
+ // saved workflow may carry — the engine admits them in `compare`).
+ const json = tryParseJsonLiteral(cursor);
+ if (json.ok) return json;
+ return { ok: false };
+}
+
+function parseCompareExpr(cursor: Cursor): { ok: true; expr: AstExpr } | { ok: false; error: string } {
+ const field = parseField(cursor);
+ if (field === null) return fail(cursor, "comparison needs a supported field");
+ skipWs(cursor);
+ const op2 = parseCompareOp2(cursor);
+ if (op2 === null) return fail(cursor, 'comparison needs an operator (==, !=, <, <=, >, >=)');
+ const literal = parseLiteral(cursor);
+ if (!literal.ok) return fail(cursor, "comparison needs a value (number, \"string\", true/false/null, or JSON)");
+ return { ok: true, expr: { op: "compare", field, op2, value: literal.value } };
+}
+
+function parseExpr(cursor: Cursor): { ok: true; expr: AstExpr } | { ok: false; error: string } {
+ skipWs(cursor);
+ if (peek(cursor) === "") return fail(cursor, "expected an expression");
+ if (eat(cursor, "exists")) {
+ skipWs(cursor);
+ if (!eat(cursor, "(")) return fail(cursor, 'expected "(" after exists');
+ skipWs(cursor);
+ const field = parseField(cursor);
+ if (field === null) return fail(cursor, "unsupported field in exists(...)");
+ skipWs(cursor);
+ if (!eat(cursor, ")")) return fail(cursor, 'expected ")" to close exists(');
+ return { ok: true, expr: { op: "exists", field } };
+ }
+ if (eat(cursor, "not")) {
+ skipWs(cursor);
+ if (!eat(cursor, "(")) return fail(cursor, 'expected "(" after not');
+ const child = parseExpr(cursor);
+ if (!child.ok) return child;
+ skipWs(cursor);
+ if (!eat(cursor, ")")) return fail(cursor, 'expected ")" to close not(');
+ return { ok: true, expr: { op: "not", child: child.expr } };
+ }
+ let and: boolean;
+ let logical = false;
+ if (eat(cursor, "all")) {
+ and = true;
+ logical = true;
+ } else if (eat(cursor, "any")) {
+ and = false;
+ logical = true;
+ }
+ if (logical) {
+ skipWs(cursor);
+ if (!eat(cursor, "(")) return fail(cursor, 'expected "(" after logical');
+ const args: AstExpr[] = [];
+ for (;;) {
+ skipWs(cursor);
+ const arg = parseExpr(cursor);
+ if (!arg.ok) return arg;
+ args.push(arg.expr);
+ skipWs(cursor);
+ if (eat(cursor, ")")) break;
+ if (!eat(cursor, ",")) {
+ return fail(cursor, 'expected "," or ")" inside logical');
+ }
+ }
+ return { ok: true, expr: { op: "logical", and: and!, args } };
+ }
+ return parseCompareExpr(cursor);
+}
+
+/** Parse a canonical condition source string into a SAFE AST. */
+export function parseConditionSource(source: string): ConditionParseResult {
+ const cursor: Cursor = { src: source, pos: 0 };
+ const expr = parseExpr(cursor);
+ if (!expr.ok) return expr;
+ skipWs(cursor);
+ if (cursor.pos !== source.length) {
+ return fail(cursor, `unexpected trailing input "${source.slice(cursor.pos)}"`);
+ }
+ return expr;
+}
+
+/** The backend authority: does this source express a runtime-admissible AST? */
+export function isConditionSourceValid(source: string): boolean {
+ const parsed = parseConditionSource(source);
+ return parsed.ok && sanitizeAst(parsed.expr) !== null;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-flow.test.ts b/frontend/src/lib/workflow-flow.test.ts
new file mode 100644
index 0000000..2d1f30e
--- /dev/null
+++ b/frontend/src/lib/workflow-flow.test.ts
@@ -0,0 +1,542 @@
+import { describe, expect, test } from "bun:test";
+import {
+ NODE_GROUPS,
+ astSummary,
+ flowToGraph,
+ graphToFlow,
+ nodeGroupOf,
+ nodeSummaryLines,
+ parseWorkflowYamlToGraph,
+ patchNodeData,
+ serializeGraphToYaml,
+ uniqueEdgeId,
+ type FlowNode,
+} from "./workflow-flow.js";
+import { NODE_TYPES } from "./workflow-nodes.js";
+import type { GraphNode, GraphPipeline } from "./workflow-nodes.js";
+
+/** Minimal valid engine graph: start → llm → end (store-contract shape). */
+function demoGraph(): GraphPipeline {
+ return {
+ id: "demo",
+ name: "demo",
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "llm", type: "llm_call", model: "SmolLM3", mode: "generate" },
+ { id: "end", type: "end" },
+ ],
+ edges: [
+ { from: "start", to: "llm" },
+ { from: "llm", to: "end" },
+ ],
+ };
+}
+
+describe("nodeGroupOf", () => {
+ test("maps all 14 engine types to the 6 groups", () => {
+ const byGroup = new Map();
+ for (const id of NODE_TYPES) {
+ const group = nodeGroupOf(id);
+ expect(NODE_GROUPS).toContain(group);
+ byGroup.set(group, (byGroup.get(group) ?? 0) + 1);
+ }
+ expect(byGroup.get("wf-start-end")).toBe(2); // start, end
+ expect(byGroup.get("wf-llm")).toBe(1); // llm_call
+ expect(byGroup.get("wf-branch")).toBe(5); // condition, loop, fan, join, router
+ expect(byGroup.get("wf-data")).toBe(4); // rag_local, data.code, memory, embeddings
+ expect(byGroup.get("wf-pipeline")).toBe(1); // pipeline
+ expect(byGroup.get("wf-output")).toBe(1); // output
+ });
+});
+
+describe("graphToFlow", () => {
+ test("converts nodes with group type and whitelisted values", () => {
+ const { nodes } = graphToFlow(demoGraph());
+ expect(nodes.map((node) => node.id)).toEqual(["start", "llm", "end"]);
+ const llm = nodes.find((node) => node.id === "llm");
+ expect(llm?.type).toBe("wf-llm");
+ expect(llm?.data.wNodeType).toBe("llm_call");
+ expect(llm?.data.values).toEqual({ model: "SmolLM3", mode: "generate" });
+ });
+
+ test("positions are deterministic for the same graph", () => {
+ const first = graphToFlow(demoGraph());
+ const second = graphToFlow(demoGraph());
+ expect(second.nodes).toEqual(first.nodes);
+ expect(second.edges).toEqual(first.edges);
+ });
+
+ test("layers advance along edges from start nodes", () => {
+ const { nodes } = graphToFlow(demoGraph());
+ const start = nodes.find((node) => node.id === "start")!;
+ const llm = nodes.find((node) => node.id === "llm")!;
+ const end = nodes.find((node) => node.id === "end")!;
+ expect(start.position.x).toBeLessThan(llm.position.x);
+ expect(llm.position.x).toBeLessThan(end.position.x);
+ expect(start.position.y).toBe(llm.position.y); // single chain: same row
+ });
+
+ test("siblings stack on the same layer column", () => {
+ const graph: GraphPipeline = {
+ id: "fan-out",
+ name: "fan-out",
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "fan", type: "fan", parallel: true },
+ { id: "a", type: "llm_call", model: "m1", mode: "generate" },
+ { id: "b", type: "llm_call", model: "m2", mode: "generate" },
+ { id: "join", type: "join" },
+ { id: "end", type: "end" },
+ ],
+ edges: [
+ { from: "start", to: "fan" },
+ { from: "fan", to: "a" },
+ { from: "fan", to: "b" },
+ { from: "a", to: "join" },
+ { from: "b", to: "join" },
+ { from: "join", to: "end" },
+ ],
+ };
+ const { nodes } = graphToFlow(graph);
+ const a = nodes.find((node) => node.id === "a")!;
+ const b = nodes.find((node) => node.id === "b")!;
+ expect(a.position.x).toBe(b.position.x); // same layer
+ expect(a.position.y).not.toBe(b.position.y); // stacked
+ });
+
+ test("cycles do not hang and stay deterministic", () => {
+ const cyclic: GraphPipeline = {
+ id: "c",
+ name: "c",
+ nodes: [
+ { id: "a", type: "start" },
+ { id: "b", type: "fan" },
+ { id: "c", type: "end" },
+ ],
+ edges: [
+ { from: "a", to: "b" },
+ { from: "b", to: "a" }, // back edge
+ { from: "b", to: "c" },
+ ],
+ };
+ const first = graphToFlow(cyclic);
+ const second = graphToFlow(cyclic);
+ expect(second.nodes).toEqual(first.nodes);
+ expect(second.edges).toEqual(first.edges);
+ });
+
+ test("duplicate edges get unique deterministic ids, guards are preserved", () => {
+ const graph: GraphPipeline = {
+ id: "dup",
+ name: "dup",
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "cond", type: "condition", condition: { op: "exists", field: "lastResponse.content" } },
+ { id: "end", type: "end" },
+ ],
+ edges: [
+ { from: "start", to: "cond" },
+ { from: "cond", to: "end", guard: "true" },
+ { from: "cond", to: "end", guard: "false" },
+ ],
+ };
+ const { edges } = graphToFlow(graph);
+ expect(edges.map((edge) => edge.id)).toEqual(["start->cond", "cond->end", "cond->end#2"]);
+ expect(edges[1].guard).toBe("true");
+ expect(edges[2].guard).toBe("false");
+ });
+});
+
+describe("uniqueEdgeId", () => {
+ test("canonical form then #n suffixes", () => {
+ const taken = new Set();
+ expect(uniqueEdgeId("a", "b", taken)).toBe("a->b");
+ taken.add("a->b");
+ expect(uniqueEdgeId("a", "b", taken)).toBe("a->b#2");
+ taken.add("a->b#2");
+ expect(uniqueEdgeId("a", "b", taken)).toBe("a->b#3");
+ });
+});
+
+describe("flowToGraph", () => {
+ test("admits only whitelisted fields — unknown values are dropped", () => {
+ const nodes: FlowNode[] = [
+ {
+ id: "llm",
+ type: "wf-llm",
+ position: { x: 0, y: 0 },
+ data: {
+ wNodeType: "llm_call",
+ label: "LLM call",
+ values: { model: "SmolLM3", mode: "generate", bogus: "nope" },
+ },
+ },
+ ];
+ const graph = flowToGraph(nodes, []);
+ expect(graph.nodes[0]).toEqual({ id: "llm", type: "llm_call", model: "SmolLM3", mode: "generate" });
+ });
+
+ test("preserves the universal parallel flag", () => {
+ const graph = flowToGraph(
+ [
+ {
+ id: "fan",
+ type: "wf-branch",
+ position: { x: 0, y: 0 },
+ data: { wNodeType: "fan", label: "Fan", values: { parallel: true } },
+ },
+ ],
+ [],
+ );
+ expect(graph.nodes[0]).toEqual({ id: "fan", type: "fan", parallel: true });
+ });
+
+ test("round-trips condition/router AST through the opaque slot", () => {
+ const condition: GraphNode = {
+ id: "cond",
+ type: "condition",
+ condition: { op: "exists", field: "lastResponse.content" },
+ };
+ const graph: GraphPipeline = {
+ id: "rt",
+ name: "rt",
+ nodes: [{ id: "start", type: "start" }, condition, { id: "end", type: "end" }],
+ edges: [
+ { from: "start", to: "cond" },
+ { from: "cond", to: "end", guard: "true" },
+ ],
+ };
+ // flowToGraph emits a working-graph placeholder id — nodes/edges must match.
+ const converted = graphToFlow(graph);
+ const roundTripped = flowToGraph(converted.nodes, converted.edges);
+ expect(roundTripped).toEqual({ id: "workflow", nodes: graph.nodes, edges: graph.edges });
+ });
+
+ test("round-trips pipeline params through the opaque slot", () => {
+ const graph: GraphPipeline = {
+ id: "pipe",
+ name: "pipe",
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "sub", type: "pipeline", pipeline: "summary", params: { topic: "io" } },
+ { id: "end", type: "end" },
+ ],
+ edges: [
+ { from: "start", to: "sub" },
+ { from: "sub", to: "end" },
+ ],
+ };
+ const converted = graphToFlow(graph);
+ const roundTripped = flowToGraph(converted.nodes, converted.edges);
+ expect(roundTripped).toEqual({ id: "workflow", nodes: graph.nodes, edges: graph.edges });
+ });
+
+ test("full canvas round-trip is stable (nodes + edges + guards)", () => {
+ const graph = demoGraph();
+ const converted = graphToFlow(graph);
+ const back = flowToGraph(converted.nodes, converted.edges);
+ expect(back).toEqual({ id: "workflow", nodes: graph.nodes, edges: graph.edges });
+ // And converting the round-tripped graph reproduces the same canvas.
+ expect(graphToFlow(back)).toEqual(converted);
+ });
+});
+
+describe("workflow YAML helpers", () => {
+ const DEMO_YAML = [
+ "name: demo",
+ "nodes:",
+ " - id: start",
+ " type: start",
+ " - id: llm",
+ " type: llm_call",
+ " model: SmolLM3",
+ " mode: generate",
+ " - id: end",
+ " type: end",
+ "edges:",
+ " - from: start",
+ " to: llm",
+ " - from: llm",
+ " to: end",
+ "",
+ ].join("\n");
+
+ test("parses valid YAML into the engine graph", () => {
+ const result = parseWorkflowYamlToGraph(DEMO_YAML);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.graph.id).toBe("demo");
+ expect(result.graph.nodes.map((node) => node.id)).toEqual(["start", "llm", "end"]);
+ expect(result.graph.edges).toEqual([
+ { from: "start", to: "llm" },
+ { from: "llm", to: "end" },
+ ]);
+ });
+
+ test("returns a friendly error envelope on malformed YAML", () => {
+ const result = parseWorkflowYamlToGraph("name: broken\nnodes: 42\n");
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.length).toBeGreaterThan(0);
+ });
+
+ test("unknown node types and edge targets are rejected by the backend parser", () => {
+ const unknownType = parseWorkflowYamlToGraph(
+ "name: x\nnodes:\n - id: start\n type: nope\nedges: []\n",
+ );
+ expect(unknownType.ok).toBe(false);
+ if (!unknownType.ok) expect(unknownType.error).toMatch(/unknown type|nope/i);
+
+ const unknownTarget = parseWorkflowYamlToGraph(
+ "name: x\nnodes:\n - id: start\n type: start\nedges:\n - from: start\n to: ghost\n",
+ );
+ expect(unknownTarget.ok).toBe(false);
+ if (!unknownTarget.ok) expect(unknownTarget.error).toMatch(/ghost/i);
+ });
+
+ test("serialize → parse round-trip deep-equals the graph", () => {
+ const graph = demoGraph();
+ const yaml = serializeGraphToYaml(graph);
+ const reparsed = parseWorkflowYamlToGraph(yaml);
+ expect(reparsed.ok).toBe(true);
+ if (!reparsed.ok) return;
+ expect(reparsed.graph).toEqual(graph);
+ });
+
+ test("serialized field order follows the engine NODE_FIELDS contract", () => {
+ const graph: GraphPipeline = {
+ id: "order",
+ nodes: [
+ {
+ id: "llm",
+ type: "llm_call",
+ model: "SmolLM3",
+ mode: "generate",
+ system: "Be brief",
+ on_429: "end",
+ provider: "openai",
+ },
+ ],
+ edges: [],
+ };
+ const yaml = serializeGraphToYaml(graph);
+ const idx = (needle: string): number => yaml.indexOf(needle);
+ // NODE_FIELDS order: id, type, parallel, model, provider, …, mode, …, system, …, on_429, …
+ expect(idx("type:")).not.toBe(-1);
+ expect(idx("type:")).toBeLessThan(idx("model:"));
+ expect(idx("model:")).toBeLessThan(idx("provider:"));
+ expect(idx("provider:")).toBeLessThan(idx("mode:"));
+ expect(idx("mode:")).toBeLessThan(idx("system:"));
+ expect(idx("system:")).toBeLessThan(idx("on_429:"));
+ });
+
+ test("round-trips guard expressions and duplicated edges through YAML", () => {
+ const graph: GraphPipeline = {
+ id: "guards",
+ name: "guards",
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "cond", type: "condition", condition: { op: "exists", field: "lastResponse.content" } },
+ { id: "end", type: "end" },
+ ],
+ edges: [
+ { from: "start", to: "cond" },
+ { from: "cond", to: "end", guard: "true" },
+ { from: "cond", to: "end", guard: "false" },
+ ],
+ };
+ const yaml = serializeGraphToYaml(graph);
+ const reparsed = parseWorkflowYamlToGraph(yaml);
+ expect(reparsed.ok).toBe(true);
+ if (!reparsed.ok) return;
+ expect(reparsed.graph).toEqual(graph);
+ });
+});
+
+describe("display helpers", () => {
+ test("astSummary renders every SAFE AST shape", () => {
+ expect(astSummary({ op: "exists", field: "lastResponse.content" })).toBe(
+ "has lastResponse.content",
+ );
+ expect(
+ astSummary({ op: "compare", field: "lastResponse.status", op2: "==", value: 200 }),
+ ).toBe('lastResponse.status == 200');
+ expect(astSummary({ op: "not", child: { op: "exists", field: "x" } })).toBe("not (has x)");
+ expect(
+ astSummary({
+ op: "logical",
+ and: false,
+ args: [
+ { op: "exists", field: "a" },
+ { op: "exists", field: "b" },
+ ],
+ }),
+ ).toBe("any(has a, has b)");
+ });
+
+ test("nodeSummaryLines is honest per type", () => {
+ expect(
+ nodeSummaryLines({
+ wNodeType: "llm_call",
+ label: "LLM call",
+ values: { model: "SmolLM3", mode: "generate" },
+ }),
+ ).toEqual([
+ { key: "model", value: "SmolLM3" },
+ { key: "mode", value: "generate" },
+ ]);
+
+ expect(
+ nodeSummaryLines({
+ wNodeType: "llm_call",
+ label: "LLM call",
+ values: {},
+ }),
+ ).toEqual([{ key: "model", value: "unset" }]);
+
+ expect(
+ nodeSummaryLines({
+ wNodeType: "condition",
+ label: "Condition",
+ values: {},
+ condition: { op: "exists", field: "lastResponse.content" },
+ }),
+ ).toEqual([{ key: "condition", value: "has lastResponse.content" }]);
+
+ expect(
+ nodeSummaryLines({ wNodeType: "loop", label: "Loop", values: { body: ["a", "b"], iterations: 2 } }),
+ ).toEqual([
+ { key: "iterations", value: "2" },
+ { key: "body", value: "2 nodes" },
+ ]);
+
+ expect(nodeSummaryLines({ wNodeType: "start", label: "Start", values: {} })).toEqual([]);
+ });
+});
+
+describe("U06 — patchNodeData + full-corpus round-trip", () => {
+ /**
+ * The backend YAML corpus (workflow-yaml.test.ts) exercised every node type
+ * and field; this is its canvas twin — graphToFlow → flowToGraph must
+ * deep-equal the graph (up to the flowToGraph id placeholder), proving the
+ * inspector's edits never lose a field the engine would keep.
+ */
+ const corpus: GraphPipeline = {
+ id: "corpus",
+ name: "corpus",
+ nodes: [
+ { id: "start", type: "start" },
+ {
+ id: "llm",
+ type: "llm_call",
+ model: "SmolLM3",
+ provider: "openai",
+ mode: "refine",
+ ctx: 4096,
+ system: "You are brief.",
+ assistant: "Understood.",
+ user: "Reserved.",
+ on_429: "fallback",
+ tool_calls_route: "router",
+ parallel: true,
+ },
+ {
+ id: "cond",
+ type: "condition",
+ condition: {
+ op: "logical",
+ and: true,
+ args: [
+ { op: "compare", field: "lastResponse.status", op2: "==", value: 200 },
+ { op: "exists", field: "lastResponse.content" },
+ ],
+ },
+ },
+ {
+ id: "router",
+ type: "router",
+ condition: { op: "compare", field: "variables.attempt", op2: "<", value: 3 },
+ },
+ { id: "fan", type: "fan", parallel: true },
+ { id: "join", type: "join" },
+ { id: "loop", type: "loop", body: ["fan", "llm"], iterations: 3 },
+ { id: "fallback", type: "llm_call", model: "tiny" },
+ {
+ id: "pipeline",
+ type: "pipeline",
+ pipeline: "summary",
+ params: { topic: "io", depth: "2" },
+ },
+ { id: "rag", type: "rag_local", k: 4 },
+ { id: "code", type: "data.code", code: "return ctx.last" },
+ { id: "mem", type: "memory", convId: "chat-1" },
+ { id: "emb", type: "embeddings", text: "explicit text" },
+ { id: "out", type: "output" },
+ { id: "end", type: "end" },
+ ],
+ edges: [
+ { from: "start", to: "llm" },
+ { from: "llm", to: "cond" },
+ { from: "cond", to: "router", guard: "true" },
+ { from: "cond", to: "fan", guard: "false" },
+ { from: "router", to: "fan" },
+ { from: "fan", to: "join" },
+ { from: "join", to: "loop" },
+ { from: "loop", to: "pipeline" },
+ { from: "pipeline", to: "rag" },
+ { from: "rag", to: "code" },
+ { from: "code", to: "mem" },
+ { from: "mem", to: "emb" },
+ { from: "emb", to: "out" },
+ { from: "out", to: "end" },
+ ],
+ };
+
+ test("full 14-type corpus round-trips through canvas and back", () => {
+ const converted = graphToFlow(corpus);
+ expect(converted.nodes).toHaveLength(corpus.nodes.length);
+ const back = flowToGraph(converted.nodes, converted.edges);
+ expect(back).toEqual({ id: "workflow", nodes: corpus.nodes, edges: corpus.edges });
+ // Reproducing the canvas from the round-tripped graph is stable too.
+ expect(graphToFlow(back)).toEqual(converted);
+ });
+
+ test("full corpus survives the YAML serializer (parse(serialize) deep-equals)", () => {
+ const yaml = serializeGraphToYaml(corpus);
+ const reparsed = parseWorkflowYamlToGraph(yaml);
+ expect(reparsed.ok).toBe(true);
+ if (!reparsed.ok) return;
+ expect(reparsed.graph).toEqual(corpus);
+ });
+
+ test("patchNodeData immutably patches only the targeted node's data", () => {
+ const { nodes } = graphToFlow(corpus);
+ const patched = patchNodeData(nodes, "llm", { values: { ...nodes[1]!.data.values, mode: "generate" } });
+
+ expect(patched).not.toBe(nodes);
+ const target = patched.find((n) => n.id === "llm")!;
+ expect(target.data.values.mode).toBe("generate");
+ expect(target.position).toEqual(nodes.find((n) => n.id === "llm")!.position);
+ expect(target.type).toBe("wf-llm");
+ // Untouched nodes are the same object references (structural sharing).
+ expect(patched.find((n) => n.id === "end")).toBe(nodes.find((n) => n.id === "end"));
+ // The source array is unchanged.
+ expect(nodes.find((n) => n.id === "llm")!.data.values.mode).toBe("refine");
+ });
+
+ test("patchNodeData can write opaque slots (condition AST)", () => {
+ const { nodes } = graphToFlow(corpus);
+ const expr = { op: "exists", field: "error" } as const;
+ const patched = patchNodeData(nodes, "cond", { condition: expr });
+ expect(patched.find((n) => n.id === "cond")!.data.condition).toEqual(expr);
+ // And the patched canvas serializes through flowToGraph with that slot.
+ const back = flowToGraph(patched, []);
+ expect(back.nodes.find((n) => n.id === "cond")!.condition).toEqual(expr);
+ });
+
+ test("patchNodeData on an unknown id returns an equal array", () => {
+ const { nodes } = graphToFlow(corpus);
+ const patched = patchNodeData(nodes, "ghost", { label: "x" });
+ expect(patched).toEqual(nodes);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-flow.ts b/frontend/src/lib/workflow-flow.ts
new file mode 100644
index 0000000..d500396
--- /dev/null
+++ b/frontend/src/lib/workflow-flow.ts
@@ -0,0 +1,480 @@
+/**
+ * Graph ⇄ canvas conversion + workflow YAML helpers (U05).
+ *
+ * The canvas (SvelteFlow) and the engine (`GraphPipeline`) are two shapes of
+ * the same graph:
+ *
+ * - `graphToFlow` converts engine → canvas, synthesizing deterministic
+ * positions (layers from `start` nodes, BFS over edges, cycle-safe) so
+ * any stored workflow has a stable, readable layout with no geometry of
+ * its own to store.
+ * - `flowToGraph` converts canvas → engine, admitting ONLY taxonomy-whitelist
+ * fields (via `sanitizeNodeFields`) plus the universal `parallel` flag and
+ * the opaque `condition` / `params` slots — the editor can never emit a
+ * field the engine would drop.
+ *
+ * YAML goes through the backend parser/serializer rather than a second
+ * implementation: `parse(serialize(graph))` deep-equals `graph` round-trip is
+ * guaranteed there, so this module only wraps it in a friendly result type.
+ *
+ * Pure functions — no DOM, no Svelte, no stores — so everything here runs
+ * under `bun test` and in the Astro build.
+ */
+import {
+ serializeWorkflowGraph,
+ parseWorkflowGraph,
+} from "../../../src/orchestrator/workflow-yaml.js";
+import {
+ nodeTypeDef,
+ sanitizeNodeFields,
+ type FieldKind,
+ type NodeFieldValues,
+ type NodeType,
+} from "./workflow-nodes.js";
+import type {
+ AstExpr,
+ GraphEdge,
+ GraphNode,
+ GraphPipeline,
+} from "./workflow-nodes.js";
+
+// ── Canvas shapes ────────────────────────────────────────────────────────────
+
+/** SvelteFlow node type keys — one custom node component per group. */
+export type NodeGroup =
+ | "wf-start-end"
+ | "wf-llm"
+ | "wf-branch"
+ | "wf-data"
+ | "wf-pipeline"
+ | "wf-output";
+
+export const NODE_GROUPS: readonly NodeGroup[] = [
+ "wf-start-end",
+ "wf-llm",
+ "wf-branch",
+ "wf-data",
+ "wf-pipeline",
+ "wf-output",
+];
+
+/** Map each engine node type to its custom-node group. */
+export function nodeGroupOf(type: NodeType): NodeGroup {
+ switch (type) {
+ case "start":
+ case "end":
+ return "wf-start-end";
+ case "llm_call":
+ return "wf-llm";
+ case "condition":
+ case "loop":
+ case "fan":
+ case "join":
+ case "router":
+ return "wf-branch";
+ case "rag_local":
+ case "data.code":
+ case "memory":
+ case "embeddings":
+ return "wf-data";
+ case "pipeline":
+ return "wf-pipeline";
+ case "output":
+ return "wf-output";
+ }
+}
+
+/** Per-node data the canvas carries; positions live on the node object. */
+export interface FlowNodeData {
+ /** The engine node type this canvas node represents (group discriminator). */
+ wNodeType: NodeType;
+ /** Display label (taxonomy label at creation; edited in U06). */
+ label: string;
+ /** Primitive GraphNode fields present on this node (whitelisted). */
+ values: NodeFieldValues;
+ /** SAFE AST for condition/router nodes — opaque slot (round-trip fidelity). */
+ condition?: AstExpr;
+ /** Input parameters for pipeline composition nodes — opaque slot. */
+ params?: Record;
+}
+
+export interface FlowNode {
+ id: string;
+ /** Custom-node group key (one SvelteFlow node type per group). */
+ type: NodeGroup;
+ position: FlowPosition;
+ data: FlowNodeData;
+}
+
+export interface FlowEdge {
+ id: string;
+ source: string;
+ target: string;
+ /** Engine guard ("true"/"false") preserved for U06 — not rendered yet. */
+ guard?: "true" | "false";
+}
+
+/** A point on the canvas in flow coordinates. */
+export type FlowPosition = { x: number; y: number };
+
+/**
+ * Minimal seam over the SvelteFlow instance the island needs (U05). The real
+ * instance exists only inside the SvelteFlow provider context, so a child
+ * component exposes exactly these two capabilities to the island: fit the
+ * viewport, and translate a client (screen) point into flow coordinates —
+ * used for click-to-add at the canvas center.
+ */
+export interface CanvasApi {
+ fitView: (options?: { padding?: number }) => void;
+ screenToFlowPosition: (position: FlowPosition, snapToGrid?: boolean) => FlowPosition;
+}
+
+// ── Layout ───────────────────────────────────────────────────────────────────
+
+const LAYER_GAP_X = 280;
+const ROW_GAP_Y = 150;
+const MARGIN = 40;
+
+/**
+ * Deterministic layered layout: BFS from `start` nodes over edges assigns a
+ * layer (column), nodes are stacked by array order within their layer, and
+ * any node unreached from a start (no start, disconnected islands) is appended
+ * after the BFS layers in array order. Pure function of the graph — the same
+ * input always yields the same positions, so round-trips are stable.
+ */
+function layoutFor(graph: GraphPipeline): Map {
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
+ const out = new Map();
+ for (const edge of graph.edges) {
+ if (!byId.has(edge.from) || !byId.has(edge.to)) continue;
+ const targets = out.get(edge.from) ?? [];
+ targets.push(edge.to);
+ out.set(edge.from, targets);
+ }
+
+ const layerOf = new Map();
+ const queue: string[] = [];
+ for (const node of graph.nodes) {
+ if (node.type === "start") {
+ layerOf.set(node.id, 0);
+ queue.push(node.id);
+ }
+ }
+ let head = 0;
+ while (head < queue.length) {
+ const id = queue[head];
+ head += 1;
+ const layer = layerOf.get(id) ?? 0;
+ for (const next of out.get(id) ?? []) {
+ if (layerOf.has(next)) continue; // cycle-safe: first visit wins
+ layerOf.set(next, layer + 1);
+ queue.push(next);
+ }
+ }
+
+ let fallback = 0;
+ for (const layer of layerOf.values()) fallback = Math.max(fallback, layer);
+ for (const node of graph.nodes) {
+ if (!layerOf.has(node.id)) {
+ fallback += 1;
+ layerOf.set(node.id, fallback);
+ }
+ }
+
+ const rowIn = new Map();
+ const positions = new Map();
+ for (const node of graph.nodes) {
+ const layer = layerOf.get(node.id) ?? 0;
+ const row = rowIn.get(layer) ?? 0;
+ rowIn.set(layer, row + 1);
+ positions.set(node.id, { x: MARGIN + layer * LAYER_GAP_X, y: MARGIN + row * ROW_GAP_Y });
+ }
+ return positions;
+}
+
+// ── Conversion ───────────────────────────────────────────────────────────────
+
+/**
+ * Unique edge id for a connection — the canonical form is `${from}->${to}`
+ * with a `#n` suffix when a parallel edge already used it (the engine allows
+ * duplicate edges; the canvas needs unique ids). Deterministic for a given
+ * ordered set.
+ */
+export function uniqueEdgeId(
+ from: string,
+ to: string,
+ existing: ReadonlySet,
+): string {
+ let id = `${from}->${to}`;
+ let suffix = 2;
+ while (existing.has(id)) {
+ id = `${from}->${to}#${suffix}`;
+ suffix += 1;
+ }
+ return id;
+}
+
+function edgeFromGraph(edge: GraphEdge, taken: Set): FlowEdge {
+ const id = uniqueEdgeId(edge.from, edge.to, taken);
+ taken.add(id);
+ const out: FlowEdge = { id, source: edge.from, target: edge.to };
+ if (edge.guard !== undefined) out.guard = edge.guard;
+ return out;
+}
+
+/** Engine graph → canvas nodes/edges with synthesized layered positions. */
+export function graphToFlow(graph: GraphPipeline): {
+ nodes: FlowNode[];
+ edges: FlowEdge[];
+} {
+ const positions = layoutFor(graph);
+ const nodes: FlowNode[] = graph.nodes.map((node) => {
+ const def = nodeTypeDef(node.type);
+ const data: FlowNodeData = {
+ wNodeType: node.type,
+ label: def.label,
+ values: pickNodeFieldValues(node),
+ };
+ if (node.condition !== undefined && (node.type === "condition" || node.type === "router")) {
+ data.condition = node.condition;
+ }
+ if (node.params !== undefined && node.type === "pipeline") {
+ data.params = node.params;
+ }
+ return {
+ id: node.id,
+ type: nodeGroupOf(node.type),
+ position: positions.get(node.id) ?? { x: MARGIN, y: MARGIN },
+ data,
+ };
+ });
+
+ const taken = new Set();
+ const edges: FlowEdge[] = graph.edges.map((edge) => edgeFromGraph(edge, taken));
+ return { nodes, edges };
+}
+
+/** Pick the primitive, whitelisted fields a node carries onto the canvas. */
+function pickNodeFieldValues(node: GraphNode): NodeFieldValues {
+ const out: NodeFieldValues = {};
+ if (node.parallel !== undefined) out.parallel = node.parallel;
+ const def = nodeTypeDef(node.type);
+ for (const field of def.fields) {
+ if (field.key === "condition") continue; // opaque AST slot
+ const raw = (node as unknown as Record)[field.key];
+ if (raw === undefined) continue;
+ const cleaned = sanitizeFieldValueFor(field.kind, field.key, raw);
+ if (cleaned !== undefined) out[field.key] = cleaned;
+ }
+ return out;
+}
+
+function sanitizeFieldValueFor(
+ kind: FieldKind,
+ key: string,
+ raw: unknown,
+): string | number | string[] | boolean | undefined {
+ switch (kind) {
+ case "toggle":
+ return typeof raw === "boolean" ? raw : undefined;
+ case "number":
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
+ case "tokens":
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
+ return typeof raw === "string" && raw.trim() !== "" ? raw : undefined;
+ case "select":
+ return typeof raw === "string" ? raw : undefined;
+ case "textarea":
+ if (Array.isArray(raw)) {
+ return key === "body"
+ ? raw.filter((item): item is string => typeof item === "string")
+ : undefined;
+ }
+ return typeof raw === "string" ? raw : undefined;
+ case "text":
+ case "code":
+ return typeof raw === "string" ? raw : undefined;
+ }
+}
+
+/** Shape guard mirroring the backend's SAFE AST admission (structural only). */
+function isAstExpr(value: unknown): value is AstExpr {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
+ const candidate = value as Record;
+ if (typeof candidate.op !== "string") return false;
+ return (
+ candidate.op === "exists" ||
+ candidate.op === "not" ||
+ candidate.op === "logical" ||
+ candidate.op === "compare"
+ );
+}
+
+function isStringRecord(value: unknown): value is Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
+ return Object.values(value).every((item) => typeof item === "string");
+}
+
+/** Canvas nodes/edges → engine graph (whitelist admission, no casts). */
+export function flowToGraph(nodes: FlowNode[], edges: FlowEdge[]): GraphPipeline {
+ const graphNodes: GraphNode[] = nodes.map((node) => {
+ const type = node.data.wNodeType;
+ const values = sanitizeNodeFields(type, node.data.values);
+ const graphNode: GraphNode = { id: node.id, type };
+ Object.assign(graphNode, values);
+ if ((type === "condition" || type === "router") && isAstExpr(node.data.condition)) {
+ graphNode.condition = node.data.condition;
+ }
+ if (type === "pipeline" && isStringRecord(node.data.params)) {
+ graphNode.params = node.data.params;
+ }
+ return graphNode;
+ });
+
+ const graphEdges: GraphEdge[] = edges.map((edge) => {
+ const out: GraphEdge = { from: edge.source, to: edge.target };
+ if (edge.guard !== undefined) out.guard = edge.guard;
+ return out;
+ });
+ return { id: "workflow", nodes: graphNodes, edges: graphEdges };
+}
+
+// ── YAML helpers ─────────────────────────────────────────────────────────────
+
+export type WorkflowYamlResult =
+ | { ok: true; graph: GraphPipeline }
+ | { ok: false; error: string };
+
+/**
+ * Parse workflow YAML through the backend parser, wrapped in a friendly
+ * result type (`{ ok: false, error }` on malformed input). The backend names
+ * the offending node/edge slot in its error messages, which the editor shows
+ * verbatim.
+ */
+export function parseWorkflowYamlToGraph(yamlText: string): WorkflowYamlResult {
+ try {
+ const doc = parseWorkflowGraph(yamlText);
+ return { ok: true, graph: doc.graph };
+ } catch (error) {
+ return {
+ ok: false,
+ error: error instanceof Error ? error.message : "Invalid workflow YAML",
+ };
+ }
+}
+
+/** Serialize an engine graph to workflow YAML via the backend serializer. */
+export function serializeGraphToYaml(graph: GraphPipeline): string {
+ return serializeWorkflowGraph(graph, null);
+}
+
+// ── Inspector seam (U06) ─────────────────────────────────────────────────────
+
+/**
+ * Immutably patch one canvas node's DATA (fields, opaque slots) while
+ * preserving its id, group type and position. Returns a new array — the
+ * inspector's write seam into `bind:nodes` — so a Select→Reset or Undo never
+ * has to restore geometry.
+ */
+export function patchNodeData(
+ nodes: readonly FlowNode[],
+ id: string,
+ patch: Partial,
+): FlowNode[] {
+ return nodes.map((node) =>
+ node.id === id ? { ...node, data: { ...node.data, ...patch } } : node,
+ );
+}
+
+// ── Display helpers (shared by node components and the inspector) ────────────
+
+/** Human-readable one-line rendering of a SAFE AST expression. */
+export function astSummary(expr: AstExpr): string {
+ switch (expr.op) {
+ case "exists":
+ return `has ${expr.field}`;
+ case "not":
+ return `not (${astSummary(expr.child)})`;
+ case "logical":
+ return `${expr.and ? "all" : "any"}(${expr.args.map(astSummary).join(", ")})`;
+ case "compare":
+ return `${expr.field} ${expr.op2} ${JSON.stringify(expr.value)}`;
+ }
+}
+
+/** Compact per-node summary lines for the canvas cards (best effort). */
+export function nodeSummaryLines(data: FlowNodeData): { key: string; value: string }[] {
+ const lines: { key: string; value: string }[] = [];
+ switch (data.wNodeType) {
+ case "llm_call": {
+ const model = data.values.model;
+ const mode = data.values.mode;
+ if (typeof model === "string" && model !== "") lines.push({ key: "model", value: model });
+ else lines.push({ key: "model", value: "unset" });
+ if (typeof mode === "string") lines.push({ key: "mode", value: mode });
+ if (typeof data.values.provider === "string" && data.values.provider !== "") {
+ lines.push({ key: "provider", value: data.values.provider });
+ }
+ break;
+ }
+ case "loop": {
+ lines.push({ key: "iterations", value: String(data.values.iterations ?? 1) });
+ const count = Array.isArray(data.values.body) ? data.values.body.length : 0;
+ lines.push({ key: "body", value: count > 0 ? `${count} ${count === 1 ? "node" : "nodes"}` : "unset" });
+ break;
+ }
+ case "condition":
+ case "router": {
+ lines.push({
+ key: "condition",
+ value: data.condition !== undefined ? astSummary(data.condition) : "unset",
+ });
+ break;
+ }
+ case "pipeline": {
+ const name = typeof data.values.pipeline === "string" ? data.values.pipeline : "";
+ lines.push({ key: "pipeline", value: name !== "" ? name : "unset" });
+ if (data.params !== undefined && Object.keys(data.params).length > 0) {
+ lines.push({ key: "params", value: String(Object.keys(data.params).length) });
+ }
+ break;
+ }
+ case "rag_local": {
+ if (typeof data.values.k === "number") {
+ lines.push({ key: "top-k", value: String(data.values.k) });
+ }
+ break;
+ }
+ case "data.code": {
+ const code = typeof data.values.code === "string" ? data.values.code : "";
+ const count = code === "" ? 0 : code.split("\n").length;
+ lines.push({ key: "code", value: count > 0 ? `${count} ${count === 1 ? "line" : "lines"}` : "unset" });
+ break;
+ }
+ case "memory": {
+ if (typeof data.values.convId === "string" && data.values.convId !== "") {
+ lines.push({ key: "convId", value: data.values.convId });
+ }
+ break;
+ }
+ case "embeddings": {
+ const text = typeof data.values.text === "string" ? data.values.text : "";
+ lines.push({ key: "text", value: text !== "" ? truncate(text, 28) : "unset" });
+ break;
+ }
+ case "fan": {
+ if (data.values.parallel === true) lines.push({ key: "parallel", value: "on" });
+ break;
+ }
+ case "start":
+ case "end":
+ case "join":
+ case "output":
+ break;
+ }
+ return lines;
+}
+
+function truncate(text: string, max: number): string {
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-nodes.test.ts b/frontend/src/lib/workflow-nodes.test.ts
index 4c30d55..eff515a 100644
--- a/frontend/src/lib/workflow-nodes.test.ts
+++ b/frontend/src/lib/workflow-nodes.test.ts
@@ -1,124 +1,232 @@
-/**
- * Editor pure-helper tests (Task 6.7, workflow-editor spec): palette
- * completeness, cycle rejection, validation errors, and YAML round-trips.
- */
import { describe, expect, test } from "bun:test";
-import type { GraphNode } from "../../../src/orchestrator/graph.js";
import {
- editorToYaml,
- flowEdgeFrom,
- graphEdgeFrom,
- makeEditorNode,
- paletteCoversTaxonomy,
- validateEditorGraph,
- wouldCreateCycle,
- yamlToEditor,
+ NODE_TYPES,
+ WORKFLOW_NODE_TYPES,
+ hasSourceHandle,
+ hasTargetHandle,
+ makeNodeId,
+ nodeCategory,
+ nodeLabel,
+ nodeTypeDef,
+ nodeTypeExists,
+ sanitizeNodeFields,
} from "./workflow-nodes.js";
+import type { FieldKind } from "./workflow-nodes.js";
-const basic = (): { nodes: GraphNode[]; edges: ReturnType[] } => ({
- nodes: [
- { id: "start", type: "start" },
- { id: "a", type: "llm_call", model: "llama" },
- { id: "end", type: "end" },
- ],
- edges: [
- { from: "start", to: "a" },
- { from: "a", to: "end" },
- ],
+/** Runtime mirror of GraphNode fields (kept in sync with src/orchestrator/graph.ts). */
+const GRAPH_NODE_KEYS = new Set([
+ "id",
+ "type",
+ "parallel",
+ "model",
+ "provider",
+ "condition",
+ "body",
+ "pipeline",
+ "params",
+ "mode",
+ "ctx",
+ "system",
+ "assistant",
+ "user",
+ "on_429",
+ "tool_calls_route",
+ "code",
+ "k",
+ "text",
+ "convId",
+ "iterations",
+]);
+
+describe("WORKFLOW_NODE_TYPES taxonomy", () => {
+ test("covers every engine node type exactly once", () => {
+ const ids = WORKFLOW_NODE_TYPES.map((entry) => entry.id);
+ expect(ids).toEqual([...NODE_TYPES]);
+ expect(new Set(ids).size).toBe(NODE_TYPES.length);
+ });
+
+ test("palette order matches the contract (start → … → output)", () => {
+ const ids = WORKFLOW_NODE_TYPES.map((entry) => entry.id);
+ expect(ids).toEqual([
+ "start",
+ "end",
+ "llm_call",
+ "condition",
+ "loop",
+ "fan",
+ "join",
+ "pipeline",
+ "rag_local",
+ "data.code",
+ "memory",
+ "embeddings",
+ "router",
+ "output",
+ ]);
+ });
+
+ test("every entry carries label, category, color and description", () => {
+ for (const entry of WORKFLOW_NODE_TYPES) {
+ expect(entry.label.length).toBeGreaterThan(0);
+ expect(["flow", "llm", "data", "integration", "output"]).toContain(entry.category);
+ expect(["accent", "secondary", "success"]).toContain(entry.color);
+ expect(entry.description.length).toBeGreaterThan(0);
+ }
+ });
+
+ test("field definitions bind only real GraphNode keys", () => {
+ for (const entry of WORKFLOW_NODE_TYPES) {
+ expect(Array.isArray(entry.fields), `${entry.id} fields is an array`).toBe(true);
+ for (const field of entry.fields) {
+ expect(GRAPH_NODE_KEYS.has(field.key), `${entry.id}.${field.key} is a GraphNode key`).toBe(true);
+ expect(field.label.length).toBeGreaterThan(0);
+ }
+ }
+ });
+
+ test("llm_call exposes the engine's llm semantics only", () => {
+ const llm = nodeTypeDef("llm_call");
+ const keys = llm.fields.map((field) => field.key);
+ expect(keys).toEqual([
+ "model",
+ "mode",
+ "provider",
+ "ctx",
+ "system",
+ "assistant",
+ "user",
+ "on_429",
+ "tool_calls_route",
+ ]);
+ const modeField = llm.fields.find((field) => field.key === "mode");
+ expect(modeField?.kind).toBe("select");
+ expect(modeField?.options).toEqual(["generate", "refine", "passthrough"]);
+ expect(llm.defaults).toEqual({ mode: "generate" });
+ });
+
+ test("rag_local/embeddings do NOT invent a model field", () => {
+ // GraphNode has no `model` on these types — the runtime default applies.
+ expect(nodeTypeDef("rag_local").fields.map((f) => f.key)).toEqual(["k"]);
+ expect(nodeTypeDef("embeddings").fields.map((f) => f.key)).toEqual(["text"]);
+ });
+
+ test("condition/router default to valid SAFE AST shapes", () => {
+ expect(nodeTypeDef("condition").defaults).toEqual({
+ condition: { op: "exists", field: "lastResponse.content" },
+ });
+ expect(nodeTypeDef("router").defaults).toEqual({
+ condition: { op: "compare", field: "lastResponse.status", op2: "==", value: 200 },
+ });
+ });
+
+ test("loop/fan defaults are engine-valid", () => {
+ expect(nodeTypeDef("loop").defaults).toEqual({ body: [], iterations: 1 });
+ expect(nodeTypeDef("fan").defaults).toEqual({ parallel: true });
+ });
});
-describe("editor palette (6.7)", () => {
- test("palette covers the full node taxonomy (>= 10 types)", () => {
- expect(paletteCoversTaxonomy()).toBe(true);
- expect(makeEditorNode("start", new Map(), { x: 0, y: 0 }).type).toBe("start");
+describe("nodeTypeExists / nodeTypeDef / nodeLabel / nodeCategory", () => {
+ test("accepts only real engine types", () => {
+ for (const id of NODE_TYPES) expect(nodeTypeExists(id)).toBe(true);
+ expect(nodeTypeExists("llm")).toBe(false);
+ expect(nodeTypeExists("")).toBe(false);
});
- test("makeEditorNode applies palette defaults and increments ids per type", () => {
- const counter = new Map();
- const first = makeEditorNode("llm_call", counter, { x: 10, y: 20 });
- const second = makeEditorNode("llm_call", counter, { x: 30, y: 40 });
- expect(first).toMatchObject({ id: "llm_call-1", model: "llama", mode: "generate" });
- expect(first.position).toEqual({ x: 10, y: 20 });
- expect(second.id).toBe("llm_call-2");
+ test("nodeTypeDef throws on unknown ids (invariant helper)", () => {
+ expect(() => nodeTypeDef("nope" as never)).toThrow(/unknown node type/);
+ });
+
+ test("labels and categories resolve", () => {
+ expect(nodeLabel("llm_call")).toBe("LLM call");
+ expect(nodeLabel("data.code")).toBe("Data (code)");
+ expect(nodeCategory("start")).toBe("flow");
+ expect(nodeCategory("pipeline")).toBe("integration");
+ expect(nodeCategory("output")).toBe("output");
});
});
-describe("cycle rejection (6.7)", () => {
- test("back edge closes a cycle; a forward edge does not", () => {
- const graph = basic();
- expect(wouldCreateCycle(graph, "end", "start")).toBe(true);
- expect(wouldCreateCycle(graph, "start", "end")).toBe(false);
- });
-
- test("self edge is a cycle", () => {
- expect(wouldCreateCycle(basic(), "a", "a")).toBe(true);
- });
-
- test("transitive loop (c -> a when a -> b -> c) is detected", () => {
- const graph = {
- nodes: [
- { id: "a", type: "start" },
- { id: "b", type: "llm_call", model: "llama" },
- { id: "c", type: "end" },
- ],
- edges: [
- { from: "a", to: "b" },
- { from: "b", to: "c" },
- ],
- };
- expect(wouldCreateCycle(graph, "c", "a")).toBe(true);
- expect(wouldCreateCycle(graph, "c", "b")).toBe(true);
- expect(wouldCreateCycle(graph, "a", "c")).toBe(false);
+describe("canvas handle semantics", () => {
+ test("start has no target; end has no source; everything else has both", () => {
+ for (const id of NODE_TYPES) {
+ expect(hasTargetHandle(id), `${id} target`).toBe(id !== "start");
+ expect(hasSourceHandle(id), `${id} source`).toBe(id !== "end");
+ }
});
});
-describe("validation (6.7)", () => {
- test("a valid acyclic graph has no errors", () => {
- const graph = basic();
- expect(validateEditorGraph(graph.nodes, graph.edges)).toEqual([]);
+describe("makeNodeId", () => {
+ test("kebab base forms", () => {
+ expect(makeNodeId("start", new Set())).toBe("start");
+ expect(makeNodeId("llm_call", new Set())).toBe("llm-call");
+ expect(makeNodeId("data.code", new Set())).toBe("data-code");
+ });
+
+ test("dedupes against taken ids with numeric suffixes", () => {
+ expect(makeNodeId("llm_call", new Set(["llm-call"]))).toBe("llm-call-2");
+ expect(makeNodeId("llm_call", new Set(["llm-call", "llm-call-2"]))).toBe("llm-call-3");
+ });
+
+ test("deterministic for the same input set", () => {
+ const taken = new Set(["start", "llm-call"]);
+ expect(makeNodeId("llm_call", taken)).toBe("llm-call-2");
+ expect(makeNodeId("llm_call", taken)).toBe("llm-call-2");
+ });
+});
+
+describe("sanitizeNodeFields", () => {
+ test("keeps only whitelisted keys and drops unknowns", () => {
+ const out = sanitizeNodeFields("llm_call", {
+ model: "SmolLM3",
+ mode: "generate",
+ bogus: "nope",
+ });
+ expect(out).toEqual({ model: "SmolLM3", mode: "generate" });
+ expect("bogus" in out).toBe(false);
+ });
+
+ test("admits the universal parallel flag on every type", () => {
+ expect(sanitizeNodeFields("start", { parallel: true })).toEqual({ parallel: true });
+ expect(sanitizeNodeFields("llm_call", { parallel: true, model: "m" })).toEqual({
+ parallel: true,
+ model: "m",
+ });
+ });
+
+ test("rejects invalid values per kind", () => {
+ expect(sanitizeNodeFields("llm_call", { mode: "not-a-mode" })).toEqual({});
+ expect(sanitizeNodeFields("llm_call", { ctx: 4096 })).toEqual({ ctx: 4096 });
+ // ctx is number | string in GraphNode — the engine accepts percentage
+ // strings, so only empty strings are dropped.
+ expect(sanitizeNodeFields("llm_call", { ctx: "50%" })).toEqual({ ctx: "50%" });
+ expect(sanitizeNodeFields("llm_call", { ctx: "" })).toEqual({});
+ expect(sanitizeNodeFields("rag_local", { k: "four" })).toEqual({});
+ expect(sanitizeNodeFields("rag_local", { k: 4 })).toEqual({ k: 4 });
+ expect(sanitizeNodeFields("fan", { parallel: "yes" })).toEqual({});
+ });
+
+ test("body is the only textarea-shaped string[] field", () => {
+ expect(sanitizeNodeFields("loop", { body: ["a", "b"], iterations: 2 })).toEqual({
+ body: ["a", "b"],
+ iterations: 2,
+ });
+ expect(sanitizeNodeFields("llm_call", { system: ["should-not-survive"] })).toEqual({});
});
- test("llm_call without a model is flagged by node", () => {
- const graph = basic();
- graph.nodes[1] = { id: "a", type: "llm_call" };
- const errors = validateEditorGraph(graph.nodes, graph.edges);
- expect(errors.some((e) => e.includes("a") && e.includes("model"))).toBe(true);
+ test("condition key is opaque and never admitted into primitives", () => {
+ const out = sanitizeNodeFields("condition", {
+ condition: { op: "exists", field: "lastResponse.content" },
+ });
+ expect(out).toEqual({});
});
});
-describe("YAML import/export (6.7)", () => {
- const GOOD_YAML =
- "name: Demo\n" +
- "nodes:\n" +
- " - {id: start, type: start}\n" +
- " - {id: a, type: llm_call, model: llama}\n" +
- " - {id: end, type: end}\n" +
- "edges:\n" +
- " - {from: start, to: a}\n" +
- " - {from: a, to: end}\n";
-
- test("import → export round-trips the graph", () => {
- const imported = yamlToEditor(GOOD_YAML);
- if (!imported.ok) throw new Error(imported.error);
- expect(imported.nodes.map((n) => n.id)).toEqual(["start", "a", "end"]);
- expect(imported.edges).toHaveLength(2);
- const exported = editorToYaml(imported.nodes, imported.edges, imported.name);
- expect(exported).toContain("name: Demo");
- expect(exported).toContain("type: llm_call");
- });
-
- test("invalid YAML is rejected with a message naming the node", () => {
- const result = yamlToEditor(
- "name: broken\nnodes:\n - {id: start, type: start}\n - {id: x2, type: wat}\nedges: []\n",
- );
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.error).toContain("wat");
- });
-
- test("flow edge conversion carries the guard", () => {
- const flow = flowEdgeFrom({ from: "a", to: "b", guard: "true" });
- expect(flow).toEqual({ id: "a->b", source: "a", target: "b", guard: "true" });
- expect(graphEdgeFrom(flow)).toEqual({ from: "a", to: "b", guard: "true" });
- expect(graphEdgeFrom(flowEdgeFrom({ from: "a", to: "b" }))).toEqual({ from: "a", to: "b" });
+describe("field kind sanity (compile-time surface)", () => {
+ test("every field kind used by the taxonomy is real", () => {
+ const kinds: FieldKind[] = ["text", "select", "number", "textarea", "code", "tokens", "toggle"];
+ for (const entry of WORKFLOW_NODE_TYPES) {
+ for (const field of entry.fields) {
+ expect(kinds).toContain(field.kind);
+ }
+ }
});
});
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-nodes.ts b/frontend/src/lib/workflow-nodes.ts
index a933ae9..ff99b8c 100644
--- a/frontend/src/lib/workflow-nodes.ts
+++ b/frontend/src/lib/workflow-nodes.ts
@@ -1,257 +1,384 @@
/**
- * Editor node palette + pure graph helpers (Task 6.7, workflow-editor spec).
+ * Single frontend node taxonomy (U05) — the mirror of `src/orchestrator/graph.ts`.
*
- * Pure functions only — no DOM, no stores — so the Svelte canvas stays thin
- * and the palette/cycle/validation logic is unit-tested with `bun test`
- * (relative imports, no Vite alias, so the same file runs in both runners).
+ * `graph.ts` is the authoritative contract: this module re-exports its
+ * `NodeType` union and `NODE_TYPES` registry (one source of truth) and adds
+ * the UI-facing surface the visual editor needs:
+ *
+ * - `WORKFLOW_NODE_TYPES` — 14 palette entries (ordered for the palette)
+ * with label, category, description, a token-key color and the per-type
+ * field definitions. Fields ONLY come from `GraphNode` semantics — no
+ * invented node fields.
+ * - value sanitization (`sanitizeNodeFields`) — enforces the "unknown
+ * fields are dropped" rule the backend parser applies, so the editor can
+ * never serialize a field the engine would drop.
+ * - id / handle helpers used by the canvas and the palette.
+ *
+ * Pure functions only — no DOM, no Svelte, no stores — so the same module
+ * runs under `bun test` and in the Astro build.
*/
-import type { GraphEdge, GraphNode, GraphPipeline, NodeType } from "../../../src/orchestrator/graph.js";
-import { NODE_TYPES, validateGraph } from "../../../src/orchestrator/graph.js";
-import { parseWorkflowGraph, serializeWorkflowGraph } from "../../../src/orchestrator/workflow-yaml.js";
+import {
+ NODE_TYPES,
+ type GraphNode,
+ type NodeType,
+} from "../../../src/orchestrator/graph.js";
-/** A node on the canvas: the engine node + canvas position + display label. */
-export interface EditorNode extends GraphNode {
- position: { x: number; y: number };
- data: { label: string };
-}
+// The taxonomy IDs ARE the engine registry — re-export verbatim instead of
+// duplicating a list that could drift.
+export { NODE_TYPES } from "../../../src/orchestrator/graph.js";
+export type {
+ AstExpr,
+ GraphEdge,
+ GraphNode,
+ GraphPipeline,
+ NodeType,
+} from "../../../src/orchestrator/graph.js";
-/** An edge on the canvas (xyflow shape), carrying the engine `guard`. */
-export interface FlowEdge {
- id: string;
- source: string;
- target: string;
- guard?: "true" | "false";
-}
+/** Palette category grouping (palette order: flow → llm → data → integration → output). */
+export type NodeCategory = "flow" | "llm" | "data" | "integration" | "output";
+
+export const CATEGORY_LABELS: Record = {
+ flow: "Flow",
+ llm: "LLM",
+ data: "Data",
+ integration: "Integration",
+ output: "Output",
+};
+
+/** Palette group order (flow → llm → data → integration → output). */
+export const CATEGORY_ORDER: readonly NodeCategory[] = [
+ "flow",
+ "llm",
+ "data",
+ "integration",
+ "output",
+];
+
+/** Field input kinds the inspector forms (U06) will render. */
+export type FieldKind =
+ | "text"
+ | "select"
+ | "number"
+ | "textarea"
+ | "code"
+ | "tokens"
+ | "toggle";
+
+/** Token-key color for a node type chip — resolved to `var(--)` in CSS. */
+export type ColorTokenKey = "accent" | "secondary" | "success";
-/** Canvas node with the engine's edge shape (for cycle/validation checks). */
-export interface EditorGraph {
- nodes: GraphNode[];
- edges: GraphEdge[];
+/** One editable field of a node type, bound to a `GraphNode` field key. */
+export interface NodeFieldDef {
+ /** The GraphNode field key this input reads/writes. */
+ key: string;
+ label: string;
+ kind: FieldKind;
+ /** Valid selections when kind === "select". */
+ options?: readonly string[];
+ placeholder?: string;
}
-export interface PaletteEntry {
- type: NodeType;
+/** Full taxonomy entry for one node type. */
+export interface WorkflowNodeTypeDef {
+ id: NodeType;
label: string;
+ category: NodeCategory;
description: string;
- defaults: Omit;
+ /** Token key for the node color chip (CSS `var(--)`). */
+ color: ColorTokenKey;
+ /** UI field surface — a subset of GraphNode semantics for this type. */
+ fields: readonly NodeFieldDef[];
+ /** Canvas defaults for click-to-add (GraphNode fields only). */
+ defaults: Partial;
}
-/** The full node taxonomy as a palette (≥ 10 types, per the spec). */
-export const PALETTE: readonly PaletteEntry[] = [
+/**
+ * Per-type UI field definitions. `condition` and `params` are GraphNode
+ * fields of non-primitive shape (SAFE AST / param map); they travel in the
+ * opaque `FlowNodeData.condition` / `.params` slots rather than in
+ * `NodeFieldValues`, and their `kind` here ("code") only documents that U06
+ * renders them as structured editors. `rag_local`/`embeddings` expose no
+ * `model` — GraphNode defines no such field (the runtime default applies),
+ * so adding one would invent a field the backend parser drops.
+ */
+export const WORKFLOW_NODE_TYPES: readonly WorkflowNodeTypeDef[] = [
{
- type: "start",
+ id: "start",
label: "Start",
+ category: "flow",
description: "Graph entry — exactly one per workflow",
+ color: "success",
+ fields: [],
defaults: {},
},
{
- type: "end",
+ id: "end",
label: "End",
+ category: "flow",
description: "Graph exit — the last response becomes the completion",
+ color: "secondary",
+ fields: [],
defaults: {},
},
{
- type: "llm_call",
+ id: "llm_call",
label: "LLM call",
+ category: "llm",
description: "Generate with a model (mode: generate/refine/passthrough)",
- defaults: { model: "llama", mode: "generate" },
+ color: "accent",
+ fields: [
+ { key: "model", label: "Model", kind: "text", placeholder: "Model id" },
+ { key: "mode", label: "Mode", kind: "select", options: ["generate", "refine", "passthrough"] },
+ { key: "provider", label: "Provider", kind: "text", placeholder: "Optional override" },
+ { key: "ctx", label: "Context", kind: "tokens", placeholder: "Token limit" },
+ { key: "system", label: "System", kind: "textarea", placeholder: "System scaffold" },
+ { key: "assistant", label: "Assistant", kind: "textarea", placeholder: "Assistant scaffold" },
+ { key: "user", label: "User", kind: "textarea", placeholder: "Reserved — user scaffold" },
+ { key: "on_429", label: "On 429", kind: "text", placeholder: "Target node id" },
+ { key: "tool_calls_route", label: "Tool-calls route", kind: "text", placeholder: "Target node id" },
+ ],
+ defaults: { mode: "generate" },
},
{
- type: "condition",
+ id: "condition",
label: "Condition",
+ category: "flow",
description: "Guard expression selecting true/false branches",
+ color: "accent",
+ fields: [{ key: "condition", label: "Condition", kind: "code", placeholder: "Safe AST expression" }],
defaults: { condition: { op: "exists", field: "lastResponse.content" } },
},
{
- type: "loop",
+ id: "loop",
label: "Loop",
+ category: "flow",
description: "Repeat a body of nodes",
+ color: "accent",
+ fields: [
+ { key: "body", label: "Body", kind: "textarea", placeholder: "Body node ids, one per line" },
+ { key: "iterations", label: "Iterations", kind: "number" },
+ ],
defaults: { body: [], iterations: 1 },
},
{
- type: "fan",
+ id: "fan",
label: "Fan",
+ category: "flow",
description: "Fan out branches in parallel",
+ color: "accent",
+ fields: [],
defaults: { parallel: true },
},
{
- type: "join",
+ id: "join",
label: "Join",
+ category: "flow",
description: "Merge branch results before continuing",
+ color: "accent",
+ fields: [],
defaults: {},
},
{
- type: "pipeline",
+ id: "pipeline",
label: "Pipeline",
+ category: "integration",
description: "Compose another stored workflow",
+ color: "accent",
+ fields: [{ key: "pipeline", label: "Pipeline", kind: "text", placeholder: "Stored workflow name" }],
defaults: { pipeline: "" },
},
{
- type: "rag_local",
+ id: "rag_local",
label: "RAG (local)",
+ category: "data",
description: "Local retrieval over chunked documents",
+ color: "secondary",
+ fields: [{ key: "k", label: "Top-k", kind: "number", placeholder: "4" }],
defaults: { k: 4 },
},
{
- type: "data.code",
+ id: "data.code",
label: "Data (code)",
+ category: "data",
description: "Sandboxed script step",
- defaults: { code: "// sandboxed step\n" },
+ color: "secondary",
+ fields: [{ key: "code", label: "Code", kind: "code", placeholder: "Sandboxed script" }],
+ defaults: { code: "" },
},
{
- type: "memory",
+ id: "memory",
label: "Memory",
+ category: "data",
description: "Conversation memory scope",
+ color: "secondary",
+ fields: [{ key: "convId", label: "Conversation id", kind: "text", placeholder: "Optional scope id" }],
defaults: {},
},
{
- type: "embeddings",
+ id: "embeddings",
label: "Embeddings",
+ category: "data",
description: "Embed the last response (or an explicit text)",
+ color: "secondary",
+ fields: [{ key: "text", label: "Text", kind: "textarea", placeholder: "Defaults to last response" }],
defaults: {},
},
{
- type: "router",
+ id: "router",
label: "Router",
+ category: "flow",
description: "Route by a comparison (e.g. status or content)",
- defaults: {
- condition: { op: "compare", field: "lastResponse.status", op2: "==", value: 200 },
- },
+ color: "accent",
+ fields: [{ key: "condition", label: "Condition", kind: "code", placeholder: "Safe AST expression" }],
+ defaults: { condition: { op: "compare", field: "lastResponse.status", op2: "==", value: 200 } },
},
{
- type: "output",
+ id: "output",
label: "Output",
+ category: "output",
description: "Emit an explicit output from the graph",
+ color: "secondary",
+ fields: [],
defaults: {},
},
];
-/** The palette covers the taxonomy exactly (palette completeness gate). */
-export function paletteCoversTaxonomy(): boolean {
- return (
- PALETTE.length >= 10 &&
- PALETTE.every((entry) => NODE_TYPES.includes(entry.type)) &&
- NODE_TYPES.every((type) => PALETTE.some((entry) => entry.type === type))
- );
+/** Guard: is this string a real engine node type? */
+export function nodeTypeExists(id: string): id is NodeType {
+ return (NODE_TYPES as readonly string[]).includes(id);
}
-export function paletteFor(type: NodeType): PaletteEntry {
- const entry = PALETTE.find((e) => e.type === type);
- if (entry === undefined) throw new Error(`no palette entry for node type "${type}"`);
+/** Taxonomy entry for a type (throws on unknown ids — invariant helper). */
+export function nodeTypeDef(type: NodeType): WorkflowNodeTypeDef {
+ const entry = WORKFLOW_NODE_TYPES.find((e) => e.id === type);
+ if (entry === undefined) throw new Error(`unknown node type "${type}"`);
return entry;
}
-/** Build one canvas node from the palette; ids increment per type. */
-export function makeEditorNode(
- type: NodeType,
- counter: Map,
- position: { x: number; y: number },
-): EditorNode {
- const entry = paletteFor(type);
- const n = (counter.get(type) ?? 0) + 1;
- counter.set(type, n);
- const base = entry.defaults as GraphNode;
- return {
- id: `${type}-${n}`,
- type,
- ...base,
- position,
- data: { label: entry.label },
- };
+/** Palette category of a node type. */
+export function nodeCategory(type: NodeType): NodeCategory {
+ return nodeTypeDef(type).category;
}
-export function flowEdgeFrom(edge: GraphEdge): FlowEdge {
- return {
- id: `${edge.from}->${edge.to}`,
- source: edge.from,
- target: edge.to,
- ...(edge.guard !== undefined ? { guard: edge.guard } : {}),
- };
+/** Human label of a node type. */
+export function nodeLabel(type: NodeType): string {
+ return nodeTypeDef(type).label;
}
-export function graphEdgeFrom(edge: FlowEdge): GraphEdge {
- return {
- from: edge.source,
- to: edge.target,
- ...(edge.guard !== undefined ? { guard: edge.guard } : {}),
- };
+/** Canvas defaults of a node type (click-to-add seeds from here). */
+export function nodeDefaults(type: NodeType): Partial {
+ return nodeTypeDef(type).defaults;
}
-/** True when adding the edge `from → to` would close a cycle. */
-export function wouldCreateCycle(
- graph: { nodes: GraphNode[]; edges: GraphEdge[] },
- from: string,
- to: string,
-): boolean {
- if (from === to) return true;
- const out = new Map();
- for (const edge of graph.edges) {
- const list = out.get(edge.from) ?? [];
- list.push(edge.to);
- out.set(edge.from, list);
- }
- // BFS from the target; reaching the source means the new edge closes a loop.
- const seen = new Set([to]);
- const queue = [to];
- while (queue.length > 0) {
- const current = queue.shift() as string;
- for (const next of out.get(current) ?? []) {
- if (next === from) return true;
- if (!seen.has(next)) {
- seen.add(next);
- queue.push(next);
+/**
+ * Canvas handle semantics, matching the backend edge model: an edge flows
+ * node → node, so `start` has no incoming (target) handle and `end` has no
+ * outgoing (source) handle; every other type gets both.
+ */
+export function hasTargetHandle(type: NodeType): boolean {
+ return type !== "start";
+}
+
+export function hasSourceHandle(type: NodeType): boolean {
+ return type !== "end";
+}
+
+/**
+ * Sparse per-node field values on the canvas — primitives only, keyed by
+ * GraphNode field names. `condition`/`params` live in the opaque
+ * `FlowNodeData` slots instead (see workflow-flow.ts).
+ */
+export type NodeFieldValues = Record<
+ string,
+ string | number | string[] | boolean | undefined
+>;
+
+/** Keep only the primitive part of a field value that fits its kind. */
+function sanitizeFieldValue(
+ field: NodeFieldDef,
+ raw: unknown,
+): string | number | string[] | boolean | undefined {
+ switch (field.kind) {
+ case "toggle":
+ return typeof raw === "boolean" ? raw : undefined;
+ case "number":
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
+ case "tokens":
+ // GraphNode.ctx is number | string (the engine passes it through).
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
+ return typeof raw === "string" && raw.trim() !== "" ? raw : undefined;
+ case "select":
+ return typeof raw === "string" && field.options?.includes(raw) ? raw : undefined;
+ case "textarea":
+ // `body` is the one textarea-shaped field whose value IS a string[].
+ if (Array.isArray(raw)) {
+ return field.key === "body"
+ ? raw.filter((item): item is string => typeof item === "string")
+ : undefined;
}
- }
+ return typeof raw === "string" ? raw : undefined;
+ case "text":
+ case "code":
+ return typeof raw === "string" ? raw : undefined;
}
- return false;
}
-/** Engine validation over the current canvas (missing fields, structure). */
-export function validateEditorGraph(nodes: GraphNode[], edges: GraphEdge[]): string[] {
- const graph: GraphPipeline = { id: "workflow", nodes, edges };
- return validateGraph(graph, {}).errors;
+/**
+ * Whitelist a raw field-values record against the taxonomy + the universal
+ * `parallel` flag (a real GraphNode field the backend keeps on every node),
+ * dropping unknown keys and type-invalid values — the same "unknown fields
+ * are dropped, known fields type-checked" admission the backend parser runs.
+ * Input is deliberately `Record` (admission boundary);
+ * the OUTPUT is always a clean `NodeFieldValues`.
+ */
+export function sanitizeNodeFields(
+ type: NodeType,
+ values: Record,
+): NodeFieldValues {
+ const def = nodeTypeDef(type);
+ const out: NodeFieldValues = {};
+ if (values.parallel !== undefined) {
+ if (typeof values.parallel === "boolean") out.parallel = values.parallel;
+ }
+ for (const field of def.fields) {
+ if (field.key === "condition") continue; // opaque AST slot, not a primitive
+ const raw = values[field.key];
+ if (raw === undefined) continue;
+ const cleaned = sanitizeFieldValue(field, raw);
+ if (cleaned !== undefined) out[field.key] = cleaned;
+ }
+ return out;
}
-/** Export the canvas to the workflow YAML doc (positions/labels stripped). */
-export function editorToYaml(
- nodes: EditorNode[],
- edges: FlowEdge[],
- name: string,
-): string {
- const cleanNodes = nodes.map(({ position: _position, data: _data, ...rest }) => rest as GraphNode);
- return serializeWorkflowGraph(
- {
- id: "workflow",
- name,
- nodes: cleanNodes,
- edges: edges.map(graphEdgeFrom),
- },
- null,
- );
+/**
+ * Fresh canvas id for a type: kebab form of the type id plus a numeric
+ * suffix when the base is taken (deduped against `taken`). Stable for a
+ * given input set — no randomness.
+ */
+export function makeNodeId(type: NodeType, taken: ReadonlySet): string {
+ const base = type.replace("_", "-").replace(".", "-");
+ let candidate = base;
+ let suffix = 2;
+ while (taken.has(candidate)) {
+ candidate = `${base}-${suffix}`;
+ suffix += 1;
+ }
+ return candidate;
}
-export type YAMLImportResult =
- | { ok: true; name: string; nodes: EditorNode[]; edges: FlowEdge[] }
- | { ok: false; error: string };
-
-/** Import a workflow YAML doc onto the canvas (flow-down layout). */
-export function yamlToEditor(source: string): YAMLImportResult {
- let doc: ReturnType;
- try {
- doc = parseWorkflowGraph(source);
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
+/** Render a primitive field value for display (never throws on odd shapes). */
+export function formatValue(
+ kind: FieldKind,
+ value: unknown,
+): string {
+ if (value === undefined || value === null || value === "") return "—";
+ if (kind === "number" || kind === "tokens") {
+ return typeof value === "number" ? String(value) : String(value);
+ }
+ if (kind === "toggle") return value === true ? "on" : "off";
+ if (Array.isArray(value)) {
+ return value.length === 0 ? "—" : value.join(", ");
}
- const graph = doc.graph;
- const nodes: EditorNode[] = graph.nodes.map((node, index) => ({
- ...node,
- position: { x: 40 + index * 240, y: 120 },
- data: { label: paletteFor(node.type).label },
- }));
- return {
- ok: true,
- name: graph.name ?? graph.id,
- nodes,
- edges: graph.edges.map(flowEdgeFrom),
- };
+ if (typeof value === "string") return value;
+ return String(value);
}
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-validate.test.ts b/frontend/src/lib/workflow-validate.test.ts
new file mode 100644
index 0000000..243f324
--- /dev/null
+++ b/frontend/src/lib/workflow-validate.test.ts
@@ -0,0 +1,173 @@
+/**
+ * U06 — structural pre-validation mirror tests.
+ *
+ * Mirrors the `validateGraph` cases the task mandates structurally: zero
+ * start, zero end, ghost edges, missing per-type fields, missing loop body
+ * member (engine-runner admission — a documented superset), and the
+ * on_429 / tool_calls_route target checks.
+ */
+import { describe, expect, test } from "bun:test";
+import type { GraphEdge, GraphNode, GraphPipeline } from "../../../src/orchestrator/graph.js";
+import { validateWorkflow } from "./workflow-validate.js";
+
+function graph(overrides: { nodes?: GraphNode[]; edges?: GraphEdge[]; id?: string } = {}): GraphPipeline {
+ return {
+ id: overrides.id ?? "test",
+ nodes:
+ overrides.nodes ??
+ [
+ { id: "start", type: "start" },
+ { id: "end", type: "end" },
+ ],
+ edges: overrides.edges ?? [],
+ };
+}
+
+const valid = graph();
+
+describe("validateWorkflow — structure", () => {
+ test("accepts a minimal valid graph (start + end)", () => {
+ const result = validateWorkflow(valid);
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ test("zero start nodes", () => {
+ const result = validateWorkflow(
+ graph({
+ nodes: [
+ { id: "llm", type: "llm_call", model: "smol" },
+ { id: "end", type: "end" },
+ ],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('graph "test" must have exactly one start node (found 0)');
+ });
+
+ test("multiple start nodes", () => {
+ const result = validateWorkflow(
+ graph({
+ nodes: [
+ { id: "s1", type: "start" },
+ { id: "s2", type: "start" },
+ { id: "end", type: "end" },
+ ],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0]).toBe('graph "test" must have exactly one start node (found 2)');
+ });
+
+ test("zero end nodes", () => {
+ const result = validateWorkflow(
+ graph({
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "llm", type: "llm_call", model: "smol" },
+ ],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('graph "test" must have at least one end node');
+ });
+
+ test("ghost edge from side", () => {
+ const result = validateWorkflow(
+ graph({ edges: [{ from: "nope", to: "end" }] }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('edge from "nope" references an unknown node');
+ });
+
+ test("ghost edge to side", () => {
+ const result = validateWorkflow(
+ graph({ edges: [{ from: "start", to: "nope" }] }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('edge to "nope" references an unknown node');
+ });
+
+ test("missing llm_call model", () => {
+ const result = validateWorkflow(
+ graph({
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "llm", type: "llm_call" },
+ { id: "end", type: "end" },
+ ],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('node LLM_CALL (llm) is missing its required "model" field');
+ });
+
+ test("missing required per-type fields", () => {
+ const result = validateWorkflow(
+ graph({
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "cond", type: "condition" },
+ { id: "loop", type: "loop" },
+ { id: "pipe", type: "pipeline" },
+ { id: "code", type: "data.code", code: " " },
+ { id: "router", type: "router" },
+ { id: "end", type: "end" },
+ ],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual([
+ 'node CONDITION (cond) is missing its required "condition" field',
+ 'node LOOP (loop) is missing its required "body" field',
+ 'node PIPELINE (pipe) is missing its required "pipeline" field',
+ 'node DATA.CODE (code) is missing its required "code" field',
+ 'node ROUTER (router) is missing its required "condition" field',
+ ]);
+ });
+
+ test("missing loop body member (engine-runtime admission)", () => {
+ const result = validateWorkflow(
+ graph({
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "loop", type: "loop", body: ["ghost"] },
+ { id: "end", type: "end" },
+ ],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('node LOOP (loop) body references an unknown node "ghost"');
+ });
+
+ test("on_429 and tool_calls_route targets must exist", () => {
+ const result = validateWorkflow(
+ graph({
+ nodes: [
+ { id: "start", type: "start" },
+ { id: "llm", type: "llm_call", model: "smol", on_429: "ghost", tool_calls_route: "void" },
+ { id: "end", type: "end" },
+ ],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('node LLM_CALL model smol (llm) references an unknown on_429 target ("ghost")');
+ expect(result.errors).toContain(
+ 'node LLM_CALL model smol (llm) references an unknown tool_calls_route target ("void")',
+ );
+ });
+
+ test("known model existence is checked only when a set is provided", () => {
+ const nodes: GraphNode[] = [
+ { id: "start", type: "start" },
+ { id: "llm", type: "llm_call", model: "nope" },
+ { id: "end", type: "end" },
+ ];
+ expect(validateWorkflow(graph({ nodes })).valid).toBe(true);
+ const withModels = validateWorkflow(graph({ nodes }), { knownModels: ["smol"] });
+ expect(withModels.valid).toBe(false);
+ expect(withModels.errors).toContain('node LLM_CALL (llm) references an unknown model "nope"');
+ // Empty set behaves like absent (backend parity: no model check).
+ expect(validateWorkflow(graph({ nodes }), { knownModels: [] }).valid).toBe(true);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/workflow-validate.ts b/frontend/src/lib/workflow-validate.ts
new file mode 100644
index 0000000..0bb4d3b
--- /dev/null
+++ b/frontend/src/lib/workflow-validate.ts
@@ -0,0 +1,139 @@
+/**
+ * Client-side structural pre-validation for the workflow editor (U06).
+ *
+ * The backend is the authority (`validateGraph`, src/orchestrator/graph.ts):
+ * the editor runs this mirror BEFORE sending a save so obvious problems stop
+ * the round-trip early, but the same graph still hits the real validator on
+ * PUT. This module mirrors the STRUCTURAL subset of `validateGraph` with
+ * verbatim error wording:
+ *
+ * - exactly one start / at least one end
+ * - edges (and on_429 / tool_calls_route targets) reference real nodes
+ * - required per-type fields (llm_call.model, condition.condition,
+ * loop.body, pipeline.pipeline, data.code.code, router.condition)
+ * - loop body members exist — the engine RUNNER enforces this at runtime
+ * ("loop body references unknown node"), graph.ts does not; the task
+ * requires the client to flag it, so this is a documented superset
+ * - model existence when a known-model set is provided (same as the backend
+ * `{ knownModels }` option — empty set means "skip")
+ *
+ * NOT mirrored (backend-only, deliberate): cycle legality, connectivity /
+ * reachability, and stale loop-body edges — those need the engine's effective
+ * edge-set synthesis and are decided by the real validator on save.
+ *
+ * Pure functions — no DOM, no Svelte, no stores — runnable under `bun test`.
+ */
+import type { GraphNode, GraphPipeline } from "./workflow-nodes.js";
+
+export interface WorkflowValidation {
+ /** True when no errors were found (mirror of `GraphValidation.ok`). */
+ valid: boolean;
+ /** Error messages mirroring `validateGraph` wording verbatim. */
+ errors: string[];
+}
+
+/**
+ * Human-readable node label matching graph.ts's `nodeLabel`: `LLM_CALL model
+ * SmolLM3 (n3)` when an llm_call carries a model, else `CONDITION (c2)`.
+ */
+function nodeLabel(n: GraphNode, includeModel = true): string {
+ const type = n.type.toUpperCase();
+ if (includeModel && n.type === "llm_call" && n.model) {
+ return `LLM_CALL model ${n.model} (${n.id})`;
+ }
+ return `${type} (${n.id})`;
+}
+
+/**
+ * Structural mirror of `validateGraph` (see header for the exact subset).
+ *
+ * @param graph engine graph — pass `flowToGraph(nodes, edges)` output
+ * @param opts `knownModels` — only when non-empty (backend parity)
+ */
+export function validateWorkflow(
+ graph: GraphPipeline,
+ opts: { knownModels?: string[] } = {},
+): WorkflowValidation {
+ const errors: string[] = [];
+ const known = new Set(opts.knownModels ?? []);
+
+ const byId = new Map();
+ for (const n of graph.nodes) byId.set(n.id, n);
+
+ // Exactly one start.
+ const starts = graph.nodes.filter((n) => n.type === "start");
+ if (starts.length !== 1) {
+ errors.push(`graph "${graph.id}" must have exactly one start node (found ${starts.length})`);
+ }
+
+ // At least one end.
+ const ends = graph.nodes.filter((n) => n.type === "end");
+ if (ends.length < 1) {
+ errors.push(`graph "${graph.id}" must have at least one end node`);
+ }
+
+ // Edges reference real nodes.
+ for (const edge of graph.edges) {
+ if (!byId.has(edge.from)) {
+ errors.push(`edge from "${edge.from}" references an unknown node`);
+ }
+ if (!byId.has(edge.to)) {
+ errors.push(`edge to "${edge.to}" references an unknown node`);
+ }
+ }
+
+ // Required per-type fields.
+ for (const n of graph.nodes) {
+ if (n.type === "llm_call" && !n.model) {
+ errors.push(`node ${nodeLabel(n)} is missing its required "model" field`);
+ }
+ if (n.type === "condition" && !n.condition) {
+ errors.push(`node ${nodeLabel(n)} is missing its required "condition" field`);
+ }
+ if (n.type === "loop" && (!n.body || n.body.length === 0)) {
+ errors.push(`node ${nodeLabel(n)} is missing its required "body" field`);
+ }
+ if (n.type === "pipeline" && !n.pipeline) {
+ errors.push(`node ${nodeLabel(n)} is missing its required "pipeline" field`);
+ }
+ if (n.type === "data.code" && (!n.code || n.code.trim() === "")) {
+ errors.push(`node ${nodeLabel(n)} is missing its required "code" field`);
+ }
+ if (n.type === "router" && !n.condition) {
+ errors.push(`node ${nodeLabel(n)} is missing its required "condition" field`);
+ }
+ }
+
+ // Loop body members exist — engine-runner admission (super-set, see header).
+ for (const n of graph.nodes) {
+ if (n.type !== "loop" || !Array.isArray(n.body)) continue;
+ for (const memberId of n.body) {
+ if (!byId.has(memberId)) {
+ errors.push(`node ${nodeLabel(n)} body references an unknown node "${memberId}"`);
+ }
+ }
+ }
+
+ // Broken fallback/tool-calls routes (graph.ts wording).
+ for (const n of graph.nodes) {
+ if (n.on_429 && !byId.has(n.on_429)) {
+ errors.push(`node ${nodeLabel(n)} references an unknown on_429 target ("${n.on_429}")`);
+ }
+ if (n.tool_calls_route && !byId.has(n.tool_calls_route)) {
+ errors.push(
+ `node ${nodeLabel(n)} references an unknown tool_calls_route target ("${n.tool_calls_route}")`,
+ );
+ }
+ }
+
+ // Model existence (only when a known-model set is provided).
+ if (opts.knownModels && opts.knownModels.length > 0) {
+ for (const n of graph.nodes) {
+ if (n.type === "llm_call" && n.model && !known.has(n.model)) {
+ errors.push(`node ${nodeLabel(n, false)} references an unknown model "${n.model}"`);
+ }
+ }
+ }
+
+ return { valid: errors.length === 0, errors };
+}
\ No newline at end of file
diff --git a/frontend/src/lib/workflows-ui.test.ts b/frontend/src/lib/workflows-ui.test.ts
new file mode 100644
index 0000000..234bac0
--- /dev/null
+++ b/frontend/src/lib/workflows-ui.test.ts
@@ -0,0 +1,143 @@
+/**
+ * Unit tests for the workflow library display helpers (workflows-ui.ts).
+ * No IO: "now" is injected, and absolute-timestamp expectations are built
+ * through the same Date API the helpers use, so assertions are timezone-robust.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ formatLocalDateTime,
+ formatMs,
+ formatUpdatedAt,
+ runOutcomeContent,
+ workflowRow,
+} from "./workflows-ui.js";
+import type { RunWorkflowResult, WorkflowRecord } from "./api/types.js";
+
+const NOW = 1_700_000_000_000; // 2023-11-14T22:13:20Z
+const MINUTE_MS = 60_000;
+const DAY_MS = 86_400_000;
+
+function isoAgo(msAgo: number): string {
+ return new Date(NOW - msAgo).toISOString();
+}
+
+describe("formatUpdatedAt", () => {
+ test("invalid timestamps render em dash", () => {
+ expect(formatUpdatedAt("not-a-date", NOW)).toBe("—");
+ expect(formatUpdatedAt("", NOW)).toBe("—");
+ });
+
+ test("the present and near-future render just now", () => {
+ expect(formatUpdatedAt(isoAgo(0), NOW)).toBe("just now");
+ expect(formatUpdatedAt(isoAgo(-5 * MINUTE_MS), NOW)).toBe("just now");
+ });
+
+ test("under an hour renders minutes ago", () => {
+ expect(formatUpdatedAt(isoAgo(MINUTE_MS), NOW)).toBe("1 min ago");
+ expect(formatUpdatedAt(isoAgo(59 * MINUTE_MS), NOW)).toBe("59 min ago");
+ });
+
+ test("under a day renders hours ago", () => {
+ expect(formatUpdatedAt(isoAgo(2 * 60 * MINUTE_MS), NOW)).toBe("2 h ago");
+ expect(formatUpdatedAt(isoAgo(23 * 60 * MINUTE_MS), NOW)).toBe("23 h ago");
+ });
+
+ test("under seven days renders days ago", () => {
+ expect(formatUpdatedAt(isoAgo(DAY_MS), NOW)).toBe("1 d ago");
+ expect(formatUpdatedAt(isoAgo(6 * DAY_MS + 12 * 60 * MINUTE_MS), NOW)).toBe("6 d ago");
+ });
+
+ test("seven days or older renders an absolute local timestamp", () => {
+ const date = new Date(2026, 0, 15, 9, 30); // local wall clock
+ const formatted = formatUpdatedAt(date.toISOString(), date.getTime() + 7 * DAY_MS);
+ expect(formatted).toBe("2026-01-15 09:30");
+ });
+});
+
+describe("formatLocalDateTime", () => {
+ test("zero-pads month, day, hours and minutes", () => {
+ expect(formatLocalDateTime(new Date(2026, 0, 5, 7, 5))).toBe("2026-01-05 07:05");
+ expect(formatLocalDateTime(new Date(2026, 11, 31, 23, 59))).toBe("2026-12-31 23:59");
+ });
+});
+
+describe("formatMs", () => {
+ test("null and invalid durations render em dash", () => {
+ expect(formatMs(null)).toBe("—");
+ expect(formatMs(-5)).toBe("—");
+ expect(formatMs(Number.NaN)).toBe("—");
+ });
+
+ test("sub-second durations render milliseconds", () => {
+ expect(formatMs(0)).toBe("0ms");
+ expect(formatMs(384)).toBe("384ms");
+ expect(formatMs(999)).toBe("999ms");
+ });
+
+ test("one second and beyond render seconds with one decimal", () => {
+ expect(formatMs(1000)).toBe("1.0s");
+ expect(formatMs(1200)).toBe("1.2s");
+ expect(formatMs(1500)).toBe("1.5s");
+ expect(formatMs(123_456)).toBe("123.5s");
+ });
+});
+
+function sampleRecord(version = 2): WorkflowRecord {
+ return { name: "summary", version, updatedAt: "2026-09-20T10:00:00Z" };
+}
+
+describe("workflowRow", () => {
+ test("passes the record through untouched without a version hint", () => {
+ expect(workflowRow(sampleRecord(), null)).toEqual(sampleRecord());
+ });
+
+ test("a version hint overrides the version column", () => {
+ expect(workflowRow(sampleRecord(2), 5).version).toBe(5);
+ });
+
+ test("an explicit null hint keeps the record version", () => {
+ expect(workflowRow(sampleRecord(3), null).version).toBe(3);
+ });
+});
+
+function sampleCompletion(): RunWorkflowResult {
+ return {
+ id: "cmpl-1",
+ object: "chat.completion",
+ created: 1,
+ model: "gateway/summary",
+ choices: [
+ { index: 0, message: { role: "assistant", content: "Hello!" }, finish_reason: "stop" },
+ ],
+ usage: null,
+ };
+}
+
+describe("runOutcomeContent", () => {
+ test("extracts the first assistant completion text", () => {
+ expect(runOutcomeContent(sampleCompletion())).toBe("Hello!");
+ });
+
+ test("an empty choices list yields null", () => {
+ expect(runOutcomeContent({ ...sampleCompletion(), choices: [] })).toBeNull();
+ });
+
+ test("an empty completion text is preserved, not nulled", () => {
+ const empty: RunWorkflowResult = {
+ ...sampleCompletion(),
+ choices: [
+ { index: 0, message: { role: "assistant", content: "" }, finish_reason: "stop" },
+ ],
+ };
+ expect(runOutcomeContent(empty)).toBe("");
+ });
+
+ test("a missing content field yields null (defensive)", () => {
+ const malformed = {
+ ...sampleCompletion(),
+ choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "stop" }],
+ } as unknown as RunWorkflowResult;
+ expect(runOutcomeContent(malformed)).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/workflows-ui.ts b/frontend/src/lib/workflows-ui.ts
new file mode 100644
index 0000000..3f36c34
--- /dev/null
+++ b/frontend/src/lib/workflows-ui.ts
@@ -0,0 +1,87 @@
+/**
+ * Pure display helpers for the workflow library surface (U04).
+ *
+ * Every function here is deterministic and unit-tested in
+ * workflows-ui.test.ts. "Now" is injected (default Date.now()) so relative
+ * timestamps stay pure — the same seam buildOverviewSnapshot uses for its
+ * fetch-time input. No IO, no Svelte imports: importable under bun:test.
+ */
+
+import type { RunWorkflowResult, WorkflowRecord } from "./api/types.js";
+
+const MINUTE_MS = 60_000;
+const DAY_MS = 86_400_000;
+/** Relative timestamps switch to absolute beyond this window (7 days). */
+const RELATIVE_WINDOW_MS = 7 * DAY_MS;
+
+function pad2(value: number): string {
+ return String(value).padStart(2, "0");
+}
+
+/**
+ * Local wall-clock "YYYY-MM-DD HH:mm" (24h), locale-neutral. Exported so
+ * tests can assert it with locally-constructed Date objects (timezone-robust).
+ */
+export function formatLocalDateTime(date: Date): string {
+ return (
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
+ );
+}
+
+/**
+ * Relative "x min ago" / "x h ago" / "x d ago" under 7 days, absolute local
+ * "YYYY-MM-DD HH:mm" beyond that. The present (and near-future timestamps)
+ * render "just now"; invalid timestamps render "—".
+ */
+export function formatUpdatedAt(iso: string, now: number = Date.now()): string {
+ const time = new Date(iso).getTime();
+ if (!Number.isFinite(time)) return "—";
+ const diff = now - time;
+ if (diff < MINUTE_MS) return "just now";
+ if (diff < DAY_MS) {
+ const minutes = Math.floor(diff / MINUTE_MS);
+ if (minutes < 60) return `${minutes} min ago`;
+ return `${Math.floor(minutes / 60)} h ago`;
+ }
+ if (diff < RELATIVE_WINDOW_MS) {
+ return `${Math.floor(diff / DAY_MS)} d ago`;
+ }
+ return formatLocalDateTime(new Date(time));
+}
+
+/**
+ * Duration label: "1.2s" at one second and beyond, "384ms" below. Null or
+ * otherwise invalid durations render "—".
+ */
+export function formatMs(ms: number | null): string {
+ if (ms === null || !Number.isFinite(ms) || ms < 0) return "—";
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+/**
+ * Display row for the library list. The backend record is the source of
+ * truth; `yamlVersionHint` overrides the version column only when the caller
+ * holds a fresher revision (e.g. right after a save). The list itself passes
+ * null and always treats the API list as truth.
+ */
+export function workflowRow(
+ record: WorkflowRecord,
+ yamlVersionHint: number | null = null,
+): WorkflowRecord {
+ return {
+ name: record.name,
+ version: yamlVersionHint ?? record.version,
+ updatedAt: record.updatedAt,
+ };
+}
+
+/**
+ * The assistant completion text of a run response, or null when the shape
+ * carries no extractable content (defensive — never throws).
+ */
+export function runOutcomeContent(result: RunWorkflowResult): string | null {
+ const content = result.choices?.[0]?.message?.content;
+ return typeof content === "string" ? content : null;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/ws.test.ts b/frontend/src/lib/ws.test.ts
new file mode 100644
index 0000000..b9ff596
--- /dev/null
+++ b/frontend/src/lib/ws.test.ts
@@ -0,0 +1,249 @@
+/**
+ * bun:test suite for the workflow-run socket client (ws.ts). A controllable
+ * FakeWebSocket stands in for the browser global — every drive helper fires
+ * handlers synchronously, so event order is deterministic under bun:test.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { getWsOrigin } from "./api/config.js";
+import { connectWorkflowSocket } from "./ws.js";
+import type { WorkflowChatMessage, WorkflowSocket, WorkflowWsEvent } from "./ws.js";
+
+/** Minimal browser-WebSocket-shaped stand-in with test drive helpers. */
+class FakeWebSocket {
+ static instances: FakeWebSocket[] = [];
+
+ readonly sent: string[] = [];
+ url: string;
+ readyState: number;
+ onopen: ((event: unknown) => void) | null = null;
+ onmessage: ((event: { data: unknown }) => void) | null = null;
+ onclose: ((event: { code?: number; reason?: string }) => void) | null = null;
+
+ constructor(url: string) {
+ this.url = url;
+ this.readyState = 0; // CONNECTING
+ FakeWebSocket.instances.push(this);
+ }
+
+ send(data: string): void {
+ this.sent.push(data);
+ }
+
+ close(): void {
+ if (this.readyState === 3) return;
+ this.readyState = 3; // CLOSED
+ this.onclose?.({ code: 1000, reason: "closed" });
+ }
+
+ // ── test drive helpers ────────────────────────────────────────────────
+
+ open(): void {
+ this.readyState = 1; // OPEN
+ this.onopen?.({});
+ }
+
+ receive(data: string): void {
+ this.onmessage?.({ data });
+ }
+
+ serverClose(): void {
+ if (this.readyState === 3) return;
+ this.readyState = 3;
+ this.onclose?.({ code: 1006, reason: "connection dropped" });
+ }
+}
+
+function fakeCtor(): typeof WebSocket {
+ return FakeWebSocket as unknown as typeof WebSocket;
+}
+
+const BIND = (workflow: string): string => JSON.stringify({ type: "bind", workflow });
+const RUN = (messages: WorkflowChatMessage[]): string =>
+ JSON.stringify({ type: "run", messages });
+
+const USER: WorkflowChatMessage[] = [{ role: "user", content: "hi" }];
+
+function connect(
+ workflow = "demo",
+): { socket: WorkflowSocket; fake: FakeWebSocket; events: WorkflowWsEvent[]; closes: number[] } {
+ const events: WorkflowWsEvent[] = [];
+ const closes: number[] = [];
+ const socket = connectWorkflowSocket({
+ workflow,
+ ws: fakeCtor(),
+ onEvent: (event) => events.push(event),
+ onClose: () => {
+ closes.push(1);
+ },
+ });
+ const fake = FakeWebSocket.instances.at(-1) as FakeWebSocket;
+ return { socket, fake, events, closes };
+}
+
+async function openAndBind(
+ workflow = "demo",
+): Promise<{
+ socket: WorkflowSocket;
+ fake: FakeWebSocket;
+ events: WorkflowWsEvent[];
+ closes: number[];
+}> {
+ const state = connect(workflow);
+ state.fake.open();
+ expect(state.fake.sent).toEqual([BIND(workflow)]);
+ state.fake.receive(JSON.stringify({ type: "status", workflow, state: "bound" }));
+ expect(await state.socket.ready).toBe(true);
+ return state;
+}
+
+describe("connectWorkflowSocket", () => {
+ test("default URL is the ws origin plus /ws", () => {
+ connect("demo");
+ const fake = FakeWebSocket.instances.at(-1) as FakeWebSocket;
+ expect(fake.url).toBe(`${getWsOrigin()}/ws`);
+ });
+
+ test("binds on open and forwards events verbatim", async () => {
+ const { fake, events } = await openAndBind("demo");
+ const started = JSON.stringify({
+ type: "step_started",
+ workflow: "demo",
+ nodeId: "llm-1",
+ nodeType: "llm_call",
+ });
+ fake.receive(started);
+ expect(events).toHaveLength(2); // bound + step_started
+ expect(events[0]).toEqual({ type: "status", workflow: "demo", state: "bound" });
+ expect(events[1]).toEqual({
+ type: "step_started",
+ workflow: "demo",
+ nodeId: "llm-1",
+ nodeType: "llm_call",
+ });
+ });
+
+ test("a run sent after binding goes out with the run envelope", async () => {
+ const { socket, fake } = await openAndBind("demo");
+ socket.sendRun(USER);
+ expect(fake.sent.at(-1)).toBe(RUN(USER));
+ });
+
+ test("a run sent before binding is queued and flushed on bound", async () => {
+ const { socket, fake, events } = connect("demo");
+ socket.sendRun(USER);
+ expect(fake.sent).toEqual([]); // nothing sent before bound
+ fake.open();
+ expect(fake.sent).toEqual([BIND("demo")]); // still only the bind
+ expect(events).toEqual([]);
+ fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "bound" }));
+ expect(await socket.ready).toBe(true);
+ expect(fake.sent).toContain(RUN(USER)); // flushed after bound
+ });
+
+ test("a queued run surfaces an error when the bind fails", async () => {
+ const { socket, events } = connect("demo");
+ socket.sendRun(USER);
+ socket.ready.then(() => undefined); // keep the promise observed
+ // Bind-phase protocol error → ready false + the queued run is reported.
+ const fake = FakeWebSocket.instances.at(-1) as FakeWebSocket;
+ fake.open();
+ fake.receive(
+ JSON.stringify({ type: "error", workflow: null, error: 'unknown workflow "ghost"' }),
+ );
+ expect(await socket.ready).toBe(false);
+ expect(events.some((e) => e.type === "error" && e.error.includes("run requested before"))).toBe(
+ true,
+ );
+ // The server's bind error is forwarded too.
+ expect(events.some((e) => e.type === "error" && e.error.includes("unknown workflow"))).toBe(
+ true,
+ );
+ });
+
+ test("an unknown server message surfaces as a local error event", async () => {
+ const { fake, events } = await openAndBind("demo");
+ fake.receive(JSON.stringify({ type: "telemetry", workflow: "demo", watts: 12 }));
+ const err = events.at(-1);
+ expect(err?.type).toBe("error");
+ expect((err as { error: string }).error).toBe("received an unrecognized message from the server");
+ expect(events.filter((e) => e.type === "error")).toHaveLength(1);
+ });
+
+ test("a non-JSON server frame surfaces as a local error event", async () => {
+ const { fake, events } = await openAndBind("demo");
+ fake.receive("this is not json {");
+ const err = events.at(-1);
+ expect(err?.type).toBe("error");
+ expect((err as { error: string }).error).toBe("received a message that is not valid JSON");
+ });
+
+ test("an unexpected close during a run emits an honest error (no reconnect)", async () => {
+ const { socket, fake, events, closes } = await openAndBind("demo");
+ socket.sendRun(USER);
+ fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" }));
+ fake.serverClose();
+ const err = events.at(-1);
+ expect(err?.type).toBe("error");
+ expect((err as { error: string }).error).toBe("connection closed during run");
+ expect(closes).toHaveLength(1);
+ // No further frames were sent — no silent reconnect.
+ expect(fake.sent.at(-1)).toBe(RUN(USER));
+ });
+
+ test("a caller-initiated close does not emit a mid-run error", async () => {
+ const { socket, fake, events, closes } = await openAndBind("demo");
+ socket.sendRun(USER);
+ fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" }));
+ socket.close();
+ expect(events.some((e) => e.type === "error")).toBe(false);
+ expect(closes).toHaveLength(1);
+ });
+
+ test("a socket that never opens resolves ready false", async () => {
+ const { socket, fake } = connect("demo");
+ socket.ready.then(() => undefined);
+ fake.serverClose();
+ expect(await socket.ready).toBe(false);
+ });
+
+ test("a full ok run forwards running → token → ok unchanged", async () => {
+ const { socket, fake, events } = await openAndBind("demo");
+ socket.sendRun(USER);
+ fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" }));
+ fake.receive(
+ JSON.stringify({
+ type: "token",
+ workflow: "demo",
+ data: 'data: {"choices":[{"message":{"content":"Hello!"}}]}\n\n',
+ }),
+ );
+ fake.receive(JSON.stringify({ type: "token", workflow: "demo", data: "data: [DONE]\n\n" }));
+ fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "ok" }));
+ expect(events.map((e) => e.type)).toEqual([
+ "status", // bound
+ "status", // running
+ "token",
+ "token",
+ "status", // ok
+ ]);
+ const tokens = events.filter((e) => e.type === "token");
+ expect(tokens.some((e) => e.type === "token" && e.data.includes("[DONE]"))).toBe(true);
+ });
+
+ test("a run-phase error (bound) is forwarded without resolving ready", async () => {
+ const { socket, fake, events } = await openAndBind("demo");
+ socket.sendRun(USER);
+ fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" }));
+ fake.receive(
+ JSON.stringify({ type: "error", workflow: "demo", nodeId: "llm-1", error: "boom" }),
+ );
+ expect(events.at(-1)).toEqual({
+ type: "error",
+ workflow: "demo",
+ nodeId: "llm-1",
+ error: "boom",
+ });
+ expect(await socket.ready).toBe(true); // already settled at bind
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts
new file mode 100644
index 0000000..4e70397
--- /dev/null
+++ b/frontend/src/lib/ws.ts
@@ -0,0 +1,230 @@
+/**
+ * Typed workflow-run socket client (frontend, U07).
+ *
+ * Mirrors the REAL server wire protocol in src/app/ws.ts field-for-field:
+ *
+ * client → {type:"bind", workflow} then {type:"run", messages}
+ * server → status | step_started | step_completed | token | error
+ *
+ * Responsibilities:
+ * - binds on open;
+ * - queues run requests made before binding, flushes them once bound, and
+ * surfaces an error if the bind fails instead of dropping the run;
+ * - surfaces every server frame as a typed event; client-side parse
+ * failures become local error events — never swallowed;
+ * - on an unexpected close during a run emits "connection closed during
+ * run" — no silent auto-reconnect mid-run.
+ *
+ * The browser's standard WebSocket is used; tests inject a stand-in via
+ * `ws`. The default URL is `${getWsOrigin()}/ws` (api/config.ts).
+ */
+
+import { getWsOrigin } from "./api/config.js";
+
+/** Server → client events (same field names as src/app/ws.ts WsEvent). */
+export type WorkflowWsEvent =
+ | {
+ type: "status";
+ workflow: string | null;
+ state: "bound" | "running" | "ok" | "error";
+ error?: string;
+ }
+ | { type: "step_started"; workflow: string; nodeId: string; nodeType: string }
+ | { type: "step_completed"; workflow: string; nodeId: string; ms: number }
+ | { type: "token"; workflow: string; data: string }
+ | { type: "error"; workflow: string | null; nodeId?: string; error: string };
+
+export interface WorkflowChatMessage {
+ role: string;
+ content: string;
+}
+
+export interface WorkflowSocketOptions {
+ workflow: string;
+ /** Injectable in tests; defaults to the browser global WebSocket. */
+ ws?: typeof WebSocket;
+ /** Full socket URL; defaults to `${getWsOrigin()}/ws`. */
+ url?: string;
+ /** Every decoded server event (and local protocol errors). */
+ onEvent: (event: WorkflowWsEvent) => void;
+ /** Fired on every socket close (caller-initiated or not). */
+ onClose?: () => void;
+}
+
+export interface WorkflowSocket {
+ /** Send a run; queued until the socket is bound, then flushed. */
+ sendRun(messages: WorkflowChatMessage[]): void;
+ /** Graceful close; caller-initiated closes never emit mid-run errors. */
+ close(): void;
+ /** Resolves true on status:"bound", false when binding fails. */
+ ready: Promise;
+}
+
+/**
+ * Shape-check a decoded server frame into the typed event union. Null for
+ * anything that is not a well-formed server envelope (surfaced by the caller
+ * as a local error event instead of being swallowed).
+ */
+function toWorkflowWsEvent(value: unknown): WorkflowWsEvent | null {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
+ const msg = value as Record;
+ switch (msg.type) {
+ case "status": {
+ const workflow = msg.workflow;
+ const state = msg.state;
+ if (workflow !== null && typeof workflow !== "string") return null;
+ if (state !== "bound" && state !== "running" && state !== "ok" && state !== "error") {
+ return null;
+ }
+ const error = typeof msg.error === "string" ? msg.error : undefined;
+ return { type: "status", workflow, state, error };
+ }
+ case "step_started": {
+ const workflow = msg.workflow;
+ const nodeId = msg.nodeId;
+ const nodeType = msg.nodeType;
+ if (
+ typeof workflow !== "string" ||
+ typeof nodeId !== "string" ||
+ typeof nodeType !== "string"
+ ) {
+ return null;
+ }
+ return { type: "step_started", workflow, nodeId, nodeType };
+ }
+ case "step_completed": {
+ const workflow = msg.workflow;
+ const nodeId = msg.nodeId;
+ const ms = msg.ms;
+ if (typeof workflow !== "string" || typeof nodeId !== "string" || typeof ms !== "number") {
+ return null;
+ }
+ return { type: "step_completed", workflow, nodeId, ms };
+ }
+ case "token": {
+ const workflow = msg.workflow;
+ const data = msg.data;
+ if (typeof workflow !== "string" || typeof data !== "string") return null;
+ return { type: "token", workflow, data };
+ }
+ case "error": {
+ const workflow = msg.workflow;
+ const error = msg.error;
+ const nodeId = msg.nodeId;
+ if (workflow !== null && typeof workflow !== "string") return null;
+ if (typeof error !== "string") return null;
+ if (nodeId !== undefined && typeof nodeId !== "string") return null;
+ return { type: "error", workflow, error, nodeId };
+ }
+ default:
+ return null;
+ }
+}
+
+export function connectWorkflowSocket(options: WorkflowSocketOptions): WorkflowSocket {
+ const Ctor = options.ws ?? WebSocket;
+ const url = options.url ?? `${getWsOrigin()}/ws`;
+ const socket = new Ctor(url);
+
+ let bound = false;
+ let runInFlight = false;
+ let closedByCaller = false;
+ let settled = false;
+ let resolveReady: (ok: boolean) => void = () => undefined;
+ const ready = new Promise((resolve) => {
+ resolveReady = resolve;
+ });
+ const queued: WorkflowChatMessage[][] = [];
+
+ function emit(event: WorkflowWsEvent): void {
+ options.onEvent(event);
+ }
+
+ function emitError(error: string): void {
+ emit({ type: "error", workflow: options.workflow, error });
+ }
+
+ function settle(ok: boolean): void {
+ if (settled) return;
+ settled = true;
+ resolveReady(ok);
+ // A queued run that can never be flushed is an error the caller must see.
+ if (!ok && !closedByCaller && queued.length > 0) {
+ emitError("run requested before the socket bound; the run was not sent");
+ }
+ }
+
+ function sendRunFrame(messages: WorkflowChatMessage[]): void {
+ runInFlight = true;
+ socket.send(JSON.stringify({ type: "run", messages }));
+ }
+
+ function handle(event: WorkflowWsEvent): void {
+ switch (event.type) {
+ case "status":
+ if (event.state === "bound") {
+ bound = true;
+ settle(true);
+ for (const messages of queued.splice(0)) sendRunFrame(messages);
+ } else if (event.state === "running") {
+ runInFlight = true;
+ } else {
+ runInFlight = false; // ok | error ends the in-flight run
+ }
+ break;
+ case "error":
+ // A protocol-level error before binding means the bind failed.
+ if (!bound) settle(false);
+ break;
+ default:
+ break;
+ }
+ emit(event);
+ }
+
+ socket.onopen = () => {
+ socket.send(JSON.stringify({ type: "bind", workflow: options.workflow }));
+ };
+
+ socket.onmessage = (event) => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : String(event.data));
+ } catch {
+ emitError("received a message that is not valid JSON");
+ return;
+ }
+ const typed = toWorkflowWsEvent(parsed);
+ if (typed === null) {
+ emitError("received an unrecognized message from the server");
+ return;
+ }
+ handle(typed);
+ };
+
+ socket.onclose = () => {
+ const wasInFlight = runInFlight;
+ runInFlight = false;
+ if (!closedByCaller && wasInFlight) {
+ emitError("connection closed during run");
+ }
+ if (!settled) settle(false);
+ options.onClose?.();
+ };
+
+ function sendRun(messages: WorkflowChatMessage[]): void {
+ if (bound) {
+ sendRunFrame(messages);
+ return;
+ }
+ queued.push(messages);
+ }
+
+ function close(): void {
+ if (closedByCaller) return;
+ closedByCaller = true;
+ socket.close();
+ }
+
+ return { sendRun, close, ready };
+}
\ No newline at end of file
diff --git a/frontend/src/pages/about.astro b/frontend/src/pages/about.astro
new file mode 100644
index 0000000..ffeed66
--- /dev/null
+++ b/frontend/src/pages/about.astro
@@ -0,0 +1,182 @@
+---
+import AppLayout from "../layouts/AppLayout.astro";
+import pkg from "../../../package.json";
+
+/**
+ * About — static build-time facts, nothing invented. Package metadata
+ * (name/version/license/homepage) comes from the root package.json at
+ * build time; the stack lines are the actual runtime facts of the project.
+ * The console is admin-only and stores no credentials.
+ */
+
+const packageRows = [
+ { label: "Package name", value: pkg.name },
+ { label: "Version", value: `v${pkg.version}` },
+ { label: "License", value: pkg.license },
+];
+
+const stackRows = [
+ {
+ label: "Runtime",
+ value: "Bun ≥ 1.4",
+ note: "Server, build tooling and tests run on Bun — not Node.",
+ },
+ {
+ label: "Language",
+ value: "TypeScript strict",
+ note: "No implicit any; ESM with explicit .js imports.",
+ },
+ {
+ label: "Frontend",
+ value: "Astro 7 static + Svelte 5 islands",
+ note: "Static shell with hydrated islands; no SPA router.",
+ },
+ {
+ label: "Backend",
+ value: "llama.cpp via llama-server",
+ note: "Managed local backend behind an OpenAI-compatible proxy.",
+ },
+];
+---
+
+
+
+
About
+
+ WeaveLLM — a local AI desktop runtime: managed llama.cpp backend,
+ visual workflow editor, model management and an OpenAI-compatible
+ proxy. Everything on this page is build-time package data or the
+ actual stack — nothing is simulated.
+
+ This console is admin-only and stores no credentials. The auth gate
+ (WEAVELLM_AUTH) and provider keys live server-side; the console only
+ probes the real backend surface and never simulates data.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/api.astro b/frontend/src/pages/api.astro
new file mode 100644
index 0000000..bc6253d
--- /dev/null
+++ b/frontend/src/pages/api.astro
@@ -0,0 +1,40 @@
+---
+import AppLayout from "../layouts/AppLayout.astro";
+import Playground from "../svelte/playground/Playground.svelte";
+
+/**
+ * /api — legacy alias of the API playground (U11): the canonical route is
+ * /playground (nav points there); this URL keeps working so existing
+ * bookmarks and deep links never 404. Same island, same wire surface.
+ */
+---
+
+
+
+
API playground
+
+ Exercise POST /v1/chat/completions against the real wire. The canonical
+ route is /playground.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/executions/index.astro b/frontend/src/pages/executions/index.astro
new file mode 100644
index 0000000..f5cef4a
--- /dev/null
+++ b/frontend/src/pages/executions/index.astro
@@ -0,0 +1,39 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import ExecutionsList from "../../svelte/executions/ExecutionsList.svelte";
+
+/**
+ * Executions — per-workflow run history. The island owns all data: it talks
+ * to the real /api/workflows and /api/workflows/:name/logs surface through
+ * the typed client (no server-side fetch here) and reports loading/error/
+ * empty states honestly. There is no global executions endpoint, so the page
+ * aggregates per-workflow logs it actually fetches — never invented totals.
+ */
+---
+
+
+
+
Executions
+
+ Per-workflow run history straight from the backend logs — latest run up
+ front, full history on expand. No auto-refresh: a completed run is
+ immutable, so data refreshes on demand.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro
index 25064fa..51585f1 100644
--- a/frontend/src/pages/index.astro
+++ b/frontend/src/pages/index.astro
@@ -1,37 +1,37 @@
---
-// WeaveLLM shell: static SPA hosting Svelte islands.
-import ModelBadge from "../svelte/ModelBadge.svelte";
-import WorkflowEditor from "../svelte/WorkflowEditor.svelte";
-import "../styles/global.css";
+import AppLayout from "../layouts/AppLayout.astro";
+import Overview from "../svelte/overview/Overview.svelte";
+
+/**
+ * Overview — console landing page. The live dashboard island gathers real
+ * values through the typed API clients (runtime health, model registry,
+ * workflow store) and refreshes every 10 seconds. It never invents metrics:
+ * unavailable values render as "—"/"Unavailable" with an honest reason.
+ */
---
-
-
-
-
-
- WeaveLLM
-
-
-
-
-
WeaveLLM
- Local model runtime
-
-
-
-
Local models
-
-
-
-
Workflow editor
-
-
-
-
-
\ No newline at end of file
+
+
+
Overview
+
+ Live runtime summary — backend endpoint, model registry state and recent
+ workflows, refreshed automatically from the /api surface.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/models/[id].astro b/frontend/src/pages/models/[id].astro
new file mode 100644
index 0000000..1aefc68
--- /dev/null
+++ b/frontend/src/pages/models/[id].astro
@@ -0,0 +1,24 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import EmptyState from "../../svelte/common/EmptyState.svelte";
+
+/**
+ * Per-model details. The route is dynamic; the static build emits no pages
+ * until model ids can come from the real backend (getStaticPaths stays empty
+ * in this phase — no fabricated models). Dev mode serves any :id on demand.
+ */
+export function getStaticPaths() {
+ return [];
+}
+
+const { id } = Astro.params;
+const modelId = id ?? "unknown";
+---
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/models/catalog.astro b/frontend/src/pages/models/catalog.astro
new file mode 100644
index 0000000..597f515
--- /dev/null
+++ b/frontend/src/pages/models/catalog.astro
@@ -0,0 +1,32 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import EmptyState from "../../svelte/common/EmptyState.svelte";
+
+/**
+ * Model catalog. Honest state: there is NO catalog endpoint in the public
+ * /api surface (verified against src/routes/api.ts + src/app/server.ts — the
+ * backend exposes only /api/health, /api/models and /api/workflows). The
+ * catalog source (src/catalog/) is backend-internal, so this section shows
+ * the "Coming from backend" state instead of inventing an endpoint or data.
+ */
+---
+
+
+
+
Model catalog
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/models/downloads.astro b/frontend/src/pages/models/downloads.astro
new file mode 100644
index 0000000..514711c
--- /dev/null
+++ b/frontend/src/pages/models/downloads.astro
@@ -0,0 +1,32 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import EmptyState from "../../svelte/common/EmptyState.svelte";
+
+/**
+ * Model downloads. Honest state: there is NO downloads endpoint in the
+ * public /api surface (verified against src/routes/api.ts +
+ * src/app/server.ts). The gosh download engine (src/downloads/) is
+ * backend-internal, so this section shows the "Coming from backend" state
+ * instead of inventing an endpoint or data.
+ */
+---
+
+
+
+
Downloads
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/models/index.astro b/frontend/src/pages/models/index.astro
new file mode 100644
index 0000000..fd00a3f
--- /dev/null
+++ b/frontend/src/pages/models/index.astro
@@ -0,0 +1,37 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import ModelsRegistry from "../../svelte/models/ModelsRegistry.svelte";
+
+/**
+ * Models registry — the island owns all data: it talks to the real
+ * /api/models surface through the typed client (auth-gated on the backend),
+ * and reports loading/error/empty states honestly. This page renders only
+ * the heading shell.
+ */
+---
+
+
+
+
Models
+
+ The managed llama-server registry — activation state, PID and port come
+ directly from the backend models API.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/playground/index.astro b/frontend/src/pages/playground/index.astro
new file mode 100644
index 0000000..030c2ea
--- /dev/null
+++ b/frontend/src/pages/playground/index.astro
@@ -0,0 +1,45 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import Playground from "../../svelte/playground/Playground.svelte";
+
+/**
+ * API playground (U11) — exercise POST /v1/chat/completions against the real
+ * wire. The island owns all data: typed v1 client (JSON + OpenAI-wire SSE),
+ * model suggestions from the real /api/models + /api/workflows surfaces
+ * (non-authoritative — external adapter ids resolve server-side). The console
+ * never sends credentials; when WEAVELLM_AUTH is on the server answers /v1
+ * with its 401 envelope and the playground shows it verbatim.
+ */
+---
+
+
+
+
API playground
+
+ POST /v1/chat/completions against the real wire — JSON for
+ non-streaming, OpenAI-wire SSE with a terminal [DONE] for
+ stream. Responses render verbatim; no derived metrics.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/providers/[kind].astro b/frontend/src/pages/providers/[kind].astro
new file mode 100644
index 0000000..b2c2033
--- /dev/null
+++ b/frontend/src/pages/providers/[kind].astro
@@ -0,0 +1,22 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import EmptyState from "../../svelte/common/EmptyState.svelte";
+
+/**
+ * Provider kind details for the three contract kinds (openai | anthropic |
+ * openrouter). These are real kinds from the backend provider registry —
+ * static pages render the honest "Coming from backend" state because the
+ * public API does not expose provider rows. Unknown slugs 404 in production.
+ */
+export function getStaticPaths() {
+ const kinds = ["openai", "anthropic", "openrouter"];
+ return kinds.map((kind) => ({ params: { kind } }));
+}
+
+const { kind } = Astro.params;
+const providerKind = kind ?? "unknown";
+---
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/providers/index.astro b/frontend/src/pages/providers/index.astro
new file mode 100644
index 0000000..2bf80d4
--- /dev/null
+++ b/frontend/src/pages/providers/index.astro
@@ -0,0 +1,8 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import EmptyState from "../../svelte/common/EmptyState.svelte";
+---
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/runtime/index.astro b/frontend/src/pages/runtime/index.astro
new file mode 100644
index 0000000..b017565
--- /dev/null
+++ b/frontend/src/pages/runtime/index.astro
@@ -0,0 +1,41 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import RuntimePanel from "../../svelte/runtime/RuntimePanel.svelte";
+import pkg from "../../../../package.json";
+
+/**
+ * Runtime — backend health, endpoints, auth gate, websocket handshake and
+ * version. The island owns all live data through the typed API client (no
+ * server-side fetch here): GET /api/health (timed), the runtime auth probe
+ * and a one-shot WS /ws connection probe. Values the browser cannot observe
+ * (server env like the listening port) are never invented — the page shows
+ * the client-side origin and marks the rest as coming from the backend.
+ * Version comes from the root package.json at build time.
+ */
+---
+
+
+
+
Runtime
+
+ Backend health, the endpoints this console talks to, the auth gate and
+ a live WebSocket handshake probe — all against the real surface.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/settings/index.astro b/frontend/src/pages/settings/index.astro
new file mode 100644
index 0000000..b758b19
--- /dev/null
+++ b/frontend/src/pages/settings/index.astro
@@ -0,0 +1,39 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import SettingsPanel from "../../svelte/settings/Settings.svelte";
+
+/**
+ * Settings — read-only console settings. There is no settings API on the
+ * backend (server config is environment-driven: WEAVELLM_HOST/PORT/AUTH/
+ * APP_DATA), so this page never mutates anything and shows no forms. The
+ * island probes the auth gate live through the typed API client and marks
+ * server-owned configuration (host/port/app-data/store) honestly as coming
+ * from the backend — the public API does not expose those values.
+ */
+---
+
+
+
+
Settings
+
+ Read-only: the auth gate is probed live, and server-owned configuration
+ is shown honestly. Nothing here mutates the server.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/workflows/[name].astro b/frontend/src/pages/workflows/[name].astro
new file mode 100644
index 0000000..03ca09b
--- /dev/null
+++ b/frontend/src/pages/workflows/[name].astro
@@ -0,0 +1,83 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import Icon from "../../svelte/common/Icon.svelte";
+import FlowEditor from "../../svelte/workflow-editor/FlowEditor.svelte";
+
+/**
+ * Workflow editor route (U05). The island loads the named workflow through
+ * the typed API client and renders the SvelteFlow surface — demo is the only
+ * statically prerendered name; other stored names hydrate client-side the
+ * same way once the real store surfaces them.
+ */
+export function getStaticPaths() {
+ return [{ params: { name: "demo" } }];
+}
+
+const { name } = Astro.params;
+const workflowName = name ?? "demo";
+---
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/workflows/index.astro b/frontend/src/pages/workflows/index.astro
new file mode 100644
index 0000000..ed000d4
--- /dev/null
+++ b/frontend/src/pages/workflows/index.astro
@@ -0,0 +1,37 @@
+---
+import AppLayout from "../../layouts/AppLayout.astro";
+import WorkflowsList from "../../svelte/workflows/WorkflowsList.svelte";
+
+/**
+ * Workflow library — the list island owns all data: it talks to the real
+ * /api/workflows surface through the typed client (no server-side fetch
+ * here), and reports loading/error/empty states honestly. The count lives in
+ * the island, so this page renders only the heading shell.
+ */
+---
+
+
+
+
Workflows
+
+ Stored workflow definitions with run, log and delete controls — powered
+ by the backend workflow API.
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/models/ModelDetail.svelte b/frontend/src/svelte/models/ModelDetail.svelte
new file mode 100644
index 0000000..9e2f04c
--- /dev/null
+++ b/frontend/src/svelte/models/ModelDetail.svelte
@@ -0,0 +1,242 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/models/ModelsRegistry.svelte b/frontend/src/svelte/models/ModelsRegistry.svelte
new file mode 100644
index 0000000..b57b6ab
--- /dev/null
+++ b/frontend/src/svelte/models/ModelsRegistry.svelte
@@ -0,0 +1,566 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/overview/StatusCard.svelte b/frontend/src/svelte/overview/StatusCard.svelte
new file mode 100644
index 0000000..07871a9
--- /dev/null
+++ b/frontend/src/svelte/overview/StatusCard.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+
{label}
+ {#if statusLabel !== ""}
+
+ {/if}
+
+
+
{value}
+
+ {#if hint !== ""}
+
{hint}
+ {/if}
+
+ {#if children}
+
+ {@render children()}
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/playground/Playground.svelte b/frontend/src/svelte/playground/Playground.svelte
new file mode 100644
index 0000000..f80627d
--- /dev/null
+++ b/frontend/src/svelte/playground/Playground.svelte
@@ -0,0 +1,895 @@
+
+
+
+
+
+
+
Request
+ POST /v1/chat/completions
+
+
+
+
+
+
+
+ Suggestions — local registry ids and gateway/<workflow> names.
+ External adapter ids resolve server-side; the server answers unknown
+ models with a 404 envelope.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Requests leave this console unauthenticated — the server owns auth
+ (WEAVELLM_AUTH) and credentials never live here.
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/runtime/RuntimePanel.svelte b/frontend/src/svelte/runtime/RuntimePanel.svelte
new file mode 100644
index 0000000..523828e
--- /dev/null
+++ b/frontend/src/svelte/runtime/RuntimePanel.svelte
@@ -0,0 +1,517 @@
+
+
+
+
+
+ Status from GET /api/health, the auth probe and a WS /ws handshake probe —
+ values the browser cannot observe come from the backend.
+
+ The endpoints this console talks to (client config). In dev, the Astro
+ proxy forwards them to the local backend.
+
+
+
+
+
+
WebSocket
+
+
+
{wsCard.value}
+
{wsCard.announce}
+ {#if ws.phase === "error"}
+
{ws.error}
+ {/if}
+
+
+
+
{wsCard.hint}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/settings/Settings.svelte b/frontend/src/svelte/settings/Settings.svelte
new file mode 100644
index 0000000..0006abd
--- /dev/null
+++ b/frontend/src/svelte/settings/Settings.svelte
@@ -0,0 +1,203 @@
+
+
+
+
+
+ Read-only settings. No settings API exists on the backend: the auth
+ gate is probed live, and server-owned configuration is marked honestly.
+
+
+
+
+
+
+ {#if probe.status === "error"}
+
{probe.error}
+
+ {/if}
+
+
+
+
+
Server environment
+
+
+ {#each SERVER_ENV_ROWS as row (row.label)}
+
+
{row.label}
+ {row.value}
+
+ {/each}
+
+
{SERVER_ENV_NOTE}
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/CanvasCommands.svelte b/frontend/src/svelte/workflow-editor/CanvasCommands.svelte
new file mode 100644
index 0000000..e837dce
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/CanvasCommands.svelte
@@ -0,0 +1,44 @@
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/FlowEditor.svelte b/frontend/src/svelte/workflow-editor/FlowEditor.svelte
new file mode 100644
index 0000000..eb20ec6
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/FlowEditor.svelte
@@ -0,0 +1,661 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/Inspector.svelte b/frontend/src/svelte/workflow-editor/Inspector.svelte
new file mode 100644
index 0000000..06c7439
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/Inspector.svelte
@@ -0,0 +1,736 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/NodePalette.svelte b/frontend/src/svelte/workflow-editor/NodePalette.svelte
new file mode 100644
index 0000000..6ccd1b0
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/NodePalette.svelte
@@ -0,0 +1,144 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/RunPanel.svelte b/frontend/src/svelte/workflow-editor/RunPanel.svelte
new file mode 100644
index 0000000..eaa1df1
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/RunPanel.svelte
@@ -0,0 +1,750 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeBranch.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeBranch.svelte
new file mode 100644
index 0000000..e797fe9
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeBranch.svelte
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeData.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeData.svelte
new file mode 100644
index 0000000..f0624cc
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeData.svelte
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeLlm.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeLlm.svelte
new file mode 100644
index 0000000..07e07dc
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeLlm.svelte
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeOutput.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeOutput.svelte
new file mode 100644
index 0000000..c011693
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeOutput.svelte
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodePipeline.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodePipeline.svelte
new file mode 100644
index 0000000..4deba44
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodePipeline.svelte
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeStartEnd.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeStartEnd.svelte
new file mode 100644
index 0000000..cccdfd6
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeStartEnd.svelte
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflow-editor/nodes/NodeShell.svelte b/frontend/src/svelte/workflow-editor/nodes/NodeShell.svelte
new file mode 100644
index 0000000..bd3a39a
--- /dev/null
+++ b/frontend/src/svelte/workflow-editor/nodes/NodeShell.svelte
@@ -0,0 +1,144 @@
+
+
+
+ {#if hasTargetHandle(entry.id)}
+
+ {/if}
+
+
+
+ {entry.label}
+
+
+
+ {#if lines.length > 0}
+ {#each lines as line (entry.id + line.key)}
+
+ {line.key}
+ {line.value}
+
+ {/each}
+ {:else}
+
{entry.description}
+ {/if}
+
+
+
{nodeId}
+
+ {#if hasSourceHandle(entry.id)}
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflows/NewWorkflowModal.svelte b/frontend/src/svelte/workflows/NewWorkflowModal.svelte
new file mode 100644
index 0000000..ffa6ec9
--- /dev/null
+++ b/frontend/src/svelte/workflows/NewWorkflowModal.svelte
@@ -0,0 +1,313 @@
+
+
+
+
event.stopPropagation()}
+ >
+
+
New workflow
+
+
+
+
+
+
+ {#if nameError() !== null}
+
{nameError()}
+ {/if}
+
+
+
+
+ {#if error !== null}
+
{error}
+ {/if}
+ {#if errorDetails.length > 0}
+
{errorDetails.join("\n")}
+ {/if}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflows/RunWorkflowModal.svelte b/frontend/src/svelte/workflows/RunWorkflowModal.svelte
new file mode 100644
index 0000000..d6f07f0
--- /dev/null
+++ b/frontend/src/svelte/workflows/RunWorkflowModal.svelte
@@ -0,0 +1,251 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflows/WorkflowLogsModal.svelte b/frontend/src/svelte/workflows/WorkflowLogsModal.svelte
new file mode 100644
index 0000000..e500409
--- /dev/null
+++ b/frontend/src/svelte/workflows/WorkflowLogsModal.svelte
@@ -0,0 +1,318 @@
+
+
+
+
event.stopPropagation()}
+ >
+
+
Logs — {name}
+
+
+
+
+ {#if error !== null}
+
+
{error}
+
+
+ {:else if rows === null}
+
+ {#each skeletons as _skel, i (i)}
+
+
+
+
+
+ {/each}
+
+
Loading logs…
+ {:else if rows.length === 0}
+
No runs yet. Run this workflow to see execution history here.
+
+
\ No newline at end of file
diff --git a/frontend/src/svelte/workflows/WorkflowsList.svelte b/frontend/src/svelte/workflows/WorkflowsList.svelte
new file mode 100644
index 0000000..5bd585d
--- /dev/null
+++ b/frontend/src/svelte/workflows/WorkflowsList.svelte
@@ -0,0 +1,664 @@
+
+
+
Delete workflow "{modalName}"? This cannot be undone.
+ {#if deleteError !== null}
+
{deleteError}
+ {/if}
+
+
+
+
+
+
+
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..ea61264
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "src/**/*", "astro.config.ts"],
+ "exclude": ["dist", "frontend", "node_modules"]
+}
\ No newline at end of file
diff --git a/odd/tasks/ui-console.md b/odd/tasks/ui-console.md
new file mode 100644
index 0000000..e08fcab
--- /dev/null
+++ b/odd/tasks/ui-console.md
@@ -0,0 +1,46 @@
+# Feature: ui-console — Consola de administración WeaveLLM (spec v2, PHASE 1–14)
+
+**Objective:** transformar el frontend actual (única página `index.astro` + ModelBadge/WorkflowEditor) en la consola completa de administración según la spec revisada v2 (~secciones 0–303, entregada 2026-09-20): Overview, Workflows (+editor visual), Models (registry/catalog/downloads), Providers, Executions, API Playground, Runtime, Settings, About.
+
+**Problem:** la spec de diseño anterior estaba basada en una versión VIEJA de WeaveLLM e inventaba endpoints (`/api/ui/*`, `/api/pipelines/*`, `/api/executions/*`) que no existen. El usuario pidió roll-back explícito ("eliminar el worktree y empezar de nuevo") y la spec v2. **Regla central (§2): la UI representa la superficie backend REAL; está prohibido inventar endpoints, telemetría o datos.**
+
+**Why:** pedido explícito del usuario + desbloqueo del trabajo pendiente de la consola sobre el runtime real (el backend ya expone /api, /v1, /ws; falta la UI).
+
+**Roll-back ejecutado (2026-09-20):** rama `feat/dashboard-api-surface` eliminada (sin commits propios); descartados `src/orchestrator/graph.ts`/`graph.test.ts` (mods), `src/app/events.ts`/`events.test.ts`, `odd/tasks/dashboard-api-surface.md`; issue #43 cerrada como `not planned` (superseded por spec v2). Working tree limpio sobre `master` → rama nueva `feat/ui-console`. `docs/` preexistente NO se tocó.
+
+**Scope (autorizado):** `frontend/` completo (Astro 7 static + Svelte 5 islands + @xyflow/svelte), `frontend/astro.config.ts` (mantener proxy `/api,/v1,/ws` → 127.0.0.1:4317, solo dev), alias `@core` → `../src/` para reusar tipos de dominio. **No** tocar `src/` backend (contratos reales) ni agregar dependencias nuevas. Copia de UI: inglés consistente, tono técnico/concise, sin mezclar idiomas (§281).
+
+**Constraints:**
+- Superficie backend real (fuente de verdad): `GET /api/health`; `GET /api/models`, `GET /api/models/:id`, `POST .../activate`, `POST .../deactivate`; `GET/PUT/DELETE /api/workflows...`, `POST /api/workflows/:name/run`, `GET /api/workflows/:name/logs`; `WS /ws`; `GET /v1/models`, `POST /v1/chat/completions|completions|embeddings`. Gaps → "Coming from backend"/"Unavailable"; nunca simular.
+- **Tokens Gentle-AI exactos** (sustituyen `#F095C8`): bg `#09090b`; card `#111113`; code `#18181c`; border `#ffffff12`, hover `#ea188959`; texto `#fafafa`; secondary `#a1a1aa`; muted `#52525b`; accent `#ea1889`, hover `#ff6db0`, subtle `#ea188926`. Radii controles 8px / cards 16px (nunca 5/11/13/22). Spacing 4/8/12/16/20/24/32/40/48. Inter + ui-monospace; máx 3 pesos por vista.
+- Stack: Astro 7 static + Svelte 5 moderno ($state/$derived/$effect/$props) + @xyflow/svelte. NO React/SvelteKit/Bootstrap/MUI/Chakra/Ant. Iconos SVG inline. Sin i18n, sin `any`, sin eslint disables inline, sin `fetch()` arbitrario en componentes (API client tipado), tests `bun:test` al lado del código. Editor: Single Graph Model (Visual|YAML), guardar = serializeWorkflowGraph → PUT YAML canónico, validateGraph() backend = autoridad. Ejecución = WS /ws (nunca reinterpretar como SSE); executions = logs de workflow.
+- Convenciones repo: Bun ≥ 1.4, ESM `.js` explícitos, Conventional Commits, ramas `^(feat|fix|...)\/[a-z0-9._-]+$`.
+
+**Checklist (PHASE 1–14 de la spec §302; verificación por fase):**
+
+- [x] **U01 (PHASE 1)** Design tokens + App shell + Navegación + Routing. `styles/tokens.css` + refactor `global.css` (quitar #F095C8) + `layouts/AppLayout.astro` (Sidebar/Topbar/Main/StatusBar) + `svelte/navigation/*` (Sidebar, Topbar, StatusBar, CommandPalette Ctrl/Cmd+K) + rutas placeholder (Overview), `/workflows[/name]`, `/models[/id|catalog|downloads]`, `/providers[/kind]`, `/executions`, `/api`, `/runtime`, `/settings`, `/about`; `lib/api/config.ts` mínimo (`getApiOrigin`/`getWsOrigin`); mount temporal de WorkflowEditor existente en `/workflows/[name]` (sin romper su funcionalidad; index deja de hardcodear qwen2.5-coder-3b-instruct/Q4_K_M/32768). ✅ commit `16b0871` — gate verde (typecheck ✓, lint ✓, 462 tests ✓, build:frontend ✓ 12 páginas). Extras: `lib/navigation.ts` (registro de nav + isRouteActive), `lib/ui-state.ts`, `lib/api/config.test.ts`, helpers `svelte/common/*` (Button/Icon/IconButton/StatusDot/EmptyState). Fixes post-agente: imports relativos en `executions/index.astro`, path de `package.json` raíz en `AppLayout.astro` y `about.astro`.
+- [x] **U02 (PHASE 2)** API layer tipado + básicos. ✅ commit `120c66d` — `lib/api/{http,types,health,models,workflows,runtime,index}.ts` + tests (26 pass) sobre la superficie real. `ApiError {status,code,message,details?}` + `request()` con fetch inyectable; `ErrorEnvelope` del backend mapeado verbatim (`unauthorized`, `model_not_found`, etc.). **No existe endpoint de providers en el backend real → NO se creó cliente de providers** (U09 mostrará "Coming from backend"). `runtime.status()` = health + sonda 401 ⇒ authEnabled. Hallazgos del writer: `bun test` root no colecta frontend (correr `bun test ./frontend/src/lib/api/*.test.ts`); typecheck root no cubre frontend (verificación TS = lint + tests path-scoped + astro build). Fix post-entrega: `ExecutionLogRow.error`/`ms` tipados `string|null` (el backend incluye la clave con null).
+- [x] **U03 (PHASE 3)** Overview. ✅ commit `a4260c6` — `lib/overview.ts` (mapa puro + 6 tests), `svelte/overview/{Overview,StatusCard}.svelte` (island `client:load`, Promise.allSettled, auto-refresh 10s con guard de stale, estados honestos "—"/Unavailable+Retry), quick actions como anchors semánticos; `index.astro` monta el dashboard. **Fix defecto U02:** `RuntimeStatusOptions` estaba referenciado pero indefinido (invisible a los gates porque **frontend no tiene tsconfig** — astro build no typechecks; hardening pendiente para U14). Caveat: en build estático, links `/workflows/` solo renderizan `demo` hasta que U04 amplíe `getStaticPaths`.
+- [x] **U04 (PHASE 4)** Workflow library. ✅ commit `c40b1c3` — `lib/workflows-ui.ts` (helpers puros + 21 tests), `svelte/workflows/{WorkflowsList,RunWorkflowModal,WorkflowLogsModal,NewWorkflowModal}.svelte`, página `/workflows` con lista real (create/run/logs/delete dialogs accesibles, estados honestos, delete con confirm). **Descubrimiento clave (verificado contra `workflow-yaml.ts`/`graph.ts`): el YAML real de workflows usa `nodes` como ARRAY de objetos con campos del engine (no mapa), sin campo `next` (flujo solo por `edges`), y `llm_call` exige `model` no vacío + `mode` (`generate`/`refine`/`passthrough`); `provider` opcional; scaffolds `system`/`assistant` (NO `prompt`). El template original (forma mapa + `prompt`) 400a en el backend — corregido en el modal antes del commit. Forma mínima canónica: `nodes: [{id: start, type: start}, {id: llm, type: llm_call, mode: generate, model: }, {id: end, type: end}]` + edges `start→llm→end`. U05 debe construir el editor sobre ESTA forma.
+- [x] **U05 (PHASE 5)** Workflow editor foundation. ✅ commit pendiente abajo — `lib/workflow-nodes.ts` (taxonomía 14 tipos: categorías, fields por tipo, defaults, orden palette, `sanitizeNodeFields`, `makeNodeId`), `lib/workflow-flow.ts` (`graphToFlow`/`flowToGraph` con whitelist de campos + round-trip estable, `parseWorkflowYamlToGraph` vía paquete `yaml` (mismo dep del backend), `NODE_GROUPS`, seam `CanvasApi`), tests (workflow-nodes + workflow-flow, round-trip y drop de campos desconocidos), `svelte/workflow-editor/{FlowEditor,CanvasCommands,NodePalette,Inspector,nodes/*}` (6 custom nodes por grupo, minimap+controls, fitView, click-to-add, inspector de lectura — formularios en U06), ruta `[name].astro` monta ``; `WorkflowEditor.svelte` + `workflow-nodes-demo.ts` huérfanos (demo movido; borrar en U06 o tras confirmar). Decisiones: sin drag-and-drop nativo (pelea con el pane; click-to-add como seam), `rag_local`/`embeddings` sin campo `model` (el GraphNode real no lo tiene). Hallazgos: esta versión de Svelte exporta `tick`, no `nextTick`.
+- [x] **U06 (PHASE 6)** Workflow semantics. ✅ commit `d17be52` — Inspector editable por tipo (`llm_call`: mode/model/provider/system/assistant/ctx/on_429/tool_calls_route con hint "local registry"; `loop`: body+iterations; `pipeline`: params; `condition`: source con subset local honesto en `workflow-condition.ts`; `rag_local` k; `memory` convId; `embeddings` text; `data.code` code), `lib/workflow-validate.ts` (prevalidación estructural espejo de graph.ts para feedback rápido — **autoridad = backend**: Save hace PUT y muestra errores 400 verbatim), Save explícito (Cmd/Ctrl+S) = `flowToGraph`→`serializeGraphToYaml`→`PUT`, flash "Saved vN", start/end protegidos (`onBeforeDelete`). Fixes: `seedFromGraph` pisaba `savedVersion` con null; keys duplicadas en Inspector (#each por índice); import depth 4 niveles (rompía build). Borrados demo: `WorkflowEditor.svelte` + `workflow-nodes-demo.ts` (cero referencias). 22 tests condition/validate; svelte-check 0 errores.
+- [x] **U07 (PHASE 7)** Workflow execution. ✅ commit `00f1899` — `lib/{ws,sse,run-transcript}.ts` (+3 suites, 45 tests), `RunPanel.svelte` (drawer en FlowEditor, tabs Run/Runs, transcript puro, fallback one-shot honesto ante bind fallido, refresh de logs tras status terminal porque `runner.ts` escribe la fila ANTES de resolver), toolbar Run con icono play, icons play/check en `Icon.svelte`. Gates verdes: typecheck ✓, lint ✓, 462 tests ✓, build:frontend ✓ 12 páginas. Confirmado: el protocolo WS NO trae tokens por paso (solo lifecycle `step_started/step_completed`) — la salida llega en frames finales `data: {JSON}` + `data: [DONE]`.
+- [x] **U08 (PHASE 8)** Models. ✅ commit `8a04fba` — `lib/models-ui.ts` (pure reducers + 18 tests: counts, chips, pid/port/error formatting), `svelte/models/ModelsRegistry.svelte` (island: 15s auto-refresh con stale-guard, honest 401 "Authorization required"/empty/error+Retry, resumen total/active/disabled/error, Activate/Deactivate POSTs reales con busy labels y refresh, errores verbatim, nunca optimista), `svelte/models/ModelDetail.svelte` (drawer a11y con getModel real). **Verificado: NO existe endpoint de catálogo ni downloads** (`src/routes/api.ts` solo lista/status/activate/deactivate; `src/catalog/`+`src/downloads/` son internos del backend) → secciones catalog/downloads muestran **"Coming from backend"** honesto. **Sin telemetría inventada**: ModelStatus es solo `{id, state, pid?, port?, error?}`. Hallazgo: `GET /api/models/:id/status` devuelve el full ModelStatus (no hay getModelStatus separado).
+- [x] **U09 (PHASE 9)** Providers. ✅ commit `bd80710` — página `/providers` + `/providers/[kind]` como shells estáticos honestos **"Coming from backend"** para los 3 kinds reales del contrato (`openai|anthropic|openrouter` de `registry.ts`): keys en el keychain del server, **nunca al frontend**; fallback links server-side (`providers` rows). Verificado de nuevo: NO existe endpoint de providers en `src/routes/api.ts` → nada que renderizar. Gotcha del build (Astro): **los const del frontmatter NO son visibles dentro de `getStaticPaths`** — se inlinea el literal de kinds (primera escritura falló el build con `PROVIDER_KINDS is not defined`; fix = literal inline; build ahora 15 páginas).
+- [x] **U10 (PHASE 10)** Executions. ✅ commit `0615954` — página `/executions` + `svelte/executions/ExecutionsList.svelte` (island): agregación honesta **sin API global** — pool lazy con concurrencia máx 2 y guard de generación (`refreshSeq`), filas expandibles (aria-expanded, refetch on expand), latest-run por workflow desde `workflowLogs`, summary strip etiquetado como "counts de logs cargados de X de N, no agregado global", Refresh manual (runs completados son inmutables — `runner.ts` escribe la fila antes de resolver), degradación por-fila ante fallo del list refresh. `lib/executions-ui.ts` + 15 tests puros (el writer reportó 18; el conteo real de la suite commiteada es 15). Nav `/executions` ya existía (U01, grupo Observe).
+- [x] **U11 (PHASE 11)** API Playground. ✅ commit `d2e1587` — `lib/api/v1.ts` (cliente /v1 tipado: chatCompletion JSON + stream async-generator `frame|done|truncated|aborted`, splitSSEFrames; **nunca envía Authorization** — la auth vive server-side WEAVELLM_AUTH y el 401 se muestra verbatim con hint; sin credenciales jamás), `lib/v1-ui.ts` (highlightJson byte-preserving + tests), `svelte/playground/Playground.svelte` (request editor: model + datalist real de /api/models ids + gateway/, messages fieldset, temperature 0–2, stream default on; vista raw SSE progresiva con [DONE]; errores verbatim: 401 Bearer hint, 404 unknown-model con hint de wiring local), AbortController cancel, aria-live. 22 tests nuevos. Nav: `/api` → `/playground`, `api.astro` queda como alias (no romper deep links). Superficie verificada: solo `POST /v1/chat/completions` + `GET /v1/models`; ids locales → envelope 404 unknown-model (boot no cableado).
+- [x] **U12 (PHASE 12)** Runtime. ✅ commit `5408f6d` — página `/runtime` (flat placeholder `/runtime.astro` → `runtime/index.astro`): tarjetas reales — Health (status verbatim + latencia medida con performance.now + localModels), Server address (API/WS origin desde config del cliente, honesto), Authentication (probe de `runtime.ts` + nota WEAVELLM_AUTH), WebSocket (probe one-shot connect→600ms→close con reducer `wsProbeState` idle|connecting|connected|closed|error, sin auto-reconnect, aria-live, Retry), Version (root package.json build-time). `lib/runtime-ui.ts` + 17 tests. Gotcha Svelte 5: el `$effect` del probe NO debe leer el `$state` del ws (loop); socket/closeTimer como locals no reactivos; cleanup nulls handlers. Import de package.json verificado contra about.astro (4 niveles).
+- [x] **U13 (PHASE 13)** Settings + About. ✅ commit `c288f0c` — `settings.astro` (placeholder U01) → `pages/settings/index.astro` + island `svelte/settings/Settings.svelte`: **Authentication** card con probe real de `runtimeStatus()` (mapeo puro `authProbe`/`authView` → Enabled/Disabled/Unavailable con estados loading/error por tarjeta, Retry, nota exacta "Owned server-side via WEAVELLM_AUTH — this console never holds or changes the key."); **Server environment** card con 4 rows honestas "Coming from backend" (Host/Port/App data directory/State store — env server NO expuesto por API pública; test enforcement anti-path-fake) + nota neutral; sin forms ni botones de save; Refresh manual re-corre el probe. `about.astro` upgrade (ruta intacta, estático, sin island): package rows reales de package.json raíz (name/version/license/homepage link), stack factual (Bun ≥1.4, TypeScript strict, Astro 7 static + Svelte 5 islands, llama.cpp vía llama-server), nota honesta admin-only + stores no credentials. Nav `/settings` y `/about` ya cableados (U01). `lib/settings-ui.ts` + 10 tests (honest-data guards). Hallazgo: eslint solo cubre `**/*.js` y `**/*.ts` (`.astro`/`.svelte` NO se lintan — verificación UI = astro build; refuerza argumento del typecheck U14).
+- [x] **U14 (PHASE 14)** a11y + responsive + performance + hardening. ✅ commit `8996603` — **cobertura de tipos al frontend lograda**: `@astrojs/check 0.9.10` + `frontend/tsconfig.json` (strict) + script `typecheck:frontend` — pasó de 53+5 errores a **0** (era exactamente el objetivo: defectos tipo invisibles para `astro build`; clases reales: unions literales de navigation.ts, discriminant de workflow-condition, fixtures de tests flojos; 77→0 contados en la primera corrida post-setup). **a11y real encontrado y fixeado**: WCAG 2.4.3 — el foco escapaba de TODOS los modales (CommandPalette + 3 modals de workflows + delete dialog) mentiendo el `aria-modal` → util compartida `trapFocus()` (focus-in, Tab/Shift+Tab containment, cleanup) cableada en los 5 diálogos; resto del checklist OK (skip-link, landmarks, aria-current, Escape+restore, aria-live, labels, reduced-motion). **Lazy islands**: chrome AppLayout (Sidebar/Topbar/CommandPalette) `client:load`→`client:idle` (StatusBar resultó server-only); islas de contenido crítico mantienen `client:load`. **Responsive** verificado CSS: shell 240px→64px (768–1023)→0 (≤767), drawer con scrim. `ui-state.test.ts` nuevo. Caveat honesto documentado: `focus-trap.ts` es DOM glue sin unit test (no hay harness DOM en el repo; sin deps nuevas). **Prueba de detección**: error introd revertido reportado por astro check. Gates finales: typecheck root ✓, typecheck:frontend ✓ 0, lint ✓, frontend 260 ✓, backend 462 ✓, build 16 páginas ✓, cero `any`/`ts-ignore`/eslint-disable.
+
+**Acceptance criteria (por fase):** `bun run typecheck && bun run lint && bun test && bun run build:frontend` verdes; UI dev habla con backend real vía proxy; ningún endpoint ficticio; copia en inglés consistente; tokens aplicados sin excepciones #F095C8.
+
+**Delivery strategy:** `ask-on-risk` (default) — forecast >> 400 líneas → antes del primer PR se pregunta estrategia de cadena (stacked-to-main | feature-branch-chain) y se registra aquí. Work-unit commits por fase/tarea en `feat/ui-console`; push/PR = decisión del usuario.
+
+**Resolved mode:** TDD no configurado explícito en proyecto; checks por tarea: `bun run typecheck`, `bun run lint`, `bun test`, `bun run build:frontend`.
+
+**Delivery (decidido 2026-09-22 por usuario):** `feature-branch-chain`. Tracker = `feat/ui-console` (acumula integración final). Al abrir PRs: PR #1 con base = tracker (rama child `ui-console/slice-1`), PRs hijos con base = rama del PR anterior (`ui-console/slice-N` contra `slice-(N-1)`); solo el tracker mergea a main. Cargar skills `chained-pr` + `work-unit-commits` + `branch-pr` por registry ANTES de planear/crear PRs.
+
+**Progress/next step:** U01 ✅ … U14 ✅ — **TODAS las 14 fases de la consola completadas e implementadas** (14 commits feature + 14 docs). Pendiente: delivery según `feature-branch-chain` (armar slices `ui-console/slice-N`, PR #1 base = tracker `feat/ui-console`, hijos base = slice previo; solo tracker → main) — cargar skills `chained-pr` + `work-unit-commits` + `branch-pr` antes, y requiere autorización explícita del usuario para push/PR (operación remota).
+
+**buildings/new-workflow (seam U05 → U06/U07, anotado 2026-09-22):** el editor U05 es lectura + edición del modelo de canvas solamente: `FlowEditor.svelte` carga el workflow vía API client, convierte a FlowNode/FlowEdge y NO persiste. `flowToGraph` emite el id de placeholder `"workflow"` (el nombre real se antepone al guardar). Guardar (U07) = `flowToGraph` → `serializeGraphToYaml` → `PUT /api/workflows/:name` con el YAML canónico; validar antes con `validateGraph()` del backend (autoridad). El slot `condition` (AST opaque en `FlowNodeData`) y `params` (pipeline) se preservan por fidelidad de round-trip pero no tienen UI de edición aún (U06). `start`/`end` no se pueden borrar ni desconectar del flujo por ahora; `llm_call` exige `model` + `mode` en el backend (la tarjeta marca `model` "unset"). No se inventaron endpoints: el editor lee `GET /api/workflows/:name`; no existe guardado de UI todavía, solo el enlace `buildings/new-workflow`.
\ No newline at end of file
diff --git a/package.json b/package.json
index 598c4ba..f82ab56 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"start": "bun run build:frontend && bun run scripts/start-desktop.ts",
"test": "bun test",
"typecheck": "tsc --noEmit",
+ "typecheck:frontend": "astro check --root frontend",
"lint": "eslint .",
"format": "eslint --fix ."
},
@@ -60,6 +61,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
+ "@astrojs/check": "0.9.10",
"@playwright/test": "^1.62.1",
"@types/bun": "^1.4.0",
"@typescript-eslint/eslint-plugin": "^8.69.0",