Merge feat/workshop-failure-first-alignment: edge + control-plane federation

Board hwId identity (Phase 1), reporter sidecar + central ingest
(Phase 2), and the central fleet dashboard (Phase 3), plus the earlier
workshop failure-first alignment + refine features.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-21 23:28:53 -07:00
co-authored by Claude Opus 4.8
60 changed files with 2680 additions and 340 deletions
+51 -1
View File
@@ -99,7 +99,9 @@ describe('collective API', () => {
it('lists submission summaries with a scored flag', async () => { it('lists submission summaries with a scored flag', async () => {
await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd }) await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd })
const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE) const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
expect(before.body[0].scored).toBe(false) // the summary must carry the identifying fields, not just `scored`
expect(before.body[0]).toMatchObject({ teamId: 't1', scored: false })
expect(typeof before.body[0].submittedAt).toBe('string')
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 }) await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 })
const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE) const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
expect(after.body[0].scored).toBe(true) expect(after.body[0].scored).toBe(true)
@@ -121,4 +123,52 @@ describe('collective API', () => {
expect(events.some((e) => e.type === 'score:new')).toBe(true) expect(events.some((e) => e.type === 'score:new')).toBe(true)
}) })
}) })
describe('federation (central-mode)', () => {
const FLEET = 'fleet-secret'
let central: ReturnType<typeof createApp>
let cEvents: WsEvent[]
beforeEach(() => {
cEvents = []
central = createApp({
store: openStore(':memory:'),
broadcast: (e) => cEvents.push(e),
adminCode: ADMIN,
judgeCode: JUDGE,
fleetSecret: FLEET,
now: () => '2026-07-27T14:00:00.000Z',
})
})
it('registers an instance (fleet-secret gated) and broadcasts instance:update', async () => {
await request(central).post('/instances/register').send({ id: 'site-a', name: 'Team A laptop' }).expect(401)
const res = await request(central)
.post('/instances/register')
.set('x-fleet-secret', FLEET)
.send({ id: 'site-a', name: 'Team A laptop' })
.expect(201)
expect(res.body).toMatchObject({ id: 'site-a', name: 'Team A laptop', lastSeen: '2026-07-27T14:00:00.000Z' })
expect(cEvents.some((e) => e.type === 'instance:update')).toBe(true)
const list = await request(central).get('/instances').set('X-Access-Code', ADMIN).expect(200)
expect(list.body).toEqual([{ id: 'site-a', name: 'Team A laptop', lastSeen: '2026-07-27T14:00:00.000Z' }])
})
it('heartbeat upserts the same instance by id', async () => {
await request(central).post('/instances/register').set('x-fleet-secret', FLEET).send({ id: 'site-a' })
await request(central).post('/instances/site-a/heartbeat').set('x-fleet-secret', FLEET).send({}).expect(201)
const list = await request(central).get('/instances').set('X-Access-Code', ADMIN)
expect(list.body).toHaveLength(1) // still one instance, not duplicated
expect(list.body[0]).toMatchObject({ id: 'site-a', name: 'site-a' }) // name falls back to id
})
it('ingests site-tagged team + submission writes so central can group by site', async () => {
await request(central).put('/teams/site-a:team-1').send(team('team-1', { site: 'site-a' })).expect(204)
await request(central)
.post('/submissions')
.send({ teamId: 'site-a:team-1', code: 'c', add: fullAdd, site: 'site-a' })
.expect(201)
const teams = await request(central).get('/teams').set('X-Access-Code', ADMIN)
expect(teams.body[0]).toMatchObject({ id: 'site-a:team-1', site: 'site-a' })
})
})
}) })
+77 -11
View File
@@ -49,6 +49,7 @@ export function createApp(opts: AppOptions): Express {
stats: { ...emptyStats, ...(b.stats ?? {}) }, stats: { ...emptyStats, ...(b.stats ?? {}) },
deviceConnected: !!b.deviceConnected, deviceConnected: !!b.deviceConnected,
updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(), updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(),
site: typeof b.site === 'string' ? b.site : '',
} }
store.upsertTeam(team) store.upsertTeam(team)
broadcast({ type: 'team:update', team }) broadcast({ type: 'team:update', team })
@@ -66,6 +67,7 @@ export function createApp(opts: AppOptions): Express {
code: b.code, code: b.code,
add: b.add, add: b.add,
submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(), submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(),
site: typeof b.site === 'string' ? b.site : '',
} }
const summary = store.upsertSubmission(dto) const summary = store.upsertSubmission(dto)
broadcast({ type: 'submission:new', submission: summary }) broadcast({ type: 'submission:new', submission: summary })
@@ -110,6 +112,11 @@ export function createApp(opts: AppOptions): Express {
res.json(store.leaderboard()) res.json(store.leaderboard())
}) })
// Central-mode fleet view: the local instances that have phoned home.
app.get('/instances', requireAnyCode(adminCode, judgeCode), (_req, res) => {
res.json(store.listInstances())
})
// --- ZeroClaw nodes ----------------------------------------------------- // --- ZeroClaw nodes -----------------------------------------------------
// Registration holds bearer tokens → admin only. Prompting is a public // Registration holds bearer tokens → admin only. Prompting is a public
// participant action (like PUT /teams/:id). List/broadcasts never leak tokens. // participant action (like PUT /teams/:id). List/broadcasts never leak tokens.
@@ -150,17 +157,55 @@ export function createApp(opts: AppOptions): Express {
return res.status(401).json({ error: 'unauthorized' }) return res.status(401).json({ error: 'unauthorized' })
} }
const b = req.body ?? {} const b = req.body ?? {}
if (![b.kitId, b.url, b.token, b.claimCode].every((v) => typeof v === 'string' && v)) { // `url` is optional: an App-Lab-containerised node can't see its host LAN IP,
return res.status(400).json({ error: 'kitId, url, token and claimCode are required' }) // but its request is SNAT'd to the host, so we derive http://<source-ip>:<port>
// from what the server actually sees. An explicit url (e.g. from the shell
// self-register) still wins.
let url = typeof b.url === 'string' && b.url ? b.url : ''
if (!url) {
const port = Number.isFinite(Number(b.port)) ? Number(b.port) : 8080
const ip = (req.ip ?? '').replace(/^::ffff:/, '') // unwrap IPv4-mapped IPv6
if (ip) url = `http://${ip}:${port}`
} }
const r = boards.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode }) const token = typeof b.token === 'string' && b.token ? b.token : 'open-lan'
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
}
// `hwId` is the durable board key (WiFi MAC + eMMC serial). Older nodes that
// don't send it fall back to keying by kitId, so they still self-register.
const hwId = typeof b.hwId === 'string' && b.hwId ? b.hwId : b.kitId
const r = boards.announce({ hwId, kitId: b.kitId, url, token, claimCode: b.claimCode })
if (r.claimed && r.teamId && nodes) { if (r.claimed && r.teamId && nodes) {
await nodes.register({ teamId: r.teamId, url: b.url, token: b.token }) await nodes.register({ teamId: r.teamId, url, token })
} }
broadcastUnclaimed() broadcastUnclaimed()
res.status(201).json({ kitId: b.kitId, claimed: r.claimed }) res.status(201).json({ kitId: b.kitId, url, claimed: r.claimed })
}) })
// --- federation: local instances phone home (central-mode) --------------
// A per-team local (edge) stack's reporter sidecar registers itself and then
// heartbeats. Fleet-secret gated (same shared secret as self-register). The
// sidecar replays team/submission writes UP via the public PUT/POST routes,
// tagged with `site` (= the instance id), so central becomes the fleet view.
const registerInstance = (req: express.Request, res: express.Response) => {
if (!fleetSecret || !matches(req.header('x-fleet-secret') ?? '', fleetSecret)) {
return res.status(401).json({ error: 'unauthorized' })
}
const b = req.body ?? {}
const id = typeof b.id === 'string' ? b.id : req.params.id
if (typeof id !== 'string' || !id) return res.status(400).json({ error: 'id is required' })
const instance = {
id,
name: typeof b.name === 'string' && b.name ? b.name : id,
lastSeen: now(),
}
store.upsertInstance(instance)
broadcast({ type: 'instance:update', instance })
return res.status(201).json(instance)
}
app.post('/instances/register', registerInstance)
app.post('/instances/:id/heartbeat', registerInstance)
// A participant claims their powered-on board to their team by proving // A participant claims their powered-on board to their team by proving
// possession of the kit's claim code. Public + rate-limited (no operator in // possession of the kit's claim code. Public + rate-limited (no operator in
// the loop); the bearer token moves straight from the pool into the bridge // the loop); the bearer token moves straight from the pool into the bridge
@@ -173,10 +218,15 @@ export function createApp(opts: AppOptions): Express {
app.post('/claim', async (req, res) => { app.post('/claim', async (req, res) => {
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' }) if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') { if (typeof b.teamId !== 'string' || typeof b.code !== 'string') {
return res.status(400).json({ error: 'kit, teamId and code are required' }) return res.status(400).json({ error: 'teamId and code are required' })
} }
const result = boards.claim(b.kit, b.code, b.teamId, Date.parse(now())) // Code-first (board scrolls its code on the matrix, no kit picked) is the
// default; a supplied `kit` keeps the legacy sticker-claim path working.
const result =
typeof b.kit === 'string' && b.kit
? boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
: boards.claimByCode(b.code, b.teamId, Date.parse(now()))
if (!result.ok) { if (!result.ok) {
if (result.reason === 'unknown') { if (result.reason === 'unknown') {
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' }) return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
@@ -184,7 +234,7 @@ export function createApp(opts: AppOptions): Express {
if (result.reason === 'rate_limited') { if (result.reason === 'rate_limited') {
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' }) return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
} }
return res.status(401).json({ error: 'wrong claim code' }) return res.status(401).json({ error: "wrong code — check what your board is showing on its matrix" })
} }
const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim) const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
await nodes.register({ teamId, url: result.board.url, token: result.board.token }) await nodes.register({ teamId, url: result.board.url, token: result.board.token })
@@ -196,7 +246,7 @@ export function createApp(opts: AppOptions): Express {
const team: TeamSnapshot = { const team: TeamSnapshot = {
id: teamId, id: teamId,
name: pickName ?? '', name: pickName ?? '',
kit: b.kit, kit: result.board.kitId,
members: pickMembers ?? [], members: pickMembers ?? [],
domain: typeof prev?.domain === 'string' ? prev.domain : '', domain: typeof prev?.domain === 'string' ? prev.domain : '',
phases: { ...emptyPhases, ...(prev?.phases ?? {}) }, phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
@@ -208,7 +258,7 @@ export function createApp(opts: AppOptions): Express {
broadcast({ type: 'team:update', team }) broadcast({ type: 'team:update', team })
broadcastUnclaimed() // the claimed kit left the pool broadcastUnclaimed() // the claimed kit left the pool
const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
res.status(201).json({ teamId, kit: b.kit, url: result.board.url, online, resumed: result.resumed, team }) res.status(201).json({ teamId, kit: result.board.kitId, url: result.board.url, online, resumed: result.resumed, team })
}) })
// Instructor action: release a kit back to the unclaimed pool and unbind its // Instructor action: release a kit back to the unclaimed pool and unbind its
@@ -243,6 +293,22 @@ export function createApp(opts: AppOptions): Express {
res.status(202).json({ accepted: true }) res.status(202).json({ accepted: true })
}) })
// Onboarding "say hi": prompt the node and WAIT for its reply (blocking) so the
// participant sees their agent answer. Greeting only — never a flash turn.
app.post('/nodes/:teamId/say-hi', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
const message = typeof b.message === 'string' && b.message.trim() ? b.message : 'Hi! Introduce yourself in one sentence.'
const agent = typeof b.agent === 'string' ? b.agent : undefined
try {
const reply = await nodes.sayHi(String(req.params.teamId), message, agent)
if (reply === null) return res.status(404).json({ error: 'no node registered for team' })
res.json({ reply })
} catch {
res.status(502).json({ error: 'your node did not answer — is it online?' })
}
})
// Public liveness for a team's board — the wizard/self-test polls this after // Public liveness for a team's board — the wizard/self-test polls this after
// a claim. Online reflects the bridge's live /health + SSE view. // a claim. Online reflects the bridge's live /health + SSE view.
app.get('/nodes/:teamId/status', (req, res) => { app.get('/nodes/:teamId/status', (req, res) => {
+47 -5
View File
@@ -11,7 +11,12 @@ const ADMIN = 'admin-code'
const JUDGE = 'judge-code' const JUDGE = 'judge-code'
const FLEET = 'fleet-secret' const FLEET = 'fleet-secret'
const board = { kitId: 'KIT-07', url: 'http://192.168.1.7:8080', token: 'zc_secret_token', claimCode: '418302' } const board: { kitId: string; url: string; token: string; claimCode: string; hwId?: string } = {
kitId: 'KIT-07',
url: 'http://192.168.1.7:8080',
token: 'zc_secret_token',
claimCode: '418302',
}
describe('board self-register + claim', () => { describe('board self-register + claim', () => {
let store: Store let store: Store
@@ -51,13 +56,24 @@ describe('board self-register + claim', () => {
await selfRegister({}, 'wrong-secret').expect(401) await selfRegister({}, 'wrong-secret').expect(401)
}) })
it('validates the body', async () => { it('validates the body (kitId + claimCode required)', async () => {
await selfRegister({ token: '' }).expect(400) await selfRegister({ kitId: '' }).expect(400)
await selfRegister({ claimCode: '' }).expect(400)
}) })
it('accepts a board presenting the fleet secret', async () => { it('accepts a board presenting the fleet secret', async () => {
const res = await selfRegister().expect(201) const res = await selfRegister().expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: false }) expect(res.body).toMatchObject({ kitId: 'KIT-07', url: board.url, claimed: false })
})
it('derives the node url from the source IP when url is omitted', async () => {
const res = await request(app)
.post('/nodes/self-register')
.set('x-fleet-secret', FLEET)
.send({ kitId: 'KIT-08', claimCode: '4821', port: 8080 })
.expect(201)
// supertest connects over loopback → derived host is 127.0.0.1
expect(res.body.url).toMatch(/^http:\/\/127\.0\.0\.1:8080$/)
}) })
it('broadcasts the unclaimed pool and lists it for the instructor', async () => { it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
@@ -112,6 +128,20 @@ describe('board self-register + claim', () => {
expect(pool.body).toEqual({ kits: [] }) expect(pool.body).toEqual({ kits: [] })
}) })
it('code-first: binds by the matrix code with no kit supplied', async () => {
const res = await request(app)
.post('/claim')
.send({ teamId: 'team-07', teamName: 'team_resonance', members: ['A. Rossi'], code: '418302' })
.expect(201)
expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', online: true, resumed: false })
expect(res.body.team).toMatchObject({ id: 'team-07', name: 'team_resonance', members: ['A. Rossi'], deviceConnected: true })
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
})
it('code-first: a wrong code is rejected', async () => {
await request(app).post('/claim').send({ teamId: 'team-07', code: '000000' }).expect(401)
})
it('exposes public per-team liveness after a claim', async () => { it('exposes public per-team liveness after a claim', async () => {
await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
@@ -152,7 +182,7 @@ describe('board self-register + claim', () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
// board reboots with a new IP + a fresh paired token and re-announces // board reboots with a new IP + a fresh paired token and re-announces
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201) const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: true }) expect(res.body).toMatchObject({ kitId: 'KIT-07', url: 'http://192.168.1.9:8080', claimed: true })
// the team's binding is refreshed (new url), still online, still not unclaimed // the team's binding is refreshed (new url), still online, still not unclaimed
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200) const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }]) expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
@@ -160,6 +190,18 @@ describe('board self-register + claim', () => {
expect(pool.body).toEqual({ kits: [] }) expect(pool.body).toEqual({ kits: [] })
}) })
it('recognizes the same board by hwId even after its kit label is re-provisioned', async () => {
// a distinct board announces with a durable hwId, then gets claimed
await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-XY', claimCode: '999999' }).expect(201)
await request(app).post('/claim').send({ kit: 'KIT-XY', teamId: 'team-xy', code: '999999' }).expect(201)
// re-provisioned under a new kit label + new IP, SAME hardware → still claimed, no orphan
const res = await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-RENAMED', claimCode: '999999', url: 'http://192.168.1.9:8080' }).expect(201)
expect(res.body).toMatchObject({ kitId: 'KIT-RENAMED', claimed: true })
// the renamed board is not offered as a fresh unclaimed board (only the default KIT-07 remains)
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
expect(pool.body).toEqual({ kits: ['KIT-07'] })
})
it('lets an instructor release a claimed kit back to the pool for reassignment', async () => { it('lets an instructor release a claimed kit back to the pool for reassignment', async () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only
+43
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim' import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({ const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
hwId: 'hw-07',
kitId: 'KIT-07', kitId: 'KIT-07',
url: 'http://192.168.1.7:8080', url: 'http://192.168.1.7:8080',
token: 'zc_secret_token', token: 'zc_secret_token',
@@ -51,6 +52,25 @@ describe('board registry', () => {
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99 expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
}) })
it('claimByCode binds the matching board with no kit, and rejects a wrong code', () => {
const r = createBoardRegistry()
r.announce(board())
expect(r.claimByCode('000000', 'team-07', 0)).toEqual({ ok: false, reason: 'bad_code' })
const out = r.claimByCode('418302', 'team-07', 0)
expect(out).toMatchObject({ ok: true, resumed: false })
expect(out.ok && out.board.kitId).toBe('KIT-07')
expect(r.isClaimed('KIT-07')).toBe(true)
})
it('claimByCode resumes an already-claimed board to its canonical team', () => {
const r = createBoardRegistry()
r.announce(board())
r.claimByCode('418302', 'team-07', 0)
const again = r.claimByCode('418302', 'team-99', 1)
expect(again).toMatchObject({ ok: true, resumed: true })
expect(again.ok && again.board.claimedBy).toBe('team-07')
})
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => { it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
const r = createBoardRegistry() const r = createBoardRegistry()
r.announce(board()) r.announce(board())
@@ -92,4 +112,27 @@ describe('board registry', () => {
r.announce(board()) // board rebooted and re-registered r.announce(board()) // board rebooted and re-registered
expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true }) expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
}) })
it('recognizes the same board (durable hwId) even when its kit label changes', () => {
const r = createBoardRegistry()
r.announce(board())
r.claim('KIT-07', '418302', 'team-07', 0)
// re-provisioned under a new kit label + new IP, same hardware → still claimed
const res = r.announce(board({ kitId: 'KIT-NEW', url: 'http://192.168.1.9:8080' }))
expect(res).toEqual({ claimed: true, teamId: 'team-07' })
expect(r.isClaimed('KIT-NEW')).toBe(true)
expect(r.get('KIT-NEW')?.claimedBy).toBe('team-07')
expect(r.unclaimedKits()).toEqual([]) // not a second, separate board
})
it('seeds from persisted boards and reports each mutation via onChange', () => {
const saved: string[] = []
const r = createBoardRegistry(
[{ hwId: 'hw-07', kitId: 'KIT-07', url: 'u', token: 't', claimCode: '418302', claimedBy: 'team-07' }],
(b) => saved.push(`${b.hwId}:${b.claimedBy}`),
)
expect(r.isClaimed('KIT-07')).toBe(true) // restored claim survives restart
r.release('KIT-07')
expect(saved).toContain('hw-07:null') // release persisted
})
}) })
+79 -21
View File
@@ -5,8 +5,13 @@ import { matches } from './auth'
* bearer token (server-side, never sent to a browser), the per-board claim code * bearer token (server-side, never sent to a browser), the per-board claim code
* an attendee proves possession with (printed / QR-encoded on the kit), and the * an attendee proves possession with (printed / QR-encoded on the kit), and the
* team it is bound to once claimed (`null` while unclaimed). * team it is bound to once claimed (`null` while unclaimed).
*
* The durable key is `hwId` — a stable hardware fingerprint (WiFi MAC + eMMC
* serial) that survives IP change, reboot, laptop-wipe, and OS reflash. `kitId`
* is a human-facing label only (it may change if the board is re-provisioned).
*/ */
export interface Board { export interface Board {
hwId: string
kitId: string kitId: string
url: string url: string
token: string token: string
@@ -27,11 +32,17 @@ export type ClaimOutcome =
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' } | { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
export interface BoardRegistry { export interface BoardRegistry {
/** A board announces itself on boot / on its timer. Upserts url/token/claimCode, /** A board announces itself on boot / on its timer, keyed by its stable `hwId`.
* preserves the existing claim, and reports whether it is already claimed. */ * Upserts url/token/claimCode/kitId, preserves the existing claim (so a board
announce(n: { kitId: string; url: string; token: string; claimCode: string }): AnnounceResult * re-announcing under a new IP — or even a new kitId label — is recognized as
* the same board), and reports whether it is already claimed. */
announce(n: { hwId: string; kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
/** Kit ids of boards that are not yet claimed — never url/token/claimCode. */ /** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
unclaimedKits(): { kitId: string }[] unclaimedKits(): { kitId: string }[]
/** Full board snapshot by durable hwId (for persistence / recognition). */
getByHwId(hwId: string): Board | undefined
/** All boards (for persistence). */
all(): Board[]
isClaimed(kitId: string): boolean isClaimed(kitId: string): boolean
/** /**
* Claim (or resume) a board by proving its code. First claim binds it to * Claim (or resume) a board by proving its code. First claim binds it to
@@ -40,6 +51,12 @@ export interface BoardRegistry {
* lost its browser can get back onto its own board. Rate-limited per kit. * lost its browser can get back onto its own board. Rate-limited per kit.
*/ */
claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
/**
* Claim (or resume) a board by its code ALONE — the attendee proves possession
* by reading the code the board scrolls on its own matrix, with no kit to pick.
* Finds the unique board whose `claimCode` matches; otherwise `bad_code`.
*/
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
/** Release a kit back to unclaimed; returns the freed teamId (or null). */ /** Release a kit back to unclaimed; returns the freed teamId (or null). */
release(kitId: string): string | null release(kitId: string): string | null
get(kitId: string): Board | undefined get(kitId: string): Board | undefined
@@ -49,56 +66,97 @@ export interface BoardRegistry {
export const MAX_FAILS = 5 export const MAX_FAILS = 5
export const WINDOW_MS = 60_000 export const WINDOW_MS = 60_000
export function createBoardRegistry(seed: Board[] = []): BoardRegistry { /**
const boards = new Map<string, Board>() * @param seed boards to restore (from the persistent store) on boot.
for (const b of seed) boards.set(b.kitId, { ...b }) * @param onChange called with a board whenever it is created or mutated
const fails = new Map<string, number[]>() // kitId -> recent failed-attempt timestamps (ms) * (announce/claim/release), so the caller can persist it.
*/
export function createBoardRegistry(
seed: Board[] = [],
onChange: (b: Board) => void = () => {},
): BoardRegistry {
const boards = new Map<string, Board>() // keyed by durable hwId
for (const b of seed) boards.set(b.hwId, { ...b })
const fails = new Map<string, number[]>() // hwId -> recent failed-attempt timestamps (ms)
const recentFails = (kitId: string, nowMs: number): number => { const recentFails = (hwId: string, nowMs: number): number => {
const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS) const arr = (fails.get(hwId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
if (arr.length) fails.set(kitId, arr) if (arr.length) fails.set(hwId, arr)
else fails.delete(kitId) else fails.delete(hwId)
return arr.length return arr.length
} }
// kitId is a display label, not the key — resolve it by scan (fleet is small).
const byKit = (kitId: string): Board | undefined => [...boards.values()].find((b) => b.kitId === kitId)
return { return {
announce(n) { announce(n) {
const claimedBy = boards.get(n.kitId)?.claimedBy ?? null const claimedBy = boards.get(n.hwId)?.claimedBy ?? null
boards.set(n.kitId, { kitId: n.kitId, url: n.url, token: n.token, claimCode: n.claimCode, claimedBy }) const board: Board = {
fails.delete(n.kitId) hwId: n.hwId,
kitId: n.kitId,
url: n.url,
token: n.token,
claimCode: n.claimCode,
claimedBy,
}
boards.set(n.hwId, board)
fails.delete(n.hwId)
onChange(board)
return { claimed: claimedBy !== null, teamId: claimedBy } return { claimed: claimedBy !== null, teamId: claimedBy }
}, },
unclaimedKits() { unclaimedKits() {
return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId })) return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
}, },
getByHwId(hwId) {
return boards.get(hwId)
},
all() {
return [...boards.values()]
},
isClaimed(kitId) { isClaimed(kitId) {
const b = boards.get(kitId) const b = byKit(kitId)
return !!b && b.claimedBy !== null return !!b && b.claimedBy !== null
}, },
claim(kitId, code, teamId, nowMs) { claim(kitId, code, teamId, nowMs) {
const board = boards.get(kitId) const board = byKit(kitId)
if (!board) return { ok: false, reason: 'unknown' } if (!board) return { ok: false, reason: 'unknown' }
if (recentFails(kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' } if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
if (!matches(code, board.claimCode)) { if (!matches(code, board.claimCode)) {
fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs]) fails.set(board.hwId, [...(fails.get(board.hwId) ?? []), nowMs])
return { ok: false, reason: 'bad_code' } return { ok: false, reason: 'bad_code' }
} }
fails.delete(kitId) fails.delete(board.hwId)
if (board.claimedBy === null) { if (board.claimedBy === null) {
board.claimedBy = teamId board.claimedBy = teamId
onChange(board)
return { ok: true, board, resumed: false } return { ok: true, board, resumed: false }
} }
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
}, },
claimByCode(code, teamId, nowMs) {
// Unique per-board codes → the code identifies the board. No match = bad code.
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
if (!board) return { ok: false, reason: 'bad_code' }
if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
fails.delete(board.hwId)
if (board.claimedBy === null) {
board.claimedBy = teamId
onChange(board)
return { ok: true, board, resumed: false }
}
return { ok: true, board, resumed: true }
},
release(kitId) { release(kitId) {
const board = boards.get(kitId) const board = byKit(kitId)
if (!board || board.claimedBy === null) return null if (!board || board.claimedBy === null) return null
const freed = board.claimedBy const freed = board.claimedBy
board.claimedBy = null board.claimedBy = null
onChange(board)
return freed return freed
}, },
get(kitId) { get(kitId) {
return boards.get(kitId) return byKit(kitId)
}, },
} }
} }
+104 -15
View File
@@ -6,7 +6,9 @@ import type {
ScoreInput, ScoreInput,
ScoreDTO, ScoreDTO,
LeaderboardRow, LeaderboardRow,
InstanceDTO,
} from './types' } from './types'
import type { Board } from './claim'
export interface Store { export interface Store {
upsertTeam(t: TeamSnapshot): void upsertTeam(t: TeamSnapshot): void
@@ -17,9 +19,26 @@ export interface Store {
getSubmission(teamId: string): SubmissionDTO | null getSubmission(teamId: string): SubmissionDTO | null
insertScore(input: ScoreInput, createdAt: string): ScoreDTO insertScore(input: ScoreInput, createdAt: string): ScoreDTO
leaderboard(): LeaderboardRow[] leaderboard(): LeaderboardRow[]
/** Persist a board (keyed by durable hwId) so the registry survives restart. */
saveBoard(b: Board): void
/** All persisted boards, to seed the in-memory registry on boot. */
listBoards(): Board[]
/** Register/refresh a federated local instance (central-mode). */
upsertInstance(i: InstanceDTO): void
/** All known instances (central-mode fleet view). */
listInstances(): InstanceDTO[]
close(): void close(): void
} }
interface BoardRow {
hw_id: string
kit_id: string
url: string
token: string
claim_code: string
claimed_by: string | null
}
interface TeamRow { interface TeamRow {
id: string id: string
name: string name: string
@@ -30,6 +49,7 @@ interface TeamRow {
stats: string stats: string
device_connected: number device_connected: number
updated_at: string updated_at: string
site: string
} }
function rowToTeam(r: TeamRow): TeamSnapshot { function rowToTeam(r: TeamRow): TeamSnapshot {
@@ -43,6 +63,7 @@ function rowToTeam(r: TeamRow): TeamSnapshot {
stats: JSON.parse(r.stats), stats: JSON.parse(r.stats),
deviceConnected: !!r.device_connected, deviceConnected: !!r.device_connected,
updatedAt: r.updated_at, updatedAt: r.updated_at,
site: r.site ?? '',
} }
} }
@@ -60,14 +81,16 @@ export function openStore(path = ':memory:'): Store {
phases TEXT NOT NULL DEFAULT '{}', phases TEXT NOT NULL DEFAULT '{}',
stats TEXT NOT NULL DEFAULT '{}', stats TEXT NOT NULL DEFAULT '{}',
device_connected INTEGER NOT NULL DEFAULT 0, device_connected INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL,
site TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS submissions ( CREATE TABLE IF NOT EXISTS submissions (
team_id TEXT PRIMARY KEY, team_id TEXT PRIMARY KEY,
team_name TEXT NOT NULL DEFAULT '', team_name TEXT NOT NULL DEFAULT '',
code TEXT NOT NULL, code TEXT NOT NULL,
add_json TEXT NOT NULL, add_json TEXT NOT NULL,
submitted_at TEXT NOT NULL submitted_at TEXT NOT NULL,
site TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS scores ( CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -78,35 +101,54 @@ export function openStore(path = ':memory:'): Store {
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS boards (
hw_id TEXT PRIMARY KEY,
kit_id TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
claim_code TEXT NOT NULL DEFAULT '',
claimed_by TEXT
);
CREATE TABLE IF NOT EXISTS instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
last_seen TEXT NOT NULL
);
`) `)
// Migration for DBs created before the `domain` column existed. CREATE TABLE // Migrations for DBs created before a column existed. CREATE TABLE IF NOT
// IF NOT EXISTS won't add it to an existing table, so add it defensively. // EXISTS won't add columns to an existing table, so add them defensively.
try { for (const stmt of [
db.exec(`ALTER TABLE teams ADD COLUMN domain TEXT NOT NULL DEFAULT ''`) `ALTER TABLE teams ADD COLUMN domain TEXT NOT NULL DEFAULT ''`,
} catch { `ALTER TABLE teams ADD COLUMN site TEXT NOT NULL DEFAULT ''`,
/* column already exists — fine */ `ALTER TABLE submissions ADD COLUMN site TEXT NOT NULL DEFAULT ''`,
]) {
try {
db.exec(stmt)
} catch {
/* column already exists — fine */
}
} }
const upsertTeamStmt = db.prepare(` const upsertTeamStmt = db.prepare(`
INSERT INTO teams (id, name, kit, domain, members, phases, stats, device_connected, updated_at) INSERT INTO teams (id, name, kit, domain, members, phases, stats, device_connected, updated_at, site)
VALUES (@id, @name, @kit, @domain, @members, @phases, @stats, @device_connected, @updated_at) VALUES (@id, @name, @kit, @domain, @members, @phases, @stats, @device_connected, @updated_at, @site)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name=excluded.name, kit=excluded.kit, domain=excluded.domain, members=excluded.members, name=excluded.name, kit=excluded.kit, domain=excluded.domain, members=excluded.members,
phases=excluded.phases, stats=excluded.stats, phases=excluded.phases, stats=excluded.stats,
device_connected=excluded.device_connected, updated_at=excluded.updated_at device_connected=excluded.device_connected, updated_at=excluded.updated_at, site=excluded.site
`) `)
const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name') const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name')
const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?') const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?')
const upsertSubStmt = db.prepare(` const upsertSubStmt = db.prepare(`
INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at) INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at, site)
VALUES (@team_id, @team_name, @code, @add_json, @submitted_at) VALUES (@team_id, @team_name, @code, @add_json, @submitted_at, @site)
ON CONFLICT(team_id) DO UPDATE SET ON CONFLICT(team_id) DO UPDATE SET
team_name=excluded.team_name, code=excluded.code, team_name=excluded.team_name, code=excluded.code,
add_json=excluded.add_json, submitted_at=excluded.submitted_at add_json=excluded.add_json, submitted_at=excluded.submitted_at, site=excluded.site
`) `)
const listSubsStmt = db.prepare(` const listSubsStmt = db.prepare(`
SELECT s.team_id, s.team_name, s.submitted_at, SELECT s.team_id AS teamId, s.team_name AS teamName, s.submitted_at AS submittedAt,
EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
FROM submissions s ORDER BY s.submitted_at FROM submissions s ORDER BY s.submitted_at
`) `)
@@ -128,6 +170,21 @@ export function openStore(path = ':memory:'): Store {
const setPhaseAddStmt = db.prepare( const setPhaseAddStmt = db.prepare(
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`, `UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
) )
const saveBoardStmt = db.prepare(`
INSERT INTO boards (hw_id, kit_id, url, token, claim_code, claimed_by)
VALUES (@hw_id, @kit_id, @url, @token, @claim_code, @claimed_by)
ON CONFLICT(hw_id) DO UPDATE SET
kit_id=excluded.kit_id, url=excluded.url, token=excluded.token,
claim_code=excluded.claim_code, claimed_by=excluded.claimed_by
`)
const listBoardsStmt = db.prepare('SELECT * FROM boards')
const upsertInstanceStmt = db.prepare(`
INSERT INTO instances (id, name, last_seen)
VALUES (@id, @name, @last_seen)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, last_seen=excluded.last_seen
`)
const listInstancesStmt = db.prepare('SELECT * FROM instances ORDER BY name')
return { return {
upsertTeam(t) { upsertTeam(t) {
@@ -141,6 +198,7 @@ export function openStore(path = ':memory:'): Store {
stats: JSON.stringify(t.stats), stats: JSON.stringify(t.stats),
device_connected: t.deviceConnected ? 1 : 0, device_connected: t.deviceConnected ? 1 : 0,
updated_at: t.updatedAt, updated_at: t.updatedAt,
site: t.site ?? '',
}) })
}, },
listTeams() { listTeams() {
@@ -157,6 +215,7 @@ export function openStore(path = ':memory:'): Store {
code: s.code, code: s.code,
add_json: JSON.stringify(s.add), add_json: JSON.stringify(s.add),
submitted_at: s.submittedAt, submitted_at: s.submittedAt,
site: s.site ?? '',
}) })
// mark the team's final phase complete if we know the team // mark the team's final phase complete if we know the team
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId) if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId)
@@ -202,6 +261,36 @@ export function openStore(path = ':memory:'): Store {
scoreCount: r.scoreCount, scoreCount: r.scoreCount,
})) }))
}, },
saveBoard(b) {
saveBoardStmt.run({
hw_id: b.hwId,
kit_id: b.kitId,
url: b.url,
token: b.token,
claim_code: b.claimCode,
claimed_by: b.claimedBy,
})
},
listBoards() {
return (listBoardsStmt.all() as BoardRow[]).map((r) => ({
hwId: r.hw_id,
kitId: r.kit_id,
url: r.url,
token: r.token,
claimCode: r.claim_code,
claimedBy: r.claimed_by,
}))
},
upsertInstance(i) {
upsertInstanceStmt.run({ id: i.id, name: i.name, last_seen: i.lastSeen })
},
listInstances() {
return (listInstancesStmt.all() as Array<{ id: string; name: string; last_seen: string }>).map((r) => ({
id: r.id,
name: r.name,
lastSeen: r.last_seen,
}))
},
close() { close() {
db.close() db.close()
}, },
+1 -1
View File
@@ -23,7 +23,7 @@ if (!FLEET_SECRET) {
const store = openStore(DB_PATH) const store = openStore(DB_PATH)
const hub = createHub() const hub = createHub()
const nodes = createNodeBridge({ broadcast: hub.broadcast }) const nodes = createNodeBridge({ broadcast: hub.broadcast })
const boards = createBoardRegistry() const boards = createBoardRegistry(store.listBoards(), (b) => store.saveBoard(b))
const app = createApp({ const app = createApp({
store, store,
broadcast: hub.broadcast, broadcast: hub.broadcast,
+18
View File
@@ -27,6 +27,10 @@ describe('node bridge + /nodes routes', () => {
send: async (node, message, agent) => { send: async (node, message, agent) => {
sent.push({ node, message, agent }) sent.push({ node, message, agent })
}, },
sendAndWait: async (node, message, agent) => {
sent.push({ node, message, agent })
return `echo: ${message}`
},
subscribe: () => () => {}, // no live SSE in the unit test subscribe: () => () => {}, // no live SSE in the unit test
}) })
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes }) app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes })
@@ -78,6 +82,20 @@ describe('node bridge + /nodes routes', () => {
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400) await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
}) })
it('say-hi waits for the node reply; 404s an unregistered team', async () => {
await request(app).post('/nodes/ghost/say-hi').send({}).expect(404)
await request(app)
.post('/nodes')
.set('x-access-code', ADMIN)
.send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
.expect(201)
const res = await request(app).post('/nodes/t1/say-hi').send({ agent: 'cloud' }).expect(200)
expect(res.body.reply).toMatch(/^echo: /)
// a default greeting is sent when no message is supplied
expect(sent.at(-1)?.message).toMatch(/introduce yourself/i)
expect(sent.at(-1)?.agent).toBe('cloud')
})
it('503s when no bridge is configured', async () => { it('503s when no bridge is configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE }) const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503) await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503)
+26
View File
@@ -123,6 +123,22 @@ export async function sendPrompt(node: NodeRef, message: string, agent = 'defaul
}) })
} }
/**
* Prompt a node and WAIT for its reply text (the blocking `/webhook` response).
* Only for non-flashing turns (e.g. the onboarding greeting) — a flash turn must
* stay fire-and-forget via {@link sendPrompt} or it hangs the agent's task.
*/
export async function promptAndWait(node: NodeRef, message: string, agent = 'default'): Promise<string> {
const res = await fetch(`${node.url}/webhook?agent=${encodeURIComponent(agent)}`, {
method: 'POST',
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ message }),
})
if (!res.ok) throw new Error(`webhook ${res.status}`)
const body = (await res.json().catch(() => ({}))) as { response?: string }
return (body.response ?? '').trim()
}
export interface SubscribeOptions { export interface SubscribeOptions {
/** Aborts the whole reconnect loop when fired. */ /** Aborts the whole reconnect loop when fired. */
signal?: AbortSignal signal?: AbortSignal
@@ -219,6 +235,9 @@ export interface NodeBridge {
remove(teamId: string): void remove(teamId: string): void
list(): NodeView[] list(): NodeView[]
prompt(teamId: string, message: string, agent?: string): Promise<boolean> prompt(teamId: string, message: string, agent?: string): Promise<boolean>
/** Say-hi: prompt the node and return its reply text (blocking). `null` if the
* team has no registered node. Non-flash use only (the greeting). */
sayHi(teamId: string, message: string, agent?: string): Promise<string | null>
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */ /** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void stopAll(): void
@@ -230,6 +249,7 @@ export interface NodeBridgeDeps {
/** Injectable for tests. */ /** Injectable for tests. */
ping?: (n: NodeRef) => Promise<boolean> ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string, agent?: string) => Promise<void> send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
} }
@@ -243,6 +263,7 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const registry = deps.registry ?? createNodeRegistry() const registry = deps.registry ?? createNodeRegistry()
const ping = deps.ping ?? pingNode const ping = deps.ping ?? pingNode
const send = deps.send ?? sendPrompt const send = deps.send ?? sendPrompt
const sendAndWait = deps.sendAndWait ?? promptAndWait
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus })) const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
const online = new Map<string, boolean>() const online = new Map<string, boolean>()
const stops = new Map<string, () => void>() const stops = new Map<string, () => void>()
@@ -287,6 +308,11 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
await send(node, message, agent) await send(node, message, agent)
return true return true
}, },
async sayHi(teamId, message, agent) {
const node = registry.get(teamId)
if (!node) return null
return sendAndWait(node, message, agent)
},
onTeamActivity(teamId, listener) { onTeamActivity(teamId, listener) {
let set = teamListeners.get(teamId) let set = teamListeners.get(teamId)
if (!set) { if (!set) {
+24 -1
View File
@@ -27,6 +27,9 @@ export interface TeamSnapshot {
stats: SessionStats stats: SessionStats
deviceConnected: boolean deviceConnected: boolean
updatedAt: string updatedAt: string
/** Federation tag: the local instance (site) this team belongs to. '' on a
* single-fleet deploy; set by the reporter sidecar on a central deploy. */
site?: string
} }
export interface SubmissionDTO { export interface SubmissionDTO {
@@ -35,6 +38,18 @@ export interface SubmissionDTO {
code: string code: string
add: AddLayers add: AddLayers
submittedAt: string submittedAt: string
/** Federation tag — see {@link TeamSnapshot.site}. */
site?: string
}
/**
* A running local (edge) stack that has registered with the central control
* plane. Identified by a stable `site` id; `lastSeen` drives online/offline.
*/
export interface InstanceDTO {
id: string
name: string
lastSeen: string
} }
export interface SubmissionSummary { export interface SubmissionSummary {
@@ -72,7 +87,13 @@ export interface LeaderboardRow {
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback' export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] } | {
type: 'snapshot'
teams: TeamSnapshot[]
submissions: SubmissionSummary[]
unclaimed?: string[]
instances?: InstanceDTO[]
}
| { 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 }
@@ -80,3 +101,5 @@ export type WsEvent =
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string } | { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
// Kit ids of boards that have self-registered but aren't claimed yet. // Kit ids of boards that have self-registered but aren't claimed yet.
| { type: 'unclaimed:update'; kits: string[] } | { type: 'unclaimed:update'; kits: string[] }
// A local instance (site) registered or heartbeated on the central plane.
| { type: 'instance:update'; instance: InstanceDTO }
+1 -1
View File
@@ -26,7 +26,7 @@ describe('collective WS feed', () => {
store = openStore(':memory:') store = openStore(':memory:')
const hub = createHub() const hub = createHub()
const boards = createBoardRegistry([ const boards = createBoardRegistry([
{ kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null }, { hwId: 'hw-05', kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null },
]) ])
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE }) const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
server = http.createServer(app) server = http.createServer(app)
+1
View File
@@ -36,6 +36,7 @@ export function attachWs(
teams: store.listTeams(), teams: store.listTeams(),
submissions: store.listSubmissions(), submissions: store.listSubmissions(),
unclaimed: boards?.unclaimedKits().map((u) => u.kitId) ?? [], unclaimed: boards?.unclaimedKits().map((u) => u.kitId) ?? [],
instances: store.listInstances(),
}), }),
) )
hub.add(ws) hub.add(ws)
+159
View File
@@ -0,0 +1,159 @@
# APESS 2026 — Lecture Deck Revision
**Follow-up to the deck summary.** What changed since the deck was written (ZeroClaw v0.1.0 →
v0.8.3, the network-node pivot, the failure-first reframe, and the flash pathology), the flow the
deck should now tell, and slide-by-slide target copy.
> **Authoritative sources — pull from these, do not retype from memory:**
> - ADD layer titles → `src/lib/addLayers.ts`
> - Schedule → `src/pages/Landing.tsx` (`PROGRAMME`)
> - Connect flow → the network-node model (web-chat-by-IP over LAN), not Web Serial
---
## 1. What needs to change
Grouped by severity. The deck's **spine is sound** — the perception loop, threshold-as-design,
the two-failure-modes block, and the vision slides all still hold. The failure-first block (deck
slides 1317) is *more* central now, not less. What follows is only what breaks against the app
as shipped and the hardware as measured.
### 🔴 Wrong — contradicts the app or the programme
| # | Slide(s) | Deck says | Reality | Fix |
|---|---|---|---|---|
| C1 | 11, 22 | ADD layers: L1 Actor map · L2 Harness spec · L3 Decision graph · L4 Failure-mode analysis · L5 AI-native redesign | **L1 Domain & events · L2 Skills · L3 Policies · L4 Harness · L5 Loops** | Re-author both slides. The field→layer mapping on 11 collapses entirely — Harness moved to L4, L2 is Skills. Failure is now *woven through* L3/L4/L5, not a standalone layer. |
| C2 | 22, 23 | Modules at 15:00 / 16:00 / 17:00; lecture inside the afternoon | Lecture is a **separate 10:4512:15 morning session**; hackathon **14:0019:00**, modules ~14:45 / 16:10 / 17:40 | Retime slide 22; stop implying the lecture is part of the hack block. |
| C3 | 23 | "Open Chrome · Connect your kit · Web Serial · flash ZeroClaw to the UNO Q" | Students **already have the board** with a week of their own sensor work. *Omar* backs up → reflashes → provisions ZeroClaw. Students reach their node **over the LAN via web-chat-by-IP (HTTP + SSE)** — no Web Serial, no self-flash. `apess.redclaw.dev` URL still correct. | Rewrite the mechanism sentence; keep the URL/QR. |
### 🟡 Review — probably-wrong numbers/labels
| # | Slide(s) | Issue | Action |
|---|---|---|---|
| R1 | 2 | "ZeroClaw v0.1.0" + `latency_ms: 318` | We're on **v0.8.3**. 318 ms is a *cloud*-path figure; on-board warm tool call is **~3.8 s** (0.5B). Decide which path the slide shows and label it. |
| R2 | 2, 4, 9 | Model `claude-haiku-4-5` | Verify the actual model string in the shipped config; don't trust the deck. Provider "anthropic" is correct. |
| R3 | 10 | "WebSocket to browser" | Transport is **SSE**, not WebSocket (we specifically avoid the blocking `/webhook`). Say "streamed to the browser (SSE)". |
### 🟢 Careful reframe — new hardware truth
| # | Slide(s) | Issue | Action |
|---|---|---|---|
| H1 | 10 | LED matrix as an agent "Act" output in the loop | **Agent-triggered flashing hangs the chat turn** — openocd's SWD/GPIO activity poisons the turn's task (the flash physically lands, but the turn never acknowledges). So severity→LED must be driven by a **pre-flashed responder sketch the agent talks to**, not the agent re-flashing per event. Never demo "the agent flashes the matrix in the loop" — it visibly hangs. Flashing itself is demoed once, from the CLI, by the instructor. |
### 🟢 Fine as-is
Slides 1, 3, 59, 1221. In particular slide 9's "Dragonwing makes the HTTP call directly, no UART
bridge" is **correct** to our architecture. Vibration bands (15/16) are already environment-tagged.
---
## 2. How the flow should be
The deck's job narrows to one arc: **the sensor already thinks — now design it to fail well.**
The students spent a week making the board *sense*. The deck's payload is the turn from sensing to
*judgment under failure*, ending in the ADD as the deliverable. Seven beats:
1. **Hook** (12) — "what if your sensor could think?" → a real on-device result. *It already does;
here's proof from your board.*
2. **Ground the LLM** (34) — a function from situation-description to interpretation. Demystify.
3. **The loop** (510) — Perceive → Window → Threshold → Reason → Act. The thing they'll configure.
**Land the weight on Threshold (8): the trigger engine is the intelligence.**
4. **The deliverable** (1112) — every runtime field maps to an ADD layer. The ADD is the design
language, not homework.
5. **The pivot — failure-first** (1317) — building is easy; designing for the two failure modes is
the work. *This is the spine of the whole session and the school's own theme (Nardin's
robustness/resilience, Georgakis's "timely reaction").* A fail-safe must never degrade to
"nominal."
6. **Vision at scale** (1821) — one board → bridges, hospitals, fault networks classifying
themselves. The constraint was never compute; it was assuming intelligence lived elsewhere.
7. **Handoff** (2223) — the corrected schedule, then "connect to your node and start Module 1."
**What moved the flow:** the connect step (7) is no longer "flash the board" — the board is already
an agentic node when they sit down. The deck should say *meet the node you already have*, matching
the app's 14:25 "Meet your node" beat. And the failure block (5) is now explicitly the school's
Week-1/Week-3 through-line, not a generic caveat.
---
## 3. Slide-by-slide targets (only the ones that change)
### Slide 2 · Live output — *review numbers*
- Caption: **"Arduino UNO Q 4GB running ZeroClaw v0.8.3 — the same board at your seat."**
- If the JSON shows the **cloud** path: keep a sub-300 msish latency but label it *cloud*.
- If it shows **on-device**: latency must read seconds, not milliseconds (~3.8 s warm, 0.5B).
- Verify `provider` and the model string against shipped config (R2).
### Slide 9 · Reason — *review model/latency only*
- Keep the architecture claim (direct HTTP from the Linux side, no UART bridge) — it's correct.
- Fix the footer model/latency to match slide 2's resolved values.
### Slide 10 · Act — *transport + LED reframe*
- Three channels stay. Change "WebSocket" → **"streamed to the browser (SSE)."**
- Reframe the LED channel: **"the agent talks to a pre-flashed responder sketch that owns the
matrix"** — it does not re-flash per event. (One-time flash is an instructor CLI step, off-deck.)
### Slide 11 · Output → ADD — *full re-author*
Map each output field to the **current** layers:
| field | layer |
|---|---|
| `classification` | **L3 · Policies** (what the decision authorises) |
| `reasoning` | **L1 · Domain & events** (the actor/event frame it's reasoning over) |
| `confidence` + `latency_ms` | **L4 · Harness** (the runtime envelope) |
| the loop cadence / cooldown | **L5 · Loops** |
| the skills it invoked | **L2 · Skills** |
Footer: *"Failure isn't a sixth layer — it's the question you ask of L3, L4, and L5."*
### Slide 22 · Your work today — *retime + relabel*
Layer chips: **L1 Domain & events · L2 Skills · L3 Policies · L4 Harness · L5 Loops.**
Schedule (verbatim from `Landing.tsx` `PROGRAMME`):
| time | block |
|---|---|
| 10:4512:15 | Lecture (separate morning session) |
| 14:00 | Arrival & registration — boards backed up & reflashed while you register |
| 14:25 | Meet your node — the board you already know, now carrying an agent |
| 14:45 | Module 1 · Domain & events → **L1** |
| 16:10 | Module 2 · Skills & policies → **L2 + L3** (drive a real sensor, enumerate failure states, set the gate) |
| 17:40 | Module 3 · Harness, loops & submit → **L4 + L5** |
| 19:00 | Judging & award |
Judged on **rigour, not technical complexity** — keep that line.
### Slide 23 · Handoff — *rewrite mechanism*
- Keep: `apess.redclaw.dev` + QR.
- Replace the setup sentence with: **"Your board is already an agentic node. Open the app, connect
to your node on the workshop network, and start Module 1."**
- Delete: Web Serial, Chrome-only requirement, "flash ZeroClaw," OS-specific toolchain install.
---
## 4. How it plays into the flow
- **C1 (layers)** is load-bearing for beats 4 and 7 — if the deck names layers the app doesn't, the
judging rubric and the student's document won't match what the slides promised. This is the same
drift that was a P0 bug in the app; fix the deck to the *same* single source (`addLayers.ts`).
- **C2/C3 (schedule + connect)** land in beat 7 (handoff). They're what students act on in the first
25 minutes; getting them wrong strands people at the door. The corrected flow also *recovers an
hour* by moving the lecture out of the afternoon.
- **H1 (LED/flash)** protects beat 3 (the loop demo). The loop is the deck's centrepiece; a live
agent-flash hang there would undercut the whole "it already thinks" promise. Demo the loop with a
pre-flashed responder; demo flashing once, from the CLI, as a separate instructor moment.
- **The failure block (beat 5)** is the payload the rest of the deck now serves. Everything before it
is setup for "design it to fail well"; everything after (the vision, the handoff) is the reward and
the call to action. Keep it dark, keep it central, and tie it explicitly to the school's own
resilience theme.
---
## 5. Checklist before the deck ships
- [ ] Slide 11 & 22 layer names match `src/lib/addLayers.ts` exactly.
- [ ] Slide 22 schedule matches `Landing.tsx` `PROGRAMME` (lecture 10:45, hack 14:0019:00).
- [ ] Slide 23 has no Web-Serial / self-flash language; URL + QR retained.
- [ ] Slides 2/9 version = v0.8.3; model string verified against shipped config; latency labelled
cloud-vs-onboard.
- [ ] Slide 10 says SSE; LED channel = pre-flashed responder, not per-event flash.
- [ ] No slide implies the agent flashes the board inside the loop.
</content>
</invoke>
+92
View File
@@ -0,0 +1,92 @@
# APESS Demo Runbook — Multi-Channel Agent Controls the LED Matrix
One on-board agent on the Arduino Uno Q changes the physical **13×8 LED-matrix
animation** on command from **web chat, Telegram, and voice** — same agent, same
`matrix_pattern` tool, all on cloud sonnet via the reliable `/webhook` path.
## Secrets
All three env-only secrets live in **Infisical** on the `icarus` instance (project
`cloud-providers`, env `prod`). Pull them into the environment in one block:
```bash
DOM=http://icarus.lan:8443 # or https://icarus.taila4f562.ts.net
CP=0788e188-b746-4ea0-a4b2-e0c2d0aec1b6 # cloud-providers project id
export INFISICAL_TOKEN=$(infisical login --method=universal-auth \
--client-id=$(cat ~/.infisical/macbook-admin-id) \
--client-secret=$(cat ~/.infisical/macbook-admin-secret) \
--domain=$DOM --silent --plain)
get(){ infisical secrets get "$1" --projectId=$CP --env=prod --domain=$DOM --plain; }
export ANTHROPIC_OAUTH_TOKEN=$(get ANTHROPIC_OAUTH_TOKEN) # Claude Max setup-token (cloud brain)
export NODE_TOKEN=$(get APESS_NODE_TOKEN) # Uno Q gateway bearer token
export ELEVENLABS_API_KEY=$(get ELEVENLABS_API_KEY) # ElevenLabs TTS voice
```
**None of these may be written to disk or committed** — env-only. Infisical is the vault;
`recover.sh` and `serve.py` read them from the environment. (Vault key `APESS_NODE_TOKEN`
maps to the `NODE_TOKEN` env var the scripts expect.)
## Pre-flight (~5 min before, board plugged into USB)
```bash
cd ~/projects/apress
# (secrets exported per above)
./deploy/uno-q/recover.sh # brings up node + verifies sonnet + matrix (all-green)
# voice proxy — leave running in its own terminal:
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=$NODE_TOKEN ELEVENLABS_API_KEY=$ELEVENLABS_API_KEY \
python3 deploy/voice-client/serve.py 8090
```
`recover.sh` re-tunnels (`adb forward :8080`), relaunches the daemon with the cloud
token in its environment, starts the matrix bridge app, and confirms
`agent=demo` is on `claude-sonnet-5` and `matrix_pattern` fires.
**On the workshop LAN:** point `NODE_URL` at the board's LAN IP (`http://192.168.x.x:8080`)
instead of the adb-forwarded `127.0.0.1:8080`, so the browser voice client reaches the
board over the network.
## The three acts
1. **Web chat** — prompt *"show the rain animation"* → matrix changes + live activity feed.
2. **Telegram****t.me/Apess2026Bot***"change it to a beating heart"* → the *same
physical matrix* changes, driven from a phone. (One-time pairing: `/bind <code>` — the
code prints in the daemon log at startup; grep `bind code`.)
3. **Voice****http://localhost:8090** in Chrome → hold-to-talk *"make it wave"* → matrix
changes and the reply is **spoken back in the ElevenLabs voice** (Sarah).
Patterns the agent understands: `off, rain, heart, wave, sparkle, checker, solid, blink`.
## If the board disconnects mid-demo
The recurring USB drop kills the daemon/llama/bridge and loses the env-only cloud token.
Re-plug, then:
```bash
./deploy/uno-q/recover.sh # ~30s, re-injects the token, verifies end-to-end
```
The voice proxy auto-recovers via the re-armed tunnel (no restart needed). If it was
stopped, relaunch the `serve.py` line above.
## Gotchas / facts
- **Cloud token is env-only** — a disconnect loses it; recovery *must* re-export it (the
script uses `$ANTHROPIC_OAUTH_TOKEN` from your shell).
- **Use `/webhook`, not `/ws/chat`** — the WS path builds a fresh agent that omits the
peripheral `matrix_pattern` tool; the voice client's `serve.py` proxies `/webhook` for
this reason.
- **First turn after a fresh daemon** is a touch slower (cold); the pre-flight
`recover.sh` call warms it.
- **ElevenLabs free tier** can only use the premade voices attached to the account (not
"library" voices → 402). Default `EXAVITQu4vr4xnSDxMaL` (Sarah) works.
- **Board serial** `65301572`. **Bridge app**: `~/ArduinoApps/uno-q-bridge`
(`arduino-app-cli`, needs `TMPDIR=/tmp`).
## Where things live
- `deploy/uno-q/recover.sh` — one-command node recovery.
- `deploy/voice-client/` — browser voice client + `serve.py` proxy (STT/TTS + ElevenLabs).
- zeroclaw fork (`fix/uno-q-flash-timeouts`) — resident matrix responder + `matrix_pattern`
tool + Telegram channel.
</content>
+9
View File
@@ -6,3 +6,12 @@ JUDGE_CODE=jdg-xxxxxxxx
FLEET_SECRET=change-me FLEET_SECRET=change-me
# Host port for the web UI. Use 8080 if the box can't bind privileged :80. # Host port for the web UI. Use 8080 if the box can't bind privileged :80.
WEB_PORT=80 WEB_PORT=80
# --- Federated mode (optional) ----------------------------------------------
# Only needed if you run the reporter sidecar (`--profile federated`) to mirror
# this instance UP to the central control plane. Omit for a purely local room.
# SITE_ID must be unique per team/instance — it namespaces every id at central.
SITE_ID=team-01
SITE_NAME=Team 01
# Central control-plane API base (its /api origin).
CENTRAL_API=https://apess.redclaw.dev/api
+25
View File
@@ -78,6 +78,31 @@ work): a rebooted board re-announces and the API refreshes its binding; a team
that lost its browser just re-scans the QR + re-enters the code to resume. For that lost its browser just re-scans the QR + re-enters the code to resume. For
anything stuck, release the kit from `/admin` and let the team re-claim. anything stuck, release the kit from `/admin` and let the team re-claim.
## 4. Federated mode (optional) — phone home to a central dashboard
For a multi-team event you can run **one local stack per team** (each with its
board) and have every instance mirror its state UP to a **central control
plane** — a fleet dashboard where judges see the whole cohort at once. This is
outbound-only, so it works from behind the room's NAT.
```sh
# in .env, set a UNIQUE SITE_ID per team + the central api base:
# SITE_ID=team-07 SITE_NAME="Team 07" CENTRAL_API=https://apess.redclaw.dev/api
docker compose --env-file .env -f docker-compose.yml --profile federated up -d --build
```
The **reporter** sidecar joins the local network, subscribes to the local API's
WS feed, and replays every `team:update` / submission UP to `CENTRAL_API`,
**namespaced by `SITE_ID`** (so ids never collide across instances) and
`site`-tagged (so central groups by team/site). It registers the instance and
heartbeats on a timer. If the uplink is down, writes queue in an in-memory
outbox and backfill on reconnect — the local workshop never blocks on it.
Central is **observe-only**: it never touches a board (it can't reach the NAT'd
boards — only the local stack drives them). Run the central instance from
`deploy/docker-compose.yml` with the same `FLEET_SECRET`; the reporter presents
it on every federation call.
## Notes ## Notes
- **Data** persists in the `apess-lan-data` volume (`docker compose down` keeps - **Data** persists in the `apess-lan-data` volume (`docker compose down` keeps
+25
View File
@@ -50,6 +50,31 @@ services:
# Not published: boards + browsers reach the API through the web's /api proxy. # Not published: boards + browsers reach the API through the web's /api proxy.
networks: [apess-lan] networks: [apess-lan]
# Optional edge→central reporter. Enable with `--profile federated`; mirrors
# this instance's state UP to the central control plane (outbound-only). Needs
# SITE_ID + CENTRAL_API set (see .env.example). Omit the profile for a purely
# local, offline single-room workshop.
reporter:
build:
context: ../..
dockerfile: deploy/lan/reporter/Dockerfile
image: apess-reporter:lan
container_name: apess-reporter-lan
restart: unless-stopped
profiles: [federated]
environment:
SITE_ID: ${SITE_ID:?set SITE_ID for federated mode}
SITE_NAME: ${SITE_NAME:-}
CENTRAL_API: ${CENTRAL_API:?set CENTRAL_API for federated mode}
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
LOCAL_WS: ws://apess-api-lan:3000/ws
LOCAL_API: http://apess-api-lan:3000
# LOCAL_CODE auths the reporter's read of the local feed — reuse ADMIN_CODE.
LOCAL_CODE: ${ADMIN_CODE:?set ADMIN_CODE}
depends_on:
- apess-api
networks: [apess-lan]
networks: networks:
apess-lan: apess-lan:
driver: bridge driver: bridge
+8
View File
@@ -0,0 +1,8 @@
# APESS reporter sidecar — tiny outbound-only state mirror (edge → central).
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY deploy/lan/reporter/package.json ./
RUN npm install --omit=dev
COPY deploy/lan/reporter/index.mjs ./
CMD ["node", "index.mjs"]
+151
View File
@@ -0,0 +1,151 @@
// APESS reporter sidecar — mirrors one local (edge) stack's state UP to the
// central control plane. Outbound-only (NAT-friendly): it never accepts inbound
// traffic, it just consumes the LOCAL api's WS feed and replays the public write
// contract (PUT /teams/:id, POST /submissions) to CENTRAL, tagged with `site`.
//
// Failed central writes queue in an in-memory outbox and retry, so a flaky or
// offline uplink never blocks — the workshop runs fully on the local stack and
// central backfills when the link returns.
//
// Env:
// SITE_ID stable id for this instance (namespaces every id at central)
// SITE_NAME human label for the fleet dashboard (defaults to SITE_ID)
// LOCAL_WS ws url of the local api feed (default ws://apess-api-lan:3000/ws)
// LOCAL_API http base of the local api (default http://apess-api-lan:3000)
// LOCAL_CODE admin code — auths the WS + the full-submission read
// CENTRAL_API http base of the central api (e.g. https://apess.redclaw.dev/api)
// FLEET_SECRET shared secret central gates federation on
// HEARTBEAT_MS instance heartbeat cadence (default 30000)
import WebSocket from 'ws'
const SITE_ID = must('SITE_ID')
const SITE_NAME = process.env.SITE_NAME || SITE_ID
const LOCAL_WS = process.env.LOCAL_WS || 'ws://apess-api-lan:3000/ws'
const LOCAL_API = (process.env.LOCAL_API || 'http://apess-api-lan:3000').replace(/\/$/, '')
const LOCAL_CODE = must('LOCAL_CODE')
const CENTRAL_API = must('CENTRAL_API').replace(/\/$/, '')
const FLEET_SECRET = must('FLEET_SECRET')
const HEARTBEAT_MS = Number(process.env.HEARTBEAT_MS || 30000)
function must(name) {
const v = process.env[name]
if (!v) {
console.error(`[reporter] missing required env ${name}`)
process.exit(1)
}
return v
}
const log = (...a) => console.log('[reporter]', ...a)
const nsId = (id) => `${SITE_ID}:${id}` // namespace a local id so it never collides at central
// --- offline outbox: {key, run: () => fetch-promise} ------------------------
// keyed so a newer team snapshot collapses the older one still queued.
const outbox = new Map()
function enqueue(key, run) {
outbox.set(key, run)
}
async function flush() {
for (const [key, run] of [...outbox]) {
try {
await run()
outbox.delete(key)
} catch (e) {
// leave it queued; try again next tick
log(`outbox retry pending (${outbox.size}) — ${key}: ${e.message}`)
break // preserve order; stop on first failure
}
}
}
async function central(path, body, method = 'POST') {
const res = await fetch(`${CENTRAL_API}${path}`, {
method,
headers: { 'content-type': 'application/json', 'x-fleet-secret': FLEET_SECRET },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`${method} ${path}${res.status}`)
return res
}
// --- replay the public write contract UP, namespaced + site-tagged ----------
function pushTeam(team) {
const id = nsId(team.id)
enqueue(`team:${id}`, () =>
central(`/teams/${encodeURIComponent(id)}`, { ...team, id, site: SITE_ID }, 'PUT'),
)
}
function pushSubmission(sub) {
const teamId = nsId(sub.teamId)
enqueue(`sub:${teamId}`, () =>
central('/submissions', { ...sub, teamId, site: SITE_ID }),
)
}
// The WS submission:new event only carries a summary — fetch the full record
// (add layers + code) from the LOCAL api before replaying it up.
async function fetchFullSubmission(localTeamId) {
const res = await fetch(`${LOCAL_API}/submissions/${encodeURIComponent(localTeamId)}`, {
headers: { 'x-access-code': LOCAL_CODE },
})
if (!res.ok) throw new Error(`local GET /submissions/${localTeamId}${res.status}`)
return res.json()
}
// --- instance registration + heartbeat --------------------------------------
async function register() {
enqueue('instance', () => central('/instances/register', { id: SITE_ID, name: SITE_NAME }))
}
function startHeartbeat() {
setInterval(() => {
enqueue('instance', () =>
central(`/instances/${encodeURIComponent(SITE_ID)}/heartbeat`, { name: SITE_NAME }),
)
void flush()
}, HEARTBEAT_MS)
}
// --- local WS subscription (auto-reconnecting) ------------------------------
function connect() {
const ws = new WebSocket(`${LOCAL_WS}?code=${encodeURIComponent(LOCAL_CODE)}`)
ws.on('open', () => log(`connected to local feed ${LOCAL_WS}`))
ws.on('message', async (raw) => {
let ev
try {
ev = JSON.parse(raw.toString())
} catch {
return
}
if (ev.type === 'snapshot') {
// backfill: replay every known team + submission on (re)connect
for (const t of ev.teams ?? []) pushTeam(t)
for (const s of ev.submissions ?? []) {
try {
pushSubmission(await fetchFullSubmission(s.teamId))
} catch (e) {
log(`snapshot submission skip ${s.teamId}: ${e.message}`)
}
}
} else if (ev.type === 'team:update') {
pushTeam(ev.team)
} else if (ev.type === 'submission:new') {
try {
pushSubmission(await fetchFullSubmission(ev.submission.teamId))
} catch (e) {
log(`submission fetch failed ${ev.submission.teamId}: ${e.message}`)
}
}
void flush()
})
ws.on('close', () => {
log('local feed closed — reconnecting in 3s')
setTimeout(connect, 3000)
})
ws.on('error', (e) => log(`local feed error: ${e.message}`))
}
log(`starting — site=${SITE_ID} → central ${CENTRAL_API}`)
await register()
void flush()
startHeartbeat()
connect()
setInterval(() => void flush(), 15000) // periodic drain even when idle
+36
View File
@@ -0,0 +1,36 @@
{
"name": "apess-reporter",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "apess-reporter",
"version": "1.0.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "apess-reporter",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "APESS edge→central reporter sidecar (outbound-only state mirror)",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs"
},
"dependencies": {
"ws": "^8.18.0"
}
}
+31 -2
View File
@@ -77,8 +77,11 @@ where systemd isn't usable. Two properties matter:
- It polls **`/health` endpoints, not `pgrep`** — a wedged process passes `pgrep` but - It polls **`/health` endpoints, not `pgrep`** — a wedged process passes `pgrep` but
fails a health check. Process liveness is not service liveness. fails a health check. Process liveness is not service liveness.
- It respects a **warmup grace** (`LLAMA_WARMUP=300`). A cold GGUF load takes **35 - It respects a **warmup grace** (`LLAMA_WARMUP=300`); reaping mid-load produces an
minutes**; reaping mid-load produces an infinite restart loop that never converges. infinite restart loop that never converges. The 35 minute cold-load figure was measured
against the 1.1 GB coder model — the 409 MB `qwen.gguf` we standardised on came up
**healthy in ~5 s** from a cold boot (2026-07-20). The 300 s grace is now generous rather
than necessary, which is harmless.
Children start under `setsid` so they survive the launching shell closing — plain Children start under `setsid` so they survive the launching shell closing — plain
`nohup … &` inside an `adb shell` does **not** give you that, which is why services `nohup … &` inside an `adb shell` does **not** give you that, which is why services
@@ -187,6 +190,32 @@ Three ways to destroy that advantage — all easy to do by accident:
- **`max_tool_iterations = 6`.** Each iteration is a full model call — ~3 min per task. - **`max_tool_iterations = 6`.** Each iteration is a full model call — ~3 min per task.
Acceptable for a background loop, not for anything interactive. Acceptable for a background loop, not for anything interactive.
### The offline capability boundary (measured 2026-07-20)
Speed is not the only limit — there is a hard capability cliff. Same board, same lean
profile, same on-board 0.5B:
| task | result |
|---|---|
| Call a simple tool (`i2cdetect`, 708-token prompt) | **works — 20 s**, tool call fired, real answer |
| Write + compile + flash a sketch (1,997-token prompt) | **never completed** — 450 s, two identical requests, no tool call ever emitted |
So offline the node can **sense and decide, but it cannot author new code**. Code
generation needs the cloud model. This is the concrete degradation boundary to state in
ADD Layer 4: what still works with no network is the sensing and decision loop over
*already-flashed* firmware — not writing new firmware.
Two related prompt-shape findings from the same session:
- **Imperative, not interrogative.** `"List the I2C devices on the bus."` fires a tool call
in 20 s; `"What sensors can you find on the I2C bus?"` produced **no tool call at all**
in 200 s. Same agent, same 708-token prompt — phrasing was the only variable.
- **It does not hallucinate hardware.** Asked to list I2C devices on a board with an empty
bus, it ran the tool and reported the bus numbers rather than inventing a sensor.
Note also that `BuildFlash` in the SPA routes to the **`cloud`** agent, not `local` — so the
student build/flash exercise does not depend on the boundary above.
### What this means for offline work ### What this means for offline work
On the **0.5B** (measured or extrapolated from the curve above): On the **0.5B** (measured or extrapolated from the curve above):
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# agent-mode.sh — flip EVERY agent on the Uno Q between cloud and offline.
#
# cloud (default, workshop): every agent → anthropic.max (claude-sonnet-5),
# full tool set (risk_profile=default), full context (runtime=unoq).
# Nothing feels broken; the on-board fallback is NOT in the way.
# offline (resilience demo): every agent → llamacpp.local (on-board Qwen),
# lean tools (risk_profile=demo) + lean context (runtime=offline) so
# the 0.5B is actually usable. This is the fallback you enable on purpose.
#
# Usage: ./agent-mode.sh [cloud|offline]
# Env: SERIAL (default 65301572)
#
# Restart note: kills the daemon so the supervisor relaunches it with the new
# config. Cloud mode needs the Max token in the supervisor's env — if it's not
# there (e.g. after a bare reboot), run deploy/uno-q/recover.sh first.
set -u
MODE="${1:-cloud}"
SERIAL="${SERIAL:-65301572}"
CFG=/home/arduino/.zeroclaw/config.toml
S(){ adb -s "$SERIAL" shell "$@"; }
case "$MODE" in
cloud) PROV="anthropic.max"; RISK="default"; RT="unoq" ;;
offline) PROV="llamacpp.local"; RISK="demo"; RT="offline" ;;
*) echo "usage: $0 [cloud|offline]"; exit 1 ;;
esac
echo "→ setting ALL agents to: provider=$PROV risk=$RISK runtime=$RT"
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { echo "✗ board $SERIAL not attached"; exit 1; }
S "cp -f $CFG ${CFG}.pre-${MODE}.bak"
# Rewrite every top-level [agents.<name>] block (not its .sub-tables).
S "python3 - <<'PY'
import re
cfg='$CFG'; prov='$PROV'; risk='$RISK'; rt='$RT'
hdr=re.compile(r'^\[agents\.[A-Za-z0-9_]+\]\$')
out=[]; ina=False
for ln in open(cfg).read().split('\n'):
if hdr.match(ln): ina=True
elif ln.startswith('['): ina=False
if ina:
if re.match(r'^\s*model_provider\s*=', ln): ln='model_provider = \"%s\"'%prov
elif re.match(r'^\s*risk_profile\s*=', ln): ln='risk_profile = \"%s\"'%risk
elif re.match(r'^\s*runtime_profile\s*=', ln): ln='runtime_profile = \"%s\"'%rt
out.append(ln)
open(cfg,'w').write('\n'.join(out))
print('rewrote agent blocks')
PY"
echo "→ restarting daemon (supervisor relaunches with new config)…"
S 'for p in $(ps -C zeroclaw -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done'
for i in $(seq 1 15); do
sleep 4
S 'curl -sf -m3 http://127.0.0.1:8080/health >/dev/null 2>&1' && break
printf '.'
done; echo
echo "=== agents now ==="
S "awk '/^\[agents\.[A-Za-z0-9_]+\]\$/{a=substr(\$0,9,length(\$0)-9)} /^model_provider/{print a\": \"\$3}' $CFG"
if [ "$MODE" = cloud ]; then
N=$(S 'P=$(pgrep -f "[z]eroclaw daemon" | head -1); cat /proc/$P/environ 2>/dev/null | tr "\0" "\n" | grep -c "ANTHROPIC_OAUTH_TOKEN"')
[ "${N:-0}" -ge 1 ] && echo "✓ cloud token present in daemon env" \
|| echo "✗ cloud token NOT in daemon env — run deploy/uno-q/recover.sh (with ANTHROPIC_OAUTH_TOKEN exported)"
fi
echo "done ($MODE)."
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Back up a student's Uno Q before reflashing it for the APESS workshop.
#
# ./backup-uno-q.sh <adb-serial> [team-label] [outdir]
#
# Students arrive having spent a week doing their own sensor work on these
# boards. THAT WORK IS IRREPLACEABLE — this runs before provision-uno-q.sh and
# must succeed before anything is overwritten.
#
# Strategy: DENYLIST, not allowlist. We do not know where a given team put
# their data (App Lab project dir, a loose CSV, a sketch folder), so we capture
# all of /home/arduino and exclude only what we can reproduce ourselves:
# the GGUF models, the Arduino core cache, our llama/zeroclaw binaries, the
# embedded SPA, and build output. On a reference board that leaves ~1-2 MB;
# a board with a week of logged data will be larger but still quick.
#
# The archive is VERIFIED READABLE before the script reports success — an
# unverified backup is not a backup.
set -euo pipefail
SERIAL="${1:?usage: backup-uno-q.sh <adb-serial> [team-label] [outdir]}"
LABEL="${2:-$SERIAL}"
OUTDIR="${3:-./backups}"
a() { adb -s "$SERIAL" "$@"; }
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
NAME="unoq-${LABEL}-${STAMP}"
REMOTE_TAR="/tmp/${NAME}.tar.gz"
LOCAL_TAR="${OUTDIR}/${NAME}.tar.gz"
LOCAL_MANIFEST="${OUTDIR}/${NAME}.manifest.txt"
# Reproducible — ours, or regenerable. Everything else is theirs and is kept.
#
# DELIBERATELY NOT EXCLUDED: ~/.local/share/arduino-app-cli/examples (~37M).
# Those are Arduino's stock App Lab examples, so in principle reproducible —
# but several of them (real-time-accelerometer, anomaly-detection,
# air-quality-monitoring) are exactly what a student doing sensor work would
# open and then edit IN PLACE. Excluding them would silently discard a week of
# work to save 25MB. We keep them. A backup is insurance, not a size contest.
EXCLUDES=(
'--exclude=./models' # 1.5G of GGUF weights — we push these
'--exclude=./.arduino15' # ~570M Arduino cores/index — arduino-cli refetches
'--exclude=./llama' # llama.cpp binaries — we push these
'--exclude=./web-dist' # embedded SPA build — we push this
'--exclude=./.cache' # regenerable
'--exclude=./lost+found'
'--exclude=./zeroclaw' # the daemon binary...
'--exclude=./zeroclaw.*' # ...and its .bak copies
'--exclude=./llama8083.log'
'--exclude=./zc-daemon.log'
'--exclude=./zc-supervisor.log'
'--exclude=*/build' # sketch build output — recompiled on demand
)
mkdir -p "$OUTDIR"
echo "==> [1/5] board reachable?"
a shell 'echo ok' >/dev/null 2>&1 || { echo " FAIL: board $SERIAL not reachable over adb"; exit 1; }
a shell 'test -d /home/arduino' >/dev/null 2>&1 || { echo " FAIL: /home/arduino missing"; exit 1; }
echo " $SERIAL ok"
echo "==> [2/5] inventory what will be captured"
a shell '
cd /home/arduino || exit 1
echo " user-work directories:"
for d in sketches Arduino ArduinoApps .arduino-bricks; do
if [ -e "$d" ]; then printf " %-18s %s\n" "$d" "$(du -sh "$d" 2>/dev/null | cut -f1)"; fi
done
echo " data-shaped files (top 10 by size):"
find . -maxdepth 4 \( -name "*.csv" -o -name "*.tsv" -o -name "*.dat" -o -name "*.json" -o -name "*.txt" -o -name "*.py" \) \
-not -path "./.arduino15/*" -not -path "./.cache/*" -not -path "*/build/*" -not -path "./.zeroclaw/*" \
-printf " %s\t%p\n" 2>/dev/null | sort -rn | head -10
echo " (none found)"
' 2>/dev/null || true
echo "==> [3/5] archive on board"
a shell "cd /home/arduino && tar czf '$REMOTE_TAR' ${EXCLUDES[*]} . 2>/dev/null; echo done" >/dev/null
SIZE="$(a shell "du -h '$REMOTE_TAR' 2>/dev/null | cut -f1" | tr -d '\r\n ')"
echo " archive: $SIZE"
echo "==> [4/5] pull + verify"
a pull "$REMOTE_TAR" "$LOCAL_TAR" >/dev/null 2>&1 || { echo " FAIL: could not pull archive"; exit 1; }
# An unverified backup is not a backup: list the archive and require real content.
if ! tar tzf "$LOCAL_TAR" > "$LOCAL_MANIFEST" 2>/dev/null; then
echo " FAIL: archive is not readable — DO NOT REFLASH THIS BOARD"
exit 1
fi
ENTRIES="$(wc -l < "$LOCAL_MANIFEST" | tr -d ' ')"
if [ "$ENTRIES" -lt 5 ]; then
echo " FAIL: archive has only $ENTRIES entries — suspiciously empty, DO NOT REFLASH"
exit 1
fi
echo " verified: $ENTRIES entries, manifest at $LOCAL_MANIFEST"
echo "==> [5/5] clean up board temp"
a shell "rm -f '$REMOTE_TAR'" >/dev/null 2>&1 || true
cat <<EOF
BACKUP OK — safe to reflash $SERIAL
archive : $LOCAL_TAR ($SIZE)
manifest: $LOCAL_MANIFEST
restore (after reflash):
adb -s $SERIAL push $LOCAL_TAR /tmp/restore.tar.gz
adb -s $SERIAL shell 'cd /home/arduino && tar xzf /tmp/restore.tar.gz && rm /tmp/restore.tar.gz'
EOF
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# provision-node-app.sh — install the "ZeroClaw Node" App Lab app onto a Uno Q and
# carry in its runtime bits (binary + config + .secret_key + token + node env), then
# start it. The app itself is secret-free; this script provisions the secrets.
#
# Usage (board on USB, secrets in env):
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-…
# KIT_ID=crimson-otter CLAIM_CODE=4821 FLEET_SECRET=apress2026 \
# APESS_URL=http://192.168.x.x:3000 ./deploy/uno-q/provision-node-app.sh
#
# Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above.
set -u
SERIAL="${SERIAL:-65301572}"
NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
DEST=/home/arduino/ArduinoApps/zeroclaw-node
S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; }
[ -f "$NODE_APP_DIR/app.yaml" ] || { bad "app not found at $NODE_APP_DIR"; exit 1; }
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set"; exit 1; }
echo "→ pushing the app to $DEST"
S "mkdir -p $DEST/bin $DEST/.zeroclaw"
adb -s "$SERIAL" push "$NODE_APP_DIR/app.yaml" "$DEST/app.yaml" >/dev/null
adb -s "$SERIAL" push "$NODE_APP_DIR/sketch" "$DEST/" >/dev/null
adb -s "$SERIAL" push "$NODE_APP_DIR/python" "$DEST/" >/dev/null
ok "app files"
echo "→ carrying in runtime bits (secret-bearing — provisioned, not committed)"
# binary: reuse the board's known-good one (already the right arch)
S "cp -f /home/arduino/zeroclaw $DEST/bin/zeroclaw && chmod +x $DEST/bin/zeroclaw"
# config + enc2 key (the config's secrets are bound to this key)
S "cp -f /home/arduino/.zeroclaw/config.toml $DEST/.zeroclaw/config.toml"
S "cp -f /home/arduino/.zeroclaw/.secret_key $DEST/.zeroclaw/.secret_key"
# web dashboard assets must live inside the app dir (/app in-container), not a host
# path — carry them in and repoint web_dist_dir so :8080/ serves the dashboard.
S "cp -rf /home/arduino/web-dist $DEST/web-dist 2>/dev/null; sed -i 's#^web_dist_dir = .*#web_dist_dir = \"/app/web-dist\"#' $DEST/.zeroclaw/config.toml"
# hardware skills — the [skill_bundles.unoq] bundle loads from shared/skills so the
# agent knows it's on an Uno Q and how to drive its devices. (copy skills/ directly,
# not the parent, to avoid cp nesting when the dir already exists.)
S "rm -rf $DEST/.zeroclaw/shared/skills; mkdir -p $DEST/.zeroclaw/shared && cp -rf /home/arduino/.zeroclaw/shared/skills $DEST/.zeroclaw/shared/skills"
# cloud token (env-only → written to the board's app dir, never to the repo)
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" | S "cat > $DEST/.zeroclaw/oauth_token"
# node env (self-register inputs)
S "cat > $DEST/.zeroclaw/apess-node.env" <<EOF
KIT_ID=${KIT_ID:-node-$SERIAL}
CLAIM_CODE=${CLAIM_CODE:-$(( (RANDOM % 9000) + 1000 ))}
FLEET_SECRET=${FLEET_SECRET:-}
APESS_URL=${APESS_URL:-}
GATEWAY_PORT=${GATEWAY_PORT:-8080}
EOF
ok "binary + config + .secret_key + oauth_token + apess-node.env"
echo "→ starting the app (compiles+flashes the matrix sketch, launches daemon in-container)"
S "cd $DEST && TMPDIR=/tmp timeout 300 arduino-app-cli app start $DEST 2>&1 | tail -4"
# The App Lab app IS the boot mechanism now. Remove the legacy @reboot supervisor
# cron so it doesn't race the app for :8080 on boot (it wins, and the app fails).
S 'crontab -l 2>/dev/null | grep -v "zeroclaw-supervisor" | crontab - 2>/dev/null; for p in $(pgrep -f "[z]eroclaw-supervisor"); do kill -9 $p 2>/dev/null; done'
ok "removed legacy supervisor @reboot cron (App Lab app owns boot now)"
echo "→ enable Run-at-startup for boot persistence:"
echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'"
ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run."
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# recover.sh — one-command recovery for the APESS Uno Q demo node after a USB drop.
#
# On a disconnect the daemon/llama/bridge die and the cloud token (env-only) is lost.
# This re-tunnels, relaunches the supervisor WITH the token in its environment,
# restarts the matrix bridge app, and verifies the whole chain end-to-end.
#
# Secrets are read from the environment — NEVER hardcoded here. Export first:
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # required: cloud brain
# export NODE_TOKEN=zc_... # optional: end-to-end verify
# ./recover.sh
#
# Env knobs: SERIAL (default 65301572), the two tokens above.
set -u
SERIAL="${SERIAL:-65301572}"
A(){ adb -s "$SERIAL" "$@"; }
S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
step(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
step "0· Preconditions"
if ! adb devices | grep -q "^${SERIAL}[[:space:]]*device"; then
bad "board $SERIAL not attached — re-plug USB, then re-run"; exit 1
fi
ok "board $SERIAL attached"
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set — cloud brain will fail. export it and re-run"; exit 1; }
ok "cloud token present in env"
step "1· Tunnel"
A forward tcp:8080 tcp:8080 >/dev/null && ok "adb forward :8080 → laptop localhost:8080"
step "2· Stop stale supervisor + daemons (preserve llama)"
S 'for p in $(ps -C zeroclaw-supervisor -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
for p in $(ps -C zeroclaw -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
rm -f /home/arduino/.zc-supervisor.lock; sleep 2
echo " daemons left: $(ps -C zeroclaw -o pid= 2>/dev/null | wc -l)"'
step "3· Relaunch supervisor WITH token env (env-only, never on disk)"
S "export ANTHROPIC_OAUTH_TOKEN='$ANTHROPIC_OAUTH_TOKEN'; \
export ZEROCLAW_providers__models__anthropic__max__api_key='$ANTHROPIC_OAUTH_TOKEN'; \
setsid nohup /home/arduino/zeroclaw-supervisor.sh >/dev/null 2>&1 </dev/null & sleep 2; echo done" >/dev/null
S 'pgrep -f "[z]eroclaw-supervisor" >/dev/null' && ok "supervisor relaunched" || bad "supervisor did NOT start"
step "4· Matrix bridge app (start only if down)"
if [ "$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')" = "pong" ]; then
ok "bridge already running"
else
S 'cd ~/ArduinoApps/uno-q-bridge && TMPDIR=/tmp arduino-app-cli app start ~/ArduinoApps/uno-q-bridge 2>&1 | tail -1'
fi
step "5· Wait for services"
for i in $(seq 1 30); do
L=$(S 'curl -sf -m3 http://127.0.0.1:8083/health >/dev/null 2>&1 && echo 1 || echo 0')
D=$(S 'curl -sf -m3 http://127.0.0.1:8080/health >/dev/null 2>&1 && echo 1 || echo 0')
printf '\r [%02d] llama=%s daemon=%s ' "$i" "$L" "$D"
[ "$D" = 1 ] && break; sleep 6
done; echo
[ "$L" = 1 ] && ok "llama :8083 healthy" || bad "llama :8083 DOWN (cold load can take 35 min; re-check)"
[ "$D" = 1 ] && ok "daemon :8080 healthy" || { bad "daemon :8080 DOWN"; exit 1; }
step "6· Bridge (matrix responder)"
P=$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')
[ "$P" = "pong" ] && ok "bridge :9999 responds (ping→pong)" || bad "bridge :9999 not responding — re-run step 4"
step "7· End-to-end: demo agent = cloud sonnet + matrix fires"
if [ -n "${NODE_TOKEN:-}" ]; then
S 'printf "matrix 0\n" | timeout 5 nc 127.0.0.1 9999 >/dev/null 2>&1'
R=$(curl -s -m 30 -X POST "http://127.0.0.1:8080/webhook?agent=demo" \
-H "Authorization: Bearer $NODE_TOKEN" -H 'Content-Type: application/json' \
-d '{"message":"Show the rain animation on the LED matrix"}')
echo "$R" | grep -q "claude-sonnet-5" && ok "agent=demo on claude-sonnet-5" || bad "agent NOT on sonnet — token may not have loaded: $R"
M=$(S "docker logs --since 40s uno-q-bridge-main-1 2>&1 | grep -c \"parts=\['matrix', '1'\]\"")
[ "${M:-0}" -ge 1 ] && ok "matrix_pattern fired (rain)" || bad "matrix did not change"
else
echo " (NODE_TOKEN unset — skipping authenticated end-to-end check)"
fi
step "Recovery complete."
echo " Voice proxy (laptop): if it was running it auto-recovers via the re-armed tunnel."
echo " If not running: NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=\$NODE_TOKEN python3 deploy/voice-client/serve.py 8090"
+53
View File
@@ -0,0 +1,53 @@
# APESS Voice → Node client
Talk to the on-board agent and change the LED-matrix animation **by voice** — same
agent, same `matrix_pattern` tool as web chat and Telegram.
- **STT + TTS run in the browser** (Web Speech API) — no ElevenLabs, no keys.
- **`serve.py` serves the page AND proxies `/webhook`** to the node on the same origin,
so the browser needs no CORS and no bearer token, and we reuse the reliable
`/webhook` path (the `/ws/chat` path drops the peripheral matrix tool, so we avoid it).
## Run it
The node is reached over USB via `adb forward`, or by its LAN IP on the day.
```bash
# 1. expose the board's gateway locally (USB path)
adb forward tcp:8080 tcp:8080
# 2. serve the client + proxy (tokens stay server-side, never in the browser)
cd deploy/voice-client
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=<gateway-bearer-token> \
ELEVENLABS_API_KEY=<sk_...> python3 serve.py 8090
# on the day, point NODE_URL at the board's LAN IP instead:
# NODE_URL=http://192.168.x.x:8080 NODE_TOKEN=... ELEVENLABS_API_KEY=... python3 serve.py 8090
# 3. open http://localhost:8090 in Chrome
```
Then: the dot goes green (proxy reachable), **hold** the circle, say
*"show the wave animation"*, release. The node runs the agent → `matrix_pattern`
the matrix changes, and the reply is spoken back.
`localhost` is a secure context so Chrome grants mic access; the proxy hop is
server-side so there is no CORS. One process, one origin, no keys. Chrome required
(Web Speech API).
## TTS: ElevenLabs vs browser
`serve.py` synthesizes replies with **ElevenLabs** when `ELEVENLABS_API_KEY` is set
(server-side `/tts` endpoint → the client plays the returned MP3); otherwise the client
falls back to the browser's built-in `speechSynthesis` voice. The client learns which
mode is active from `GET /config`, so no client change is needed either way.
- Default voice: **Sarah** (`EXAVITQu4vr4xnSDxMaL`). Override with `ELEVENLABS_VOICE_ID`.
- **Free-tier gotcha:** free ElevenLabs accounts can only use the ~21 *premade voices
attached to the account* — not "library" voices (e.g. Rachel `21m00…`), which return
HTTP 402 `paid_plan_required`. List usable voices:
`curl -s https://api.elevenlabs.io/v1/voices -H "xi-api-key: $ELEVENLABS_API_KEY"`.
- Model: `eleven_turbo_v2_5` (low latency) — override with `ELEVENLABS_MODEL`.
(The gateway also has a native voice-duplex path, but it shares the `/ws/chat`
peripheral-tool gap noted above and needs a fork fix first.)
</content>
+173
View File
@@ -0,0 +1,173 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APESS · Voice → Node</title>
<style>
:root { --bg:#0d0d0f; --fg:#eae6df; --dim:#8a857c; --accent:#e8543f; --line:#26242a; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
min-height:100vh; display:flex; flex-direction:column; align-items:center; }
header { width:100%; border-bottom:1px solid var(--line); padding:14px 18px;
text-transform:uppercase; letter-spacing:.18em; font-size:12px; color:var(--dim);
display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
.cfg { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.cfg input { background:#141317; color:var(--fg); border:1px solid var(--line);
padding:6px 8px; font:inherit; font-size:12px; border-radius:4px; }
.cfg input#ip { width:150px; }
.cfg input#agent { width:80px; }
button { font:inherit; cursor:pointer; }
.conn { padding:6px 10px; border:1px solid var(--line); border-radius:4px;
background:transparent; color:var(--fg); font-size:12px; text-transform:uppercase; letter-spacing:.1em; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#5a5750; margin-right:6px; vertical-align:middle; }
.dot.on { background:#4caf72; } .dot.err { background:var(--accent); }
main { flex:1; width:100%; max-width:720px; padding:24px 18px; display:flex; flex-direction:column; gap:18px; }
.mic { align-self:center; width:150px; height:150px; border-radius:50%; border:2px solid var(--line);
background:#141317; color:var(--fg); font-size:13px; text-transform:uppercase; letter-spacing:.12em;
display:flex; align-items:center; justify-content:center; transition:all .15s; user-select:none; }
.mic:hover { border-color:var(--dim); }
.mic.live { border-color:var(--accent); background:#2a1512; box-shadow:0 0 0 6px rgba(232,84,63,.12); }
.mic:disabled { opacity:.4; cursor:not-allowed; }
.hint { text-align:center; color:var(--dim); font-size:12px; margin-top:-8px; }
.log { display:flex; flex-direction:column; gap:12px; }
.row { border:1px solid var(--line); border-radius:6px; padding:12px 14px; }
.row .who { font-size:10px; text-transform:uppercase; letter-spacing:.16em; color:var(--dim); margin-bottom:6px; }
.row.you { border-color:#2f3a44; } .row.node { border-color:#3a2f2c; }
.row.tool { border-style:dashed; color:var(--dim); font-size:12px; }
.txt { font-size:15px; line-height:1.5; white-space:pre-wrap; }
.interim { color:var(--dim); font-style:italic; }
</style>
</head>
<body>
<header>
<span>APESS · VOICE → NODE</span>
<div class="cfg">
<input id="ip" placeholder="board-ip:8080" />
<input id="agent" value="demo" />
<button class="conn" id="connect"><span class="dot" id="dot"></span><span id="connlabel">Connect</span></button>
</div>
</header>
<main>
<button class="mic" id="mic" disabled>Hold&nbsp;to&nbsp;talk</button>
<div class="hint" id="hint">Connect to your node, then hold the circle and speak.</div>
<div class="log" id="log"></div>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
const ipEl=$('ip'), agentEl=$('agent'), micEl=$('mic'), logEl=$('log'),
dot=$('dot'), connLabel=$('connlabel'), hint=$('hint'), connectBtn=$('connect');
// The node address is configured on serve.py; the browser only picks the agent.
ipEl.style.display = 'none';
agentEl.value = localStorage.getItem('apess_agent') || 'demo';
let connected=false;
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { hint.textContent = 'This browser has no Web Speech API — use Chrome.'; }
function setConn(state){ // 'on' | 'err' | ''
dot.className = 'dot' + (state ? ' '+state : '');
connected = state==='on';
connLabel.textContent = connected ? 'Ready' : (state==='err'?'Retry':'Connect');
micEl.disabled = !connected || !SR;
if (connected) hint.textContent = 'Hold the circle, speak, release. The node acts and talks back.';
}
function addRow(cls, who, text){
const r=document.createElement('div'); r.className='row '+cls;
r.innerHTML=`<div class="who">${who}</div><div class="txt"></div>`;
r.querySelector('.txt').textContent=text; logEl.appendChild(r);
r.scrollIntoView({behavior:'smooth',block:'end'}); return r;
}
// Transport: POST to a SAME-ORIGIN /webhook that serve.py proxies to the node's
// gateway (reliable path — the WS path drops peripheral tools). No CORS, no auth
// in the browser (serve.py holds the bearer token).
function connect(){
const agent = agentEl.value.trim() || 'demo';
localStorage.setItem('apess_agent', agent);
setConn('');
fetch('/ping').then(r => setConn(r.ok?'on':'err')).catch(()=>setConn('err'));
}
let ttsMode = 'browser', curAudio = null;
fetch('/config').then(r=>r.json()).then(c=>{ ttsMode = c.tts||'browser'; }).catch(()=>{});
async function speak(text){
if (!text) return;
// stop anything currently playing (barge-in)
try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch(e){}
if (curAudio) { try{ curAudio.pause(); }catch(e){} curAudio=null; }
if (ttsMode === 'elevenlabs') {
try {
const r = await fetch('/tts', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({text})});
if (r.ok) {
const url = URL.createObjectURL(await r.blob());
curAudio = new Audio(url); curAudio.onended=()=>URL.revokeObjectURL(url);
await curAudio.play(); return;
}
} catch(e){ /* fall through to browser TTS */ }
}
if (window.speechSynthesis) {
const u = new SpeechSynthesisUtterance(text);
u.rate = 1.02; u.pitch = 1.0; window.speechSynthesis.speak(u);
}
}
async function send(text){
if (!text) return;
addRow('you','You',text);
const agent = agentEl.value.trim() || 'demo';
const pending = addRow('node','Node','…');
try {
const r = await fetch('/webhook?agent='+encodeURIComponent(agent), {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({message:text})
});
const j = await r.json();
const reply = (j.response || j.error || '(no response)').trim();
pending.querySelector('.txt').textContent = reply;
speak(reply);
} catch(e) {
pending.querySelector('.txt').textContent = 'error: '+e.message;
}
}
// --- push-to-talk: hold the circle, speak, release ---
let rec=null, listening=false, finalText='';
function startListen(){
if (!SR || !connected || listening) return;
finalText=''; listening=true; micEl.classList.add('live'); micEl.textContent='Listening…';
rec = new SR(); rec.lang='en-US'; rec.interimResults=true; rec.continuous=false;
rec.onresult = e => {
let interim='';
for (let i=e.resultIndex;i<e.results.length;i++){
const t=e.results[i][0].transcript;
if (e.results[i].isFinal) finalText+=t; else interim+=t;
}
hint.innerHTML = '<span class="interim">'+(finalText+interim||'…')+'</span>';
};
rec.onerror = () => {};
rec.onend = () => { listening=false; micEl.classList.remove('live'); micEl.textContent='Hold to talk';
const t=finalText.trim(); hint.textContent='Hold the circle and speak.'; if (t) send(t); };
try { rec.start(); } catch(e){ listening=false; }
}
function stopListen(){ if (rec && listening) { try{ rec.stop(); }catch(e){} } }
micEl.addEventListener('mousedown', startListen);
micEl.addEventListener('mouseup', stopListen);
micEl.addEventListener('mouseleave', stopListen);
micEl.addEventListener('touchstart', e=>{e.preventDefault();startListen();},{passive:false});
micEl.addEventListener('touchend', e=>{e.preventDefault();stopListen();},{passive:false});
connectBtn.addEventListener('click', connect);
setConn('');
connect(); // auto-check the proxy on load
})();
</script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""APESS voice client host + gateway proxy.
Serves index.html AND proxies POST /webhook to the node's ZeroClaw gateway on the
same origin — so the browser needs no CORS and no bearer token, and we reuse the
reliable /webhook path (the /ws/chat path drops the peripheral matrix tool).
Usage:
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=zc_xxx python3 serve.py [port]
- NODE_URL node gateway base (default http://127.0.0.1:8080; via `adb forward
tcp:8080 tcp:8080` over USB, or the board's LAN IP:8080 on the day).
- NODE_TOKEN gateway bearer token (kept server-side, never sent to the browser).
- port local port to serve on (default 8090). Open http://localhost:<port>.
localhost is a secure context, so the browser grants mic access; the proxy hop is
server-side, so there is no CORS. One process, one origin.
"""
import os, sys, json, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
NODE_URL = os.environ.get("NODE_URL", "http://127.0.0.1:8080").rstrip("/")
NODE_TOKEN = os.environ.get("NODE_TOKEN", "")
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
# Optional ElevenLabs TTS (server-side; key never reaches the browser). If unset,
# the client falls back to the browser's built-in speechSynthesis voice.
ELEVEN_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
ELEVEN_VOICE = os.environ.get("ELEVENLABS_VOICE_ID", "EXAVITQu4vr4xnSDxMaL") # Sarah (free-tier usable)
ELEVEN_MODEL = os.environ.get("ELEVENLABS_MODEL", "eleven_turbo_v2_5")
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass # quiet
def _send(self, code, body, ctype="application/json"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/ping":
return self._send(200, b'{"ok":true}')
if self.path == "/config":
mode = "elevenlabs" if ELEVEN_KEY else "browser"
return self._send(200, json.dumps({"tts": mode}).encode())
path = "/index.html" if self.path in ("/", "") else self.path.split("?")[0]
fp = os.path.normpath(os.path.join(HERE, path.lstrip("/")))
if not fp.startswith(HERE) or not os.path.isfile(fp):
return self._send(404, b"not found", "text/plain")
ctype = "text/html" if fp.endswith(".html") else "text/plain"
with open(fp, "rb") as f:
self._send(200, f.read(), ctype)
def do_POST(self):
if self.path == "/tts":
return self._tts()
if not self.path.startswith("/webhook"):
return self._send(404, b'{"error":"not found"}')
n = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(n)
q = self.path[len("/webhook"):] # keep ?agent=...
req = urllib.request.Request(
f"{NODE_URL}/webhook{q}", data=body, method="POST",
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {NODE_TOKEN}"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
self._send(resp.status, resp.read())
except urllib.error.HTTPError as e:
self._send(e.code, e.read() or b'{"error":"upstream"}')
except Exception as e:
self._send(502, json.dumps({"error": str(e)}).encode())
def _tts(self):
if not ELEVEN_KEY:
return self._send(503, b'{"error":"tts disabled"}')
n = int(self.headers.get("Content-Length", 0))
try:
text = json.loads(self.rfile.read(n)).get("text", "").strip()
except Exception:
text = ""
if not text:
return self._send(400, b'{"error":"no text"}')
payload = json.dumps({
"text": text, "model_id": ELEVEN_MODEL,
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
}).encode()
req = urllib.request.Request(
f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVEN_VOICE}",
data=payload, method="POST",
headers={"xi-api-key": ELEVEN_KEY, "Content-Type": "application/json",
"Accept": "audio/mpeg"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
self._send(200, resp.read(), "audio/mpeg")
except urllib.error.HTTPError as e:
self._send(e.code, e.read() or b'{"error":"tts upstream"}')
except Exception as e:
self._send(502, json.dumps({"error": str(e)}).encode())
if __name__ == "__main__":
if not NODE_TOKEN:
print("WARN: NODE_TOKEN is empty — gateway calls will 401.", file=sys.stderr)
print(f"APESS voice client → {NODE_URL}")
print(f"TTS: {'ElevenLabs ('+ELEVEN_VOICE+')' if ELEVEN_KEY else 'browser (speechSynthesis)'}")
print(f"open http://localhost:{PORT}")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
+7 -17
View File
@@ -1,5 +1,6 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { ADD_LAYERS } from '@/lib/addLayers'
function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactNode }) { function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactNode }) {
return ( return (
@@ -20,7 +21,6 @@ export function AddDocument() {
const team = useSession((s) => s.team) const team = useSession((s) => s.team)
const add = useSession((s) => s.add) const add = useSession((s) => s.add)
const domain = useSession((s) => s.domain) const domain = useSession((s) => s.domain)
const stats = useSession((s) => s.stats)
return ( return (
<article <article
@@ -38,23 +38,13 @@ export function AddDocument() {
</div> </div>
</header> </header>
<LayerBlock n={1} title="Domain & events" body={add.L1} /> {ADD_LAYERS.map((l) => (
<LayerBlock n={2} title="Skills" body={add.L2} /> <LayerBlock key={l.key} n={l.n} title={l.title} body={add[l.key]} />
<LayerBlock n={3} title="Policies" body={add.L3} /> ))}
<LayerBlock n={4} title="Harness" body={add.L4} />
<LayerBlock n={5} title="Loops" body={add.L5} />
<div className="grid sm:grid-cols-2 gap-4 border-t border-border pt-4 break-inside-avoid"> <div className="border-t border-border pt-4 break-inside-avoid space-y-1">
<div className="space-y-1"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Domain</div>
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Domain</div> <div className="font-mono text-[11px]">{domain || '—'}</div>
<div className="font-mono text-[11px]">{domain || '—'}</div>
</div>
<div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Session</div>
<div className="font-mono text-[11px]">
{stats.calls} frames · {stats.nominal} nominal · {stats.anomalous} anomalous · {stats.critical} critical
</div>
</div>
</div> </div>
</article> </article>
) )
+27 -2
View File
@@ -1,13 +1,21 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { AddLayerForm } from './AddLayerForm' import { AddLayerForm } from './AddLayerForm'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { askNode } from '@/lib/api'
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
askNode: vi.fn(),
}))
const mockAsk = vi.mocked(askNode)
describe('AddLayerForm', () => { describe('AddLayerForm', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
mockAsk.mockReset()
}) })
it('writes a string layer (L2) to the store as the user types', async () => { it('writes a string layer (L2) to the store as the user types', async () => {
@@ -29,4 +37,21 @@ describe('AddLayerForm', () => {
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance') await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
expect(useSession.getState().add.L1).toBe('structural resonance') expect(useSession.getState().add.L1).toBe('structural resonance')
}) })
it('Refine is disabled while empty, then reformats the text via the node', async () => {
const user = userEvent.setup()
useSession.getState().setAddLayer('L1', 'rough notes about impact spikes')
mockAsk.mockResolvedValue('Domain: impact monitoring.\n\n- Impact spike\n- Sustained sway')
render(<AddLayerForm layer="L1" title="Layer 1" />)
await user.click(screen.getByRole('button', { name: /refine/i }))
await waitFor(() => expect(useSession.getState().add.L1).toMatch(/Impact spike/))
// the reformatted text replaced the original notes
expect(useSession.getState().add.L1).not.toMatch(/rough notes/)
})
it('Refine is disabled when the layer is empty', () => {
render(<AddLayerForm layer="L1" title="Layer 1" />)
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
})
}) })
+46 -6
View File
@@ -1,7 +1,9 @@
import { useId } from 'react' import { useId, useState } from 'react'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useSession, type AddLayers } from '@/store/session' import { useSession, type AddLayers } from '@/store/session'
import { askNode } from '@/lib/api'
export interface AddLayerFormProps { export interface AddLayerFormProps {
layer: keyof AddLayers layer: keyof AddLayers
@@ -13,13 +15,37 @@ export interface AddLayerFormProps {
/** /**
* Keystone ADD capture component. Drives one layer of the 5-layer Agent Design * Keystone ADD capture component. Drives one layer of the 5-layer Agent Design
* Document — each layer is free text rendered as a single textarea. All edits * Document — each layer is free text rendered as a single textarea, with a
* flow straight into the session store. * "Refine" wand that asks the team's node to reformat + structure what they wrote
* (meaning preserved) so it's submission-ready.
*/ */
export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) { export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) {
const value = useSession((s) => s.add[layer]) const value = useSession((s) => s.add[layer])
const setAddLayer = useSession((s) => s.setAddLayer) const setAddLayer = useSession((s) => s.setAddLayer)
const teamId = useSession((s) => s.teamId)
const baseId = useId() const baseId = useId()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refine = async () => {
const text = value.trim()
if (!text || busy) return
setBusy(true)
setError(null)
try {
const refined = await askNode(
teamId,
`Reformat and lightly structure the following notes for the "${title}" section of an Agent Design Document. ` +
`Preserve the author's meaning and facts — do NOT invent new content. Improve clarity and grammar, and add ` +
`light structure (short paragraphs or bullets) where it helps readability. Return ONLY the improved text, no preamble.\n\nNotes:\n${text}`,
)
if (refined.trim()) setAddLayer(layer, refined.trim())
} catch {
setError('Could not reach your node — say hi first, then try again.')
} finally {
setBusy(false)
}
}
return ( return (
<Card> <Card>
@@ -29,15 +55,29 @@ export function AddLayerForm({ layer, title, description, placeholder }: AddLaye
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="flex items-center justify-between gap-3">
{title} <label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
</label> {title}
</label>
<Button
type="button"
size="sm"
variant="secondary"
className="h-7 px-2.5 font-mono text-[11px] tracking-wider"
disabled={!value.trim() || busy}
onClick={refine}
title="Reformat and structure your notes"
>
{busy ? 'Refining…' : '🪄 Refine'}
</Button>
</div>
<Textarea <Textarea
id={baseId} id={baseId}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(e) => setAddLayer(layer, e.target.value)} onChange={(e) => setAddLayer(layer, e.target.value)}
/> />
{error && <p role="alert" className="text-xs text-red-500 leading-relaxed">{error}</p>}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+3 -10
View File
@@ -1,11 +1,5 @@
import type { SubmissionDTO } from '@/types' import type { SubmissionDTO } from '@/types'
import { ADD_LAYERS } from '@/lib/addLayers'
const LAYERS: { key: 'L2' | 'L3' | 'L4' | 'L5'; title: string }[] = [
{ key: 'L2', title: 'Reasoning policy' },
{ key: 'L3', title: 'Action contract' },
{ key: 'L4', title: 'Failure modes' },
{ key: 'L5', title: 'AI-native redesign' },
]
function Block({ n, title, body }: { n: number; title: string; body: string }) { function Block({ n, title, body }: { n: number; title: string; body: string }) {
return ( return (
@@ -31,9 +25,8 @@ export function AddReview({ submission }: AddReviewProps) {
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div>
<h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3> <h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3>
</header> </header>
<Block n={1} title="Domain & events" body={submission.add.L1} /> {ADD_LAYERS.map((l) => (
{LAYERS.map((l, i) => ( <Block key={l.key} n={l.n} title={l.title} body={submission.add[l.key]} />
<Block key={l.key} n={i + 2} title={l.title} body={submission.add[l.key]} />
))} ))}
</article> </article>
) )
+27 -13
View File
@@ -5,28 +5,31 @@ import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
export interface BoardClaimProps { export interface BoardClaimProps {
teamId: string teamId: string
kit: string
teamName: string teamName: string
members: string[]
connected: boolean connected: boolean
port: string | null port: string | null
onClaimed: (result: ClaimResult) => void onClaimed: (result: ClaimResult) => void
/** Pre-fill the code (from the kit QR's ?code= param). */ /** Pre-fill the code (e.g. from a ?code= param). */
initialCode?: string initialCode?: string
} }
/** The three physical bring-up steps an attendee performs before claiming. */ /** The self-service bring-up steps — the board is already set up from the week. */
const STEPS = [ const STEPS = [
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.', 'On your board, open a terminal and run the workshop setup script (below).',
'Wait ~30 s for it to boot and join the workshop network.', 'It checks your node is ready, registers it, and scrolls a code across the LED matrix.',
'Enter the 6-digit claim code printed on your kit sticker.', 'Type the code your board is showing to bind it to your team.',
] ]
const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
/** /**
* The board bring-up wizard: walks the attendee through powering on their Uno Q * The board bring-up wizard for pre-deployed devices: the attendee runs the
* and claims it to their team by proving the kit's claim code. On success the * setup script on their own Uno Q, which self-registers the node and shows a
* board is bound server-side (its bearer token never touches the browser). * code on its matrix; entering that code binds the board to the team. The
* bearer token never touches the browser.
*/ */
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed, initialCode }: BoardClaimProps) { export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, initialCode }: BoardClaimProps) {
const [code, setCode] = useState(initialCode ?? '') const [code, setCode] = useState(initialCode ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -49,7 +52,7 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
const result = await claimBoard({ teamId, kit, teamName, code: trimmed }) const result = await claimBoard({ teamId, teamName, members, code: trimmed })
onClaimed(result) onClaimed(result)
} catch (e) { } catch (e) {
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.') setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
@@ -68,18 +71,29 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
</li> </li>
))} ))}
</ol> </ol>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<code className="font-mono text-[11px] text-foreground/90 select-all flex-1 truncate">{SETUP_CMD}</code>
<button
type="button"
aria-label="Copy setup command"
onClick={() => navigator.clipboard?.writeText(SETUP_CMD)}
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground shrink-0"
>
Copy
</button>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label="Claim code" aria-label="Claim code"
inputMode="numeric" inputMode="numeric"
placeholder="418302" placeholder="code on your matrix"
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && claim()} onKeyDown={(e) => e.key === 'Enter' && claim()}
className="font-mono" className="font-mono"
/> />
<Button onClick={claim} disabled={busy || !code.trim()}> <Button onClick={claim} disabled={busy || !code.trim()}>
{busy ? 'Claiming…' : 'Claim board'} {busy ? 'Binding…' : 'Bind board'}
</Button> </Button>
</div> </div>
{error && ( {error && (
+58 -16
View File
@@ -14,6 +14,26 @@ interface Entry {
label: string label: string
} }
/**
* Starter prompts. Deliberately sensor-agnostic — teams wired different devices
* during their own sensing week, so the agent discovers what is actually on the
* bus rather than being told. The last one needs no sensor at all, as a fallback
* for hardware that will not enumerate.
*
* KEEP THESE IMPERATIVE. Measured on the board 2026-07-20: the on-board 0.5B
* reliably calls its tools when told to do something ("List the I2C devices on
* the bus." -> tool call, 20s) but stalls without ever calling one when asked a
* question ("What sensors can you find on the I2C bus?" -> no tool call, >200s).
* Same agent, same 708-token prompt; phrasing was the only variable. Anything
* added here should be an instruction, not a question.
*/
const EXAMPLE_PROMPTS = [
'List the I2C devices on the bus',
'Read my sensor and print a value once a second',
'Light the matrix red when the reading crosses a threshold I set',
'Scroll GO CLAWS on the LED matrix',
]
const KIND_DOT: Record<NodeActivityKind, string> = { const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground', thinking: 'bg-muted-foreground',
tool: 'bg-amber', tool: 'bg-amber',
@@ -68,26 +88,48 @@ export function BuildFlash() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<p className="text-sm text-muted-foreground leading-relaxed max-w-md"> <p className="text-sm text-muted-foreground leading-relaxed max-w-md">
Ask your node to build something it uses its skills, writes a sketch, and flashes the MCU. Point the agent at the sensor you already have wired. It finds the device, writes a sketch using
The conversation lives in the node; watch it happen there while the activity feed streams here. its skills, and flashes the MCU sense, decide, act, on your own hardware. The conversation lives
in the node; the activity feed streams here.
<br />
<span className="text-foreground">
Phrase it as an instruction, not a question.
</span>{' '}
List the I2C devices works; what sensors can you find? tends to stall without ever calling a
tool. That asymmetry is itself a failure mode of small on-device models worth a line in Layer 3.
</p> </p>
<OpenYourNode variant="inline" className="shrink-0 mt-0.5" /> <OpenYourNode variant="inline" className="shrink-0 mt-0.5" />
</div> </div>
<div className="flex gap-2"> <div className="space-y-2">
<Input <div className="flex flex-wrap gap-1.5">
aria-label="Prompt your board" {EXAMPLE_PROMPTS.map((ex) => (
placeholder="e.g. scroll the message GO CLAWS on the LED matrix" <button
value={prompt} key={ex}
onChange={(e) => setPrompt(e.target.value)} type="button"
onKeyDown={(e) => { onClick={() => setPrompt(ex)}
if (e.key === 'Enter') void run() className="font-mono text-[10px] px-2 py-1 rounded border border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-left"
}} >
className="font-mono text-sm" {ex}
/> </button>
<Button onClick={() => void run()} disabled={busy || !prompt.trim()}> ))}
{busy ? 'Working…' : 'Send'} </div>
</Button>
<div className="flex gap-2">
<Input
aria-label="Prompt your board"
placeholder="Ask your node to read your sensor and act on it…"
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>
</div> </div>
<ul data-testid="activity-log" className="space-y-1.5 min-h-[3rem]"> <ul data-testid="activity-log" className="space-y-1.5 min-h-[3rem]">
+41 -2
View File
@@ -1,12 +1,20 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DomainPicker } from './DomainPicker' import { DomainPicker } from './DomainPicker'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { askNode } from '@/lib/api'
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
askNode: vi.fn(),
}))
const mockAsk = vi.mocked(askNode)
describe('DomainPicker', () => { describe('DomainPicker', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
mockAsk.mockReset()
}) })
it('binds the input to the session domain', () => { it('binds the input to the session domain', () => {
@@ -22,4 +30,35 @@ describe('DomainPicker', () => {
expect(screen.getByText(label)).toBeInTheDocument() expect(screen.getByText(label)).toBeInTheDocument()
} }
}) })
it('disables Refine until a domain is named', () => {
render(<DomainPicker />)
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
fireEvent.change(screen.getByLabelText(/your domain/i), { target: { value: 'air quality' } })
expect(screen.getByRole('button', { name: /refine/i })).toBeEnabled()
})
it('refine drafts all four dimensions into the store and opens a modal', async () => {
mockAsk.mockImplementation(async (_t, prompt) => {
const which = /SKILLS/.test(prompt) ? 'skills'
: /POLICIES/.test(prompt) ? 'policies'
: /HARNESS/.test(prompt) ? 'harness' : 'loops'
return `draft for ${which}`
})
useSession.getState().setDomain('air quality')
render(<DomainPicker />)
fireEvent.click(screen.getByRole('button', { name: /refine/i }))
await waitFor(() => expect(useSession.getState().add.L2).toBe('draft for skills'))
expect(useSession.getState().add.L3).toBe('draft for policies')
expect(useSession.getState().add.L4).toBe('draft for harness')
expect(useSession.getState().add.L5).toBe('draft for loops')
// the Skills card is now done + clickable → clicking pops the modal
const skills = await screen.findByTestId('dim-L2')
expect(skills).toHaveAttribute('data-state', 'done')
fireEvent.click(skills)
expect(screen.getByRole('dialog')).toHaveTextContent(/draft for skills/i)
})
}) })
+130 -21
View File
@@ -1,26 +1,87 @@
import { useId } from 'react' import { useId, useState } from 'react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useSession } from '@/store/session' import { Button } from '@/components/ui/button'
import { Modal } from '@/components/ui/modal'
import { useSession, type AddLayers } from '@/store/session'
import { askNode } from '@/lib/api'
import { cn } from '@/lib/utils'
/** Generic, domain-agnostic scaffolding prompts for the four design dimensions type LayerKey = 'L2' | 'L3' | 'L4' | 'L5'
* the team builds next. Deliberately NOT a fixed catalog — just questions. */ type GenState = 'idle' | 'running' | 'done'
const DIMENSION_HINTS: { label: string; prompt: string }[] = [
{ label: 'Skills', prompt: 'What domain knowledge must it know?' }, /** The four design dimensions the domain refine jump-starts, mapped to ADD layers.
{ label: 'Policies', prompt: 'What may it do autonomously vs. need approval?' }, * Each prompt is failure-first framed so the drafts seed the real deliverable. */
{ label: 'Harness', prompt: 'When does it decide locally vs. escalate?' }, const DIMENSIONS: { key: LayerKey; label: string; hint: string; prompt: (d: string) => string }[] = [
{ label: 'Loops', prompt: 'How often does it check its world + report by exception?' }, {
key: 'L2',
label: 'Skills',
hint: 'What domain knowledge must it know?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 34 concise sentences, describe the SKILLS it needs — the domain knowledge and sensing/interpretation capabilities required to understand this domain. Plain prose, no preamble, no heading.`,
},
{
key: 'L3',
label: 'Policies',
hint: 'What may it do autonomously vs. need approval?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 34 concise sentences, describe its POLICIES — what it may do autonomously vs. what needs human approval, and how each thing fails safe (a failure must never read as "nominal"; degrade to unknown/escalate). Plain prose, no preamble, no heading.`,
},
{
key: 'L4',
label: 'Harness',
hint: 'When does it decide locally vs. escalate?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 34 concise sentences, describe its HARNESS — the degradation path (cloud → on-board → fully offline) and what still works with no network at all. Plain prose, no preamble, no heading.`,
},
{
key: 'L5',
label: 'Loops',
hint: 'How often does it check its world + report by exception?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 34 concise sentences, describe its LOOPS — how often it checks its world, how it reports by exception, and what it does when a cycle fails (stale readings, missed ticks, partial data). Plain prose, no preamble, no heading.`,
},
] ]
/** /**
* Names the problem domain the team's node is for, plus the events it senses. * Names the problem domain and, via "Refine", asks the team's node to jump-start
* Bound straight to the session store's free-text `domain`. Below the input we * drafts for the four design dimensions (ADD layers L2L5). Each card generates
* surface generic scaffolding prompts for the next four design dimensions. * behind the scenes, turns green when done, and opens a modal with its draft.
* Drafts land in the session store so later modules pick them up.
*/ */
export function DomainPicker() { export function DomainPicker() {
const domain = useSession((s) => s.domain) const domain = useSession((s) => s.domain)
const setDomain = useSession((s) => s.setDomain) const setDomain = useSession((s) => s.setDomain)
const add = useSession((s) => s.add)
const setAddLayer = useSession((s) => s.setAddLayer)
const teamId = useSession((s) => s.teamId)
const inputId = useId() const inputId = useId()
const [state, setState] = useState<Record<LayerKey, GenState>>({ L2: 'idle', L3: 'idle', L4: 'idle', L5: 'idle' })
const [open, setOpen] = useState<LayerKey | null>(null)
const [error, setError] = useState<string | null>(null)
const anyRunning = Object.values(state).some((s) => s === 'running')
const doneCount = DIMENSIONS.filter((d) => state[d.key] === 'done').length
const refine = () => {
if (!domain.trim() || anyRunning) return
setError(null)
for (const dim of DIMENSIONS) {
setState((s) => ({ ...s, [dim.key]: 'running' }))
askNode(teamId, dim.prompt(domain))
.then((text) => {
setAddLayer(dim.key as keyof AddLayers, text.trim())
setState((s) => ({ ...s, [dim.key]: 'done' }))
})
.catch(() => {
setState((s) => ({ ...s, [dim.key]: 'idle' }))
setError('Could not reach your node — say hi first, then try again.')
})
}
}
const active = DIMENSIONS.find((d) => d.key === open)
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
@@ -34,23 +95,71 @@ export function DomainPicker() {
onChange={(e) => setDomain(e.target.value)} onChange={(e) => setDomain(e.target.value)}
/> />
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed"> <p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Name the domain your node is for and the events it senses. This frames everything you design next. Name the domain your node is for and the events it senses ideally the one you have been measuring
already. This frames everything you design next.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="flex items-center justify-between gap-3">
What you&rsquo;ll design next <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
What you&rsquo;ll design next
</div>
<Button
size="sm"
variant="secondary"
className="h-7 px-2.5 font-mono text-[11px] tracking-wider"
disabled={!domain.trim() || anyRunning}
onClick={refine}
title="Draft all four dimensions from your domain"
>
{anyRunning ? `Refining… ${doneCount}/4` : doneCount > 0 ? '↻ Refine again' : '✦ Refine'}
</Button>
</div> </div>
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
{DIMENSION_HINTS.map((d) => ( {DIMENSIONS.map((d) => {
<div key={d.label} className="rounded-md border border-border px-3 py-2.5"> const st = state[d.key]
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</div> const content = add[d.key]
<div className="text-sm text-muted-foreground leading-snug mt-1">{d.prompt}</div> const clickable = st === 'done' && !!content
</div> return (
))} <button
key={d.key}
type="button"
data-testid={`dim-${d.key}`}
data-state={st}
disabled={!clickable}
onClick={() => clickable && setOpen(d.key)}
className={cn(
'text-left rounded-md border px-3 py-2.5 transition-colors',
st === 'done'
? 'border-teal/40 bg-teal/10 hover:bg-teal/15 cursor-pointer'
: st === 'running'
? 'border-amber/40 bg-amber/5'
: 'border-border',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</span>
{st === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-amber animate-pulse" />}
{st === 'done' && <span className="font-mono text-[9px] uppercase tracking-widest text-teal">ready </span>}
</div>
<div className="text-sm text-muted-foreground leading-snug mt-1 line-clamp-2">
{st === 'done' && content ? content : d.hint}
</div>
</button>
)
})}
</div> </div>
{error && <p role="alert" className="text-xs text-red-500 leading-relaxed">{error}</p>}
</div> </div>
<Modal open={open !== null} onClose={() => setOpen(null)} title={active ? `Draft · ${active.label}` : ''}>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-foreground/90">{open ? add[open] : ''}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-4 leading-relaxed">
A starting draft from your node refine it further in the modules ahead.
</p>
</Modal>
</div> </div>
) )
} }
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { MemberFields } from './MemberFields'
function Wrapper({ initial = [] as string[] }) {
const [members, setMembers] = useState(initial)
return (
<>
<MemberFields members={members} onChange={setMembers} />
<output data-testid="committed">{members.join(',')}</output>
</>
)
}
describe('MemberFields', () => {
it('renders one empty row by default', () => {
render(<Wrapper />)
expect(screen.getByLabelText('Member 1')).toBeInTheDocument()
})
it('commits trimmed, non-empty names to the parent', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.type(screen.getByLabelText('Member 1'), ' A. Rossi ')
expect(screen.getByTestId('committed')).toHaveTextContent('A. Rossi')
})
it('adds a field below when "+" is clicked', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('button', { name: /add member/i }))
expect(screen.getByLabelText('Member 2')).toBeInTheDocument()
})
it('seeds a row per existing member and can remove one', async () => {
const user = userEvent.setup()
render(<Wrapper initial={['A. Rossi', 'K. Tanaka']} />)
expect(screen.getByLabelText('Member 1')).toHaveValue('A. Rossi')
await user.click(screen.getByRole('button', { name: /remove member 1/i }))
expect(screen.getByTestId('committed')).toHaveTextContent('K. Tanaka')
expect(screen.getByTestId('committed')).not.toHaveTextContent('A. Rossi')
})
it('caps at 5 members', () => {
render(<Wrapper initial={['A', 'B', 'C', 'D', 'E']} />)
expect(screen.getByRole('button', { name: /add member/i })).toBeDisabled()
})
})
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const MAX_MEMBERS = 5
export interface MemberFieldsProps {
members: string[]
onChange: (next: string[]) => void
}
/**
* Inline member entry: one text field per member with a "+" to append another
* row below, and a "×" to remove a row. The parent store only ever sees the
* trimmed, non-empty names; empty rows are a local editing affordance.
*/
export function MemberFields({ members, onChange }: MemberFieldsProps) {
// Seed local rows from the parent (always at least one row to type into).
const [rows, setRows] = useState<string[]>(members.length ? members : [''])
const commit = (next: string[]) => {
setRows(next)
onChange(next.map((r) => r.trim()).filter(Boolean))
}
const setRow = (i: number, value: string) => {
const next = rows.slice()
next[i] = value
commit(next)
}
const addRow = () => {
if (rows.length >= MAX_MEMBERS) return
setRows([...rows, '']) // don't commit — empty row adds nothing to the store
}
const removeRow = (i: number) => {
const next = rows.length > 1 ? rows.filter((_, idx) => idx !== i) : ['']
commit(next)
}
const full = rows.length >= MAX_MEMBERS
const filled = rows.filter((r) => r.trim()).length
return (
<div className="space-y-2" data-testid="member-fields">
{rows.map((row, i) => (
<div key={i} className="flex gap-2 items-center">
<span className="font-mono text-[10px] text-muted-foreground w-4 shrink-0 text-right">
{i + 1}
</span>
<Input
aria-label={`Member ${i + 1}`}
placeholder="e.g. A. Rossi"
value={row}
onChange={(e) => setRow(i, e.target.value)}
className="font-mono text-sm"
/>
<button
type="button"
aria-label={`Remove member ${i + 1}`}
onClick={() => removeRow(i)}
className="text-muted-foreground hover:text-destructive transition leading-none px-1.5 text-lg shrink-0"
>
×
</button>
</div>
))}
<div className="flex items-center justify-between pl-6">
<Button
type="button"
variant="ghost"
size="sm"
onClick={addRow}
disabled={full}
className="font-mono text-[11px] tracking-wider uppercase h-7 px-2"
>
+ Add member
</Button>
<span className="font-mono text-[10px] text-muted-foreground tracking-wider uppercase">
{filled} / {MAX_MEMBERS}
</span>
</div>
</div>
)
}
+7 -2
View File
@@ -58,9 +58,14 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
)} )}
> >
<div> <div>
<div className="text-xl font-bold tracking-tight">Open your node </div> <div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight">Open your node </span>
<span className="font-mono text-[9px] uppercase tracking-widest text-teal border border-teal/40 bg-teal/10 rounded px-1.5 py-0.5">
Connected
</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs"> <div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
{device.nodeUrl} {device.port ? `${device.port} · ` : ''}{device.nodeUrl}
</div> </div>
</div> </div>
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden /> <span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
+5 -5
View File
@@ -24,10 +24,10 @@ describe('PhaseStrip', () => {
expect(phases).toHaveLength(5) expect(phases).toHaveLength(5)
expect(phases.map((p) => p.textContent)).toEqual([ expect(phases.map((p) => p.textContent)).toEqual([
expect.stringContaining('Team reg'), expect.stringContaining('Team reg'),
expect.stringContaining('Env setup'), expect.stringContaining('Meet your node'),
expect.stringContaining('Module 1'), expect.stringContaining('Module 1'),
expect.stringContaining('Module 2'), expect.stringContaining('Module 2'),
expect.stringContaining('ADD · submit'), expect.stringContaining('Module 3'),
]) ])
}) })
@@ -42,7 +42,7 @@ describe('PhaseStrip', () => {
useSession.getState().completePhase('setup') useSession.getState().completePhase('setup')
renderAt('/workshop/module1', 'm1') renderAt('/workshop/module1', 'm1')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'done') expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Env setup').closest('a')).toHaveAttribute('data-state', 'done') expect(screen.getByText('Meet your node').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('data-state', 'active') expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('data-state', 'active')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('data-state', 'pending') expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('data-state', 'pending')
}) })
@@ -50,9 +50,9 @@ describe('PhaseStrip', () => {
it('links each phase to its workshop sub-route', () => { it('links each phase to its workshop sub-route', () => {
renderAt('/workshop') renderAt('/workshop')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop') expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop')
expect(screen.getByText('Env setup').closest('a')).toHaveAttribute('href', '/workshop/setup') expect(screen.getByText('Meet your node').closest('a')).toHaveAttribute('href', '/workshop/setup')
expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('href', '/workshop/module1') expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('href', '/workshop/module1')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('href', '/workshop/module2') expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('href', '/workshop/module2')
expect(screen.getByText('ADD · submit').closest('a')).toHaveAttribute('href', '/workshop/add') expect(screen.getByText('Module 3').closest('a')).toHaveAttribute('href', '/workshop/add')
}) })
}) })
+2 -2
View File
@@ -10,10 +10,10 @@ interface PhaseMeta {
const PHASES: PhaseMeta[] = [ const PHASES: PhaseMeta[] = [
{ key: 'reg', name: 'Team reg', to: '/workshop' }, { key: 'reg', name: 'Team reg', to: '/workshop' },
{ key: 'setup', name: 'Env setup', to: '/workshop/setup' }, { key: 'setup', name: 'Meet your node', to: '/workshop/setup' },
{ key: 'm1', name: 'Module 1', to: '/workshop/module1' }, { key: 'm1', name: 'Module 1', to: '/workshop/module1' },
{ key: 'm2', name: 'Module 2', to: '/workshop/module2' }, { key: 'm2', name: 'Module 2', to: '/workshop/module2' },
{ key: 'add', name: 'ADD · submit', to: '/workshop/add' }, { key: 'add', name: 'Module 3', to: '/workshop/add' },
] ]
export interface PhaseStripProps { export interface PhaseStripProps {
+13 -5
View File
@@ -17,12 +17,18 @@ export interface TeamCardProps {
team: TeamSnapshot team: TeamSnapshot
submitted: boolean submitted: boolean
scored: boolean scored: boolean
/**
* Real per-team tallies from the node activity stream (useCollective.counts).
* `team.stats` is NOT used here: nothing has called recordEvent since the
* simulator was removed, so those counters are permanently zero.
*/
counts?: { calls: number; flashes: number; errors: number }
/** current wall-clock ms; 0 (default) disables stale dimming */ /** current wall-clock ms; 0 (default) disables stale dimming */
now?: number now?: number
} }
/** One team's live tile in the instructor grid. Pure presentational. */ /** One team's live tile in the instructor grid. Pure presentational. */
export function TeamCard({ team, submitted, scored, now = 0 }: TeamCardProps) { export function TeamCard({ team, submitted, scored, counts, now = 0 }: TeamCardProps) {
const stale = now > 0 && now - new Date(team.updatedAt).getTime() > STALE_MS const stale = now > 0 && now - new Date(team.updatedAt).getTime() > STALE_MS
return ( return (
<div <div
@@ -57,10 +63,12 @@ export function TeamCard({ team, submitted, scored, now = 0 }: TeamCardProps) {
))} ))}
</div> </div>
<div className="font-mono text-[10px] text-muted-foreground tabular-nums"> <div
{team.stats.calls} · <span className="text-teal">{team.stats.nominal}</span>{' '} className="font-mono text-[10px] text-muted-foreground tabular-nums"
<span className="text-amber">{team.stats.anomalous}</span>{' '} title="agent runs · flashes · errors"
<span className="text-rose">{team.stats.critical}</span> >
{counts?.calls ?? 0} runs · <span className="text-primary">{counts?.flashes ?? 0}</span> flashed{' '}
{(counts?.errors ?? 0) > 0 && <span className="text-destructive">{counts?.errors} err</span>}
</div> </div>
{(submitted || scored) && ( {(submitted || scored) && (
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface ModalProps {
open: boolean
onClose: () => void
title?: ReactNode
children: ReactNode
className?: string
}
/** Lightweight accessible modal: dimmed overlay, click-outside + Esc to close. */
export function Modal({ open, onClose, title, children, className }: ModalProps) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
className={cn(
'w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-xl border border-border bg-background shadow-2xl',
className,
)}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3.5 sticky top-0 bg-background">
<div className="font-mono text-[11px] uppercase tracking-widest text-primary">{title}</div>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="text-muted-foreground hover:text-foreground text-lg leading-none"
>
×
</button>
</div>
)}
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { AddLayers } from '@/store/session'
/**
* The five ADD layers, in order — the single source of truth for their numbers
* and titles.
*
* These titles were previously duplicated in AddDocument (what the student
* writes) and AddReview (what the judge reads). The two drifted after the
* domain-node reshape, so judges were scoring "Skills" under the heading
* "Reasoning policy". Both now render from this list; add a layer here and
* both surfaces follow.
*/
export const ADD_LAYERS: { key: keyof AddLayers; n: number; title: string }[] = [
{ key: 'L1', n: 1, title: 'Domain & events' },
{ key: 'L2', n: 2, title: 'Skills' },
{ key: 'L3', n: 3, title: 'Policies' },
{ key: 'L4', n: 4, title: 'Harness' },
{ key: 'L5', n: 5, title: 'Loops' },
]
+30 -1
View File
@@ -5,6 +5,7 @@ import type {
ScoreInput, ScoreInput,
ScoreDTO, ScoreDTO,
LeaderboardRow, LeaderboardRow,
InstanceDTO,
WsEvent, WsEvent,
} from '@/types' } from '@/types'
@@ -82,11 +83,19 @@ 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')
} }
/** Central-mode fleet view: the local instances that have phoned home. Empty on
* a single-fleet deploy (the route just returns []). */
export async function getInstances(code: string): Promise<InstanceDTO[]> {
return asJson(await fetch(`${API_BASE}/instances`, { headers: authHeaders(code) }), 'getInstances')
}
// --- board onboarding (participant) --------------------------------------- // --- board onboarding (participant) ---------------------------------------
export interface ClaimInput { export interface ClaimInput {
teamId: string teamId: string
kit: string /** Optional legacy sticker path; omit for code-first (board shows its code). */
kit?: string
teamName?: string teamName?: string
members?: string[]
code: string code: string
} }
export interface ClaimResult { export interface ClaimResult {
@@ -159,6 +168,26 @@ export async function releaseBoard(kit: string, code: string): Promise<{ release
// --- ZeroClaw node (participant) ------------------------------------------ // --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */ /** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
/** Onboarding "say hi": prompt the team's node and wait for its reply text. */
export async function sayHi(teamId: string, agent?: string, message?: string): Promise<string> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/say-hi`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ agent, message }),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `sayHi ${res.status}`)
}
return ((await res.json()) as { reply: string }).reply
}
/** Ask the team's node a one-off prompt and wait for its reply (reuses the
* blocking say-hi path). Backs the "Refine" features. Defaults to the cloud agent. */
export async function askNode(teamId: string, prompt: string, agent = 'cloud'): Promise<string> {
return sayHi(teamId, agent, prompt)
}
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> { export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, { const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
method: 'POST', method: 'POST',
+34 -1
View File
@@ -14,7 +14,15 @@ const team = (id: string): TeamSnapshot => ({
updatedAt: '2026-07-27T13:00:00.000Z', updatedAt: '2026-07-27T13:00:00.000Z',
}) })
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {}, unclaimed: [] } const empty: CollectiveState = {
teams: {},
submissions: {},
nodes: {},
activity: [],
counts: {},
unclaimed: [],
instances: {},
}
describe('collectiveReducer', () => { describe('collectiveReducer', () => {
it('seeds from a snapshot', () => { it('seeds from a snapshot', () => {
@@ -39,6 +47,31 @@ describe('collectiveReducer', () => {
expect(next.unclaimed).toEqual(['KIT-07']) expect(next.unclaimed).toEqual(['KIT-07'])
}) })
it('seeds instances from a snapshot and upserts them on instance:update', () => {
const seeded = collectiveReducer(empty, {
type: 'snapshot',
teams: [],
submissions: [],
instances: [{ id: 'site-a', name: 'Team A', lastSeen: 't0' }],
})
expect(seeded.instances['site-a'].name).toBe('Team A')
const next = collectiveReducer(seeded, {
type: 'instance:update',
instance: { id: 'site-a', name: 'Team A', lastSeen: 't1' },
})
expect(next.instances['site-a'].lastSeen).toBe('t1')
})
it('a snapshot without instances preserves the ones already known', () => {
const seeded = collectiveReducer(empty, {
type: 'instance:update',
instance: { id: 'site-a', name: 'Team A', lastSeen: 't0' },
})
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
expect(next.instances['site-a']).toBeDefined() // not wiped by an instance-less snapshot
expect(Object.keys(next.teams)).toEqual(['a'])
})
it('preserves unclaimed when a snapshot omits it (REST seed)', () => { it('preserves unclaimed when a snapshot omits it (REST seed)', () => {
const seeded = collectiveReducer(empty, { type: 'unclaimed:update', kits: ['KIT-01'] }) const seeded = collectiveReducer(empty, { type: 'unclaimed:update', kits: ['KIT-01'] })
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] }) const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
+26 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useReducer, useRef, useState } from 'react' import { useEffect, useReducer, useRef, useState } from 'react'
import { openCollective, getTeams, getSubmissions, getUnclaimed } from './api' import { openCollective, getTeams, getSubmissions, getUnclaimed, getInstances } from './api'
import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types' import type { TeamSnapshot, SubmissionSummary, InstanceDTO, WsEvent, NodeActivityKind } from '@/types'
export interface NodeActivityEntry { export interface NodeActivityEntry {
teamId: string teamId: string
@@ -27,10 +27,20 @@ export interface CollectiveState {
counts: Record<string, NodeCounts> counts: Record<string, NodeCounts>
/** kit ids of powered-on boards no team has claimed yet. */ /** kit ids of powered-on boards no team has claimed yet. */
unclaimed: string[] unclaimed: string[]
/** siteId → federated local instance (central-mode; empty single-fleet). */
instances: Record<string, InstanceDTO>
} }
const MAX_ACTIVITY = 40 const MAX_ACTIVITY = 40
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {}, unclaimed: [] } const empty: CollectiveState = {
teams: {},
submissions: {},
nodes: {},
activity: [],
counts: {},
unclaimed: [],
instances: {},
}
const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 } const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 }
const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = { const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = {
@@ -46,10 +56,15 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
event.teams.forEach((t) => (teams[t.id] = t)) event.teams.forEach((t) => (teams[t.id] = t))
const submissions: Record<string, SubmissionSummary> = {} const submissions: Record<string, SubmissionSummary> = {}
event.submissions.forEach((s) => (submissions[s.teamId] = s)) event.submissions.forEach((s) => (submissions[s.teamId] = s))
return { ...state, teams, submissions, unclaimed: event.unclaimed ?? state.unclaimed } const instances = event.instances
? Object.fromEntries(event.instances.map((i) => [i.id, i]))
: state.instances
return { ...state, teams, submissions, unclaimed: event.unclaimed ?? state.unclaimed, instances }
} }
case 'unclaimed:update': case 'unclaimed:update':
return { ...state, unclaimed: event.kits } return { ...state, unclaimed: event.kits }
case 'instance:update':
return { ...state, instances: { ...state.instances, [event.instance.id]: event.instance } }
case 'team:update': case 'team:update':
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } } return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
case 'submission:new': case 'submission:new':
@@ -96,6 +111,8 @@ export interface Collective {
activity: NodeActivityEntry[] activity: NodeActivityEntry[]
counts: Record<string, NodeCounts> counts: Record<string, NodeCounts>
unclaimed: string[] unclaimed: string[]
/** federated instances, sorted by name (central-mode; empty single-fleet). */
instances: InstanceDTO[]
status: CollectiveStatus status: CollectiveStatus
} }
@@ -117,7 +134,10 @@ export function useCollective(code: string): Collective {
const poll = async () => { const poll = async () => {
try { try {
const [teams, submissions] = await Promise.all([getTeams(code), getSubmissions(code)]) const [teams, submissions] = await Promise.all([getTeams(code), getSubmissions(code)])
if (!cancelled && !live.current) dispatch({ type: 'snapshot', teams, submissions }) // instances is central-mode only (empty/absent otherwise) — never let its
// failure drop the teams/submissions seed.
const instances = await getInstances(code).catch(() => undefined)
if (!cancelled && !live.current) dispatch({ type: 'snapshot', teams, submissions, instances })
} catch { } catch {
/* the WS snapshot may still arrive; leave state as-is */ /* the WS snapshot may still arrive; leave state as-is */
} }
@@ -159,6 +179,7 @@ export function useCollective(code: string): Collective {
activity: state.activity, activity: state.activity,
counts: state.counts, counts: state.counts,
unclaimed: state.unclaimed, unclaimed: state.unclaimed,
instances: Object.values(state.instances).sort((a, b) => a.name.localeCompare(b.name)),
status, status,
} }
} }
+19 -6
View File
@@ -68,15 +68,28 @@ export function AddBuilder() {
<div className="grid lg:grid-cols-2 gap-6 print:hidden"> <div className="grid lg:grid-cols-2 gap-6 print:hidden">
<AddLayerForm <AddLayerForm
layer="L4" layer="L4"
title="ADD · Layer 4 — Harness (reasoning + tiering)" title="ADD · Layer 4 — Harness (where each decision runs)"
description="How it reasons — Claude via the Max token, on-board Qwen as the offline fallback — and when it escalates to a home hub, phone, or cloud." description="Which decisions run on the board, which escalate to the cloud — and what still works with no network at all. Describe the degradation path, not just the happy path."
placeholder="Reason locally with on-board Qwen; escalate ambiguous calls to Claude via the Max token; fall back to the home hub when offline." placeholder={
'Routine checks: on-board model, no network needed.\n' +
'Ambiguous or high-consequence calls: escalate to the cloud model.\n\n' +
'Degradation path:\n' +
'• cloud slow or rate-limited → fall back on-board, note reduced confidence\n' +
'• no network at all → keep sensing, logging and safing locally; queue anything that needs escalation\n' +
'• on-board model unavailable → stop actuating, alert, keep recording'
}
/> />
<AddLayerForm <AddLayerForm
layer="L5" layer="L5"
title="ADD · Layer 5 — Loops (autonomous cadence)" title="ADD · Layer 5 — Loops (cadence, and what happens when a cycle fails)"
description="How it monitors its domain over time — cron / heartbeat — and reports by exception." description="How often it checks, what it reports by exception — and how the loop behaves when a cycle fails: stale readings, missed ticks, partial data."
placeholder="Heartbeat every 30s; sample the IMU each minute; report only on anomaly; nightly cron summary." placeholder={
'Heartbeat every 30 s; sample the sensor each minute; report only on exception; daily summary.\n\n' +
'When a cycle fails:\n' +
'• missed tick → skip, do not back-fill invented data\n' +
'• partial data → report what is missing, not an average of what is left\n' +
'• N consecutive failures → escalate to a human and stop acting on the readings'
}
/> />
</div> </div>
+25
View File
@@ -13,6 +13,7 @@ vi.mock('@/lib/api', () => ({
getTeams: vi.fn().mockResolvedValue([]), getTeams: vi.fn().mockResolvedValue([]),
getSubmissions: vi.fn().mockResolvedValue([]), getSubmissions: vi.fn().mockResolvedValue([]),
getUnclaimed: vi.fn().mockResolvedValue([]), getUnclaimed: vi.fn().mockResolvedValue([]),
getInstances: vi.fn().mockResolvedValue([]),
releaseBoard: vi.fn().mockResolvedValue({ released: true, teamId: null }), releaseBoard: vi.fn().mockResolvedValue({ released: true, teamId: null }),
})) }))
@@ -85,6 +86,30 @@ describe('Admin', () => {
expect(within(panel).getByText('KIT-09')).toBeInTheDocument() expect(within(panel).getByText('KIT-09')).toBeInTheDocument()
}) })
it('groups teams under their instance in central (federated) mode', async () => {
renderAdmin()
const a = { ...team(1), site: 'site-a' }
const b = { ...team(2), site: 'site-b' }
act(() =>
emit({
type: 'snapshot',
teams: [a, b],
submissions: [],
instances: [
{ id: 'site-a', name: 'Team A laptop', lastSeen: new Date().toISOString() },
{ id: 'site-b', name: 'Team B laptop', lastSeen: new Date().toISOString() },
],
}),
)
// fleet view replaces the flat grid; one section per instance
await waitFor(() => expect(screen.getByTestId('fleet-view')).toBeInTheDocument())
const sections = screen.getAllByTestId('fleet-instance')
expect(sections).toHaveLength(2)
const siteA = sections.find((s) => s.getAttribute('data-site') === 'site-a')!
expect(within(siteA).getByText('Team A laptop')).toBeInTheDocument()
expect(within(siteA).getAllByTestId('team-card')).toHaveLength(1)
})
it('gates behind the access code when none is stored', () => { it('gates behind the access code when none is stored', () => {
sessionStorage.clear() sessionStorage.clear()
renderAdmin() renderAdmin()
+117 -15
View File
@@ -3,10 +3,15 @@ import { Link } from 'react-router-dom'
import { AccessGate } from '@/components/AccessGate' import { AccessGate } from '@/components/AccessGate'
import { TeamCard } from '@/components/TeamCard' import { TeamCard } from '@/components/TeamCard'
import { BoardActivity } from '@/components/BoardActivity' import { BoardActivity } from '@/components/BoardActivity'
import { useCollective } from '@/lib/useCollective' import { useCollective, type Collective } from '@/lib/useCollective'
import { useNow } from '@/lib/useNow' import { useNow } from '@/lib/useNow'
import { releaseBoard } from '@/lib/api' import { releaseBoard } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TeamSnapshot } from '@/types'
/** A local instance is considered offline if it hasn't heartbeated recently
* (heartbeat cadence is 30s; allow ~2.5× before flagging it down). */
const INSTANCE_STALE_MS = 75_000
/** Release a mis-claimed / reassigned kit back to the pool so another team can /** Release a mis-claimed / reassigned kit back to the pool so another team can
* claim it. The collective WS pushes the refreshed unclaimed list automatically. */ * claim it. The collective WS pushes the refreshed unclaimed list automatically. */
@@ -62,8 +67,110 @@ function Stat({ label, value }: { label: string; value: number | string }) {
) )
} }
/** The team tiles for a set of teams (shared by the flat + grouped views). */
function TeamGrid({
teams,
submissions,
counts,
now,
}: {
teams: TeamSnapshot[]
submissions: Collective['submissions']
counts: Collective['counts']
now: number
}) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4" data-testid="team-grid">
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
submitted={!!submissions[t.id]}
scored={!!submissions[t.id]?.scored}
counts={counts[t.id]}
now={now}
/>
))}
</div>
)
}
/**
* Central-mode fleet view: teams grouped under the local instance (site) that
* reported them, each with an online/offline indicator + last-seen. Teams whose
* site matches no known instance fall into an "unlinked" group so nothing is
* hidden.
*/
function FleetView({
instances,
teams,
submissions,
counts,
now,
}: {
instances: Collective['instances']
teams: TeamSnapshot[]
submissions: Collective['submissions']
counts: Collective['counts']
now: number
}) {
const bySite = new Map<string, TeamSnapshot[]>()
for (const t of teams) {
const site = t.site || '—'
;(bySite.get(site) ?? bySite.set(site, []).get(site)!).push(t)
}
// instances first (in name order), then any orphan site buckets
const known = new Set(instances.map((i) => i.id))
const orphanSites = [...bySite.keys()].filter((s) => !known.has(s)).sort()
const Section = ({
id,
name,
lastSeen,
}: {
id: string
name: string
lastSeen?: string
}) => {
const group = bySite.get(id) ?? []
const online = lastSeen ? now - new Date(lastSeen).getTime() < INSTANCE_STALE_MS : false
const ageS = lastSeen ? Math.round((now - new Date(lastSeen).getTime()) / 1000) : null
return (
<div className="space-y-3" data-testid="fleet-instance" data-site={id}>
<div className="flex items-center gap-2 border-b border-border pb-1">
<span
className={cn('w-2 h-2 rounded-full shrink-0', lastSeen ? (online ? 'bg-teal animate-pulse' : 'bg-amber') : 'bg-muted-foreground')}
/>
<span className="font-mono text-xs font-semibold tracking-wide">{name}</span>
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{group.length} team{group.length === 1 ? '' : 's'}
{ageS !== null && ` · ${online ? 'live' : `${ageS}s ago`}`}
{!lastSeen && ' · unlinked'}
</span>
</div>
{group.length === 0 ? (
<p className="text-xs text-muted-foreground">No teams reported yet.</p>
) : (
<TeamGrid teams={group} submissions={submissions} counts={counts} now={now} />
)}
</div>
)
}
return (
<div className="space-y-8" data-testid="fleet-view">
{instances.map((i) => (
<Section key={i.id} id={i.id} name={i.name} lastSeen={i.lastSeen} />
))}
{orphanSites.map((s) => (
<Section key={s} id={s} name={s === '—' ? 'No site' : s} />
))}
</div>
)
}
function AdminBoard({ code }: { code: string }) { function AdminBoard({ code }: { code: string }) {
const { teams, submissions, nodes, activity, counts, unclaimed, status } = useCollective(code) const { teams, submissions, nodes, activity, counts, unclaimed, instances, status } = useCollective(code)
const now = useNow() const now = useNow()
const connected = teams.filter((t) => t.deviceConnected).length const connected = teams.filter((t) => t.deviceConnected).length
const boardsLive = Object.values(nodes).filter(Boolean).length const boardsLive = Object.values(nodes).filter(Boolean).length
@@ -71,6 +178,8 @@ function AdminBoard({ code }: { code: string }) {
const submittedCount = Object.keys(submissions).length const submittedCount = Object.keys(submissions).length
const judgedCount = Object.values(submissions).filter((s) => s.scored).length const judgedCount = Object.values(submissions).filter((s) => s.scored).length
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
const federated = instances.length > 0 // central-mode: teams arrive tagged by site
const instancesLive = instances.filter((i) => now - new Date(i.lastSeen).getTime() < INSTANCE_STALE_MS).length
return ( return (
<main className="min-h-screen bg-background"> <main className="min-h-screen bg-background">
@@ -91,7 +200,8 @@ function AdminBoard({ code }: { code: string }) {
</header> </header>
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6"> <section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
<div className="grid grid-cols-2 md:grid-cols-7 gap-px bg-border rounded-md overflow-hidden"> <div className={cn('grid grid-cols-2 gap-px bg-border rounded-md overflow-hidden', federated ? 'md:grid-cols-8' : 'md:grid-cols-7')}>
{federated && <Stat label="Instances" value={`${instancesLive}/${instances.length}`} />}
<Stat label="Teams" value={teams.length} /> <Stat label="Teams" value={teams.length} />
<Stat label="Connected" value={connected} /> <Stat label="Connected" value={connected} />
<Stat label="Boards live" value={boardsLive} /> <Stat label="Boards live" value={boardsLive} />
@@ -131,20 +241,12 @@ function AdminBoard({ code }: { code: string }) {
<BoardActivity activity={activity} nameFor={nameFor} /> <BoardActivity activity={activity} nameFor={nameFor} />
</div> </div>
{teams.length === 0 ? ( {teams.length === 0 && !federated ? (
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p> <p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
) : federated ? (
<FleetView instances={instances} teams={teams} submissions={submissions} counts={counts} now={now} />
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4" data-testid="team-grid"> <TeamGrid teams={teams} submissions={submissions} counts={counts} now={now} />
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
submitted={!!submissions[t.id]}
scored={!!submissions[t.id]?.scored}
now={now}
/>
))}
</div>
)} )}
</section> </section>
</main> </main>
+45 -49
View File
@@ -1,15 +1,15 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup' import { EnvSetup } from './EnvSetup'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { getNodeStatus } from '@/lib/api' import { sayHi } from '@/lib/api'
vi.mock('@/lib/api', async (orig) => ({ vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()), ...(await orig<typeof import('@/lib/api')>()),
getNodeStatus: vi.fn(), sayHi: vi.fn(),
})) }))
const mockNodeStatus = vi.mocked(getNodeStatus) const mockSayHi = vi.mocked(sayHi)
function renderPage() { function renderPage() {
return render( return render(
@@ -21,13 +21,14 @@ function renderPage() {
/** Connect a board with a live node URL — the common precondition. */ /** Connect a board with a live node URL — the common precondition. */
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') { function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl }) useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
} }
describe('EnvSetup — Meet your node', () => { describe('EnvSetup — Meet your node', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
mockSayHi.mockReset()
}) })
it('renders the phase strip set to setup and the heading', () => { it('renders the phase strip set to setup and the heading', () => {
@@ -42,19 +43,17 @@ describe('EnvSetup — Meet your node', () => {
it('prompts to claim a board first when not connected', () => { it('prompts to claim a board first when not connected', () => {
renderPage() renderPage()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeDisabled()
// say-hi is disabled with no device
expect(screen.getByRole('button', { name: /say hi \/ confirm online/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
it('renders the Open-your-node CTA to the board url when connected with a nodeUrl', () => { it('shows the node as Connected with the board url when claimed', () => {
connect('http://192.168.1.7:8080') connect('http://192.168.1.7:8080')
renderPage() renderPage()
const link = screen.getByRole('link', { name: /open your node/i }) const link = screen.getByRole('link', { name: /open your node/i })
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080') expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer') expect(screen.getByText(/^connected$/i)).toBeInTheDocument()
}) })
it('falls back to the claim prompt when connected but there is no nodeUrl', () => { it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
@@ -64,55 +63,52 @@ describe('EnvSetup — Meet your node', () => {
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
}) })
it('requires a domain even after the board is online', async () => { it('says hi to the agent and shows its reply', async () => {
vi.useFakeTimers() mockSayHi.mockResolvedValue("Hi! I'm your node — I can read your sensor and drive the matrix.")
try { connect()
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true }) renderPage()
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i })) fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await act(async () => { expect(await screen.findByTestId('node-reply')).toHaveTextContent(/read your sensor/i)
await vi.advanceTimersByTimeAsync(2000) expect(screen.getByRole('button', { name: /your node replied/i })).toBeInTheDocument()
})
expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
// online but no domain → still gated
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
} finally {
vi.useRealTimers()
}
}) })
it('keeps Proceed gated when a domain is named but the board is not confirmed online', () => { it('requires a domain even after the agent replies', async () => {
mockSayHi.mockResolvedValue('hello there')
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await screen.findByTestId('node-reply')
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
})
it('keeps Proceed gated when a domain is named but the agent has not replied', () => {
connect() connect()
useSession.getState().setDomain('air quality') useSession.getState().setDomain('air quality')
renderPage() renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
describe('say hi / online confirmation', () => { it('enables Proceed once the agent has replied AND a domain is named', async () => {
beforeEach(() => vi.useFakeTimers()) mockSayHi.mockResolvedValue('hello there')
afterEach(() => vi.useRealTimers()) connect()
useSession.getState().setDomain('structural stress')
renderPage()
it('enables Proceed once online AND a domain is named, without polluting stats', async () => { const proceed = screen.getByRole('button', { name: /proceed/i })
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true }) expect(proceed).toBeDisabled()
connect()
useSession.getState().setDomain('structural stress')
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i }) fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(proceed).toBeDisabled() await screen.findByTestId('node-reply')
expect(proceed).toBeEnabled()
expect(useSession.getState().stats.calls).toBe(0)
})
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i })) it('surfaces an error when the node does not answer', async () => {
await act(async () => { mockSayHi.mockRejectedValue(new Error('your node did not answer — is it online?'))
await vi.advanceTimersByTimeAsync(2000) connect()
}) renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(screen.getByText(/online ✓/i)).toBeInTheDocument() expect(await screen.findByRole('alert')).toHaveTextContent(/did not answer/i)
expect(proceed).toBeEnabled()
// confirmation polls must NOT be counted as workshop events
expect(useSession.getState().stats.calls).toBe(0)
})
}) })
}) })
+57 -33
View File
@@ -7,14 +7,10 @@ import { PhaseStrip } from '@/components/PhaseStrip'
import { OpenYourNode } from '@/components/OpenYourNode' import { OpenYourNode } from '@/components/OpenYourNode'
import { DomainPicker } from '@/components/DomainPicker' import { DomainPicker } from '@/components/DomainPicker'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { getNodeStatus } from '@/lib/api' import { sayHi } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type SelfTest = 'idle' | 'running' | 'ok' type HiState = 'idle' | 'running' | 'ok'
/** Say-hi confirmation: poll the claimed board's liveness this many times. */
const SELFTEST_POLLS = 12
const SELFTEST_POLL_MS = 500
export function EnvSetup() { export function EnvSetup() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -22,27 +18,27 @@ export function EnvSetup() {
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const domain = useSession((s) => s.domain) const domain = useSession((s) => s.domain)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const [selfTest, setSelfTest] = useState<SelfTest>('idle') const [hi, setHi] = useState<HiState>('idle')
const [reply, setReply] = useState('')
const [hiError, setHiError] = useState<string | null>(null)
const runSelfTest = async () => { const saidHi = async () => {
setSelfTest('running') if (hi === 'running') return
// Confirm the claimed board is reachable and online. setHi('running')
for (let i = 0; i < SELFTEST_POLLS; i++) { setHiError(null)
try { setReply('')
const s = await getNodeStatus(teamId) try {
if (s.online) { // Chat with the agent on the team's own board; a reply means it's live.
setSelfTest('ok') const r = await sayHi(teamId, 'cloud')
return setReply(r || '(your node replied)')
} setHi('ok')
} catch { } catch (e) {
/* board not registered yet / transient — keep polling */ setHiError(e instanceof Error ? e.message : 'Could not reach your node.')
} setHi('idle')
await new Promise((r) => setTimeout(r, SELFTEST_POLL_MS))
} }
setSelfTest('idle') // couldn't confirm — let them retry
} }
const ready = device.connected && selfTest === 'ok' && domain.trim().length > 0 const ready = device.connected && hi === 'ok' && domain.trim().length > 0
const onProceed = () => { const onProceed = () => {
completePhase('setup') completePhase('setup')
@@ -92,26 +88,54 @@ export function EnvSetup() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Open your node&rsquo;s dashboard and say hi it introduces itself and lists the skills Say hi to the agent running on your board. When it replies, your node is live and
it already has. Then confirm it&rsquo;s online here. listening and you&rsquo;re clear to name its domain.
</p> </p>
{/* the exchange */}
{(hi !== 'idle' || reply) && (
<div className="space-y-2" data-testid="say-hi-chat">
<div className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
</div>
{hi === 'running' && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
your node is thinking
</div>
)}
{reply && (
<div className="flex justify-start">
<span className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]" data-testid="node-reply">
{reply}
</span>
</div>
)}
</div>
)}
<Button <Button
variant="outline" variant={hi === 'ok' ? 'outline' : 'default'}
className="w-full justify-start" className="w-full justify-start"
disabled={!device.connected || selfTest === 'running'} disabled={!device.connected || hi === 'running'}
onClick={runSelfTest} onClick={saidHi}
> >
<span <span
className={cn( className={cn(
'w-2 h-2 rounded-full mr-3', 'w-2 h-2 rounded-full mr-3',
selfTest === 'ok' ? 'bg-teal' : selfTest === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground', hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)} )}
/> />
{selfTest === 'ok' ? 'Online ✓' : selfTest === 'running' ? 'Checking…' : 'Say hi / confirm online'} {hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
</Button> </Button>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed"> {hiError && (
Confirms the board is reachable and live. Does not count toward your session stats. <p role="alert" className="text-xs text-red-500 leading-relaxed">{hiError}</p>
</p> )}
{!device.connected && (
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Bind your board in team registration first.
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
+1 -1
View File
@@ -21,7 +21,7 @@ describe('Landing', () => {
it('exposes the four PRD success-metric stats', () => { it('exposes the four PRD success-metric stats', () => {
renderLanding() renderLanding()
expect(screen.getByText(/5h 30m/i)).toBeInTheDocument() expect(screen.getByText(/14:00 19:00/i)).toBeInTheDocument()
expect(screen.getByText(/15 teams/i)).toBeInTheDocument() expect(screen.getByText(/15 teams/i)).toBeInTheDocument()
expect(screen.getAllByText(/Arduino Uno Q/i).length).toBeGreaterThan(0) expect(screen.getAllByText(/Arduino Uno Q/i).length).toBeGreaterThan(0)
}) })
+12 -11
View File
@@ -11,12 +11,13 @@ interface ProgrammeRow {
} }
const PROGRAMME: ProgrammeRow[] = [ const PROGRAMME: ProgrammeRow[] = [
{ time: '13:00', title: 'Arrival & kit pickup', desc: 'Teams collect Arduino Uno Q boards + sensor kits.', tags: ['setup'] }, { time: '10:45', title: 'Lecture · Agentic design thinking', desc: 'Separate morning session — the five layers, and designing for failure.', tags: ['lecture'] },
{ time: '14:00', title: 'Lecture · 5 movements', desc: 'Domain & events, skills, policies, harness, loops.', tags: ['lecture'] }, { time: '14:00', title: 'Arrival & registration', desc: 'Boards backed up and reflashed for the workshop while you register.', tags: ['setup'] },
{ time: '15:00', title: 'Module 1 · Domain & Skills', desc: 'Pick a domain, name its events, draft the skill library — Layers 1 + 2.', tags: ['build'] }, { time: '14:25', title: 'Meet your node', desc: 'The board you already know — now carrying an agent that can drive your devices.', tags: ['setup'] },
{ time: '16:15', title: 'Module 2 · Policies & Harness', desc: 'Set the actuation gate, tier the reasoning, talk to your node — Layers 3 + 4.', tags: ['build'] }, { time: '14:45', title: 'Module 1 · Domain & events', desc: 'Your sensors, your data, the events that matter — Layer 1.', tags: ['build'] },
{ time: '17:45', title: 'ADD builder & submit', desc: 'Design the autonomous loops, PDF export, submission — Layer 5.', tags: ['add'] }, { time: '16:10', title: 'Module 2 · Skills & policies', desc: 'Drive a real sensor, enumerate the failure states, set the actuation gate — Layers 2 + 3.', tags: ['build'] },
{ time: '19:00', title: 'Judging & award', desc: 'Demartino panel reviews ADDs; RedClaw Systems award announced.', tags: ['judge'] }, { time: '17:40', title: 'Module 3 · Harness, loops & submit', desc: 'How it degrades, how often it runs, then submit — Layers 4 + 5.', tags: ['add'] },
{ time: '19:00', title: 'Judging & award', desc: 'Panel reviews the Agent Design Documents; RedClaw Systems award announced.', tags: ['judge'] },
] ]
const STACK = [ const STACK = [
@@ -80,9 +81,9 @@ export function Landing() {
<span className="text-primary">a Claude agent on the edge</span> <span className="text-primary">a Claude agent on the edge</span>
</h1> </h1>
<p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto"> <p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
Pick a domain, then engineer the skills, policies, harness, and loops of a Claude-powered ZeroClaw node Your Uno Q already senses. Today it gets an agent one you talk to, one that drives your devices, and
on an Arduino Uno Q one you talk to and one that works on its own. Complete a five-layer Agent Design one that keeps working when things break. Design its skills, policies, harness and loops around the
Document in a single 5.5-hour session. failure states you find, and leave with a five-layer Agent Design Document.
</p> </p>
<div className="flex flex-wrap gap-3 justify-center pt-4"> <div className="flex flex-wrap gap-3 justify-center pt-4">
<Button asChild size="lg"> <Button asChild size="lg">
@@ -95,7 +96,7 @@ export function Landing() {
</div> </div>
<div className="max-w-4xl mx-auto mt-16 grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden text-center"> <div className="max-w-4xl mx-auto mt-16 grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden text-center">
{[ {[
['Duration', '5h 30m'], ['Hackathon', '14:00 19:00'],
['Teams', '15 teams'], ['Teams', '15 teams'],
['Per team', '35 students'], ['Per team', '35 students'],
['Hardware', 'Arduino Uno Q · 4 GB'], ['Hardware', 'Arduino Uno Q · 4 GB'],
@@ -112,7 +113,7 @@ export function Landing() {
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<div> <div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Programme</p> <p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Programme</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Five and a half hours · seven moves</h2> <h2 className="text-2xl md:text-3xl font-bold tracking-tight">Lecture at 10:45 · build from 14:00</h2>
</div> </div>
<div data-testid="programme" className="border border-border rounded-lg overflow-hidden bg-card divide-y divide-border"> <div data-testid="programme" className="border border-border rounded-lg overflow-hidden bg-card divide-y divide-border">
{PROGRAMME.map((row) => ( {PROGRAMME.map((row) => (
+1 -1
View File
@@ -21,7 +21,7 @@ const MOVEMENTS: Movement[] = [
thesis: 'An agent is a system that closes the loop between sensing the world and acting on it — without a human in the middle. First you choose the world.', thesis: 'An agent is a system that closes the loop between sensing the world and acting on it — without a human in the middle. First you choose the world.',
body: [ body: [
'Most embedded software is reactive plumbing: read a sensor, threshold it, toggle a pin. An agent is different in kind, not degree — it holds a goal, forms a belief about its environment, and chooses an action it expects to advance that goal.', 'Most embedded software is reactive plumbing: read a sensor, threshold it, toggle a pin. An agent is different in kind, not degree — it holds a goal, forms a belief about its environment, and chooses an action it expects to advance that goal.',
'Today you design a domain node: pick a domain — a workshop, a greenhouse, a stairwell, a bike — and name the events it must notice and answer for. The board becomes an expert in that world, and everything downstream — its skills, its policies, its cadence — is justified by the events you name here.', 'Today you design a domain node. Pick the domain you are already working in — whatever you have been measuring this fortnight — and name the events it must notice and answer for. The board becomes an expert in that world, and everything downstream — its skills, its policies, its cadence — is justified by the events you name here. You have the data; you already know which events matter and which are noise.',
], ],
takeaways: [ takeaways: [
'Agency = goal + perception + decision + action, closed in a loop', 'Agency = goal + perception + decision + action, closed in a loop',
+10 -3
View File
@@ -95,9 +95,16 @@ export function Module2() {
/> />
<AddLayerForm <AddLayerForm
layer="L3" layer="L3"
title="ADD · Layer 3 — Policies" title="ADD · Layer 3 — Policies & failure"
description="The actuation gate: what may it do autonomously vs. need approval, and where is the e-stop?" description="The actuation gate — and what happens when things break. For each failure you can name, which way does it fail? A fail-safe must never quietly report 'normal'."
placeholder="Autonomous: log + alert. Needs approval: drive the damper. E-stop: operator can halt actuation at any time." placeholder={
'Autonomous: log + alert. Needs approval: drive the actuator. E-stop: operator halts actuation at any time.\n\n' +
'Failure states → response:\n' +
'• sensor disconnected / stuck value / drifting → mark UNKNOWN, never "nominal"\n' +
'• reading older than 60 s → treat as no reading\n' +
'• cloud unreachable → decide on-board, flag reduced confidence\n' +
'• agent unsure → escalate to a human, do not actuate'
}
/> />
</div> </div>
+18 -33
View File
@@ -36,13 +36,6 @@ describe('TeamRegistration', () => {
expect(useSession.getState().team.name).toBe('team_resonance') expect(useSession.getState().team.name).toBe('team_resonance')
}) })
it('persists the selected kit to the session store on click', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: 'KIT-04' }))
expect(useSession.getState().team.kit).toBe('KIT-04')
})
it('gates the Proceed button until name + member + board are ready', async () => { it('gates the Proceed button until name + member + board are ready', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
@@ -52,8 +45,8 @@ describe('TeamRegistration', () => {
await user.type(screen.getByLabelText(/team name/i), 'team_x') await user.type(screen.getByLabelText(/team name/i), 'team_x')
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
const memberInput = screen.getByLabelText(/team member/i) const memberInput = screen.getByLabelText('Member 1')
await user.type(memberInput, 'A. Rossi{Enter}') await user.type(memberInput, 'A. Rossi')
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
// a claimed board satisfies the device requirement // a claimed board satisfies the device requirement
@@ -61,35 +54,37 @@ describe('TeamRegistration', () => {
expect(proceed).toBeEnabled() expect(proceed).toBeEnabled()
}) })
it('claims a board with the kit code and marks it connected', async () => { it('binds a board by the matrix code and marks it connected', async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ teamId: 't', kit: 'KIT-01', online: true }), json: async () => ({ teamId: 't', kit: 'crimson-otter', online: true }),
}) })
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/claim code/i), '418302') await user.type(screen.getByLabelText(/claim code/i), '4821')
await user.click(screen.getByRole('button', { name: /claim board/i })) await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByTestId('board-connected')).toBeInTheDocument() expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
expect(useSession.getState().device.connected).toBe(true) expect(useSession.getState().device.connected).toBe(true)
// the claim went out with the typed code + selected kit // code-first: the claim carries the code, no kit
const [, init] = fetchMock.mock.calls[0] const [, init] = fetchMock.mock.calls[0]
expect(JSON.parse(init.body)).toMatchObject({ kit: 'KIT-01', code: '418302' }) const body = JSON.parse(init.body)
expect(body).toMatchObject({ code: '4821' })
expect(body.kit).toBeUndefined()
}) })
it('surfaces a wrong-code error from the server', async () => { it('surfaces a wrong-code error from the server', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong claim code' }) }), vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong code — check your matrix' }) }),
) )
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/claim code/i), '000000') await user.type(screen.getByLabelText(/claim code/i), '0000')
await user.click(screen.getByRole('button', { name: /claim board/i })) await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong claim code/i) expect(await screen.findByRole('alert')).toHaveTextContent(/wrong code/i)
expect(useSession.getState().device.connected).toBe(false) expect(useSession.getState().device.connected).toBe(false)
}) })
@@ -97,28 +92,18 @@ describe('TeamRegistration', () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/team name/i), 'team_x') await user.type(screen.getByLabelText(/team name/i), 'team_x')
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}') await user.type(screen.getByLabelText('Member 1'), 'A. Rossi')
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 })) act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
await user.click(screen.getByRole('button', { name: /proceed/i })) await user.click(screen.getByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.reg).toBe(true) expect(useSession.getState().phases.reg).toBe(true)
}) })
it('pre-selects kit from the ?kit= URL param (QR sticker flow)', () => { it('pre-fills the claim code from the ?code= URL param', () => {
render( render(
<MemoryRouter initialEntries={['/workshop?kit=KIT-12']}> <MemoryRouter initialEntries={['/workshop?code=4821']}>
<TeamRegistration /> <TeamRegistration />
</MemoryRouter>, </MemoryRouter>,
) )
expect(useSession.getState().team.kit).toBe('KIT-12') expect(screen.getByLabelText(/claim code/i)).toHaveValue('4821')
expect(screen.getByRole('button', { name: 'KIT-12' })).toHaveAttribute('aria-pressed', 'true')
})
it('pre-fills the claim code from the ?code= URL param (full QR flow)', () => {
render(
<MemoryRouter initialEntries={['/workshop?kit=KIT-07&code=418302']}>
<TeamRegistration />
</MemoryRouter>,
)
expect(screen.getByLabelText(/claim code/i)).toHaveValue('418302')
}) })
}) })
+8 -21
View File
@@ -1,11 +1,9 @@
import { useEffect } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MemberChips } from '@/components/MemberChips' import { MemberFields } from '@/components/MemberFields'
import { KitSelector } from '@/components/KitSelector'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { BoardClaim } from '@/components/BoardClaim' import { BoardClaim } from '@/components/BoardClaim'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
@@ -21,11 +19,6 @@ export function TeamRegistration() {
const resumeTeam = useSession((s) => s.resumeTeam) const resumeTeam = useSession((s) => s.resumeTeam)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
useEffect(() => {
const k = params.get('kit')
if (k && /^KIT-\d{2}$/.test(k)) setTeam({ kit: k })
}, [params, setTeam])
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
const onProceed = () => { const onProceed = () => {
@@ -55,8 +48,8 @@ export function TeamRegistration() {
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1> <h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Name your team, add 35 members, pick up your kit, and claim your board. Name your team, add 35 members, then bind the board you already set up this
The QR sticker on your kit pre-selects the kit number for you. week run the setup script and enter the code it scrolls on its LED matrix.
</p> </p>
</div> </div>
</div> </div>
@@ -83,7 +76,7 @@ export function TeamRegistration() {
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Members Members
</div> </div>
<MemberChips <MemberFields
members={team.members} members={team.members}
onChange={(members) => setTeam({ members })} onChange={(members) => setTeam({ members })}
/> />
@@ -93,26 +86,20 @@ export function TeamRegistration() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Kit & device</CardTitle> <CardTitle className="text-base">Your board</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Select kit Bind your node
</div>
<KitSelector value={team.kit} onChange={(kit) => setTeam({ kit })} />
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Board
</div> </div>
<BoardClaim <BoardClaim
teamId={teamId} teamId={teamId}
kit={team.kit}
teamName={team.name} teamName={team.name}
members={team.members}
connected={device.connected} connected={device.connected}
port={device.port} port={device.port}
initialCode={/^\d{6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined} initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
onClaimed={(r) => { onClaimed={(r) => {
// Resume (a lost-browser re-claim): adopt the board's canonical // Resume (a lost-browser re-claim): adopt the board's canonical
// team + restore its progress instead of keeping this fresh id. // team + restore its progress instead of keeping this fresh id.
+21 -1
View File
@@ -13,6 +13,9 @@ export interface TeamSnapshot {
stats: SessionStats stats: SessionStats
deviceConnected: boolean deviceConnected: boolean
updatedAt: string updatedAt: string
/** Federation tag: the local instance (site) this team belongs to. '' on a
* single-fleet deploy; set by the reporter sidecar on a central deploy. */
site?: string
} }
export interface SubmissionDTO { export interface SubmissionDTO {
@@ -21,6 +24,15 @@ export interface SubmissionDTO {
code: string code: string
add: AddLayers add: AddLayers
submittedAt: string submittedAt: string
/** Federation tag — see {@link TeamSnapshot.site}. */
site?: string
}
/** A running local (edge) stack registered with the central control plane. */
export interface InstanceDTO {
id: string
name: string
lastSeen: string
} }
/** Lightweight queue row for the judge (no full ADD payload). */ /** Lightweight queue row for the judge (no full ADD payload). */
@@ -60,7 +72,13 @@ export interface LeaderboardRow {
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback' export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] } | {
type: 'snapshot'
teams: TeamSnapshot[]
submissions: SubmissionSummary[]
unclaimed?: string[]
instances?: InstanceDTO[]
}
| { 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 }
@@ -68,3 +86,5 @@ export type WsEvent =
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string } | { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
// Kit ids of boards that have self-registered but aren't claimed yet. // Kit ids of boards that have self-registered but aren't claimed yet.
| { type: 'unclaimed:update'; kits: string[] } | { type: 'unclaimed:update'; kits: string[] }
// A local instance (site) registered or heartbeated on the central plane.
| { type: 'instance:update'; instance: InstanceDTO }