Compare commits

..
10 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 45cc3a1f85 feat(fleet): central fleet dashboard — instances → teams grouped by site
Phase 3 of the edge + control-plane architecture (frontend). The
instructor dashboard becomes fleet-aware: when local instances have
phoned home (central mode), it groups teams under the instance that
reported them, each with an online/offline dot (from heartbeat
last-seen) + team count; otherwise it renders the existing flat grid
unchanged (single-fleet deploys are unaffected).

- src/types.ts: mirror the api — `site?` on TeamSnapshot/SubmissionDTO,
  new InstanceDTO, `instance:update` event + `instances` on snapshot.
- api.ts: getInstances(code) (GET /instances; [] on single-fleet).
- useCollective: instances in state + reducer (snapshot seeds, instance:
  update upserts, an instance-less snapshot preserves known instances),
  polled in the REST seed without dropping the team seed on failure.
- Admin: FleetView (grouped) vs TeamGrid (flat), an Instances stat
  (live/total). Shared TeamGrid extracted from the old inline grid.

Judging already works centrally (submission ids are site-namespaced by
the reporter); deeper site-grouping in Judge + Landing-as-distribution
are deferred (plan's "refined as we go").

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:24:22 -07:00
Omar SobhandClaude Opus 4.8 5ef14655b9 feat(federation): reporter sidecar + central ingest (edge → control plane)
Phase 2 of the edge + control-plane architecture. A per-team local stack
can now mirror its state UP to a central instance so judges get a fleet
view — outbound-only, so it works from behind the room NAT.

Central ingest (same api/, central-mode):
- POST /instances/register + /instances/:id/heartbeat (fleet-secret
  gated) + an `instances` table and GET /instances (admin) fleet read.
- PUT /teams/:id and POST /submissions accept an optional `site` tag;
  `site` column added to teams + submissions (grouping/filtering).
- WS snapshot now carries instances; instance:update broadcast added.

Reporter sidecar (deploy/lan/reporter/, opt-in `federated` compose
profile): subscribes to the local WS feed and replays team/submission
writes up to central, namespaced by SITE_ID (ids never collide) and
site-tagged. Registers + heartbeats; failed writes queue in an in-memory
outbox and backfill on reconnect. Proven end-to-end (local→reporter→
central) before commit.

Also fixes a latent bug: listSubmissions selected snake_case columns but
mapped camelCase, so GET /submissions summaries were missing teamId/
teamName/submittedAt. Aliased the columns; added a regression assertion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:18:03 -07:00
Omar SobhandClaude Opus 4.8 60416cb8c7 feat(api): anchor board identity on durable hwId + persist the registry
The board registry keyed on kitId, so a board that changed IP, was
re-provisioned under a new label, or came up on a wiped laptop looked
like a brand-new board — breaking claim resume and auto-heal.

Key the registry on hwId (WiFi MAC + eMMC serial, survives OS reflash;
machine-id does not). kitId is now a display label only. announce()
takes hwId and upserts by it, preserving claimedBy across IP/label
changes. /nodes/self-register accepts hwId (falls back to kitId for
older nodes). Persist boards in a new sqlite `boards` table keyed by
hwId and seed the in-memory registry from it on boot, so recognition
survives an API restart.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:08:50 -07:00
Omar SobhandClaude Opus 4.8 d71899520e fix(provision): remove legacy @reboot supervisor cron (App Lab owns boot)
A real power-cycle exposed a boot race: the old '@reboot zeroclaw-supervisor.sh' cron
started a host daemon that grabbed :8080 before the App Lab app, so the app's container
failed to bind and exited ('Failed to start default app'). The App Lab app + 'properties
set default' is the boot mechanism now, so the provisioner strips the cron and kills any
running supervisor. Verified the app then comes up clean on :8080.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 20:18:32 -07:00
Omar SobhandClaude Opus 4.8 410e5aba68 fix(provision): carry hardware skills into the node app (skill_bundles)
Copy shared/skills into the app so the [skill_bundles.unoq] bundle loads all 12 Uno Q
skills onto every agent — agent now knows the board + how to drive its devices. Fixes
the cp-nesting gotcha (copy skills/ directly, not the parent). Verified: dashboard agent
lists led-matrix/uno-q-hardware/flashing/sketch-patterns/modulino/bridge/… as loaded.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 18:54:38 -07:00
Omar SobhandClaude Opus 4.8 bedb1b4adc fix(provision): carry web-dist into the app + repoint web_dist_dir; drop stray tag
The gateway dashboard (:8080/) 503'd because web_dist_dir pointed at a host path
absent in the App Lab container. Provisioner now copies web-dist into /app and
repoints web_dist_dir=/app/web-dist so the dashboard serves. Also removed a stray
</content> line. Verified: :8080/ -> 200 (title ZeroClaw), whole node one app.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 18:17:30 -07:00
Omar SobhandClaude Opus 4.8 40bee45842 feat(node): self-register derives node url from source IP; App Lab provisioner
A containerised App Lab node can't see its host LAN IP, but its self-register POST is
SNAT'd to the host, so /nodes/self-register now derives http://<source-ip>:<port> when
url is omitted (explicit url still wins; token defaults to open-lan). Adds
deploy/uno-q/provision-node-app.sh to install the ZeroClaw Node app + carry in its
runtime bits. Tests updated + derived-url coverage (64 pass).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 13:07:32 -07:00
Omar SobhandClaude Opus 4.8 8001e100c3 feat(refine): AI Refine on domain (Phase 2) + wand on ADD layers
Phase 2 'Pick your domain' gets a Refine button that asks the team's node (cloud
sonnet) to draft all four design dimensions from the typed domain — Skills/Policies/
Harness/Loops → ADD layers L2-L5. Each card generates behind the scenes, turns green
when ready, and opens a modal with its draft (failure-first framed prompts). Drafts
land in the session store for later modules.

