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:
co-authored by
Claude Opus 4.8
parent
d58065d292
commit
f21bbdf31e
+5
-2
@@ -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",
|
||||||
|
|||||||
Generated
+6
-5
@@ -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
@@ -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,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 & 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 & sense path</CardTitle>
|
<CardTitle className="text-base">Board & 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 team’s 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
|
||||||
|
|||||||
@@ -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
@@ -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 } })),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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/**'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user