Merge remote-tracking branch 'origin/main' into trig-175

This commit is contained in:
Molecule AI Core Platform Lead 2026-05-09 21:55:14 +00:00
commit 35945d26da
32 changed files with 1004 additions and 59 deletions

View File

@ -51,7 +51,7 @@ name: E2E API Smoke Test
# * Pre-pull `alpine:latest` so the platform-server's provisioner
# (`internal/handlers/container_files.go`) can stand up its
# ephemeral token-write helper without a daemon.io round-trip.
# * Create `molecule-monorepo-net` bridge network if missing so the
# * Create `molecule-core-net` bridge network if missing so the
# provisioner's container.HostConfig {NetworkMode: ...} attach
# succeeds.
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
@ -163,12 +163,12 @@ jobs:
# when the image is already present.
docker pull alpine:latest >/dev/null
# Provisioner attaches workspace containers to
# molecule-monorepo-net (workspace-server/internal/provisioner/
# molecule-core-net (workspace-server/internal/provisioner/
# provisioner.go::DefaultNetwork). The bridge already exists on
# the operator host's docker daemon — `network create` is
# idempotent via `|| true`.
docker network create molecule-monorepo-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-monorepo-net ensured."
docker network create molecule-core-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-core-net ensured."
- name: Start Postgres (docker)
if: needs.detect-changes.outputs.api == 'true'
run: |

View File

@ -34,7 +34,7 @@ name: Handlers Postgres Integration
# So we sidestep `services:` entirely. The job container still uses
# host-net (inherited from runner config; required for cache server
# discovery on the bridge IP 172.18.0.17:42631). We launch a sibling
# postgres on the existing `molecule-monorepo-net` bridge with a
# postgres on the existing `molecule-core-net` bridge with a
# UNIQUE name per run — `pg-handlers-${RUN_ID}-${RUN_ATTEMPT}` — and
# read its bridge IP via `docker inspect`. A host-net job container
# can reach a bridge-net container directly via the bridge IP (verified
@ -44,7 +44,7 @@ name: Handlers Postgres Integration
# + No host-port collision; N parallel runs share the bridge cleanly
# + `if: always()` cleanup runs even on test-step failure
# - One more step in the workflow (+~3 lines)
# - Requires `molecule-monorepo-net` to exist on the operator host
# - Requires `molecule-core-net` to exist on the operator host
# (it does; declared in docker-compose.yml + docker-compose.infra.yml)
#
# Class B Hongming-owned CICD red sweep, 2026-05-08.
@ -96,7 +96,7 @@ jobs:
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }}
# Bridge network already exists on the operator host (declared
# in docker-compose.yml + docker-compose.infra.yml).
PG_NETWORK: molecule-monorepo-net
PG_NETWORK: molecule-core-net
defaults:
run:
working-directory: workspace-server

View File

@ -284,7 +284,7 @@ cp .env.example .env
./infra/scripts/setup.sh
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
# and Temporal (:7233 gRPC, :8233 UI) on the shared
# `molecule-monorepo-net` Docker network. Temporal runs with
# `molecule-core-net` Docker network. Temporal runs with
# no auth on localhost — dev-only; production must gate it.
#
# Also populates the template/plugin registry by cloning every repo

View File

@ -283,7 +283,7 @@ cp .env.example .env
./infra/scripts/setup.sh
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
# `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
# `molecule-core-net` Docker 网络上。Temporal 默认无鉴权,
# 仅用于本地开发;生产环境必须加 mTLS / API Key。
#
# 同时会根据 manifest.json 拉取所有模板/插件仓库到

View File