AddLayerForm (incl. Module 1's L1 Domain & events) gets a 🪄 Refine wand that reformats
+ structures the author's notes in place (meaning preserved) for submission.

Both reuse a new askNode() over the blocking say-hi/promptAndWait path. New reusable
Modal. Proven against the board: Skills draft for 'stress fractures' in 5.6s. Tests +196.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 10:16:18 -07:00
Omar SobhandClaude Opus 4.8 9d44931752 feat(uno-q): agent-mode.sh — flip all agents cloud/offline
Workshop reliability: 'cloud' sets EVERY agent to anthropic.max (claude-sonnet-5) with
full tools (risk_profile=default) + full context (runtime=unoq) so nothing feels broken
and the on-board fallback is out of the way. 'offline' flips all to llamacpp.local with
lean tools (demo) + lean context (offline) so the 0.5B is usable — the fallback you
enable on purpose. Rewrites every [agents.*] block, restarts the daemon (token preserved
via the supervisor env), and verifies. Proven: default/sense/local all now sonnet +
matrix_pattern fires.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 09:58:05 -07:00
Omar SobhandClaude Opus 4.8 cefddb245c feat(phase2): real say-hi chat + Connected node state on Meet-your-node
- Open your node: shows a 'Connected' badge (+ board name) once the board is claimed.
- Say hi: replaces the liveness-poll self-test with an actual round-trip to the
  agent on the team's own board — sends a greeting, shows the agent's reply, and
  only unlocks once it answers. New blocking /nodes/:teamId/say-hi route (greeting
  never flashes, so no hang risk) + bridge.sayHi + client sayHi(). The /prompt route
  stays fire-and-forget for the flash path.
- Pick your domain already captures to the store and syncs to the server DB via
  useCollectiveSync -> pushTeam, available to later steps/judging.
- Proceed gates on connected + agent-replied + domain. Tests updated + say-hi
  coverage (route + page).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 09:26:50 -07:00
35 changed files with 1469 additions and 189 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' })
})
})
}) })
+66 -5
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
@@ -248,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) => {
+33 -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 () => {
@@ -166,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 }])
@@ -174,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
+24
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',
@@ -111,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
})
}) })
+63 -23
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
@@ -55,43 +66,70 @@ 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
@@ -100,23 +138,25 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
// Unique per-board codes → the code identifies the board. No match = bad code. // Unique per-board codes → the code identifies the board. No match = bad code.
const board = [...boards.values()].find((b) => matches(code, b.claimCode)) const board = [...boards.values()].find((b) => matches(code, b.claimCode))
if (!board) return { ok: false, reason: 'bad_code' } if (!board) return { ok: false, reason: 'bad_code' }
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' } if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
fails.delete(board.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 } 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)
+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"
}
}
+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)."
+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."
+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>
+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)
})
}) })
+128 -20
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 3–4 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 3–4 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 3–4 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 3–4 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 L2–L5). 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">
@@ -40,18 +101,65 @@ export function DomainPicker() {
</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>
) )
} }
+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 />
+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>
)
}
+27
View File
@@ -5,6 +5,7 @@ import type {
ScoreInput, ScoreInput,
ScoreDTO, ScoreDTO,
LeaderboardRow, LeaderboardRow,
InstanceDTO,
WsEvent, WsEvent,
} from '@/types' } from '@/types'
@@ -82,6 +83,12 @@ 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
@@ -161,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,
} }
} }
+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 -16
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,21 +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}
counts={counts[t.id]}
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>
+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 }