Compare commits

...
24 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 49d75111f1 Merge feat/workshop-failure-first-alignment: edge + control-plane federation
Board hwId identity (Phase 1), reporter sidecar + central ingest
(Phase 2), and the central fleet dashboard (Phase 3), plus the earlier
workshop failure-first alignment + refine features.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:28:53 -07:00
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
Omar SobhandClaude Opus 4.8 fc5f66021f feat(reg): correct Phase 1 for pre-deployed devices + matrix-code binding
Teams already have their boards, so drop the kit pickup + sticker claim. New flow:
run the setup script on your board -> it self-registers and scrolls a code on its
LED matrix -> enter that code to bind the node to your team.

- api: BoardRegistry.claimByCode() binds the unique board whose claimCode matches,
  no kit needed; /claim accepts code-first ({teamId, code[, members]}) and keeps the
  legacy kit path. Snapshot/response use the resolved board's kitId.
- client: ClaimInput.kit optional, carries members.
- BoardClaim: setup-script command + copy, code from the matrix, 'Bind board'.
- TeamRegistration: remove KitSelector + QR-kit preselect; card is now 'Your board'.
- Tests updated + code-first coverage (registry + HTTP).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 06:18:09 -07:00
Omar SobhandClaude Opus 4.8 1b31f6db1b feat(reg): inline MemberFields (+ add-row) replacing the chip input
Phase 1 members now entered as inline text rows with a '+ Add member' affordance and
per-row remove, per the pre-deployed-devices onboarding rework. Parent store still
receives only trimmed, non-empty names. Caps at 5. MemberChips retained (unused) for
now. Tests updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 06:08:28 -07:00
Omar SobhandClaude Opus 4.8 1642fbce39 docs(demo): all three demo secrets now vaulted in Infisical
ANTHROPIC_OAUTH_TOKEN + APESS_NODE_TOKEN added to cloud-providers/prod alongside
ELEVENLABS_API_KEY. Runbook secrets section is now a single Infisical pull for all
three (APESS_NODE_TOKEN vault key -> NODE_TOKEN env). Verified end-to-end.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 05:12:34 -07:00
Omar SobhandClaude Opus 4.8 eb8c41b14b docs(demo): add DEMO-RUNBOOK.md; ElevenLabs key sourced from Infisical
Full demo-day runbook — secrets, pre-flight (recover.sh + voice proxy), the three
acts (web/Telegram/voice), mid-demo recovery, and gotchas. ELEVENLABS_API_KEY now
lives in Infisical on icarus (project cloud-providers, env prod, alongside the other
provider keys); runbook pulls it via the macbook-admin machine identity. Cloud token
and node token stay manual env exports for now.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 05:06:07 -07:00
Omar SobhandClaude Opus 4.8 413230255e feat(voice): ElevenLabs TTS in serve.py (server-side, key env-only)
serve.py gains /tts (ElevenLabs synthesis) + /config (advertises tts mode). Client
plays the returned MP3 when ElevenLabs is on, else falls back to browser speechSynthesis;
barge-in stops in-flight audio. Default voice Sarah (free-tier usable); key read from
ELEVENLABS_API_KEY env, never committed. README documents the free-tier library-voice
402 gotcha. Verified: 200 audio/mpeg, 38KB MP3.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:58:54 -07:00
Omar SobhandClaude Opus 4.8 3fef8c4d23 feat(uno-q): one-command demo-node recovery after USB drop
recover.sh: re-tunnels, relaunches the supervisor with the cloud token in its
environment (env-only, read from ANTHROPIC_OAUTH_TOKEN — never on disk), starts the
matrix bridge app only if down, preserves a running llama to skip cold reload, and
verifies end-to-end (llama/daemon/bridge health + demo agent on claude-sonnet-5 +
matrix_pattern fires). Turns a mid-demo disconnect into a ~30s fix. Tokens are read
from the env, so the script carries no secrets.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:51:43 -07:00
Omar SobhandClaude Opus 4.8 b09068b60c feat(voice): same-origin proxy client reusing the reliable /webhook path
The /ws/chat path builds a fresh Agent that omits the dynamically-registered
peripheral tools (matrix_pattern), so the model improvises with shell/read and the
matrix never changes. Switch the voice client to serve.py, which serves the page and
proxies POST /webhook same-origin to the node gateway (bearer token server-side).
Browser does STT+TTS (Web Speech API, no keys); the agent runs on cloud sonnet and
fires matrix_pattern reliably. Validated end-to-end server-side (checker in 6.2s).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:43:24 -07:00
Omar SobhandClaude Opus 4.8 1c762e51ba feat(voice): browser voice client for talking to the on-board agent
Single-file client: browser-side STT + TTS (Web Speech API, no keys), drives the
lean demo agent over /ws/chat (?agent=demo, no auth on the workshop board) to change
the LED-matrix animation by voice. Push-to-talk, live transcript, spoken reply, tool
activity display. README covers the localhost-serve secure-context requirement and the
ElevenLabs/voice-duplex upgrade path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 21:54:10 -07:00
Omar SobhandClaude Opus 4.8 62e4a3b688 docs(deck): revision guide reconciling the lecture deck to shipped reality
The deck predates v0.8.3, the network-node pivot, and the flash
pathology. Capture what breaks against the app/hardware (stale ADD
layer names, pre-correction schedule, Web-Serial connect flow), the
narrowed flow, and slide-by-slide target copy pinned to addLayers.ts
and Landing.tsx so deck and app cannot re-drift.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 15:28:17 -07:00
Omar SobhandClaude Opus 4.8 37a54521e8 docs(uno-q): record the offline capability boundary
Measured on the board 2026-07-20. Offline is limited by capability, not only
by speed:

  simple tool call (i2cdetect, 708 tok)  -> works, 20s, real answer
  write+compile+flash a sketch (1997 tok) -> never completed in 450s, two
                                             identical requests, no tool call