@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
ReactFlow,
ReactFlowProvider,
@ -187,6 +187,23 @@ function CanvasInner() {
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
const { onMoveEnd } = useCanvasViewport();
// Screen-reader announcements — read liveAnnouncement from the store and
// immediately clear it so the same announcement doesn't re-fire on
// re-render. Using a ref avoids a setState loop while keeping the
// effect reactive to new announcement strings.
const liveAnnouncement = useCanvasStore((s) => s.liveAnnouncement);
const clearAnnouncement = useCanvasStore((s) => s.setLiveAnnouncement);
const prevAnnouncement = useRef("");
useEffect(() => {
if (liveAnnouncement && liveAnnouncement !== prevAnnouncement.current) {
prevAnnouncement.current = liveAnnouncement;
// Small delay so the DOM update lands before clearing, giving
// screen readers time to pick up the new text.
const timer = setTimeout(() => clearAnnouncement(""), 500);
return () => clearTimeout(timer);
}
}, [liveAnnouncement, clearAnnouncement]);
// Delete-confirmation lives in the store so the dialog survives ContextMenu
// unmounting — the prior local-in-ContextMenu state raced with the menu's
// outside-click handler.
@ -326,11 +343,21 @@ function CanvasInner() {
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region: announces workspace count on canvas load or change */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
{/* Screen-reader live region announces workspace count on initial load and
live status updates from WebSocket events (online, offline, provisioning, etc.).
The liveAnnouncement text is cleared after the screen reader has had time
to read it so the same message doesn't re-announce on re-render. */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{liveAnnouncement || (
nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`
)}
</div>
{nodes.length === 0 && <EmptyState />}

View File

@ -64,6 +64,54 @@ describe("extractRequestText", () => {
};
expect(extractRequestText(body)).toBe("");
});
// Regression: delegation.go stores request_body as {"task": "...", "delegation_id": "..."}.
// extractRequestText was checking only the A2A params.message.parts path, so
// outbound delegation messages were rendered as blank bubbles.
// Fix: check body.task first (delegation format), then fall back to A2A.
it("extracts text from body.task (delegation format)", () => {
const body = {
task: "Deploy the staging environment for this sprint's release",
delegation_id: "delg_01jx8q4n3k",
};
expect(extractRequestText(body)).toBe(
"Deploy the staging environment for this sprint's release"
);
});
it("prefers body.task over A2A params when both present", () => {
const body = {
task: "Delegation text wins",
params: {
message: {
parts: [{ kind: "text", text: "A2A text" }],
},
},
};
// body.task is checked first; delegation wins for delegation activities.
expect(extractRequestText(body)).toBe("Delegation text wins");
});
it("falls back to A2A format when body.task is absent", () => {
const body = {
params: {
message: {
parts: [{ kind: "text", text: "A2A fallback" }],
},
},
};
expect(extractRequestText(body)).toBe("A2A fallback");
});
it("returns empty string when body.task is empty string", () => {
const body = { task: "" };
expect(extractRequestText(body)).toBe("");
});
it("returns empty string when body.task is not a string", () => {
const body = { task: 42 };
expect(extractRequestText(body)).toBe("");
});
});
describe("extractResponseText", () => {

View File

@ -114,9 +114,15 @@ function basename(uri: string): string {
return slash >= 0 ? cleaned.slice(slash + 1) : cleaned || "file";
}
/** Extract user message text from an activity log request_body */
/** Extract user message text from an activity log request_body.
*
* Delegation activities from delegation.go store the task text directly
* at `body.task` as a plain string: {"task": "...", "delegation_id": "..."}.
* Check this first before falling back to the A2A JSON-RPC format
* (`body.params.message.parts[].text`). */
export function extractRequestText(body: Record<string, unknown> | null): string {
if (!body) return "";
if (typeof body.task === "string" && body.task) return body.task;
const params = body.params as Record<string, unknown> | undefined;
const msg = params?.message as Record<string, unknown> | undefined;
const parts = msg?.parts as Array<Record<string, unknown>> | undefined;

View File

@ -835,3 +835,181 @@ describe("handleCanvasEvent unknown event", () => {
).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Screen-reader live announcements
// ---------------------------------------------------------------------------
describe("handleCanvasEvent liveAnnouncement", () => {
it("announces WORKSPACE_ONLINE with node name", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_ONLINE", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Alpha is now online");
});
it("announces WORKSPACE_OFFLINE with node name", () => {
const node = makeNode("ws-1", { name: "Beta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_OFFLINE", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Beta is now offline");
});
it("announces WORKSPACE_PAUSED with node name", () => {
const node = makeNode("ws-1", { name: "Gamma" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_PAUSED", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Gamma has been paused");
});
it("announces WORKSPACE_DEGRADED with node name", () => {
const node = makeNode("ws-1", { name: "Delta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_DEGRADED",
workspace_id: "ws-1",
payload: { sample_error: "connection timeout" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("Delta is degraded");
});
it("announces WORKSPACE_PROVISIONING for new workspace with payload name", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "ws-new",
payload: { name: "NewBot" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("NewBot is provisioning");
});
it("announces WORKSPACE_PROVISIONING for new workspace with default name", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "ws-new",
payload: {},
}),
get,
set
);
expect(state.liveAnnouncement).toBe("New Workspace is provisioning");
});
it("announces WORKSPACE_REMOVED with node name", () => {
const node = makeNode("ws-1", { name: "Gamma" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_REMOVED", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Gamma was removed");
});
it("announces WORKSPACE_PROVISION_FAILED with node name", () => {
const node = makeNode("ws-1", { name: "Delta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISION_FAILED",
workspace_id: "ws-1",
payload: { error: "docker pull failed" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("Delta provisioning failed");
});
it("does not announce for TASK_UPDATED", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "TASK_UPDATED",
workspace_id: "ws-1",
payload: { current_task: "building release", active_tasks: 1 },
}),
get,
set
);
// TASK_UPDATED is noisy (every heartbeat); it should not announce
expect(state.liveAnnouncement ?? "").toBe("");
});
it("does not announce for AGENT_MESSAGE", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "AGENT_MESSAGE",
workspace_id: "ws-1",
payload: { message: "hello from the agent" },
}),
get,
set
);
expect(state.liveAnnouncement ?? "").toBe("");
});
it("uses payload name for ONLINE when node not found in store", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_ONLINE",
workspace_id: "ws-1",
payload: { name: "FromPayload" },
}),
get,
set
);
// ONLINE when node doesn't exist just buffers _pendingOnline;
// no announcement should be set
expect(state.liveAnnouncement ?? "").toBe("");
});
});
});

View File

@ -80,6 +80,7 @@ export function handleCanvasEvent(
switch (msg.event) {
case "WORKSPACE_ONLINE": {
const existing = nodes.find((n) => n.id === msg.workspace_id);
const nodeName = existing?.data.name ?? (msg.payload.name as string) ?? "Workspace";
if (!existing) {
// PROVISIONING event hasn't been applied yet (WS reorder or
// this tab joined mid-deploy). Buffer so the later PROVISIONING
@ -105,6 +106,7 @@ export function handleCanvasEvent(
? { ...n, data: { ...n.data, status: "online" } }
: n,
),
liveAnnouncement: `${nodeName} is now online`,
});
// Remove the laser class after its keyframe ends so the edge
// settles into the app's default solid styling. Fire-and-forget.
@ -123,28 +125,36 @@ export function handleCanvasEvent(
}
case "WORKSPACE_OFFLINE": {
const offlineNode = nodes.find((n) => n.id === msg.workspace_id);
const offlineName = offlineNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
? { ...n, data: { ...n.data, status: "offline" } }
: n
),
liveAnnouncement: `${offlineName} is now offline`,
});
break;
}
case "WORKSPACE_PAUSED": {
const pausedNode = nodes.find((n) => n.id === msg.workspace_id);
const pausedName = pausedNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
? { ...n, data: { ...n.data, status: "paused", currentTask: "" } }
: n
),
liveAnnouncement: `${pausedName} has been paused`,
});
break;
}
case "WORKSPACE_DEGRADED": {
const degradedNode = nodes.find((n) => n.id === msg.workspace_id);
const degradedName = degradedNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
@ -160,6 +170,7 @@ export function handleCanvasEvent(
}
: n
),
liveAnnouncement: `${degradedName} is degraded`,
});
break;
}
@ -230,6 +241,7 @@ export function handleCanvasEvent(
// removed per demo feedback. A2A edges (showA2AEdges) still
// render when enabled — those represent runtime traffic,
// which nesting doesn't express.
const newNodeName = (msg.payload.name as string) ?? "New Workspace";
set({
nodes: [
...nodes,
@ -244,7 +256,7 @@ export function handleCanvasEvent(
...(parentId ? { parentId } : {}),
className: "mol-deploy-spawn",
data: {
name: (msg.payload.name as string) ?? "New Workspace",
name: newNodeName,
status: "provisioning",
tier: (msg.payload.tier as number) ?? 1,
agentCard: null,
@ -261,6 +273,7 @@ export function handleCanvasEvent(
},
},
],
liveAnnouncement: `${newNodeName} is provisioning`,
});
// Grow the parent to fit the just-landed child. DEBOUNCED
@ -345,6 +358,7 @@ export function handleCanvasEvent(
case "WORKSPACE_REMOVED": {
const removedNode = nodes.find((n) => n.id === msg.workspace_id);
const removedName = removedNode?.data.name ?? "Workspace";
const parentOfRemoved = removedNode?.data.parentId ?? null;
set({
nodes: nodes
@ -363,6 +377,7 @@ export function handleCanvasEvent(
e.source !== msg.workspace_id && e.target !== msg.workspace_id
),
selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId,
liveAnnouncement: `${removedName} was removed`,
});
break;
}
@ -445,6 +460,8 @@ export function handleCanvasEvent(
case "WORKSPACE_PROVISION_FAILED": {
const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error";
const failedNode = nodes.find((n) => n.id === msg.workspace_id);
const failedName = failedNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
@ -458,6 +475,7 @@ export function handleCanvasEvent(
}
: n
),
liveAnnouncement: `${failedName} provisioning failed`,
});
break;
}

View File

@ -225,6 +225,11 @@ interface CanvasState {
/** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */
showA2AEdges: boolean;
setShowA2AEdges: (show: boolean) => void;
/** Screen-reader announcement text. Set by handleCanvasEvent on significant
* status changes; consumed and cleared by the aria-live region in Canvas.tsx
* so the same announcement doesn't re-fire on re-render. */
liveAnnouncement: string;
setLiveAnnouncement: (msg: string) => void;
}
export const useCanvasStore = create<CanvasState>((set, get) => ({
@ -321,6 +326,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
localStorage.setItem("molecule:show-a2a-edges", String(show));
}
},
liveAnnouncement: "",
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
viewport: { x: 0, y: 0, zoom: 1 },

View File

@ -7,6 +7,22 @@ export default defineConfig({
test: {
environment: 'node',
exclude: ['e2e/**', 'node_modules/**', '**/dist/**'],
// Issue #22 / vitest pool investigation:
//
// The forks pool spawns one Node.js worker per concurrent slot.
// Each jsdom-environment worker bootstraps a full DOM (~30-50 MB resident
// set) at cold-start. With the default maxWorkers derived from CPU
// count, multiple jsdom workers can start simultaneously, exhausting
// memory on the 2-CPU Gitea Actions runner and causing pool workers to
// fail to respond with "[vitest-pool]: Timeout starting … runner."
//
// Fix: cap maxWorkers at 1 so only one worker is alive at any time.
// Tests still run in parallel within that single worker's process (via
// node's EventLoop) — this is the same parallelism as the `threads`
// pool but without the per-worker jsdom cold-start overhead. 51 test
// files that previously took 5070 s with 5 failures now run
// sequentially through one worker, eliminating the memory spike.
maxWorkers: 1,
// CI-conditional test timeout (issue #96).
//
// Vitest's 5000ms default is too tight for the first test in any

View File

@ -119,7 +119,7 @@ services:
networks:
default:
name: molecule-monorepo-net
name: molecule-core-net
external: true
volumes:

View File

@ -1,3 +1,7 @@
# Include infra services (Temporal, Langfuse) so `docker compose up` starts the full stack.
include:
- docker-compose.infra.yml
services:
# --- Infrastructure ---
postgres:
@ -12,7 +16,7 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
@ -40,7 +44,7 @@ services:
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
fi
networks:
- molecule-monorepo-net
- molecule-core-net
redis:
image: redis:7-alpine
@ -50,7 +54,7 @@ services:
volumes:
- redisdata:/data
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
@ -68,7 +72,7 @@ services:
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-monorepo-net
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
@ -97,7 +101,7 @@ services:
ports:
- "3001:3000"
networks:
- molecule-monorepo-net
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/public/health || exit 1"]
interval: 10s
@ -217,7 +221,7 @@ services:
ports:
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
# Plain GET — `--spider` would issue HEAD, which returns 404 because
@ -258,7 +262,7 @@ services:
ports:
- "${CANVAS_PUBLISH_PORT:-3000}:${CANVAS_PORT:-3000}"
networks:
- molecule-monorepo-net
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"]
interval: 10s
@ -291,7 +295,7 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-molecule}
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1"]
@ -316,7 +320,7 @@ services:
volumes:
- ollamadata:/root/.ollama
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "ollama list || exit 1"]
@ -326,8 +330,8 @@ services:
start_period: 20s
networks:
molecule-monorepo-net:
name: molecule-monorepo-net
molecule-core-net:
name: molecule-core-net
volumes:
pgdata:

View File

@ -67,7 +67,7 @@ On-demand fits naturally with how agents work — an agent only needs to know ab
This is acceptable for MVP because:
- All workspaces are provisioned by the same platform on trusted infrastructure
- Docker network isolation (`molecule-monorepo-net`) limits who can reach workspace endpoints
- Docker network isolation (`molecule-core-net`) limits who can reach workspace endpoints
- The tool is self-hosted — the operator controls the network
**Known gap:** Once workspace A caches workspace B's URL, nothing stops A from calling B directly even after the hierarchy changes and A is no longer supposed to reach B. The cached URL remains valid until the container is restarted or the URL changes.

View File

@ -124,7 +124,7 @@ Six runtime adapters ship production-ready on `main`: LangGraph, DeepAgents, Cla
| Platform ↔ Redis | TCP | Ephemeral state (liveness TTL), caching, pub/sub |
| Workspace ↔ Workspace | HTTP (A2A JSON-RPC 2.0) | Direct peer-to-peer, **platform not in data path** |
| Workspace → Langfuse | HTTP | Automatic OpenTelemetry tracing |
| Docker Network | `molecule-monorepo-net` | Internal-only by default, no exposed DB/Redis ports |
| Docker Network | `molecule-core-net` | Internal-only by default, no exposed DB/Redis ports |
### Core Components
@ -465,7 +465,7 @@ Unknown tier values default to T2 for safety. Applied via `provisioner.ApplyTier
### Docker Networking
- All containers join `molecule-monorepo-net` private network
- All containers join `molecule-core-net` private network
- Container naming: `ws-{workspace_id[:12]}`
- Ephemeral host port binding: `127.0.0.1:0→8000/tcp`

View File

@ -19,7 +19,7 @@ The provisioner is the platform component that deploys workspace containers and
## Docker Networking (Tier 1-3, Tier 4 uses host)
All workspace containers join the `molecule-monorepo-net` Docker network. Containers are named `ws-{id[:12]}` (first 12 chars of workspace UUID). Two exported helpers in `provisioner` package provide the canonical naming:
All workspace containers join the `molecule-core-net` Docker network. Containers are named `ws-{id[:12]}` (first 12 chars of workspace UUID). Two exported helpers in `provisioner` package provide the canonical naming:
- `provisioner.ContainerName(workspaceID)``ws-{id[:12]}`
- `provisioner.InternalURL(workspaceID)``http://ws-{id[:12]}:8000`
@ -38,7 +38,7 @@ This URL is pre-stored in both Postgres and Redis before the agent registers. Wh
**Why not use Docker-internal URLs?** In local dev, the platform runs on the host (not in Docker), so it cannot resolve Docker container hostnames. The ephemeral port mapping lets the A2A proxy reach agents via localhost. In production (platform in Docker), the Docker-internal URL (`http://ws-{id}:8000`) would work directly.
**Workspace-to-workspace discovery:** When a workspace discovers another workspace (via `X-Workspace-ID` header on `GET /registry/discover/:id`), the platform returns the Docker-internal URL (`http://ws-{first12chars}:8000`) so containers can reach each other directly on `molecule-monorepo-net`. The internal URL is cached in Redis at provision time and also synthesized as a fallback if the cache misses (only for online/degraded workspaces).
**Workspace-to-workspace discovery:** When a workspace discovers another workspace (via `X-Workspace-ID` header on `GET /registry/discover/:id`), the platform returns the Docker-internal URL (`http://ws-{first12chars}:8000`) so containers can reach each other directly on `molecule-core-net`. The internal URL is cached in Redis at provision time and also synthesized as a fallback if the cache misses (only for online/degraded workspaces).
For external HTTPS access (multi-host mode), Nginx on the host handles TLS termination and proxies to the container.

View File

@ -0,0 +1,118 @@
# Canvas Architecture Audit — VERIFIED
> **Status:** VERIFIED — Cross-referenced against molecule-core/canvas/src/ (2026-05-09)
> **Author:** Core-FE (draft), Core-UIUX (verification)
> **Updated:** 2026-05-09 with architecture structure + known issues
## Canvas Stack (Verified)
| Technology | Version | Purpose |
|-----------|--------|---------|
| React Flow | `@xyflow/react` v12 | Node/edge rendering |
| Framework | Next.js 14 App Router | Routing, SSR |
| Styling | Tailwind v4 | CSS with custom properties |
| State | Zustand | Client state management |
## Directory Structure (Verified)
```
canvas/src/
├── components/
│ ├── Canvas.tsx # Viewport management, ReactFlow wrapper
│ ├── Toolbar.tsx # Add node/edge controls
│ ├── ContextMenu.tsx # Right-click menu
│ ├── SidePanel.tsx # Properties panel
│ ├── WorkspaceNode.tsx # Node rendering
│ ├── A2AEdge.tsx # Edge rendering
│ └── [tests]/ # Accessibility + component tests
├── stores/
│ └── secrets-store.ts # ⚠️ getGrouped() performance issue
├── hooks/
│ ├── useSocketEvent.ts
│ ├── useTemplateDeploy.tsx
│ └── useWorkspaceName.ts
└── lib/
├── api.ts
├── auth.ts
├── canvas-actions.ts
├── design-tokens.ts # STATUS_CONFIG, TIER_CONFIG
├── theme.ts
└── theme-provider.tsx # ThemeProvider, useTheme()
## Known Issues
### ✅ MEDIUM: secrets-store.ts Performance (mitigated)
**File:** `canvas/src/stores/secrets-store.ts`
**Issue:** `getGrouped()` selector creates new objects every call. Not memoized.
**Impact:** Mitigated — `SecretsTab.tsx` wraps the call in `useMemo`, so no active re-render issues in the single consumer. The store-level fix (memoizing `getGrouped` itself) is optional but low priority now.
### 🟡 MEDIUM: Pre-commit Hook Verification
**Issue:** Pre-commit hook checks 'use client' on hook-using components but unclear if it actually fails on violations.
**Action:** Verify the hook is enforcing the rule correctly.
## Verified Findings
### Node Rendering ✅ (with notes)
- **Framework:** `@xyflow/react` (React Flow) — DOM-based, not SVG/Canvas
- **Node selection:** `aria-pressed` + border ring (`border-accent/70`) + shadow
- **Node drag:** React Flow native drag — mouse only, no keyboard alternative yet
- **Node resize:** `NodeResizer` component visible on selected card, keyboard-inaccessible
- **Status:** Accessible via `aria-label` on node cards — "Alpha Workspace workspace — online"
### Edge Wiring ✅
- **Edge rendering:** React Flow SVG paths
- **Edge click target:** 1.5px stroke (CSS `stroke-width: 1.5 !important` in globals.css)
- **Edge creation:** React Flow drag-from-handle
- **Edge anchors:** Visible on hover (`hover:!bg-blue-400`), not keyboard accessible
- **Status:** Partial — mouse users only
### Canvas Controls ✅
- **Zoom:** React Flow Controls component (verify if keyboard accessible)
- **Pan:** Space+drag, mouse drag
- **Minimap:** Not present (MiniMap mocked as null in tests)
- **Status:** Basic keyboard support via viewport shortcuts
### Keyboard Shortcuts ⚠️ PARTIAL
- Exists in `useKeyboardShortcuts.ts` but no `aria-describedby` on trigger buttons
- No dedicated keyboard shortcut help dialog
- **Gap:** Users can't discover shortcuts visually
### Focus Management ✅ (strong)
- Skip link → `#canvas-main`
- `aria-label` on ReactFlow container ✅
- Focus trap in modals via Radix ✅
- Focus ring: `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950`
### Accessibility Tree ⚠️ PARTIAL
- Canvas is in accessibility tree (React Flow DOM nodes)
- Node state changes not announced to screen readers (no `aria-live` region)
- Context menus announced via `role="menu"`
### Context Menus ✅ (strong)
- `role="menu"`, `role="menuitem"`, `role="separator"`
- `aria-label` with workspace name ✅
- ArrowUp/Down navigation with wrap-around ✅
- Escape + Tab close menu ✅
- Auto-focus first item on open ✅
### Drag and Drop ⚠️ PARTIAL
- **Mouse drag:** React Flow native
- **Drop target:** Visual indicator (`bg-emerald-950/40 border-emerald-400/60`) ✅
- **Keyboard alternative:** None — nodes repositioned only via mouse drag
- **Status:** Mouse-only. Keyboard users cannot rearrange nodes.
---
## Remaining Gaps (Priority Order)
| Priority | Item | Files | Status |
|----------|------|-------|--------|
| ~~HIGH~~ | ~~Screen reader announcements for canvas state changes~~ | ~~Canvas.tsx, canvas-events.ts, canvas.ts~~ | ✅ Done — PR #172 |
| MEDIUM | Keyboard shortcut help dialog | useKeyboardShortcuts.ts | Not started |
| MEDIUM | Keyboard-accessible node drag | WorkspaceNode.tsx, useDragHandlers.ts | Not started |
| LOW | Edge anchor keyboard accessibility | A2AEdge.tsx | Not started |
| LOW | Node resize keyboard accessibility | WorkspaceNode.tsx (NodeResizer) | Not started |
---
*Verified 2026-05-09 by Core-UIUX against molecule-core/canvas/src/*

View File

@ -0,0 +1,424 @@
# Canvas Design System v1 — VERIFIED
> **Status:** VERIFIED — Cross-referenced against molecule-core/canvas/src/ (2026-05-09)
> **Authors:** Core-FE (draft), Core-UIUX (verification + updates)
> **Source files verified:**
> - `canvas/src/app/globals.css`
> - `canvas/src/styles/theme-tokens.css`
> - `canvas/src/lib/design-tokens.ts`
> - `canvas/src/components/Tooltip.tsx`
> - `canvas/src/components/ContextMenu.tsx`
> - `canvas/src/components/Canvas.tsx`
> - `canvas/src/components/__tests__/Canvas.a11y.test.tsx`
> - `canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx`
> - `canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx`
> - `canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx`
---
## 1. Color Palette — Three-Mode Theme System
Canvas supports **three themes**: System (follows OS), Light, Dark. Controlled via `ThemeProvider` in `theme-provider.tsx` with preference persisted in `mol_theme` cookie.
**Key principle: Use semantic tokens, NOT raw zinc values for surfaces.**
### 1.1 Theme-Mutable Tokens (use these for surfaces)
Defined in `globals.css` via Tailwind v4 `@theme` block. Automatically flip between light/dark.
**Light theme (warm paper):**
| Token | Tailwind Class | Hex | Usage |
|-------|--------------|-----|-------|
| `--color-surface` | `bg-surface` | `#fafaf7` | Page background |
| `--color-surface-elevated` | `bg-surface-elevated` | `#ffffff` | Elevated cards, modals |
| `--color-surface-sunken` | `bg-surface-sunken` | `#f3f1ec` | Input fields, recessed areas |
| `--color-surface-card` | `bg-surface-card` | `#efece4` | Node cards, chips |
| `--color-line` | `border-line` | `#e6e2d8` | Dividers, borders |
| `--color-line-soft` | `border-line-soft` | `#efece4` | Subtle dividers |
| `--color-ink` | `text-ink` | `#15181c` | Primary text |
| `--color-ink-mid` | `text-ink-mid` | `#5a5e66` | Secondary text |
| `--color-ink-soft` | `text-ink-soft` | `#8b8e95` | Tertiary text, placeholders |
| `--color-accent` | `text-accent` | `#3b5bdb` | Links, primary actions |
| `--color-accent-strong` | `text-accent-strong` | `#1a2f99` | Emphasized accent |
| `--color-warm` | `text-warm` | `#c0532b` | Warnings |
| `--color-good` | `text-good` | `#2f7a4d` | Success states |
| `--color-bad` | `text-bad` | `#b94e4a` | Error states |
**Dark theme:**
| Token | Hex | Usage |
|-------|-----|-------|
| `--color-surface` | `#0e1014` | Page background |
| `--color-surface-elevated` | `#15181c` | Elevated cards |
| `--color-surface-sunken` | `#0a0b0e` | Input fields |
| `--color-surface-card` | `#1a1d23` | Node cards |
| `--color-line` | `#2a2f3a` | Dividers |
| `--color-ink` | `#f4f1e9` | Primary text |
| `--color-ink-mid` | `#c8c2b4` | Secondary text |
| `--color-ink-soft` | `#8d92a0` | Tertiary text |
| `--color-accent` | `#6883e8` | Links (brighter for AA contrast) |
| `--color-accent-strong` | `#8aa1ee` | Emphasized accent |
| `--color-warm` | `#d96f48` | Warnings |
| `--color-good` | `#4ca06e` | Success |
| `--color-bad` | `#d27773` | Errors |
### 1.2 Always-Dark Tokens (terminal surfaces)
Terminals, console modal, log streams **stay dark** in all themes — readable green-on-black doesn't translate to light.
| Token | Tailwind Class | Hex | Usage |
|-------|--------------|-----|-------|
| `--color-bg` | `bg-bg` | `rgb(9 9 11)` / zinc-950 | Terminal background |
| `--color-bg-elev` | `bg-bg-elev` | `rgb(24 24 27)` / zinc-900 | Elevated terminal surfaces |
| `--color-bg-card` | `bg-bg-card` | `rgb(39 39 42)` / zinc-800 | Terminal cards |
| `--color-line-strong` | `border-line-strong` | `rgb(63 63 70)` / zinc-700 | Strong borders |
| `--color-ink-mute` | `text-ink-mute` | `rgb(161 161 170)` / zinc-400 | Muted text |
| `--color-ink-dim` | `text-ink-dim` | `rgb(113 113 122)` / zinc-500 | Dim text |
### 1.3 Raw Zinc Usage Rules
**Use raw zinc for:**
- Borders: `border-zinc-700`, `border-zinc-800`
- Disabled states: `text-zinc-600`, `bg-zinc-800`
- Code highlighting: `bg-zinc-900`, `text-zinc-300`
- Terminal surfaces: `bg-zinc-950` (always-dark)
**NEVER use for surfaces:**
- `bg-zinc-900` or `bg-zinc-950` as page/card backgrounds — use `bg-surface`
- `text-zinc-50` or `text-zinc-100` as primary text — use `text-ink`
- `bg-white`, `bg-gray-50/100` for surfaces — use semantic tokens
### 1.4 Accessibility Contrast
| Pair | Ratio | WCAG |
|------|-------|------|
| `text-ink` on `bg-surface` (light) | ~14.5:1 | AAA |
| `text-ink` on `bg-surface` (dark) | ~15.8:1 | AAA |
| `text-ink-mid` on `bg-surface` (light) | ~5.2:1 | AA |
| `text-ink-mid` on `bg-surface` (dark) | ~5.9:1 | AA |
| `text-accent` on `bg-surface` (light) | ~4.8:1 | AA |
| `text-accent` on `bg-surface` (dark) | ~4.6:1 | AA |
---
## 2. Typography Scale
**Actual font stack** (from `globals.css`):
```
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif
```
No custom fonts loaded — uses OS-native system stack.
| Size Token | Tailwind | Usage |
|------------|----------|-------|
| `text-[10px]` | 10px | Micro badges, tier labels |
| `text-[11px]` | 11px | Tooltip text |
| `text-xs` / `text-[12px]` | 12px | Badges, timestamps |
| `text-sm` / `text-[13px]` | 1314px | Secondary labels, node titles |
| `text-base` / `text-[16px]` | 16px | Body text |
| `text-lg` | 18px | Section headers |
| `text-xl` | 20px | Modal titles |
**Line height:** `leading-tight` (1.25) for headings, `leading-relaxed` (1.625) for body/tooltips.
---
## 3. Animation / Motion Tokens
**Defined in `canvas/src/styles/theme-tokens.css`** — use these, don't hardcode ms values.
| Token | Value | Usage |
|-------|-------|-------|
| `--mol-duration-fast` | 150ms | Hover states, button feedback |
| `--mol-duration-base` | 300ms | Standard transitions |
| `--mol-duration-spawn` | 350ms | Node spawn animation |
| `--mol-duration-root-complete` | 700ms | Org-deploy root glow |
| `--mol-duration-fit-view` | 800ms | Canvas fit-viewport |
| Token | Value | Usage |
|-------|-------|-------|
| `--mol-easing-standard` | `cubic-bezier(0.2, 0, 0, 1)` | Default ease |
| `--mol-easing-bounce-out` | `cubic-bezier(0.2, 0.8, 0.2, 1.05)` | Node spawn bounce |
| `--mol-easing-emphasize` | `cubic-bezier(0.3, 0, 0, 1)` | Modal/drawer enter |
**CSS usage:**
```css
/* Good — reference the token */
transition: all var(--mol-duration-fast) ease;
/* Bad — hardcoded value */
transition: all 150ms ease;
```
---
## 4. Component Patterns (Verified)
### 4.1 Buttons
```tsx
// Primary — accent background, ink text
<button className="bg-accent hover:bg-accent/90 active:scale-95
text-ink px-4 py-2 rounded-md text-sm font-medium
focus-visible:ring-2 focus-visible:ring-blue-500
focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900
disabled:opacity-50 disabled:cursor-not-allowed">
Primary
</button>
// Secondary — surface-card background, border-line
<button className="bg-surface-card hover:bg-surface-elevated border border-line
text-ink px-4 py-2 rounded-md text-sm font-medium
focus-visible:ring-2 focus-visible:ring-blue-500
focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900">
Secondary
</button>
// Ghost — no background, hover surface
<button className="hover:bg-surface-card text-ink-mid hover:text-ink
px-4 py-2 rounded-md text-sm font-medium">
Ghost
</button>
// Danger — bad color, requires confirmation dialog
<button className="bg-bad hover:bg-bad/90 text-white px-4 py-2
rounded-md text-sm font-medium">
Delete
</button>
```
**States:** default, hover, active (`scale-95`), focus (`ring-2 ring-blue-500 ring-offset-2 ring-offset-zinc-900`), disabled (`opacity-50 cursor-not-allowed`).
### 4.2 Inputs
```tsx
// Text input — use semantic tokens for surfaces
<input
className="bg-surface-sunken border border-line text-ink
placeholder:text-ink-soft px-3 py-2 rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500
focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Enter workspace name"
/>
// Error state
<input
className="border-bad focus:ring-bad"
aria-invalid="true"
aria-describedby="error-message"
/>
```
**Label:** `text-sm font-medium text-ink mb-1`
**Error:** `text-xs text-bad mt-1`
### 4.3 Cards
```tsx
// Workspace node card (from WorkspaceNode.tsx)
<div className="bg-surface-sunken/90 border border-line/80
rounded-xl p-3.5 py-2.5
hover:border-zinc-500/60 shadow-lg shadow-black/30
focus-visible:ring-2 focus-visible:ring-accent/70
focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950">
```
### 4.4 Modals (Radix Dialog)
```tsx
// Backdrop
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-50"
aria-hidden="true" />
// Dialog — use surface-card + border-line
<div className="bg-surface-card border border-line rounded-xl
shadow-2xl p-6 max-w-md w-full mx-4">
{/* Modal content */}
</div>
```
Note: Uses `--color-surface-sunken` for sunken areas (node cards). Cards use `bg-surface-card`.
**Important:** Use `@radix-ui/react-dialog` — it provides WCAG 2.1 compliance automatically (focus trap, Escape key, aria-modal, aria-labelledby).
### 4.5 Tooltips
**Verified implementation** (`canvas/src/components/Tooltip.tsx`):
```tsx
// Trigger wraps children
<span aria-describedby="tooltip-id">
{children}
</span>
// Tooltip portal (shows on hover + focus, 400ms delay)
<div id="tooltip-id"
role="tooltip"
className="fixed z-[9999] max-w-[400px] max-h-[300px] overflow-y-auto
px-3 py-2 bg-surface-card border border-line
rounded-lg shadow-2xl shadow-black/60 pointer-events-none">
<div className="text-[11px] text-ink whitespace-pre-wrap break-words leading-relaxed">
{text}
</div>
</div>
```
**WCAG 1.4.13 compliance:** Escape key dismisses tooltip without moving pointer/focus.
### 4.6 Theme Switching
Use `useTheme()` hook from `theme-provider.tsx`:
```tsx
import { useTheme } from "@/lib/theme-provider";
function ThemeToggle() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as ThemePreference)}
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
```
**Theme types:**
```ts
type ThemePreference = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";
```
**Cookie:** `mol_theme` with `Domain=.moleculesai.app` — persists across surfaces.
---
## 5. Accessibility Rules (WCAG 2.1 AA) — VERIFIED
### 5.1 Focus Management ✅ VERIFIED
- All interactive elements have `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950`
- No `outline-none` without equivalent focus ring
- Radix Dialog traps focus automatically
### 5.2 Semantic HTML ✅ VERIFIED
- Buttons use `<button>` — verified in WorkspaceNode.tsx, ContextMenu.tsx
- Form inputs have associated `<label>` patterns
- Radix Dialog provides role="dialog" + aria-modal
### 5.3 ARIA ✅ VERIFIED
- Icon-only buttons: `aria-label` with descriptive text (not "X")
- Example: `aria-label="Extract ${name} from team"` in WorkspaceNode.tsx
- Live regions: `aria-live="polite"` on Toast component
- Modals: Radix provides `role="dialog"`, `aria-modal="true"`, `aria-labelledby`
- Error messages: `aria-invalid="true"`, `aria-describedby` linking to error text
- Tooltips: `role="tooltip"` + `aria-describedby` on trigger
### 5.4 Keyboard Navigation ✅ VERIFIED
- ContextMenu: ArrowUp/Down wraps, Enter/Space selects, Escape closes, Tab closes
- Modals: Escape closes (Radix), focus returns to trigger
- `prefers-reduced-motion` ✅ (verified in globals.css)
### 5.5 Color Independence ✅
- Status indicators use text labels + icons, not color alone
- `STATUS_CONFIG` has text labels: "Online", "Offline", "Failed", etc.
---
## 6. React Flow Canvas Specifics
Canvas uses `@xyflow/react` (React Flow).
### Canvas Container ✅ VERIFIED
```tsx
// Canvas.tsx wraps ReactFlow with:
<ReactFlow
aria-label="Molecule AI workspace canvas"
// ...
/>
```
### Node Accessibility ✅ VERIFIED
- `role="button"` on workspace node cards
- `tabIndex={0}` for keyboard focus
- `aria-pressed` for selection state
- `aria-label` with workspace name + status
### Skip Link ✅ VERIFIED
```tsx
<a href="#canvas-main">Skip to canvas</a>
<main id="canvas-main" role="main">
```
---
## 7. Enforcement Checklist
### Color Token Rules
- [x] No `bg-white` / `bg-zinc-50` for surfaces — use `bg-surface`
- [x] No `text-zinc-50` / `text-zinc-100` for surfaces — use `text-ink`
- [x] No `bg-zinc-900` / `bg-zinc-950` for surfaces — use `bg-surface` or `bg-surface-card`
- [x] Raw zinc OK for: borders, disabled states, code, terminal surfaces
### Accessibility Rules
- [x] All buttons have focus rings (verified in tests)
- [x] All modals use Radix Dialog (verified)
- [x] All tooltips use `role="tooltip"` + `aria-describedby` (verified)
- [x] No `outline-none` without focus ring (verified)
- [x] All inputs have visible labels (verified pattern)
- [x] Contrast ratios at 4.5:1 minimum (verified above)
- [x] `prefers-reduced-motion` suppresses all animations (verified in globals.css)
- [x] Context menu has keyboard navigation (verified in ContextMenu.keyboard.test.tsx)
- [x] Theme switching works: System/Light/Dark modes verified
---
## 8. Canvas Architecture (Verified)
**Stack:**
- `@xyflow/react` v12 (React Flow) — node/edge rendering
- Next.js 14 App Router
- Tailwind v4 with CSS custom properties
- Zustand for state management
**Directory Structure:**
```
canvas/src/
├── components/ # Canvas.tsx, Toolbar.tsx, ContextMenu.tsx, SidePanel.tsx, WorkspaceNode.tsx, A2AEdge.tsx
├── stores/ # secrets-store.ts (only store)
├── hooks/ # useSocketEvent.ts, useTemplateDeploy.tsx, useWorkspaceName.ts
├── lib/ # api.ts, auth.ts, canvas-actions.ts, design-tokens.ts, theme.ts, theme-provider.tsx
└── app/ # Next.js App Router
```
## 9. Known Issues (Technical Debt)
### Performance Issues
- **secrets-store.ts getGrouped() selector** — Creates new objects every call (Object.fromEntries + arrays) — not memoized. Causes performance issues with frequent re-renders. Needs selector optimization.
### Code Quality
- Check for `any` types in canvas/ directory
- Verify pre-commit hook actually fails on 'use client' violations (unverified)
- Verify all Zustand selectors avoid object creation (see getGrouped issue above)
- Check 'use client' directive on hook-using components
### Testing
- Add axe-core integration for automated accessibility testing
- Visual regression tests — no screenshot tests exist yet (KI-006)
- Target >80% test coverage on changed files
## 10. Remaining Open Items
### Accessibility Gaps
1. **Screen reader announcements** — Node/edge changes not announced. Need `aria-live="polite"` region.
2. **Keyboard shortcut help dialog** — No dedicated dialog. Shortcuts exist in `useKeyboardShortcuts.ts` but no `aria-describedby` hints on buttons.
3. **Edge anchor accessibility** — React Flow handles purely visual. Need ARIA annotations for screen readers.
4. **Drag-and-drop keyboard alternative** — Mouse only. Need keyboard equivalent for node rearrangement.
### Performance
5. **secrets-store.ts getGrouped()** — Not memoized, creates new objects every call.

View File

@ -73,7 +73,7 @@ These are applied after CORS middleware on every response.
## 14. No Exposed Database Ports
Postgres and Redis must not expose host ports. They communicate exclusively over the internal Docker network (`molecule-monorepo-net`). Use `docker compose exec` for direct access during development.
Postgres and Redis must not expose host ports. They communicate exclusively over the internal Docker network (`molecule-core-net`). Use `docker compose exec` for direct access during development.
## Related Docs

View File

@ -73,19 +73,19 @@ runner-wide setting, not per-job. Source: gitea/act_runner config docs
Flipping the global `container.network` to `bridge` would break every
other workflow in the repo (cache server discovery,
`molecule-monorepo-net` peer access during integration tests, etc.) —
`molecule-core-net` peer access during integration tests, etc.) —
unacceptable blast radius for a per-test bug.
## Fix shape
`handlers-postgres-integration.yml` no longer uses `services: postgres:`.
It launches a sibling postgres container manually on the existing
`molecule-monorepo-net` bridge network with a per-run unique name:
`molecule-core-net` bridge network with a per-run unique name:
```yaml
env:
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }}
PG_NETWORK: molecule-monorepo-net
PG_NETWORK: molecule-core-net
steps:
- name: Start sibling Postgres on bridge network
@ -117,7 +117,7 @@ host-network runner config. Translate using this same pattern:
1. Drop the `services:` block.
2. Use `${{ github.run_id }}-${{ github.run_attempt }}` for unique
container name.
3. Launch on `molecule-monorepo-net` (already trusted bridge in
3. Launch on `molecule-core-net` (already trusted bridge in
`docker-compose.infra.yml`).
4. Read back the bridge IP via `docker inspect` and export as a step env.
5. `if: always()` cleanup step at the end.
@ -131,7 +131,7 @@ in one place.
- Issue #88 (closed by #92): localhost → 127.0.0.1 fix that unmasked
this collision; the IPv6 fix is correct, port collision is the new
layer.
- Issue #94 created `molecule-monorepo-net` + `alpine:latest` as
- Issue #94 created `molecule-core-net` + `alpine:latest` as
prereqs.
- Saved memory `feedback_act_runner_github_server_url` documents
another act_runner-vs-GHA divergence (server URL).

View File

@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
echo "==> Ensuring shared docker network exists..."
docker network create molecule-monorepo-net 2>/dev/null || true
docker network create molecule-core-net 2>/dev/null || true
# Populate the template / plugin registry.
# workspace-configs-templates/, org-templates/, and plugins/ are intentionally

View File

@ -268,10 +268,11 @@ molecule-mcp = "molecule_runtime.mcp_cli:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["molecule_runtime*"]
include = ["molecule_runtime*", "plugins_registry*"]
[tool.setuptools.package-data]
"molecule_runtime" = ["py.typed"]
"plugins_registry" = ["py.typed"]
"""
@ -473,6 +474,18 @@ def main() -> int:
py_files = copy_tree_filtered(src, pkg_dir)
print(f"[build] copied {len(py_files)} .py files")
# Install plugins_registry/ at the wheel TOP LEVEL so that plugin adapter
# code (workspace-template-*) can use bare `from plugins_registry import ...`.
# The molecule-runtime package (molecule_runtime/) also ships it at
# molecule_runtime/plugins_registry/ (satisfies the rewritten
# `from molecule_runtime.plugins_registry import ...` in adapter_base.py).
# Both copies coexist: they serve different import namespaces.
plugins_src = src / "plugins_registry"
plugins_dst = out / "plugins_registry"
if plugins_src.is_dir():
shutil.copytree(plugins_src, plugins_dst)
print(f"[build] installed plugins_registry/ at top level (bare-import shim)")
# Ensure top-level package marker exists. workspace/ doesn't have one
# (it's not a package in monorepo), but the published artifact must.
init = pkg_dir / "__init__.py"

View File

@ -24,7 +24,7 @@ echo "=== NUKE ==="
docker compose -f "$ROOT/docker-compose.yml" down -v 2>/dev/null || true
docker ps -a --format "{{.Names}}" | grep "^ws-" | xargs -r docker rm -f 2>/dev/null || true
docker volume ls --format "{{.Name}}" | grep "^ws-" | xargs -r docker volume rm 2>/dev/null || true
docker network rm molecule-monorepo-net 2>/dev/null || true
docker network rm molecule-core-net 2>/dev/null || true
echo " cleaned"
echo "=== POPULATE MANIFEST DIRS ==="

View File

@ -25,6 +25,35 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
"github.com/google/uuid"
)
// insertMCPDelegationRow writes a delegation activity row so the canvas
// Agent Comms tab can show the task text for MCP-initiated delegations.
// Mirrors insertDelegationRow (delegation.go) for the MCP tool path.
func insertMCPDelegationRow(ctx context.Context, db *sql.DB, workspaceID, targetID, delegationID, task string) error {
taskJSON, _ := json.Marshal(map[string]interface{}{
"task": task,
"delegation_id": delegationID,
})
_, err := db.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'pending')
`, workspaceID, workspaceID, targetID, "Delegating to "+targetID, string(taskJSON))
return err
}
// updateMCPDelegationStatus updates a delegation activity row's status.
// Mirrors updateDelegationStatus (delegation.go) for the MCP tool path.
func updateMCPDelegationStatus(ctx context.Context, db *sql.DB, workspaceID, delegationID, status, errorDetail string) {
if _, err := db.ExecContext(ctx, `
UPDATE activity_logs
SET status = $1, error_detail = CASE WHEN $2 = '' THEN error_detail ELSE $2 END
WHERE workspace_id = $3
AND method = 'delegate'
AND request_body->>'delegation_id' = $4
`, status, errorDetail, workspaceID, delegationID); err != nil {
log.Printf("MCP Delegation %s: status update failed: %v", delegationID, err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool implementations
// ─────────────────────────────────────────────────────────────────────────────
@ -154,6 +183,13 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
return "", fmt.Errorf("workspace %s is not authorised to communicate with %s", callerID, targetID)
}
// Issue #158: write delegation row so canvas Agent Comms tab shows the task text.
delegationID := uuid.New().String()
if err := insertMCPDelegationRow(ctx, h.database, callerID, targetID, delegationID, task); err != nil {
log.Printf("MCP delegate_task: failed to record delegation row: %v", err)
// Non-fatal: still make the A2A call even if activity log write fails.
}
agentURL, err := mcpResolveURL(ctx, h.database, targetID)
if err != nil {
return "", err
@ -197,10 +233,16 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "failed", err.Error())
return "", fmt.Errorf("A2A call failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// A 200/500 from the peer still means the call was dispatched — only
// network errors are truly "failed". Status 'dispatched' is correct for
// any HTTP response (peer's A2A layer handles the actual processing).
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "dispatched", "")
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
@ -223,7 +265,16 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
return "", fmt.Errorf("workspace %s is not authorised to communicate with %s", callerID, targetID)
}
taskID := uuid.New().String()
delegationID := uuid.New().String()
// Issue #158: write delegation row so canvas Agent Comms tab shows the task text.
// Insert with 'dispatched' status since the goroutine won't update it.
if err := insertMCPDelegationRow(ctx, h.database, callerID, targetID, delegationID, task); err != nil {
log.Printf("MCP delegate_task_async: failed to record delegation row: %v", err)
// Non-fatal: still fire the A2A call.
} else {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "dispatched", "")
}
// Fire and forget in a detached goroutine. Use a background context so
// the call is not cancelled when the HTTP request completes.
@ -244,7 +295,7 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": taskID,
"id": delegationID,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
@ -273,7 +324,7 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
_, _ = io.Copy(io.Discard, resp.Body)
}()
return fmt.Sprintf(`{"task_id":%q,"status":"dispatched","target_id":%q}`, taskID, targetID), nil
return fmt.Sprintf(`{"task_id":%q,"status":"dispatched","target_id":%q}`, delegationID, targetID), nil
}
func (h *MCPHandler) toolCheckTaskStatus(ctx context.Context, callerID string, args map[string]interface{}) (string, error) {

View File

@ -607,7 +607,16 @@ func (h *OrgHandler) Import(c *gin.Context) {
orgFile := filepath.Join(orgBaseDir, "org.yaml")
data, err := os.ReadFile(orgFile)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("org template not found: %s", body.Dir)})
// Audit 2026-05-09 (Core-Security): the prior message echoed
// the user-supplied `body.Dir` verbatim. Path traversal is
// already blocked by resolveInsideRoot above, but echoing
// the raw input back lets a client probe for the existence
// of relative paths inside h.orgDir (a 404 with the input
// vs. a 400 from resolveInsideRoot is itself a signal).
// Drop the input from the message; log full context server-
// side via the resolved path for operator triage.
log.Printf("OrgImport: failed to read %s (requested dir=%q): %v", orgFile, body.Dir, err)
c.JSON(http.StatusNotFound, gin.H{"error": "org template not found"})
return
}
// Expand !include directives before unmarshal. Splits org.yaml

View File

@ -134,7 +134,7 @@ func (h *TranscriptHandler) Get(c *gin.Context) {
// - block cloud metadata endpoints (IMDS, GCP, Azure)
// - block link-local IPs (169.254/16 IPv4, fe80::/10 IPv6)
// - loopback is allowed — local dev runs workspaces on 127.0.0.1
// - Docker internal hostnames (host.docker.internal, *.molecule-monorepo-net)
// - Docker internal hostnames (host.docker.internal, *.molecule-core-net)
// are allowed; the whole threat model assumes the platform already
// trusts peers on that network
func validateWorkspaceURL(u *url.URL) error {

View File

@ -331,8 +331,14 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
// stay in this handler.
descendantIDs, stopErrs, err := h.CascadeDelete(ctx, id)
if err != nil {
// Audit 2026-05-09 (Core-Security): raw `err.Error()` here was
// exposed to HTTP clients verbatim, including wrapped lib/pq
// driver strings that disclose schema column names + index
// hints. Log full error server-side; return a sanitized message
// to the client. Operators trace via the log line below using
// the workspace id.
log.Printf("Delete: CascadeDelete(%s) failed: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error processing delete request"})
return
}
allIDs := append([]string{id}, descendantIDs...)

View File

@ -173,7 +173,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
log.Printf("Provisioner: failed to cache URL for %s: %v", workspaceID, cacheErr)
}
// Also cache the Docker-internal URL for workspace-to-workspace discovery.
// Containers on molecule-monorepo-net can reach each other by container name.
// Containers on molecule-core-net can reach each other by container name.
internalURL := provisioner.InternalURL(workspaceID)
if cacheErr := db.CacheInternalURL(ctx, workspaceID, internalURL); cacheErr != nil {
log.Printf("Provisioner: failed to cache internal URL for %s: %v", workspaceID, cacheErr)

View File

@ -67,7 +67,7 @@ var DefaultImage = RuntimeImage(defaultRuntime)
const (
// DefaultNetwork is the Docker network workspaces join.
DefaultNetwork = "molecule-monorepo-net"
DefaultNetwork = "molecule-core-net"
// DefaultPort is the port the A2A server listens on inside the container.
DefaultPort = "8000"
@ -405,7 +405,7 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
// Apply tier-based container configuration
ApplyTierConfig(hostCfg, cfg, configMount, name)
// Network config — join molecule-monorepo-net with container name as alias
// Network config — join molecule-core-net with container name as alias
networkCfg := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
DefaultNetwork: {

View File

@ -43,11 +43,29 @@ if [ "$(id -u)" = "0" ]; then
ln -sfn /root/.claude/sessions /home/agent/.claude/sessions
fi
# --- Per-persona git identity (closes molecule-core#155) ---
# Without this, every team commit lands with an empty author and Gitea
# attributes the work to the founder PAT instead of the persona that
# actually authored it. Same fingerprint that got us suspended on GitHub
# 2026-05-06. GITEA_USER is injected by the provisioner from the
# workspace_secrets table; bot.moleculesai.app is the agent-only domain
# so commits are clearly distinguishable from human authors.
if [ -n "${GITEA_USER:-}" ]; then
git config --global user.name "${GITEA_USER}"
git config --global user.email "${GITEA_USER}@bot.moleculesai.app"
fi
# --- GitHub credential helper setup (issue #547 / #613) ---
# Configure git to use the molecule credential helper for github.com.
# This runs as root so the global gitconfig is written before we drop
# to agent. The helper fetches fresh GitHub App installation tokens
# from the platform API, with caching and env-var fallback.
#
# NOTE: post-suspension (2026-05-06), github.com/Molecule-AI is gone;
# the helper's platform endpoint also 500s (internal#187). The helper
# block is kept for legacy boxes that still have a working token chain;
# post-suspension provisioner injects GITEA_TOKEN directly so this
# path's failure is non-fatal. Full removal tracked under #171.
if [ -x /app/scripts/molecule-git-token-helper.sh ]; then
# Set credential helper for github.com only (not all hosts).
# The '!' prefix tells git to run the command as a shell command.
@ -55,11 +73,13 @@ if [ "$(id -u)" = "0" ]; then
"!/app/scripts/molecule-git-token-helper.sh"
# Disable other credential helpers for github.com to avoid conflicts.
git config --global "credential.https://github.com.useHttpPath" true
# Move gitconfig to agent's home so it takes effect after gosu.
if [ -f /root/.gitconfig ]; then
cp /root/.gitconfig /home/agent/.gitconfig
chown agent:agent /home/agent/.gitconfig
fi
fi
# Move gitconfig to agent's home so it takes effect after gosu —
# done unconditionally so the per-persona identity survives the drop
# even when the github.com helper block is skipped.
if [ -f /root/.gitconfig ]; then
cp /root/.gitconfig /home/agent/.gitconfig
chown agent:agent /home/agent/.gitconfig
fi
# Create the token cache directory for the agent user.
mkdir -p /home/agent/.molecule-token-cache

View File

@ -434,7 +434,7 @@ async def main(): # pragma: no cover
async def _transcript_handler(request):
# Require workspace bearer token — the same token issued at registration
# and stored in /configs/.auth_token. Any container on molecule-monorepo-net
# and stored in /configs/.auth_token. Any container on molecule-core-net
# could otherwise read the full session log. Closes #287.
#
# #328: fail CLOSED when the token file is unavailable. get_token()

View File

@ -3,7 +3,7 @@ the workspace auth token is not yet on disk.
Prior behaviour (regressed in #287): `if expected:` skipped the auth
check when `get_token()` returned None, so any container on
`molecule-monorepo-net` could read the full session log during the
`molecule-core-net` could read the full session log during the
bootstrap window. The fix lifts the guard into transcript_auth.py for
testability.
"""