feat(apess): wire real ZeroClaw nodes + live/sim + cloud-local fallback #1

Merged
osobh merged 3 commits from feat/zeroclaw-node-integration into main 2026-07-03 13:39:31 +00:00
27 changed files with 1185 additions and 26 deletions
+5 -2
View File
@@ -11,13 +11,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"ws": "^8.18.0"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
+6 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
better-sqlite3:
specifier: ^11.8.1
version: 11.10.0
specifier: ^12.11.1
version: 12.11.1
cors:
specifier: ^2.8.5
version: 2.8.6
@@ -448,8 +448,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
[email protected]0.0:
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
better-sqlite3@12.11.1:
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
[email protected]:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@@ -1477,7 +1478,7 @@ snapshots:
[email protected]: {}
[email protected]0.0:
better-sqlite3@12.11.1:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
+56 -1
View File
@@ -3,6 +3,7 @@ import cors from 'cors'
import type { Store } from './db'
import { requireCode, requireAnyCode } from './auth'
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
import type { NodeBridge } from './nodes'
export interface AppOptions {
store: Store
@@ -11,6 +12,8 @@ export interface AppOptions {
judgeCode: string
corsOrigin?: 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 }
@@ -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). */
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 app = express()
app.use(cors({ origin: opts.corsOrigin ?? true }))
@@ -101,5 +104,57 @@ export function createApp(opts: AppOptions): Express {
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 agent = typeof b.agent === 'string' ? b.agent : undefined
const ok = await nodes.prompt(String(req.params.teamId), b.message, agent)
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
}
+3
View File
@@ -3,6 +3,7 @@ import { openStore } from './db'
import { createHub } from './hub'
import { createApp } from './app'
import { attachWs } from './ws'
import { createNodeBridge } from './nodes'
const PORT = Number(process.env.PORT ?? 3000)
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 hub = createHub()
const nodes = createNodeBridge({ broadcast: hub.broadcast })
const app = createApp({
store,
broadcast: hub.broadcast,
adminCode: ADMIN_CODE,
judgeCode: JUDGE_CODE,
corsOrigin: CORS_ORIGIN,
nodes,
})
const server = http.createServer(app)
+85
View File
@@ -0,0 +1,85 @@
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; agent?: 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, agent) => {
sent.push({ node, message, agent })
},
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', agent: 'local' }).expect(202)
expect(sent).toHaveLength(1)
expect(sent[0].message).toBe('scroll HELLO')
expect(sent[0].agent).toBe('local')
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()
})
})
+258
View File
@@ -0,0 +1,258 @@
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.)
/** 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 a specific pre-provisioned agent alias on
* the node (routed via `?agent=`; defaults to `default`). Fire-and-return.
*/
export async function sendPrompt(node: NodeRef, message: string, agent = 'default'): Promise<void> {
await fetch(`${node.url}/webhook?agent=${encodeURIComponent(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, agent?: 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, agent?: 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, agent) {
const node = registry.get(teamId)
if (!node) return false
await send(node, message, agent)
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
}
/** Normalized on-device agent activity, distilled from a node's raw event stream. */
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response'
export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] }
| { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary }
| { 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 }
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BoardActivity } from './BoardActivity'
import type { NodeActivityEntry } from '@/lib/useCollective'
const entry = (over: Partial<NodeActivityEntry>): NodeActivityEntry => ({
teamId: 't1',
kind: 'flash',
label: 'Flashed to 0x80F0000',
ts: 'T',
...over,
})
describe('BoardActivity', () => {
it('shows an empty hint when there is no activity', () => {
render(<BoardActivity activity={[]} />)
expect(screen.getByTestId('board-activity-empty')).toBeInTheDocument()
})
it('renders entries newest-first with resolved team names', () => {
render(
<BoardActivity
activity={[entry({ label: 'Flashed to 0x80F0000' }), entry({ kind: 'thinking', label: 'Agent started' })]}
nameFor={(id) => (id === 't1' ? 'Team Rocket' : id)}
/>,
)
const items = screen.getByTestId('board-activity').querySelectorAll('li')
expect(items).toHaveLength(2)
expect(items[0]).toHaveTextContent('Flashed to 0x80F0000')
expect(screen.getAllByText('Team Rocket')).toHaveLength(2)
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { NodeActivityEntry } from '@/lib/useCollective'
import type { NodeActivityKind } from '@/types'
import { cn } from '@/lib/utils'
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
}
export interface BoardActivityProps {
activity: NodeActivityEntry[]
/** Resolve a teamId to a display name (falls back to the id). */
nameFor?: (teamId: string) => string
}
/** Instructor-facing rolling feed of real on-device agent activity across boards. */
export function BoardActivity({ activity, nameFor }: BoardActivityProps) {
if (activity.length === 0) {
return (
<p data-testid="board-activity-empty" className="font-mono text-[11px] text-muted-foreground">
No board activity yet boards report generate / compile / flash here as teams work.
</p>
)
}
return (
<ul data-testid="board-activity" className="space-y-1.5">
{activity.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="text-muted-foreground shrink-0">{nameFor ? nameFor(e.teamId) : e.teamId}</span>
<span
className={cn(
'truncate',
e.kind === 'error' && 'text-destructive',
e.kind === 'flash' && 'text-foreground font-medium',
)}
>
{e.label}
</span>
</li>
))}
</ul>
)
}
+95
View File
@@ -0,0 +1,95 @@
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 }))
// default harness (cloud + fallback) routes to the `default` agent alias
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'default')
// 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()
})
})
})
+136
View File
@@ -0,0 +1,136 @@
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 { harnessToAgent } from '@/lib/harness'
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 harness = useSession((s) => s.harness)
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, harnessToAgent(harness))
} 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.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: 'groq', label: 'Groq' },
{ id: 'openai', label: 'OpenAI' },
{ id: 'local', label: 'Local' },
]
export interface HarnessProviderSelectProps {
@@ -17,11 +18,12 @@ export interface HarnessProviderSelectProps {
export function HarnessProviderSelect({ readOnly }: HarnessProviderSelectProps) {
const provider = useSession((s) => s.harness.provider)
const model = useSession((s) => s.harness.model)
const fallbackLocal = useSession((s) => s.harness.fallbackLocal)
const setHarness = useSession((s) => s.setHarness)
return (
<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) => {
const selected = p.id === provider
return (
@@ -55,6 +57,22 @@ export function HarnessProviderSelect({ readOnly }: HarnessProviderSelectProps)
className="font-mono"
/>
</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>
)
}
+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')
}
// --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message, agent }),
})
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 ------------------------------------------------------------
export interface OpenCollectiveOptions {
reconnectMs?: number
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { harnessToToml } from './harness'
import { harnessToToml, harnessToAgent } from './harness'
import type { Harness } from '@/store/session'
const harness: Harness = {
@@ -8,6 +8,7 @@ const harness: Harness = {
callsPerMinute: 8,
provider: 'anthropic',
model: 'claude-haiku-4-5',
fallbackLocal: true,
}
describe('harnessToToml', () => {
@@ -29,4 +30,30 @@ describe('harnessToToml', () => {
it('reflects live edits to the threshold values', () => {
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')
})
})
describe('harnessToAgent', () => {
it('routes a local primary to the local agent', () => {
expect(harnessToAgent({ ...harness, provider: 'local' })).toBe('local')
})
it('routes a cloud primary with fallback to the default (cloud+fallback) agent', () => {
expect(harnessToAgent({ ...harness, provider: 'anthropic', fallbackLocal: true })).toBe('default')
})
it('routes a cloud primary without fallback to the cloud-only agent', () => {
expect(harnessToAgent({ ...harness, provider: 'groq', fallbackLocal: false })).toBe('cloud')
})
})
+20 -3
View File
@@ -2,7 +2,7 @@ import type { Harness } from '@/store/session'
/** Render the live harness config as a `harness.toml`-style document. */
export function harnessToToml(h: Harness): string {
return [
const lines = [
'[harness]',
`threshold_g = ${h.thresholdG}`,
`threshold_db = ${h.thresholdDb}`,
@@ -11,6 +11,23 @@ export function harnessToToml(h: Harness): string {
'[provider]',
`name = "${h.provider}"`,
`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')
}
/**
* Map a harness to the ZeroClaw agent alias a board should handle the request
* with (routed per-request via `?agent=`). Boards are provisioned with:
* - `local` — on-board Qwen only
* - `cloud` — cloud provider, no fallback
* - `default` — cloud provider with on-board Qwen fallback
*/
export function harnessToAgent(h: Harness): string {
if (h.provider === 'local') return 'local'
return h.fallbackLocal ? 'default' : 'cloud'
}
+32 -1
View File
@@ -13,7 +13,7 @@ const team = (id: string): TeamSnapshot => ({
updatedAt: '2026-07-27T13:00:00.000Z',
})
const empty: CollectiveState = { teams: {}, submissions: {} }
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] }
describe('collectiveReducer', () => {
it('seeds from a snapshot', () => {
@@ -46,4 +46,35 @@ describe('collectiveReducer', () => {
const next = collectiveReducer(empty, { type: 'score:new', teamId: 'ghost', total: 1 })
expect(next).toBe(empty)
})
it('tracks per-team board online status on node:status', () => {
const next = collectiveReducer(empty, { type: 'node:status', teamId: 'a', online: true })
expect(next.nodes.a).toBe(true)
const off = collectiveReducer(next, { type: 'node:status', teamId: 'a', online: false })
expect(off.nodes.a).toBe(false)
})
it('prepends node:activity to a bounded, most-recent-first feed', () => {
const first = collectiveReducer(empty, {
type: 'node:activity',
teamId: 'a',
kind: 'thinking',
label: 'Agent started',
ts: 'T1',
})
const second = collectiveReducer(first, {
type: 'node:activity',
teamId: 'a',
kind: 'flash',
label: 'Flashed to 0x80F0000',
ts: 'T2',
})
expect(second.activity.map((e) => e.label)).toEqual(['Flashed to 0x80F0000', 'Agent started'])
})
it('preserves node status/activity across a fresh snapshot', () => {
const withNode = collectiveReducer(empty, { type: 'node:status', teamId: 'a', online: true })
const next = collectiveReducer(withNode, { type: 'snapshot', teams: [team('a')], submissions: [] })
expect(next.nodes.a).toBe(true)
})
})
+28 -4
View File
@@ -1,13 +1,25 @@
import { useEffect, useReducer, useRef, useState } from 'react'
import { openCollective, getTeams, getSubmissions } from './api'
import type { TeamSnapshot, SubmissionSummary, WsEvent } from '@/types'
import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types'
export interface NodeActivityEntry {
teamId: string
kind: NodeActivityKind
label: string
ts: string
}
export interface CollectiveState {
teams: Record<string, TeamSnapshot>
submissions: Record<string, SubmissionSummary>
/** teamId → board online. */
nodes: Record<string, boolean>
/** rolling board-activity feed, most-recent-first, bounded. */
activity: NodeActivityEntry[]
}
const empty: CollectiveState = { teams: {}, submissions: {} }
const MAX_ACTIVITY = 40
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] }
export function collectiveReducer(state: CollectiveState, event: WsEvent): CollectiveState {
switch (event.type) {
@@ -16,7 +28,7 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
event.teams.forEach((t) => (teams[t.id] = t))
const submissions: Record<string, SubmissionSummary> = {}
event.submissions.forEach((s) => (submissions[s.teamId] = s))
return { teams, submissions }
return { ...state, teams, submissions }
}
case 'team:update':
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
@@ -33,6 +45,16 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
submissions: { ...state.submissions, [event.teamId]: { ...existing, scored: true } },
}
}
case 'node:status':
return { ...state, nodes: { ...state.nodes, [event.teamId]: event.online } }
case 'node:activity':
return {
...state,
activity: [
{ teamId: event.teamId, kind: event.kind, label: event.label, ts: event.ts },
...state.activity,
].slice(0, MAX_ACTIVITY),
}
default:
return state
}
@@ -43,6 +65,8 @@ export type CollectiveStatus = 'connecting' | 'live' | 'polling'
export interface Collective {
teams: TeamSnapshot[]
submissions: Record<string, SubmissionSummary>
nodes: Record<string, boolean>
activity: NodeActivityEntry[]
status: CollectiveStatus
}
@@ -93,5 +117,5 @@ export function useCollective(code: string): Collective {
}, [code])
const teams = Object.values(state.teams).sort((a, b) => a.name.localeCompare(b.name))
return { teams, submissions: state.submissions, status }
return { teams, submissions: state.submissions, nodes: state.nodes, activity: state.activity, status }
}
+11 -2
View File
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'
import { AccessGate } from '@/components/AccessGate'
import { TeamCard } from '@/components/TeamCard'
import { BoardActivity } from '@/components/BoardActivity'
import { useCollective } from '@/lib/useCollective'
import { useNow } from '@/lib/useNow'
import { cn } from '@/lib/utils'
@@ -15,11 +16,13 @@ function Stat({ label, value }: { label: string; value: number | string }) {
}
function AdminBoard({ code }: { code: string }) {
const { teams, submissions, status } = useCollective(code)
const { teams, submissions, nodes, activity, status } = useCollective(code)
const now = useNow()
const connected = teams.filter((t) => t.deviceConnected).length
const boardsLive = Object.values(nodes).filter(Boolean).length
const submittedCount = Object.keys(submissions).length
const judgedCount = Object.values(submissions).filter((s) => s.scored).length
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
return (
<main className="min-h-screen bg-background">
@@ -40,13 +43,19 @@ function AdminBoard({ code }: { code: string }) {
</header>
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden">
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-border rounded-md overflow-hidden">
<Stat label="Teams" value={teams.length} />
<Stat label="Connected" value={connected} />
<Stat label="Boards live" value={boardsLive} />
<Stat label="Submitted" value={submittedCount} />
<Stat label="Judged" value={judgedCount} />
</div>
<div className="border border-border rounded-md p-4 space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Board activity</div>
<BoardActivity activity={activity} nameFor={nameFor} />
</div>
{teams.length === 0 ? (
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
) : (
+57
View File
@@ -45,6 +45,7 @@ describe('EnvSetup', () => {
afterEach(() => vi.useRealTimers())
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 })
renderPage()
@@ -64,8 +65,64 @@ describe('EnvSetup', () => {
})
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 })
renderPage()
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 { PhaseStrip } from '@/components/PhaseStrip'
import { HarnessProviderSelect } from '@/components/HarnessProviderSelect'
import { useSession } from '@/store/session'
import { useSession, type RunMode } from '@/store/session'
import { requestPort } from '@/lib/serial'
import { cn } from '@/lib/utils'
@@ -16,12 +16,28 @@ const SELFTEST_FRAMES = 3
export function EnvSetup() {
const navigate = useNavigate()
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 completePhase = useSession((s) => s.completePhase)
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 () => {
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()
let count = 0
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 = () => {
completePhase('setup')
@@ -73,9 +90,44 @@ export function EnvSetup() {
<CardTitle className="text-base">Board &amp; sense path</CardTitle>
</CardHeader>
<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="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="flex items-center gap-2">
<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">
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>
@@ -96,7 +149,7 @@ export function EnvSetup() {
<Button
variant="outline"
className="w-full justify-start"
disabled={!device.connected || selfTest === 'running'}
disabled={!deviceReady || selfTest === 'running'}
onClick={runSelfTest}
>
<span
+3
View File
@@ -9,6 +9,7 @@ import { HarnessTomlPreview } from '@/components/HarnessTomlPreview'
import { TriggerButtons } from '@/components/TriggerButtons'
import { LiveFeed } from '@/components/LiveFeed'
import { StatsTally } from '@/components/StatsTally'
import { BuildFlash } from '@/components/BuildFlash'
import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial'
import { useSession } from '@/store/session'
@@ -84,6 +85,8 @@ export function Module2() {
</Card>
</div>
<BuildFlash />
<div className="grid lg:grid-cols-2 gap-6">
<AddLayerForm
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 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 {
name: string
@@ -25,6 +28,8 @@ export interface Harness {
callsPerMinute: number
provider: Provider
model: string
/** Fall back to the board's on-board Qwen when the cloud provider is unreachable. */
fallbackLocal: boolean
}
export interface SessionStats {
@@ -57,6 +62,7 @@ export interface SessionState {
teamId: string
team: Team
device: Device
mode: RunMode
phases: Record<PhaseKey, boolean>
harness: Harness
stats: SessionStats
@@ -64,6 +70,7 @@ export interface SessionState {
submission: Submission
setTeam: (patch: Partial<Team>) => void
setDevice: (patch: Partial<Device>) => void
setMode: (mode: RunMode) => void
completePhase: (phase: PhaseKey) => void
setHarness: (patch: Partial<Harness>) => void
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
@@ -76,6 +83,7 @@ const initial = {
teamId: genTeamId(),
team: { name: '', members: [] as string[], kit: 'KIT-01' },
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>,
harness: {
thresholdG: 0.8,
@@ -83,6 +91,7 @@ const initial = {
callsPerMinute: 8,
provider: 'anthropic' as Provider,
model: 'claude-haiku-4-5',
fallbackLocal: true,
},
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
add: { L1: null, L2: '', L3: '', L4: '', L5: '' } as AddLayers,
@@ -95,6 +104,7 @@ export const useSession = create<SessionState>()(
...initial,
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
setMode: (mode) => set({ mode }),
completePhase: (phase) =>
set((s) => ({ phases: { ...s.phases, [phase]: true } })),
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. */
/** Normalized on-device agent activity, distilled from a node's raw event stream. */
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response'
export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] }
| { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary }
| { 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',
setupFiles: ['./src/setupTests.ts'],
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/**'],
},
})