So with no network the node can sense and decide, but cannot author new
code - that needs the cloud model. This is the concrete degradation boundary
students are asked to state in ADD Layer 4: what survives an outage is the
loop over already-flashed firmware, not writing new firmware.

Also corrects the cold-load figure. The documented 3-5 minutes was measured
against the 1.1GB coder model; the 409MB qwen.gguf we standardised on came
up healthy in ~5s from a cold boot.

Notes that BuildFlash routes to the cloud agent, so the student build/flash
exercise does not sit behind this boundary.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 09:09:48 -07:00
Omar SobhandClaude Opus 4.8 e27fad46e2 fix(workshop): starter prompts must be imperative, not interrogative
Measured on the board 2026-07-20. Same agent, same 708-token prompt,
phrasing the only variable:

  "List the I2C devices on the bus."      -> tool call, answered in 20s
  "What sensors can you find on the bus?" -> no tool call at all, >200s

The first starter prompt shipped as the interrogative form. Teams typing a
question would have watched their board hang for minutes and concluded the
agent was broken. Now imperative, with the measurement recorded next to the
list so nobody "improves" it back into a question.

Also surfaces the asymmetry to students inline: it is a genuine failure mode
of small on-device models, and belongs in their Layer 3 rather than being
hidden.

Separately confirmed the agent does not hallucinate hardware: asked to list
I2C devices on a board with an empty bus, it ran the tool and reported the
bus numbers rather than inventing a sensor.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 08:45:03 -07:00
Omar SobhandClaude Opus 4.8 32750b0d9d fix(admin): show real node tallies on team cards, not dead counters
TeamCard displayed team.stats.{calls,nominal,anomalous,critical}, but
nothing has called recordEvent since the simulator was removed, so every
card read "0 · 0 · 0 · 0" - in the pre-pivot classifier vocabulary.

Admin already had real per-team tallies from useCollective.counts (derived
from the node activity stream, and already used for totalFlashes); they
just were not passed down. Cards now show agent runs / flashes / errors,
with errors only rendered when non-zero.

Same root cause as the AddDocument telemetry removed in 5a5810a.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 04:06:30 -07:00
Omar SobhandClaude Opus 4.8 5a5810a1c8 feat(workshop): align to the real programme; failure-first framing
Reviewed the official APESS 2026 programme against the app as shipped.
The structure held up; several published facts and one scoring bug did not.

Fixes a real scoring defect: AddReview (what judges read) still carried
pre-pivot layer titles - "Reasoning policy", "Action contract", "Failure
modes", "AI-native redesign" - so judges scored "Skills" under the heading
"Reasoning policy". Root cause was two duplicated title lists that drifted
after the domain-node reshape, so both surfaces now render from a single
ADD_LAYERS constant and cannot diverge again.

Also removes dead classifier telemetry from the graded artifact: the ADD
printed "N frames - N nominal - N anomalous - N critical", but nothing has
called recordEvent since the simulator was deleted, so those counters were
permanently zero - and the vocabulary predates the pivot.

Corrects the schedule. The app advertised the lecture at 14:00, inside the
hackathon block; the programme puts it at 10:45-12:15 as a separate morning
session, with the hackathon 14:00-19:00. Moving it out recovers an hour,
which the new timings spend on the sensor work rather than setup.

Reframes the session around designing for failure, per the workshop premise
and the school's "proactive resilient systems" theme:

  L3 becomes Policies & failure - which way each failure fails, with the
     governing rule that a fail-safe must never quietly report "nominal"
  L4 becomes where each decision runs - the degradation path, cloud to
     on-board to fully offline, not just the happy path
  L5 gains what the loop does when a cycle fails - stale reads, missed
     ticks, partial data

Students arrive having spent a week on their own sensor work with these
boards, so the hands-on now points the agent at hardware they already
wired (discover the bus, read it, act on a threshold) instead of only
scrolling text, and the framing invites the domain they are already
measuring. The domain stays free-text.

Adds backup-uno-q.sh: boards are reflashed on the day and a week of
student work is irreplaceable. Denylist rather than allowlist, because we
cannot know where a given team put their data; verifies the archive is
readable and non-trivial before reporting success. Deliberately keeps the
App Lab examples dir - stock, but exactly what someone would edit in place.
Verified end to end: byte-identical restore of a real sketch.

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