feat(apess): wire real ZeroClaw nodes + live/sim mode + cloud→local fallback

Turn the workshop shell into a real on-device agent platform: APESS now
proxies each team's Arduino Uno Q (running ZeroClaw) and drives
generate → compile → flash from the UI, with a simulation fallback.

api — node backbone
- nodes.ts: team→node registry, a pure ZeroClaw /api/events → WsEvent
  mapper, per-node live SSE subscription fanned out to the collective hub
  AND per-team participant listeners, and a prompt proxy. Bearer tokens
  stay server-side; list()/broadcasts never expose them.
- routes: POST/GET/DELETE /nodes (register is admin-only), public
  POST /nodes/:teamId/prompt, and a participant-scoped SSE
  GET /nodes/:teamId/events.
- WsEvent gains node:status / node:activity.

front-end
- session store: mode 'live' | 'sim' (default sim); a 'local' provider
  and a harness.fallbackLocal toggle ("cloud first, on-board Qwen if it
  fails") reflected in the harness.toml preview.
- EnvSetup: Simulation / Live board toggle — sim runs a virtual board
  (self-test + Proceed with no hardware), live requires a registered node.
- BuildFlash panel (in Module 2): prompt the board, watch activity stream
  live (SSE) or a simulated generate→flash sequence.
- lib/api: sendPrompt + openTeamActivity (EventSource).

housekeeping
- better-sqlite3 → 12.11 (Node 26 support; the 11.x native build fails on
  Node 26's V8).
- vitest excludes the vendored Uno-QClaw/ tree.

Tests: front-end 172, api 29, typecheck clean, prod build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-03 05:52:51 -07:00
co-authored by Claude Opus 4.8
parent d58065d292
commit f21bbdf31e
22 changed files with 1002 additions and 18 deletions
+5 -2
View File
@@ -11,13 +11,16 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.8.1", "better-sqlite3": "^12.11.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"] "onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
+6 -5
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
better-sqlite3: better-sqlite3:
specifier: ^11.8.1 specifier: ^12.11.1
version: 11.10.0 version: 12.11.1
cors: cors:
specifier: ^2.8.5 specifier: ^2.8.5
version: 2.8.6 version: 2.8.6
@@ -448,8 +448,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
[email protected]0.0: better-sqlite3@12.11.1:
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
[email protected]: [email protected]:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@@ -1477,7 +1478,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]0.0: better-sqlite3@12.11.1:
dependencies: dependencies:
bindings: 1.5.0 bindings: 1.5.0
prebuild-install: 7.1.3 prebuild-install: 7.1.3
+55 -1
View File
@@ -3,6 +3,7 @@ import cors from 'cors'
import type { Store } from './db' import type { Store } from './db'
import { requireCode, requireAnyCode } from './auth' import { requireCode, requireAnyCode } from './auth'
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types' import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
import type { NodeBridge } from './nodes'
export interface AppOptions { export interface AppOptions {
store: Store store: Store
@@ -11,6 +12,8 @@ export interface AppOptions {
judgeCode: string judgeCode: string
corsOrigin?: string corsOrigin?: string
now?: () => string now?: () => string
/** Optional ZeroClaw node bridge; when absent, /nodes routes 503. */
nodes?: NodeBridge
} }
const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false } const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false }
@@ -18,7 +21,7 @@ const emptyStats = { calls: 0, nominal: 0, anomalous: 0, critical: 0 }
/** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */ /** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */
export function createApp(opts: AppOptions): Express { export function createApp(opts: AppOptions): Express {
const { store, broadcast, adminCode, judgeCode } = opts const { store, broadcast, adminCode, judgeCode, nodes } = opts
const now = opts.now ?? (() => new Date().toISOString()) const now = opts.now ?? (() => new Date().toISOString())
const app = express() const app = express()
app.use(cors({ origin: opts.corsOrigin ?? true })) app.use(cors({ origin: opts.corsOrigin ?? true }))
@@ -101,5 +104,56 @@ export function createApp(opts: AppOptions): Express {
res.json(store.leaderboard()) res.json(store.leaderboard())
}) })
// --- ZeroClaw nodes -----------------------------------------------------
// Registration holds bearer tokens → admin only. Prompting is a public
// participant action (like PUT /teams/:id). List/broadcasts never leak tokens.
app.get('/nodes', requireAnyCode(adminCode, judgeCode), (_req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
res.json(nodes.list())
})
app.post('/nodes', requireCode(adminCode), async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
if (typeof b.teamId !== 'string' || typeof b.url !== 'string' || typeof b.token !== 'string') {
return res.status(400).json({ error: 'teamId, url and token are required' })
}
await nodes.register({ teamId: b.teamId, url: b.url, token: b.token })
res.status(201).json({ teamId: b.teamId, url: b.url })
})
app.delete('/nodes/:teamId', requireCode(adminCode), (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
nodes.remove(String(req.params.teamId))
res.status(204).end()
})
app.post('/nodes/:teamId/prompt', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
if (typeof b.message !== 'string' || !b.message.trim()) {
return res.status(400).json({ error: 'message is required' })
}
const ok = await nodes.prompt(String(req.params.teamId), b.message)
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.status(202).json({ accepted: true })
})
// Participant-scoped SSE: a team watches only its own board's activity
// (the /ws hub is admin/judge only). Public, keyed by teamId.
app.get('/nodes/:teamId/events', (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
})
res.write(': connected\n\n')
const unsub = nodes.onTeamActivity(String(req.params.teamId), (e) => {
res.write(`data: ${JSON.stringify(e)}\n\n`)
})
req.on('close', () => unsub())
})
return app return app
} }
+3
View File
@@ -3,6 +3,7 @@ import { openStore } from './db'
import { createHub } from './hub' import { createHub } from './hub'
import { createApp } from './app' import { createApp } from './app'
import { attachWs } from './ws' import { attachWs } from './ws'
import { createNodeBridge } from './nodes'
const PORT = Number(process.env.PORT ?? 3000) const PORT = Number(process.env.PORT ?? 3000)
const DB_PATH = process.env.DB_PATH ?? '/data/apess.db' const DB_PATH = process.env.DB_PATH ?? '/data/apess.db'
@@ -16,12 +17,14 @@ if (!ADMIN_CODE || !JUDGE_CODE) {
const store = openStore(DB_PATH) const store = openStore(DB_PATH)
const hub = createHub() const hub = createHub()
const nodes = createNodeBridge({ broadcast: hub.broadcast })
const app = createApp({ const app = createApp({
store, store,
broadcast: hub.broadcast, broadcast: hub.broadcast,
adminCode: ADMIN_CODE, adminCode: ADMIN_CODE,
judgeCode: JUDGE_CODE, judgeCode: JUDGE_CODE,
corsOrigin: CORS_ORIGIN, corsOrigin: CORS_ORIGIN,
nodes,
}) })
const server = http.createServer(app) const server = http.createServer(app)
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach } from 'vitest'
import request from 'supertest'
import type { Store } from './db'
import { createApp } from './app'
import { createNodeBridge, type NodeRef } from './nodes'
import type { WsEvent } from './types'
const ADMIN = 'admin-code'
const JUDGE = 'judge-code'
// The /nodes routes never touch the store, so a stub keeps this suite free of
// the native better-sqlite3 binding (see db.ts). The collective routes are
// covered by app.test.ts.
const store = {} as unknown as Store
describe('node bridge + /nodes routes', () => {
let events: WsEvent[]
let sent: { node: NodeRef; message: string }[]
let app: ReturnType<typeof createApp>
beforeEach(() => {
events = []
sent = []
const nodes = createNodeBridge({
broadcast: (e) => events.push(e),
ping: async () => true, // pretend the node is online
send: async (node, message) => {
sent.push({ node, message })
},
subscribe: () => () => {}, // no live SSE in the unit test
})
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes })
})
it('registers a node (admin only) and never leaks the token', async () => {
await request(app).post('/nodes').send({ teamId: 't1', url: 'http://n', token: 'zc_secret' }).expect(401)
await request(app)
.post('/nodes')
.set('x-access-code', ADMIN)
.send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
.expect(201)
const list = await request(app).get('/nodes').set('x-access-code', JUDGE).expect(200)
expect(list.body).toEqual([{ teamId: 't1', url: 'http://n', online: true }])
expect(JSON.stringify(list.body)).not.toContain('zc_secret')
// registration broadcasts an online status
expect(events).toContainEqual({ type: 'node:status', teamId: 't1', online: true })
})
it('validates the registration body', async () => {
await request(app).post('/nodes').set('x-access-code', ADMIN).send({ teamId: 't1' }).expect(400)
})
it('forwards a participant prompt to the registered node', async () => {
await request(app)
.post('/nodes')
.set('x-access-code', ADMIN)
.send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
.expect(201)
// prompting is public (no code) — matches PUT /teams/:id
await request(app).post('/nodes/t1/prompt').send({ message: 'scroll HELLO' }).expect(202)
expect(sent).toHaveLength(1)
expect(sent[0].message).toBe('scroll HELLO')
expect(sent[0].node.token).toBe('zc_secret')
})
it('404s a prompt for an unregistered team, 400s an empty message', async () => {
await request(app).post('/nodes/ghost/prompt').send({ message: 'hi' }).expect(404)
await request(app)
.post('/nodes')
.set('x-access-code', ADMIN)
.send({ teamId: 't1', url: 'http://n', token: 'k' })
.expect(201)
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
})
it('503s when no bridge is configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503)
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { createNodeBridge } from './nodes'
import type { WsEvent } from './types'
/** A bridge whose node subscription is driven by the returned `emit`. */
function setup() {
let emit: (e: WsEvent) => void = () => {}
const collective: WsEvent[] = []
const bridge = createNodeBridge({
broadcast: (e) => collective.push(e),
ping: async () => true,
subscribe: (_n, onEvent) => {
emit = onEvent
return () => {}
},
})
return { bridge, collective, emit: (e: WsEvent) => emit(e) }
}
const flash: WsEvent = { type: 'node:activity', teamId: 't1', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' }
describe('createNodeBridge — per-team activity', () => {
it('fans a node event out to both the collective feed and the team listener', async () => {
const { bridge, collective, emit } = setup()
const seen: WsEvent[] = []
bridge.onTeamActivity('t1', (e) => seen.push(e))
await bridge.register({ teamId: 't1', url: 'u', token: 'k' })
// register status reaches both channels
expect(collective).toContainEqual({ type: 'node:status', teamId: 't1', online: true })
expect(seen).toContainEqual({ type: 'node:status', teamId: 't1', online: true })
// a live node event reaches both
emit(flash)
expect(collective).toContainEqual(flash)
expect(seen).toContainEqual(flash)
})
it('stops delivering to a team listener after unsubscribe (collective unaffected)', async () => {
const { bridge, collective, emit } = setup()
const seen: WsEvent[] = []
const off = bridge.onTeamActivity('t1', (e) => seen.push(e))
await bridge.register({ teamId: 't1', url: 'u', token: 'k' })
off()
seen.length = 0
collective.length = 0
emit(flash)
expect(seen).toHaveLength(0) // participant unsubscribed
expect(collective).toContainEqual(flash) // admin feed still gets it
})
})
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { createNodeRegistry, mapNodeEvent } from './nodes'
describe('node registry', () => {
it('registers, gets, lists and removes nodes', () => {
const reg = createNodeRegistry()
expect(reg.list()).toEqual([])
reg.register({ teamId: 't1', url: 'http://127.0.0.1:8080', token: 'zc_a' })
reg.register({ teamId: 't2', url: 'http://127.0.0.1:8081', token: 'zc_b' })
expect(reg.get('t1')).toEqual({ teamId: 't1', url: 'http://127.0.0.1:8080', token: 'zc_a' })
expect(reg.list()).toHaveLength(2)
// re-registering the same team replaces, not duplicates
reg.register({ teamId: 't1', url: 'http://127.0.0.1:9090', token: 'zc_c' })
expect(reg.list()).toHaveLength(2)
expect(reg.get('t1')?.url).toBe('http://127.0.0.1:9090')
reg.remove('t1')
expect(reg.get('t1')).toBeUndefined()
expect(reg.list()).toHaveLength(1)
})
it('seeds from an initial list', () => {
const reg = createNodeRegistry([{ teamId: 't1', url: 'u', token: 'k' }])
expect(reg.get('t1')?.token).toBe('k')
})
})
describe('mapNodeEvent — ZeroClaw /api/events → WsEvent', () => {
it('maps agent_start to a thinking activity', () => {
const ev = mapNodeEvent('t1', { type: 'agent_start', timestamp: '2026-07-03T11:00:00Z' })
expect(ev).toEqual({
type: 'node:activity',
teamId: 't1',
kind: 'thinking',
label: expect.any(String),
ts: '2026-07-03T11:00:00Z',
})
})
it('classifies a uno_q_flash tool call as a flash activity', () => {
const ev = mapNodeEvent('t1', { type: 'tool_call_start', tool: 'uno_q_flash', timestamp: 'T' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'flash' })
})
it('classifies a non-flash tool call as a tool activity', () => {
const ev = mapNodeEvent('t1', { type: 'tool_call_start', tool: 'sysfs_led', timestamp: 'T' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'tool', label: expect.stringContaining('sysfs_led') })
})
it('maps a successful flash log message to a flash activity with the address', () => {
const ev = mapNodeEvent('t1', {
'@timestamp': '2026-07-03T11:34:00Z',
message: "Sketch compiled and flashed to the Uno Q MCU sketch partition at 0x80F0000. 'reset run' issued.",
})
expect(ev).toMatchObject({ type: 'node:activity', kind: 'flash' })
expect((ev as { label: string }).label).toContain('0x80F0000')
expect((ev as { ts: string }).ts).toBe('2026-07-03T11:34:00Z')
})
it('maps a compile error / failure to an error activity', () => {
const ev = mapNodeEvent('t1', { severity_text: 'ERROR', message: 'Arduino compile error — fix the sketch' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'error' })
})
it('maps agent_end to a response activity', () => {
const ev = mapNodeEvent('t1', { type: 'agent_end', timestamp: 'T' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'response' })
})
it('ignores noisy/internal events (llm_request, plain notes, non-objects)', () => {
expect(mapNodeEvent('t1', { type: 'llm_request' })).toBeNull()
expect(mapNodeEvent('t1', { message: 'No sandbox backend available, using application-layer security' })).toBeNull()
expect(mapNodeEvent('t1', null)).toBeNull()
expect(mapNodeEvent('t1', 'nope')).toBeNull()
})
})
+257
View File
@@ -0,0 +1,257 @@
import type { WsEvent, NodeActivityKind } from './types'
/** A team's ZeroClaw node: gateway URL + its server-side bearer token. */
export interface NodeRef {
teamId: string
url: string
token: string
}
export interface NodeRegistry {
register(ref: NodeRef): void
get(teamId: string): NodeRef | undefined
list(): NodeRef[]
remove(teamId: string): void
}
/**
* In-memory registry of team → node. Tokens live here (server-side) and never
* reach the browser; the api proxies to the node on the participant's behalf.
*/
export function createNodeRegistry(seed: NodeRef[] = []): NodeRegistry {
const nodes = new Map<string, NodeRef>()
for (const n of seed) nodes.set(n.teamId, n)
return {
register(ref) {
nodes.set(ref.teamId, ref)
},
get(teamId) {
return nodes.get(teamId)
},
list() {
return [...nodes.values()]
},
remove(teamId) {
nodes.delete(teamId)
},
}
}
function str(v: unknown): string {
return typeof v === 'string' ? v : ''
}
/**
* Distill one raw ZeroClaw `/api/events` object into a collective WsEvent, or
* null to drop noise. Handles both observability events (with `type`) and
* structured log lines (with `message`). Pure — ts comes from the event.
*/
export function mapNodeEvent(teamId: string, raw: unknown): WsEvent | null {
if (!raw || typeof raw !== 'object') return null
const e = raw as Record<string, unknown>
const ts = str(e.timestamp) || str(e['@timestamp'])
const activity = (kind: NodeActivityKind, label: string): WsEvent => ({
type: 'node:activity',
teamId,
kind,
label,
ts,
})
// 1) Observability events carry a `type`.
if (typeof e.type === 'string') {
switch (e.type) {
case 'agent_start':
return activity('thinking', 'Agent started')
case 'agent_end':
return activity('response', 'Agent finished')
case 'tool_call_start': {
const tool = str(e.tool) || str(e.name) || 'tool'
if (tool === 'uno_q_flash') return activity('flash', 'Flashing sketch to the MCU…')
return activity('tool', `Running ${tool}`)
}
default:
return null // llm_request and other chatter
}
}
// 2) Structured log lines carry a `message`.
const message = str(e.message)
if (message) {
if (/compiled and flashed/i.test(message)) {
const addr = message.match(/0x[0-9A-Fa-f]+/)?.[0]
return activity('flash', addr ? `Flashed to ${addr}` : 'Flashed to the MCU')
}
const isError = str(e.severity_text).toUpperCase() === 'ERROR' || /\b(error|failed|failure)\b/i.test(message)
if (isError) return activity('error', message.slice(0, 200))
}
return null
}
// --- I/O bridge to a node's ZeroClaw gateway --------------------------------
// (Uses global fetch; exercised by integration against a live node, not units.)
const AGENT = 'default'
/** Health-ping a node's gateway. */
export async function pingNode(node: NodeRef, timeoutMs = 3000): Promise<boolean> {
try {
const ctl = AbortSignal.timeout(timeoutMs)
const r = await fetch(`${node.url}/health`, { signal: ctl })
return r.ok
} catch {
return false
}
}
/** Forward a participant prompt to the node's agent (fire-and-return). */
export async function sendPrompt(node: NodeRef, message: string): Promise<void> {
await fetch(`${node.url}/webhook?agent=${AGENT}`, {
method: 'POST',
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ message }),
})
}
/**
* Subscribe to a node's `/api/events` SSE stream, mapping each event to a
* WsEvent via `mapNodeEvent` and handing it to `onEvent`. Returns a stop fn.
*/
export function subscribeNodeEvents(
node: NodeRef,
onEvent: (e: WsEvent) => void,
signal?: AbortSignal,
): () => void {
const ctl = new AbortController()
if (signal) signal.addEventListener('abort', () => ctl.abort())
;(async () => {
try {
const res = await fetch(`${node.url}/api/events`, {
headers: { authorization: `Bearer ${node.token}` },
signal: ctl.signal,
})
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload) continue
try {
const mapped = mapNodeEvent(node.teamId, JSON.parse(payload))
if (mapped) onEvent(mapped)
} catch {
/* skip malformed line */
}
}
}
} catch {
/* stream ended or aborted */
}
})()
return () => ctl.abort()
}
// --- Bridge: registry + live subscriptions wired to the collective hub -------
/** A node as exposed to clients — never includes the bearer token. */
export interface NodeView {
teamId: string
url: string
online: boolean
}
export interface NodeBridge {
register(ref: NodeRef): Promise<void>
remove(teamId: string): void
list(): NodeView[]
prompt(teamId: string, message: string): Promise<boolean>
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void
}
export interface NodeBridgeDeps {
broadcast: (e: WsEvent) => void
registry?: NodeRegistry
/** Injectable for tests. */
ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string) => Promise<void>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void) => () => void
}
/**
* Owns the team→node registry, opens a live `/api/events` subscription per
* registered node (mapped events fan out through the collective hub), and
* proxies participant prompts. Tokens stay here; `list()`/broadcasts never
* expose them.
*/
export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const registry = deps.registry ?? createNodeRegistry()
const ping = deps.ping ?? pingNode
const send = deps.send ?? sendPrompt
const subscribe = deps.subscribe ?? ((n, on) => subscribeNodeEvents(n, on))
const online = new Map<string, boolean>()
const stops = new Map<string, () => void>()
const teamListeners = new Map<string, Set<(e: WsEvent) => void>>()
const fanOut = (teamId: string, e: WsEvent) => {
deps.broadcast(e) // collective (admin/judge) feed
teamListeners.get(teamId)?.forEach((l) => l(e)) // that team's participant(s)
}
return {
async register(ref) {
stops.get(ref.teamId)?.()
registry.register(ref)
const up = await ping(ref)
online.set(ref.teamId, up)
fanOut(ref.teamId, { type: 'node:status', teamId: ref.teamId, online: up })
stops.set(ref.teamId, subscribe(ref, (e) => fanOut(ref.teamId, e)))
},
remove(teamId) {
stops.get(teamId)?.()
stops.delete(teamId)
online.delete(teamId)
registry.remove(teamId)
deps.broadcast({ type: 'node:status', teamId, online: false })
},
list() {
return registry.list().map((n) => ({ teamId: n.teamId, url: n.url, online: online.get(n.teamId) ?? false }))
},
async prompt(teamId, message) {
const node = registry.get(teamId)
if (!node) return false
await send(node, message)
return true
},
onTeamActivity(teamId, listener) {
let set = teamListeners.get(teamId)
if (!set) {
set = new Set()
teamListeners.set(teamId, set)
}
set.add(listener)
return () => {
set!.delete(listener)
if (set!.size === 0) teamListeners.delete(teamId)
}
},
stopAll() {
for (const stop of stops.values()) stop()
stops.clear()
},
}
}
+5
View File
@@ -63,8 +63,13 @@ export interface LeaderboardRow {
scoreCount: number scoreCount: number
} }
/** Normalized on-device agent activity, distilled from a node's raw event stream. */
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] } | { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] }
| { type: 'team:update'; team: TeamSnapshot } | { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary } | { type: 'submission:new'; submission: SubmissionSummary }
| { type: 'score:new'; teamId: string; total: number } | { type: 'score:new'; teamId: string; total: number }
| { type: 'node:status'; teamId: string; online: boolean }
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BuildFlash } from './BuildFlash'
import { useSession } from '@/store/session'
import type { WsEvent } from '@/types'
// Capture the live-activity callback so tests can push node events.
const sendPrompt = vi.fn()
let liveOnEvent: ((e: WsEvent) => void) | null = null
const closeSpy = vi.fn()
vi.mock('@/lib/api', () => ({
sendPrompt: (...args: unknown[]) => sendPrompt(...args),
openTeamActivity: (_teamId: string, onEvent: (e: WsEvent) => void) => {
liveOnEvent = onEvent
return closeSpy
},
}))
describe('BuildFlash', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
sendPrompt.mockReset()
closeSpy.mockReset()
liveOnEvent = null
})
it('disables Send until there is a prompt', () => {
render(<BuildFlash />)
expect(screen.getByRole('button', { name: /send/i })).toBeDisabled()
})
it('shows the Simulation badge by default and Live when in live mode', () => {
const { rerender } = render(<BuildFlash />)
expect(screen.getByText(/simulation/i)).toBeInTheDocument()
act(() => useSession.getState().setMode('live'))
rerender(<BuildFlash />)
expect(screen.getByText(/live board/i)).toBeInTheDocument()
})
describe('simulation mode', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('plays a simulated generate→flash sequence without hardware or the api', async () => {
render(<BuildFlash />)
fireEvent.change(screen.getByLabelText(/prompt your board/i), {
target: { value: 'scroll GO CLAWS' },
})
fireEvent.click(screen.getByRole('button', { name: /send/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(1400)
})
const log = screen.getByTestId('activity-log')
expect(log).toHaveTextContent(/flashed to 0x80F0000 \(simulated\)/i)
expect(log).toHaveTextContent(/your board is running the sketch/i)
expect(sendPrompt).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: /send/i })).toBeEnabled()
})
})
describe('live mode', () => {
beforeEach(() => {
useSession.getState().setMode('live')
})
it('sends the prompt to the api and renders streamed board activity', async () => {
const user = userEvent.setup()
render(<BuildFlash />)
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
await user.click(screen.getByRole('button', { name: /working|send/i }))
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO')
// a flash event streams in over the (mocked) SSE feed
act(() => {
liveOnEvent?.({ type: 'node:activity', teamId: 't', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' })
})
expect(screen.getByTestId('activity-log')).toHaveTextContent(/flashed to 0x80F0000/i)
// terminal event re-enables Send
expect(screen.getByRole('button', { name: /send/i })).toBeEnabled()
})
it('closes the activity stream on unmount', () => {
const { unmount } = render(<BuildFlash />)
unmount()
expect(closeSpy).toHaveBeenCalled()
})
})
})
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types'
interface Entry {
kind: NodeActivityKind
label: string
}
/** Deterministic sequence played back in simulation mode (no hardware). */
const SIM_SEQUENCE: { kind: NodeActivityKind; label: string; delay: number }[] = [
{ kind: 'thinking', label: 'Agent started', delay: 200 },
{ kind: 'tool', label: 'Writing the sketch…', delay: 600 },
{ kind: 'flash', label: 'Flashed to 0x80F0000 (simulated)', delay: 1000 },
{ kind: 'response', label: 'Done — your board is running the sketch', delay: 1300 },
]
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
}
const isTerminal = (k: NodeActivityKind) => k === 'flash' || k === 'response' || k === 'error'
/** Prompt the team's board and watch it generate → compile → flash, live. */
export function BuildFlash() {
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId)
const [prompt, setPrompt] = useState('')
const [busy, setBusy] = useState(false)
const [entries, setEntries] = useState<Entry[]>([])
const timers = useRef<ReturnType<typeof setTimeout>[]>([])
const append = (e: Entry) => setEntries((prev) => [...prev, e])
// Live mode: stream this team's own board activity.
useEffect(() => {
if (mode !== 'live') return
return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return
append({ kind: ev.kind, label: ev.label })
if (isTerminal(ev.kind)) setBusy(false)
})
}, [mode, teamId])
// Clear any pending sim timers on unmount.
useEffect(() => () => timers.current.forEach(clearTimeout), [])
const run = async () => {
const msg = prompt.trim()
if (!msg || busy) return
setEntries([])
setBusy(true)
if (mode === 'sim') {
timers.current = SIM_SEQUENCE.map((s) =>
setTimeout(() => {
append({ kind: s.kind, label: s.label })
if (isTerminal(s.kind) && s.kind !== 'flash') setBusy(false)
}, s.delay),
)
return
}
try {
await sendPrompt(teamId, msg)
} catch {
append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' })
setBusy(false)
}
}
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Build &amp; flash</CardTitle>
<Badge
variant={mode === 'live' ? 'default' : 'secondary'}
className="font-mono text-[10px] uppercase tracking-widest"
>
{mode === 'live' ? 'Live board' : 'Simulation'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
aria-label="Prompt your board"
placeholder="e.g. scroll the message GO CLAWS on the LED matrix"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void run()
}}
className="font-mono text-sm"
/>
<Button onClick={() => void run()} disabled={busy || !prompt.trim()}>
{busy ? 'Working…' : 'Send'}
</Button>
</div>
<ul data-testid="activity-log" className="space-y-1.5 min-h-[3rem]">
{entries.length === 0 ? (
<li className="font-mono text-[11px] text-muted-foreground">
Ask your board to do something it generates a sketch, compiles it, and flashes the MCU.
</li>
) : (
entries.map((e, i) => (
<li key={i} className="flex items-center gap-2 font-mono text-[11px]">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', KIND_DOT[e.kind])} />
<span
className={cn(
e.kind === 'error' && 'text-destructive',
e.kind === 'flash' && 'text-foreground font-medium',
)}
>
{e.label}
</span>
</li>
))
)}
</ul>
</CardContent>
</Card>
)
}
@@ -36,4 +36,32 @@ describe('HarnessProviderSelect', () => {
expect(screen.getByRole('button', { name: /groq/i })).toBeDisabled() expect(screen.getByRole('button', { name: /groq/i })).toBeDisabled()
expect(screen.getByLabelText(/model/i)).toHaveAttribute('readonly') expect(screen.getByLabelText(/model/i)).toHaveAttribute('readonly')
}) })
it('offers Local as a provider', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
await user.click(screen.getByRole('button', { name: /local/i }))
expect(useSession.getState().harness.provider).toBe('local')
})
it('shows the on-board Qwen fallback toggle for a cloud primary and writes it', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
const toggle = screen.getByRole('checkbox', { name: /fall back to on-board qwen/i })
expect(toggle).toBeChecked() // default on
await user.click(toggle)
expect(useSession.getState().harness.fallbackLocal).toBe(false)
})
it('hides the fallback toggle when the primary is Local', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
await user.click(screen.getByRole('button', { name: /local/i }))
expect(screen.queryByRole('checkbox', { name: /fall back/i })).not.toBeInTheDocument()
})
it('disables the fallback toggle when read-only', () => {
render(<HarnessProviderSelect readOnly />)
expect(screen.getByRole('checkbox', { name: /fall back/i })).toBeDisabled()
})
}) })
+19 -1
View File
@@ -6,6 +6,7 @@ const PROVIDERS: { id: Provider; label: string }[] = [
{ id: 'anthropic', label: 'Anthropic' }, { id: 'anthropic', label: 'Anthropic' },
{ id: 'groq', label: 'Groq' }, { id: 'groq', label: 'Groq' },
{ id: 'openai', label: 'OpenAI' }, { id: 'openai', label: 'OpenAI' },
{ id: 'local', label: 'Local' },
] ]
export interface HarnessProviderSelectProps { export interface HarnessProviderSelectProps {
@@ -17,11 +18,12 @@ export interface HarnessProviderSelectProps {
export function HarnessProviderSelect({ readOnly }: HarnessProviderSelectProps) { export function HarnessProviderSelect({ readOnly }: HarnessProviderSelectProps) {
const provider = useSession((s) => s.harness.provider) const provider = useSession((s) => s.harness.provider)
const model = useSession((s) => s.harness.model) const model = useSession((s) => s.harness.model)
const fallbackLocal = useSession((s) => s.harness.fallbackLocal)
const setHarness = useSession((s) => s.setHarness) const setHarness = useSession((s) => s.setHarness)
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-3 gap-2" role="group" aria-label="Reasoning provider"> <div className="grid grid-cols-2 gap-2" role="group" aria-label="Reasoning provider">
{PROVIDERS.map((p) => { {PROVIDERS.map((p) => {
const selected = p.id === provider const selected = p.id === provider
return ( return (
@@ -55,6 +57,22 @@ export function HarnessProviderSelect({ readOnly }: HarnessProviderSelectProps)
className="font-mono" className="font-mono"
/> />
</div> </div>
{provider !== 'local' && (
<label className="flex items-start gap-2 cursor-pointer select-none pt-1" data-testid="fallback-toggle">
<input
type="checkbox"
checked={fallbackLocal}
disabled={readOnly}
onChange={(e) => setHarness({ fallbackLocal: e.target.checked })}
className="mt-0.5 accent-primary"
aria-label="Fall back to on-board Qwen if the cloud is unreachable"
/>
<span className="font-mono text-[10px] leading-relaxed text-muted-foreground">
Fall back to <span className="text-foreground">on-board Qwen</span> if the cloud is unreachable
</span>
</label>
)}
</div> </div>
) )
} }
+24
View File
@@ -69,6 +69,30 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard') return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
} }
// --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board (public, keyed by teamId). */
export async function sendPrompt(teamId: string, message: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message }),
})
if (!res.ok) throw new Error(`sendPrompt ${res.status}`)
}
/** Open the team's own board activity SSE feed. Returns a close function. */
export function openTeamActivity(teamId: string, onEvent: (e: WsEvent) => void): () => void {
const es = new EventSource(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/events`)
es.onmessage = (ev: MessageEvent) => {
try {
onEvent(JSON.parse(ev.data) as WsEvent)
} catch {
/* ignore malformed frame */
}
}
return () => es.close()
}
// --- live feed ------------------------------------------------------------ // --- live feed ------------------------------------------------------------
export interface OpenCollectiveOptions { export interface OpenCollectiveOptions {
reconnectMs?: number reconnectMs?: number
+13
View File
@@ -8,6 +8,7 @@ const harness: Harness = {
callsPerMinute: 8, callsPerMinute: 8,
provider: 'anthropic', provider: 'anthropic',
model: 'claude-haiku-4-5', model: 'claude-haiku-4-5',
fallbackLocal: true,
} }
describe('harnessToToml', () => { describe('harnessToToml', () => {
@@ -29,4 +30,16 @@ describe('harnessToToml', () => {
it('reflects live edits to the threshold values', () => { it('reflects live edits to the threshold values', () => {
expect(harnessToToml({ ...harness, thresholdG: 1.25 })).toContain('threshold_g = 1.25') expect(harnessToToml({ ...harness, thresholdG: 1.25 })).toContain('threshold_g = 1.25')
}) })
it('adds a fallback to local-qwen when a cloud primary has fallback enabled', () => {
expect(harnessToToml(harness)).toContain('fallback = ["local-qwen"]')
})
it('omits the fallback line when fallback is disabled', () => {
expect(harnessToToml({ ...harness, fallbackLocal: false })).not.toContain('fallback')
})
it('omits the fallback line when the primary is already local', () => {
expect(harnessToToml({ ...harness, provider: 'local', fallbackLocal: true })).not.toContain('fallback')
})
}) })
+8 -3
View File
@@ -2,7 +2,7 @@ import type { Harness } from '@/store/session'
/** Render the live harness config as a `harness.toml`-style document. */ /** Render the live harness config as a `harness.toml`-style document. */
export function harnessToToml(h: Harness): string { export function harnessToToml(h: Harness): string {
return [ const lines = [
'[harness]', '[harness]',
`threshold_g = ${h.thresholdG}`, `threshold_g = ${h.thresholdG}`,
`threshold_db = ${h.thresholdDb}`, `threshold_db = ${h.thresholdDb}`,
@@ -11,6 +11,11 @@ export function harnessToToml(h: Harness): string {
'[provider]', '[provider]',
`name = "${h.provider}"`, `name = "${h.provider}"`,
`model = "${h.model}"`, `model = "${h.model}"`,
'', ]
].join('\n') // Cloud-first, local-if-it-fails: only a cloud primary can fall back to Qwen.
if (h.provider !== 'local' && h.fallbackLocal) {
lines.push('fallback = ["local-qwen"]')
}
lines.push('')
return lines.join('\n')
} }
+57
View File
@@ -45,6 +45,7 @@ describe('EnvSetup', () => {
afterEach(() => vi.useRealTimers()) afterEach(() => vi.useRealTimers())
it('passes once frames flow, enabling Proceed without polluting stats', async () => { it('passes once frames flow, enabling Proceed without polluting stats', async () => {
useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 }) useSession.getState().setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 })
renderPage() renderPage()
@@ -64,8 +65,64 @@ describe('EnvSetup', () => {
}) })
it('keeps Proceed gated when connected but self-test has not run', () => { it('keeps Proceed gated when connected but self-test has not run', () => {
useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'x', uptimeS: 0 }) useSession.getState().setDevice({ connected: true, port: 'x', uptimeS: 0 })
renderPage() renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
describe('run mode (live / simulation)', () => {
it('defaults to simulation and shows the virtual board', () => {
renderPage()
expect(useSession.getState().mode).toBe('sim')
expect(screen.getByRole('button', { name: /simulation/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByTestId('device-sim')).toBeInTheDocument()
})
it('toggles to live board (device required) and back to simulation', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: /live board/i }))
expect(useSession.getState().mode).toBe('live')
expect(screen.queryByTestId('device-sim')).not.toBeInTheDocument()
// no device in live mode → self-test disabled, Proceed gated
expect(screen.getByRole('button', { name: /run self-test/i })).toBeDisabled()
await user.click(screen.getByRole('button', { name: /simulation/i }))
expect(useSession.getState().mode).toBe('sim')
})
describe('with fake timers', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('runs a virtual self-test in simulation and enables Proceed with no hardware', async () => {
renderPage()
expect(useSession.getState().device.connected).toBe(false)
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /run self-test/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.getByText(/self-test ok/i)).toBeInTheDocument()
expect(proceed).toBeEnabled()
expect(useSession.getState().stats.calls).toBe(0)
})
it('invalidates a passed self-test when the mode changes', async () => {
renderPage()
fireEvent.click(screen.getByRole('button', { name: /run self-test/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.getByText(/self-test ok/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /live board/i }))
expect(screen.queryByText(/self-test ok/i)).not.toBeInTheDocument()
})
})
})
}) })
+58 -5
View File
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { HarnessProviderSelect } from '@/components/HarnessProviderSelect' import { HarnessProviderSelect } from '@/components/HarnessProviderSelect'
import { useSession } from '@/store/session' import { useSession, type RunMode } from '@/store/session'
import { requestPort } from '@/lib/serial' import { requestPort } from '@/lib/serial'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -16,12 +16,28 @@ const SELFTEST_FRAMES = 3
export function EnvSetup() { export function EnvSetup() {
const navigate = useNavigate() const navigate = useNavigate()
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const mode = useSession((s) => s.mode)
const setMode = useSession((s) => s.setMode)
const harness = useSession((s) => s.harness) const harness = useSession((s) => s.harness)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const [selfTest, setSelfTest] = useState<SelfTest>('idle') const [selfTest, setSelfTest] = useState<SelfTest>('idle')
// Switching mode invalidates any prior self-test result.
const onSetMode = (m: RunMode) => {
if (m !== mode) {
setMode(m)
setSelfTest('idle')
}
}
const runSelfTest = async () => { const runSelfTest = async () => {
setSelfTest('running') setSelfTest('running')
if (mode === 'sim') {
// Simulated sense path — no hardware required.
await new Promise((r) => setTimeout(r, 400))
setSelfTest('ok')
return
}
const conn = await requestPort() const conn = await requestPort()
let count = 0 let count = 0
const unsub = conn.onFrame(() => { const unsub = conn.onFrame(() => {
@@ -34,7 +50,8 @@ export function EnvSetup() {
}) })
} }
const ready = device.connected && selfTest === 'ok' && !!harness.provider && !!harness.model const deviceReady = mode === 'sim' || device.connected
const ready = deviceReady && selfTest === 'ok' && !!harness.provider && !!harness.model
const onProceed = () => { const onProceed = () => {
completePhase('setup') completePhase('setup')
@@ -73,9 +90,44 @@ export function EnvSetup() {
<CardTitle className="text-base">Board &amp; sense path</CardTitle> <CardTitle className="text-base">Board &amp; sense path</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Run mode</div>
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Run mode">
{(['sim', 'live'] as RunMode[]).map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => onSetMode(m)}
className={cn(
'font-mono text-xs px-2 py-2 rounded-md border transition',
mode === m
? 'bg-primary text-primary-foreground border-primary'
: 'bg-card text-muted-foreground border-border hover:border-primary/40',
)}
>
{m === 'sim' ? 'Simulation' : 'Live board'}
</button>
))}
</div>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
{mode === 'sim'
? 'No hardware needed — a virtual board runs the sense path and agent.'
: 'Drives your teams Arduino Uno Q through its on-board ZeroClaw agent.'}
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Device</div> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Device</div>
{device.connected ? ( {mode === 'sim' ? (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="device-sim">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-teal" />
<span className="text-sm font-medium">Simulation ready</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1">virtual board · no hardware</div>
</div>
) : device.connected ? (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3"> <div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" /> <span className="w-2 h-2 rounded-full bg-teal animate-pulse" />
@@ -86,7 +138,8 @@ export function EnvSetup() {
) : ( ) : (
<div className="border border-amber/40 bg-amber/5 rounded-md px-4 py-3 text-sm"> <div className="border border-amber/40 bg-amber/5 rounded-md px-4 py-3 text-sm">
No device. Go back to{' '} No device. Go back to{' '}
<Link to="/workshop" className="underline">team registration</Link> and connect the board. <Link to="/workshop" className="underline">team registration</Link> and connect the board,
or switch to Simulation.
</div> </div>
)} )}
</div> </div>
@@ -96,7 +149,7 @@ export function EnvSetup() {
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start" className="w-full justify-start"
disabled={!device.connected || selfTest === 'running'} disabled={!deviceReady || selfTest === 'running'}
onClick={runSelfTest} onClick={runSelfTest}
> >
<span <span
+3
View File
@@ -9,6 +9,7 @@ import { HarnessTomlPreview } from '@/components/HarnessTomlPreview'
import { TriggerButtons } from '@/components/TriggerButtons' import { TriggerButtons } from '@/components/TriggerButtons'
import { LiveFeed } from '@/components/LiveFeed' import { LiveFeed } from '@/components/LiveFeed'
import { StatsTally } from '@/components/StatsTally' import { StatsTally } from '@/components/StatsTally'
import { BuildFlash } from '@/components/BuildFlash'
import { AddLayerForm } from '@/components/AddLayerForm' import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial' import { useSerial } from '@/lib/useSerial'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
@@ -84,6 +85,8 @@ export function Module2() {
</Card> </Card>
</div> </div>
<BuildFlash />
<div className="grid lg:grid-cols-2 gap-6"> <div className="grid lg:grid-cols-2 gap-6">
<AddLayerForm <AddLayerForm
layer="L2" layer="L2"
+11 -1
View File
@@ -5,7 +5,10 @@ export type PhaseKey = 'reg' | 'setup' | 'm1' | 'm2' | 'add'
export const PHASE_ORDER: PhaseKey[] = ['reg', 'setup', 'm1', 'm2', 'add'] export const PHASE_ORDER: PhaseKey[] = ['reg', 'setup', 'm1', 'm2', 'add']
export type Provider = 'anthropic' | 'groq' | 'openai' export type Provider = 'anthropic' | 'groq' | 'openai' | 'local'
/** Whether the workshop drives a real ZeroClaw node ('live') or the simulator ('sim'). */
export type RunMode = 'live' | 'sim'
export interface Team { export interface Team {
name: string name: string
@@ -25,6 +28,8 @@ export interface Harness {
callsPerMinute: number callsPerMinute: number
provider: Provider provider: Provider
model: string model: string
/** Fall back to the board's on-board Qwen when the cloud provider is unreachable. */
fallbackLocal: boolean
} }
export interface SessionStats { export interface SessionStats {
@@ -57,6 +62,7 @@ export interface SessionState {
teamId: string teamId: string
team: Team team: Team
device: Device device: Device
mode: RunMode
phases: Record<PhaseKey, boolean> phases: Record<PhaseKey, boolean>
harness: Harness harness: Harness
stats: SessionStats stats: SessionStats
@@ -64,6 +70,7 @@ export interface SessionState {
submission: Submission submission: Submission
setTeam: (patch: Partial<Team>) => void setTeam: (patch: Partial<Team>) => void
setDevice: (patch: Partial<Device>) => void setDevice: (patch: Partial<Device>) => void
setMode: (mode: RunMode) => void
completePhase: (phase: PhaseKey) => void completePhase: (phase: PhaseKey) => void
setHarness: (patch: Partial<Harness>) => void setHarness: (patch: Partial<Harness>) => void
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
@@ -76,6 +83,7 @@ const initial = {
teamId: genTeamId(), teamId: genTeamId(),
team: { name: '', members: [] as string[], kit: 'KIT-01' }, team: { name: '', members: [] as string[], kit: 'KIT-01' },
device: { connected: false, port: null, uptimeS: 0 }, device: { connected: false, port: null, uptimeS: 0 },
mode: 'sim' as RunMode,
phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>, phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>,
harness: { harness: {
thresholdG: 0.8, thresholdG: 0.8,
@@ -83,6 +91,7 @@ const initial = {
callsPerMinute: 8, callsPerMinute: 8,
provider: 'anthropic' as Provider, provider: 'anthropic' as Provider,
model: 'claude-haiku-4-5', model: 'claude-haiku-4-5',
fallbackLocal: true,
}, },
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 }, stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
add: { L1: null, L2: '', L3: '', L4: '', L5: '' } as AddLayers, add: { L1: null, L2: '', L3: '', L4: '', L5: '' } as AddLayers,
@@ -95,6 +104,7 @@ export const useSession = create<SessionState>()(
...initial, ...initial,
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })), setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })), setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
setMode: (mode) => set({ mode }),
completePhase: (phase) => completePhase: (phase) =>
set((s) => ({ phases: { ...s.phases, [phase]: true } })), set((s) => ({ phases: { ...s.phases, [phase]: true } })),
setHarness: (patch) => set((s) => ({ harness: { ...s.harness, ...patch } })), setHarness: (patch) => set((s) => ({ harness: { ...s.harness, ...patch } })),
+5
View File
@@ -51,8 +51,13 @@ export interface LeaderboardRow {
} }
/** WebSocket events broadcast by the hub to admin/judge clients. */ /** WebSocket events broadcast by the hub to admin/judge clients. */
/** Normalized on-device agent activity, distilled from a node's raw event stream. */
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] } | { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] }
| { type: 'team:update'; team: TeamSnapshot } | { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary } | { type: 'submission:new'; submission: SubmissionSummary }
| { type: 'score:new'; teamId: string; total: number } | { type: 'score:new'; teamId: string; total: number }
| { type: 'node:status'; teamId: string; online: boolean }
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
+3
View File
@@ -14,5 +14,8 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'], setupFiles: ['./src/setupTests.ts'],
css: true, css: true,
// Our app tests only. Exclude the vendored QClaw/llama.cpp tree, which ships
// its own Svelte/e2e tests that this React+jsdom runner can't transform.
exclude: ['**/node_modules/**', '**/dist/**', 'Uno-QClaw/**'],
}, },
}) })