feat(apess): wire real ZeroClaw nodes + live/sim + cloud-local fallback #1
+5
-2
@@ -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",
|
||||
|
||||
Generated
+6
-5
@@ -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
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 & 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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 & 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 team’s 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
|
||||
|
||||
@@ -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
@@ -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 } })),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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/**'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user