Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45cc3a1f85 | ||
|
|
5ef14655b9 | ||
|
|
60416cb8c7 | ||
|
|
d71899520e | ||
|
|
410e5aba68 | ||
|
|
bedb1b4adc | ||
|
|
40bee45842 | ||
|
|
8001e100c3 | ||
|
|
9d44931752 | ||
|
|
cefddb245c |
+51
-1
@@ -99,7 +99,9 @@ describe('collective API', () => {
|
|||||||
it('lists submission summaries with a scored flag', async () => {
|
it('lists submission summaries with a scored flag', async () => {
|
||||||
await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd })
|
await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd })
|
||||||
const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
|
const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
|
||||||
expect(before.body[0].scored).toBe(false)
|
// the summary must carry the identifying fields, not just `scored`
|
||||||
|
expect(before.body[0]).toMatchObject({ teamId: 't1', scored: false })
|
||||||
|
expect(typeof before.body[0].submittedAt).toBe('string')
|
||||||
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 })
|
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 })
|
||||||
const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
|
const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
|
||||||
expect(after.body[0].scored).toBe(true)
|
expect(after.body[0].scored).toBe(true)
|
||||||
@@ -121,4 +123,52 @@ describe('collective API', () => {
|
|||||||
expect(events.some((e) => e.type === 'score:new')).toBe(true)
|
expect(events.some((e) => e.type === 'score:new')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('federation (central-mode)', () => {
|
||||||
|
const FLEET = 'fleet-secret'
|
||||||
|
let central: ReturnType<typeof createApp>
|
||||||
|
let cEvents: WsEvent[]
|
||||||
|
beforeEach(() => {
|
||||||
|
cEvents = []
|
||||||
|
central = createApp({
|
||||||
|
store: openStore(':memory:'),
|
||||||
|
broadcast: (e) => cEvents.push(e),
|
||||||
|
adminCode: ADMIN,
|
||||||
|
judgeCode: JUDGE,
|
||||||
|
fleetSecret: FLEET,
|
||||||
|
now: () => '2026-07-27T14:00:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers an instance (fleet-secret gated) and broadcasts instance:update', async () => {
|
||||||
|
await request(central).post('/instances/register').send({ id: 'site-a', name: 'Team A laptop' }).expect(401)
|
||||||
|
const res = await request(central)
|
||||||
|
.post('/instances/register')
|
||||||
|
.set('x-fleet-secret', FLEET)
|
||||||
|
.send({ id: 'site-a', name: 'Team A laptop' })
|
||||||
|
.expect(201)
|
||||||
|
expect(res.body).toMatchObject({ id: 'site-a', name: 'Team A laptop', lastSeen: '2026-07-27T14:00:00.000Z' })
|
||||||
|
expect(cEvents.some((e) => e.type === 'instance:update')).toBe(true)
|
||||||
|
const list = await request(central).get('/instances').set('X-Access-Code', ADMIN).expect(200)
|
||||||
|
expect(list.body).toEqual([{ id: 'site-a', name: 'Team A laptop', lastSeen: '2026-07-27T14:00:00.000Z' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('heartbeat upserts the same instance by id', async () => {
|
||||||
|
await request(central).post('/instances/register').set('x-fleet-secret', FLEET).send({ id: 'site-a' })
|
||||||
|
await request(central).post('/instances/site-a/heartbeat').set('x-fleet-secret', FLEET).send({}).expect(201)
|
||||||
|
const list = await request(central).get('/instances').set('X-Access-Code', ADMIN)
|
||||||
|
expect(list.body).toHaveLength(1) // still one instance, not duplicated
|
||||||
|
expect(list.body[0]).toMatchObject({ id: 'site-a', name: 'site-a' }) // name falls back to id
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ingests site-tagged team + submission writes so central can group by site', async () => {
|
||||||
|
await request(central).put('/teams/site-a:team-1').send(team('team-1', { site: 'site-a' })).expect(204)
|
||||||
|
await request(central)
|
||||||
|
.post('/submissions')
|
||||||
|
.send({ teamId: 'site-a:team-1', code: 'c', add: fullAdd, site: 'site-a' })
|
||||||
|
.expect(201)
|
||||||
|
const teams = await request(central).get('/teams').set('X-Access-Code', ADMIN)
|
||||||
|
expect(teams.body[0]).toMatchObject({ id: 'site-a:team-1', site: 'site-a' })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+66
-5
@@ -49,6 +49,7 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
stats: { ...emptyStats, ...(b.stats ?? {}) },
|
stats: { ...emptyStats, ...(b.stats ?? {}) },
|
||||||
deviceConnected: !!b.deviceConnected,
|
deviceConnected: !!b.deviceConnected,
|
||||||
updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(),
|
updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(),
|
||||||
|
site: typeof b.site === 'string' ? b.site : '',
|
||||||
}
|
}
|
||||||
store.upsertTeam(team)
|
store.upsertTeam(team)
|
||||||
broadcast({ type: 'team:update', team })
|
broadcast({ type: 'team:update', team })
|
||||||
@@ -66,6 +67,7 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
code: b.code,
|
code: b.code,
|
||||||
add: b.add,
|
add: b.add,
|
||||||
submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(),
|
submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(),
|
||||||
|
site: typeof b.site === 'string' ? b.site : '',
|
||||||
}
|
}
|
||||||
const summary = store.upsertSubmission(dto)
|
const summary = store.upsertSubmission(dto)
|
||||||
broadcast({ type: 'submission:new', submission: summary })
|
broadcast({ type: 'submission:new', submission: summary })
|
||||||
@@ -110,6 +112,11 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
res.json(store.leaderboard())
|
res.json(store.leaderboard())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Central-mode fleet view: the local instances that have phoned home.
|
||||||
|
app.get('/instances', requireAnyCode(adminCode, judgeCode), (_req, res) => {
|
||||||
|
res.json(store.listInstances())
|
||||||
|
})
|
||||||
|
|
||||||
// --- ZeroClaw nodes -----------------------------------------------------
|
// --- ZeroClaw nodes -----------------------------------------------------
|
||||||
// Registration holds bearer tokens → admin only. Prompting is a public
|
// Registration holds bearer tokens → admin only. Prompting is a public
|
||||||
// participant action (like PUT /teams/:id). List/broadcasts never leak tokens.
|
// participant action (like PUT /teams/:id). List/broadcasts never leak tokens.
|
||||||
@@ -150,17 +157,55 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
return res.status(401).json({ error: 'unauthorized' })
|
return res.status(401).json({ error: 'unauthorized' })
|
||||||
}
|
}
|
||||||
const b = req.body ?? {}
|
const b = req.body ?? {}
|
||||||
if (![b.kitId, b.url, b.token, b.claimCode].every((v) => typeof v === 'string' && v)) {
|
// `url` is optional: an App-Lab-containerised node can't see its host LAN IP,
|
||||||
return res.status(400).json({ error: 'kitId, url, token and claimCode are required' })
|
// but its request is SNAT'd to the host, so we derive http://<source-ip>:<port>
|
||||||
|
// from what the server actually sees. An explicit url (e.g. from the shell
|
||||||
|
// self-register) still wins.
|
||||||
|
let url = typeof b.url === 'string' && b.url ? b.url : ''
|
||||||
|
if (!url) {
|
||||||
|
const port = Number.isFinite(Number(b.port)) ? Number(b.port) : 8080
|
||||||
|
const ip = (req.ip ?? '').replace(/^::ffff:/, '') // unwrap IPv4-mapped IPv6
|
||||||
|
if (ip) url = `http://${ip}:${port}`
|
||||||
}
|
}
|
||||||
const r = boards.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode })
|
const token = typeof b.token === 'string' && b.token ? b.token : 'open-lan'
|
||||||
|
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
|
||||||
|
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
|
||||||
|
}
|
||||||
|
// `hwId` is the durable board key (WiFi MAC + eMMC serial). Older nodes that
|
||||||
|
// don't send it fall back to keying by kitId, so they still self-register.
|
||||||
|
const hwId = typeof b.hwId === 'string' && b.hwId ? b.hwId : b.kitId
|
||||||
|
const r = boards.announce({ hwId, kitId: b.kitId, url, token, claimCode: b.claimCode })
|
||||||
if (r.claimed && r.teamId && nodes) {
|
if (r.claimed && r.teamId && nodes) {
|
||||||
await nodes.register({ teamId: r.teamId, url: b.url, token: b.token })
|
await nodes.register({ teamId: r.teamId, url, token })
|
||||||
}
|
}
|
||||||
broadcastUnclaimed()
|
broadcastUnclaimed()
|
||||||
res.status(201).json({ kitId: b.kitId, claimed: r.claimed })
|
res.status(201).json({ kitId: b.kitId, url, claimed: r.claimed })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- federation: local instances phone home (central-mode) --------------
|
||||||
|
// A per-team local (edge) stack's reporter sidecar registers itself and then
|
||||||
|
// heartbeats. Fleet-secret gated (same shared secret as self-register). The
|
||||||
|
// sidecar replays team/submission writes UP via the public PUT/POST routes,
|
||||||
|
// tagged with `site` (= the instance id), so central becomes the fleet view.
|
||||||
|
const registerInstance = (req: express.Request, res: express.Response) => {
|
||||||
|
if (!fleetSecret || !matches(req.header('x-fleet-secret') ?? '', fleetSecret)) {
|
||||||
|
return res.status(401).json({ error: 'unauthorized' })
|
||||||
|
}
|
||||||
|
const b = req.body ?? {}
|
||||||
|
const id = typeof b.id === 'string' ? b.id : req.params.id
|
||||||
|
if (typeof id !== 'string' || !id) return res.status(400).json({ error: 'id is required' })
|
||||||
|
const instance = {
|
||||||
|
id,
|
||||||
|
name: typeof b.name === 'string' && b.name ? b.name : id,
|
||||||
|
lastSeen: now(),
|
||||||
|
}
|
||||||
|
store.upsertInstance(instance)
|
||||||
|
broadcast({ type: 'instance:update', instance })
|
||||||
|
return res.status(201).json(instance)
|
||||||
|
}
|
||||||
|
app.post('/instances/register', registerInstance)
|
||||||
|
app.post('/instances/:id/heartbeat', registerInstance)
|
||||||
|
|
||||||
// A participant claims their powered-on board to their team by proving
|
// A participant claims their powered-on board to their team by proving
|
||||||
// possession of the kit's claim code. Public + rate-limited (no operator in
|
// possession of the kit's claim code. Public + rate-limited (no operator in
|
||||||
// the loop); the bearer token moves straight from the pool into the bridge
|
// the loop); the bearer token moves straight from the pool into the bridge
|
||||||
@@ -248,6 +293,22 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
res.status(202).json({ accepted: true })
|
res.status(202).json({ accepted: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Onboarding "say hi": prompt the node and WAIT for its reply (blocking) so the
|
||||||
|
// participant sees their agent answer. Greeting only — never a flash turn.
|
||||||
|
app.post('/nodes/:teamId/say-hi', async (req, res) => {
|
||||||
|
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
|
||||||
|
const b = req.body ?? {}
|
||||||
|
const message = typeof b.message === 'string' && b.message.trim() ? b.message : 'Hi! Introduce yourself in one sentence.'
|
||||||
|
const agent = typeof b.agent === 'string' ? b.agent : undefined
|
||||||
|
try {
|
||||||
|
const reply = await nodes.sayHi(String(req.params.teamId), message, agent)
|
||||||
|
if (reply === null) return res.status(404).json({ error: 'no node registered for team' })
|
||||||
|
res.json({ reply })
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'your node did not answer — is it online?' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Public liveness for a team's board — the wizard/self-test polls this after
|
// Public liveness for a team's board — the wizard/self-test polls this after
|
||||||
// a claim. Online reflects the bridge's live /health + SSE view.
|
// a claim. Online reflects the bridge's live /health + SSE view.
|
||||||
app.get('/nodes/:teamId/status', (req, res) => {
|
app.get('/nodes/:teamId/status', (req, res) => {
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ const ADMIN = 'admin-code'
|
|||||||
const JUDGE = 'judge-code'
|
const JUDGE = 'judge-code'
|
||||||
const FLEET = 'fleet-secret'
|
const FLEET = 'fleet-secret'
|
||||||
|
|
||||||
const board = { kitId: 'KIT-07', url: 'http://192.168.1.7:8080', token: 'zc_secret_token', claimCode: '418302' }
|
const board: { kitId: string; url: string; token: string; claimCode: string; hwId?: string } = {
|
||||||
|
kitId: 'KIT-07',
|
||||||
|
url: 'http://192.168.1.7:8080',
|
||||||
|
token: 'zc_secret_token',
|
||||||
|
claimCode: '418302',
|
||||||
|
}
|
||||||
|
|
||||||
describe('board self-register + claim', () => {
|
describe('board self-register + claim', () => {
|
||||||
let store: Store
|
let store: Store
|
||||||
@@ -51,13 +56,24 @@ describe('board self-register + claim', () => {
|
|||||||
await selfRegister({}, 'wrong-secret').expect(401)
|
await selfRegister({}, 'wrong-secret').expect(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('validates the body', async () => {
|
it('validates the body (kitId + claimCode required)', async () => {
|
||||||
await selfRegister({ token: '' }).expect(400)
|
await selfRegister({ kitId: '' }).expect(400)
|
||||||
|
await selfRegister({ claimCode: '' }).expect(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a board presenting the fleet secret', async () => {
|
it('accepts a board presenting the fleet secret', async () => {
|
||||||
const res = await selfRegister().expect(201)
|
const res = await selfRegister().expect(201)
|
||||||
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: false })
|
expect(res.body).toMatchObject({ kitId: 'KIT-07', url: board.url, claimed: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives the node url from the source IP when url is omitted', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/nodes/self-register')
|
||||||
|
.set('x-fleet-secret', FLEET)
|
||||||
|
.send({ kitId: 'KIT-08', claimCode: '4821', port: 8080 })
|
||||||
|
.expect(201)
|
||||||
|
// supertest connects over loopback → derived host is 127.0.0.1
|
||||||
|
expect(res.body.url).toMatch(/^http:\/\/127\.0\.0\.1:8080$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
|
it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
|
||||||
@@ -166,7 +182,7 @@ describe('board self-register + claim', () => {
|
|||||||
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
||||||
// board reboots with a new IP + a fresh paired token and re-announces
|
// board reboots with a new IP + a fresh paired token and re-announces
|
||||||
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
|
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
|
||||||
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: true })
|
expect(res.body).toMatchObject({ kitId: 'KIT-07', url: 'http://192.168.1.9:8080', claimed: true })
|
||||||
// the team's binding is refreshed (new url), still online, still not unclaimed
|
// the team's binding is refreshed (new url), still online, still not unclaimed
|
||||||
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
|
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
|
||||||
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
|
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
|
||||||
@@ -174,6 +190,18 @@ describe('board self-register + claim', () => {
|
|||||||
expect(pool.body).toEqual({ kits: [] })
|
expect(pool.body).toEqual({ kits: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recognizes the same board by hwId even after its kit label is re-provisioned', async () => {
|
||||||
|
// a distinct board announces with a durable hwId, then gets claimed
|
||||||
|
await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-XY', claimCode: '999999' }).expect(201)
|
||||||
|
await request(app).post('/claim').send({ kit: 'KIT-XY', teamId: 'team-xy', code: '999999' }).expect(201)
|
||||||
|
// re-provisioned under a new kit label + new IP, SAME hardware → still claimed, no orphan
|
||||||
|
const res = await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-RENAMED', claimCode: '999999', url: 'http://192.168.1.9:8080' }).expect(201)
|
||||||
|
expect(res.body).toMatchObject({ kitId: 'KIT-RENAMED', claimed: true })
|
||||||
|
// the renamed board is not offered as a fresh unclaimed board (only the default KIT-07 remains)
|
||||||
|
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
|
||||||
|
expect(pool.body).toEqual({ kits: ['KIT-07'] })
|
||||||
|
})
|
||||||
|
|
||||||
it('lets an instructor release a claimed kit back to the pool for reassignment', async () => {
|
it('lets an instructor release a claimed kit back to the pool for reassignment', async () => {
|
||||||
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
||||||
await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only
|
await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
|
import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
|
||||||
|
|
||||||
const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
|
const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
|
||||||
|
hwId: 'hw-07',
|
||||||
kitId: 'KIT-07',
|
kitId: 'KIT-07',
|
||||||
url: 'http://192.168.1.7:8080',
|
url: 'http://192.168.1.7:8080',
|
||||||
token: 'zc_secret_token',
|
token: 'zc_secret_token',
|
||||||
@@ -111,4 +112,27 @@ describe('board registry', () => {
|
|||||||
r.announce(board()) // board rebooted and re-registered
|
r.announce(board()) // board rebooted and re-registered
|
||||||
expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
|
expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recognizes the same board (durable hwId) even when its kit label changes', () => {
|
||||||
|
const r = createBoardRegistry()
|
||||||
|
r.announce(board())
|
||||||
|
r.claim('KIT-07', '418302', 'team-07', 0)
|
||||||
|
// re-provisioned under a new kit label + new IP, same hardware → still claimed
|
||||||
|
const res = r.announce(board({ kitId: 'KIT-NEW', url: 'http://192.168.1.9:8080' }))
|
||||||
|
expect(res).toEqual({ claimed: true, teamId: 'team-07' })
|
||||||
|
expect(r.isClaimed('KIT-NEW')).toBe(true)
|
||||||
|
expect(r.get('KIT-NEW')?.claimedBy).toBe('team-07')
|
||||||
|
expect(r.unclaimedKits()).toEqual([]) // not a second, separate board
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds from persisted boards and reports each mutation via onChange', () => {
|
||||||
|
const saved: string[] = []
|
||||||
|
const r = createBoardRegistry(
|
||||||
|
[{ hwId: 'hw-07', kitId: 'KIT-07', url: 'u', token: 't', claimCode: '418302', claimedBy: 'team-07' }],
|
||||||
|
(b) => saved.push(`${b.hwId}:${b.claimedBy}`),
|
||||||
|
)
|
||||||
|
expect(r.isClaimed('KIT-07')).toBe(true) // restored claim survives restart
|
||||||
|
r.release('KIT-07')
|
||||||
|
expect(saved).toContain('hw-07:null') // release persisted
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+63
-23
@@ -5,8 +5,13 @@ import { matches } from './auth'
|
|||||||
* bearer token (server-side, never sent to a browser), the per-board claim code
|
* bearer token (server-side, never sent to a browser), the per-board claim code
|
||||||
* an attendee proves possession with (printed / QR-encoded on the kit), and the
|
* an attendee proves possession with (printed / QR-encoded on the kit), and the
|
||||||
* team it is bound to once claimed (`null` while unclaimed).
|
* team it is bound to once claimed (`null` while unclaimed).
|
||||||
|
*
|
||||||
|
* The durable key is `hwId` — a stable hardware fingerprint (WiFi MAC + eMMC
|
||||||
|
* serial) that survives IP change, reboot, laptop-wipe, and OS reflash. `kitId`
|
||||||
|
* is a human-facing label only (it may change if the board is re-provisioned).
|
||||||
*/
|
*/
|
||||||
export interface Board {
|
export interface Board {
|
||||||
|
hwId: string
|
||||||
kitId: string
|
kitId: string
|
||||||
url: string
|
url: string
|
||||||
token: string
|
token: string
|
||||||
@@ -27,11 +32,17 @@ export type ClaimOutcome =
|
|||||||
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
|
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
|
||||||
|
|
||||||
export interface BoardRegistry {
|
export interface BoardRegistry {
|
||||||
/** A board announces itself on boot / on its timer. Upserts url/token/claimCode,
|
/** A board announces itself on boot / on its timer, keyed by its stable `hwId`.
|
||||||
* preserves the existing claim, and reports whether it is already claimed. */
|
* Upserts url/token/claimCode/kitId, preserves the existing claim (so a board
|
||||||
announce(n: { kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
|
* re-announcing under a new IP — or even a new kitId label — is recognized as
|
||||||
|
* the same board), and reports whether it is already claimed. */
|
||||||
|
announce(n: { hwId: string; kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
|
||||||
/** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
|
/** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
|
||||||
unclaimedKits(): { kitId: string }[]
|
unclaimedKits(): { kitId: string }[]
|
||||||
|
/** Full board snapshot by durable hwId (for persistence / recognition). */
|
||||||
|
getByHwId(hwId: string): Board | undefined
|
||||||
|
/** All boards (for persistence). */
|
||||||
|
all(): Board[]
|
||||||
isClaimed(kitId: string): boolean
|
isClaimed(kitId: string): boolean
|
||||||
/**
|
/**
|
||||||
* Claim (or resume) a board by proving its code. First claim binds it to
|
* Claim (or resume) a board by proving its code. First claim binds it to
|
||||||
@@ -55,43 +66,70 @@ export interface BoardRegistry {
|
|||||||
export const MAX_FAILS = 5
|
export const MAX_FAILS = 5
|
||||||
export const WINDOW_MS = 60_000
|
export const WINDOW_MS = 60_000
|
||||||
|
|
||||||
export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
|
/**
|
||||||
const boards = new Map<string, Board>()
|
* @param seed boards to restore (from the persistent store) on boot.
|
||||||
for (const b of seed) boards.set(b.kitId, { ...b })
|
* @param onChange called with a board whenever it is created or mutated
|
||||||
const fails = new Map<string, number[]>() // kitId -> recent failed-attempt timestamps (ms)
|
* (announce/claim/release), so the caller can persist it.
|
||||||
|
*/
|
||||||
|
export function createBoardRegistry(
|
||||||
|
seed: Board[] = [],
|
||||||
|
onChange: (b: Board) => void = () => {},
|
||||||
|
): BoardRegistry {
|
||||||
|
const boards = new Map<string, Board>() // keyed by durable hwId
|
||||||
|
for (const b of seed) boards.set(b.hwId, { ...b })
|
||||||
|
const fails = new Map<string, number[]>() // hwId -> recent failed-attempt timestamps (ms)
|
||||||
|
|
||||||
const recentFails = (kitId: string, nowMs: number): number => {
|
const recentFails = (hwId: string, nowMs: number): number => {
|
||||||
const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
|
const arr = (fails.get(hwId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
|
||||||
if (arr.length) fails.set(kitId, arr)
|
if (arr.length) fails.set(hwId, arr)
|
||||||
else fails.delete(kitId)
|
else fails.delete(hwId)
|
||||||
return arr.length
|
return arr.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kitId is a display label, not the key — resolve it by scan (fleet is small).
|
||||||
|
const byKit = (kitId: string): Board | undefined => [...boards.values()].find((b) => b.kitId === kitId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
announce(n) {
|
announce(n) {
|
||||||
const claimedBy = boards.get(n.kitId)?.claimedBy ?? null
|
const claimedBy = boards.get(n.hwId)?.claimedBy ?? null
|
||||||
boards.set(n.kitId, { kitId: n.kitId, url: n.url, token: n.token, claimCode: n.claimCode, claimedBy })
|
const board: Board = {
|
||||||
fails.delete(n.kitId)
|
hwId: n.hwId,
|
||||||
|
kitId: n.kitId,
|
||||||
|
url: n.url,
|
||||||
|
token: n.token,
|
||||||
|
claimCode: n.claimCode,
|
||||||
|
claimedBy,
|
||||||
|
}
|
||||||
|
boards.set(n.hwId, board)
|
||||||
|
fails.delete(n.hwId)
|
||||||
|
onChange(board)
|
||||||
return { claimed: claimedBy !== null, teamId: claimedBy }
|
return { claimed: claimedBy !== null, teamId: claimedBy }
|
||||||
},
|
},
|
||||||
unclaimedKits() {
|
unclaimedKits() {
|
||||||
return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
|
return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
|
||||||
},
|
},
|
||||||
|
getByHwId(hwId) {
|
||||||
|
return boards.get(hwId)
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return [...boards.values()]
|
||||||
|
},
|
||||||
isClaimed(kitId) {
|
isClaimed(kitId) {
|
||||||
const b = boards.get(kitId)
|
const b = byKit(kitId)
|
||||||
return !!b && b.claimedBy !== null
|
return !!b && b.claimedBy !== null
|
||||||
},
|
},
|
||||||
claim(kitId, code, teamId, nowMs) {
|
claim(kitId, code, teamId, nowMs) {
|
||||||
const board = boards.get(kitId)
|
const board = byKit(kitId)
|
||||||
if (!board) return { ok: false, reason: 'unknown' }
|
if (!board) return { ok: false, reason: 'unknown' }
|
||||||
if (recentFails(kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
||||||
if (!matches(code, board.claimCode)) {
|
if (!matches(code, board.claimCode)) {
|
||||||
fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs])
|
fails.set(board.hwId, [...(fails.get(board.hwId) ?? []), nowMs])
|
||||||
return { ok: false, reason: 'bad_code' }
|
return { ok: false, reason: 'bad_code' }
|
||||||
}
|
}
|
||||||
fails.delete(kitId)
|
fails.delete(board.hwId)
|
||||||
if (board.claimedBy === null) {
|
if (board.claimedBy === null) {
|
||||||
board.claimedBy = teamId
|
board.claimedBy = teamId
|
||||||
|
onChange(board)
|
||||||
return { ok: true, board, resumed: false }
|
return { ok: true, board, resumed: false }
|
||||||
}
|
}
|
||||||
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
|
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
|
||||||
@@ -100,23 +138,25 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
|
|||||||
// Unique per-board codes → the code identifies the board. No match = bad code.
|
// Unique per-board codes → the code identifies the board. No match = bad code.
|
||||||
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
|
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
|
||||||
if (!board) return { ok: false, reason: 'bad_code' }
|
if (!board) return { ok: false, reason: 'bad_code' }
|
||||||
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
||||||
fails.delete(board.kitId)
|
fails.delete(board.hwId)
|
||||||
if (board.claimedBy === null) {
|
if (board.claimedBy === null) {
|
||||||
board.claimedBy = teamId
|
board.claimedBy = teamId
|
||||||
|
onChange(board)
|
||||||
return { ok: true, board, resumed: false }
|
return { ok: true, board, resumed: false }
|
||||||
}
|
}
|
||||||
return { ok: true, board, resumed: true }
|
return { ok: true, board, resumed: true }
|
||||||
},
|
},
|
||||||
release(kitId) {
|
release(kitId) {
|
||||||
const board = boards.get(kitId)
|
const board = byKit(kitId)
|
||||||
if (!board || board.claimedBy === null) return null
|
if (!board || board.claimedBy === null) return null
|
||||||
const freed = board.claimedBy
|
const freed = board.claimedBy
|
||||||
board.claimedBy = null
|
board.claimedBy = null
|
||||||
|
onChange(board)
|
||||||
return freed
|
return freed
|
||||||
},
|
},
|
||||||
get(kitId) {
|
get(kitId) {
|
||||||
return boards.get(kitId)
|
return byKit(kitId)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-12
@@ -6,7 +6,9 @@ import type {
|
|||||||
ScoreInput,
|
ScoreInput,
|
||||||
ScoreDTO,
|
ScoreDTO,
|
||||||
LeaderboardRow,
|
LeaderboardRow,
|
||||||
|
InstanceDTO,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import type { Board } from './claim'
|
||||||
|
|
||||||
export interface Store {
|
export interface Store {
|
||||||
upsertTeam(t: TeamSnapshot): void
|
upsertTeam(t: TeamSnapshot): void
|
||||||
@@ -17,9 +19,26 @@ export interface Store {
|
|||||||
getSubmission(teamId: string): SubmissionDTO | null
|
getSubmission(teamId: string): SubmissionDTO | null
|
||||||
insertScore(input: ScoreInput, createdAt: string): ScoreDTO
|
insertScore(input: ScoreInput, createdAt: string): ScoreDTO
|
||||||
leaderboard(): LeaderboardRow[]
|
leaderboard(): LeaderboardRow[]
|
||||||
|
/** Persist a board (keyed by durable hwId) so the registry survives restart. */
|
||||||
|
saveBoard(b: Board): void
|
||||||
|
/** All persisted boards, to seed the in-memory registry on boot. */
|
||||||
|
listBoards(): Board[]
|
||||||
|
/** Register/refresh a federated local instance (central-mode). */
|
||||||
|
upsertInstance(i: InstanceDTO): void
|
||||||
|
/** All known instances (central-mode fleet view). */
|
||||||
|
listInstances(): InstanceDTO[]
|
||||||
close(): void
|
close(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BoardRow {
|
||||||
|
hw_id: string
|
||||||
|
kit_id: string
|
||||||
|
url: string
|
||||||
|
token: string
|
||||||
|
claim_code: string
|
||||||
|
claimed_by: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface TeamRow {
|
interface TeamRow {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -30,6 +49,7 @@ interface TeamRow {
|
|||||||
stats: string
|
stats: string
|
||||||
device_connected: number
|
device_connected: number
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
site: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToTeam(r: TeamRow): TeamSnapshot {
|
function rowToTeam(r: TeamRow): TeamSnapshot {
|
||||||
@@ -43,6 +63,7 @@ function rowToTeam(r: TeamRow): TeamSnapshot {
|
|||||||
stats: JSON.parse(r.stats),
|
stats: JSON.parse(r.stats),
|
||||||
deviceConnected: !!r.device_connected,
|
deviceConnected: !!r.device_connected,
|
||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
|
site: r.site ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +81,16 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
phases TEXT NOT NULL DEFAULT '{}',
|
phases TEXT NOT NULL DEFAULT '{}',
|
||||||
stats TEXT NOT NULL DEFAULT '{}',
|
stats TEXT NOT NULL DEFAULT '{}',
|
||||||
device_connected INTEGER NOT NULL DEFAULT 0,
|
device_connected INTEGER NOT NULL DEFAULT 0,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
site TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS submissions (
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
team_id TEXT PRIMARY KEY,
|
team_id TEXT PRIMARY KEY,
|
||||||
team_name TEXT NOT NULL DEFAULT '',
|
team_name TEXT NOT NULL DEFAULT '',
|
||||||
code TEXT NOT NULL,
|
code TEXT NOT NULL,
|
||||||
add_json TEXT NOT NULL,
|
add_json TEXT NOT NULL,
|
||||||
submitted_at TEXT NOT NULL
|
submitted_at TEXT NOT NULL,
|
||||||
|
site TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS scores (
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -78,35 +101,54 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
notes TEXT NOT NULL DEFAULT '',
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
|
hw_id TEXT PRIMARY KEY,
|
||||||
|
kit_id TEXT NOT NULL DEFAULT '',
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
claim_code TEXT NOT NULL DEFAULT '',
|
||||||
|
claimed_by TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS instances (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TEXT NOT NULL
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Migration for DBs created before the `domain` column existed. CREATE TABLE
|
// Migrations for DBs created before a column existed. CREATE TABLE IF NOT
|
||||||
// IF NOT EXISTS won't add it to an existing table, so add it defensively.
|
// EXISTS won't add columns to an existing table, so add them defensively.
|
||||||
|
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 {
|
try {
|
||||||
db.exec(`ALTER TABLE teams ADD COLUMN domain TEXT NOT NULL DEFAULT ''`)
|
db.exec(stmt)
|
||||||
} catch {
|
} catch {
|
||||||
/* column already exists — fine */
|
/* column already exists — fine */
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const upsertTeamStmt = db.prepare(`
|
const upsertTeamStmt = db.prepare(`
|
||||||
INSERT INTO teams (id, name, kit, domain, members, phases, stats, device_connected, updated_at)
|
INSERT INTO teams (id, name, kit, domain, members, phases, stats, device_connected, updated_at, site)
|
||||||
VALUES (@id, @name, @kit, @domain, @members, @phases, @stats, @device_connected, @updated_at)
|
VALUES (@id, @name, @kit, @domain, @members, @phases, @stats, @device_connected, @updated_at, @site)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name=excluded.name, kit=excluded.kit, domain=excluded.domain, members=excluded.members,
|
name=excluded.name, kit=excluded.kit, domain=excluded.domain, members=excluded.members,
|
||||||
phases=excluded.phases, stats=excluded.stats,
|
phases=excluded.phases, stats=excluded.stats,
|
||||||
device_connected=excluded.device_connected, updated_at=excluded.updated_at
|
device_connected=excluded.device_connected, updated_at=excluded.updated_at, site=excluded.site
|
||||||
`)
|
`)
|
||||||
const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name')
|
const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name')
|
||||||
const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?')
|
const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?')
|
||||||
const upsertSubStmt = db.prepare(`
|
const upsertSubStmt = db.prepare(`
|
||||||
INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at)
|
INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at, site)
|
||||||
VALUES (@team_id, @team_name, @code, @add_json, @submitted_at)
|
VALUES (@team_id, @team_name, @code, @add_json, @submitted_at, @site)
|
||||||
ON CONFLICT(team_id) DO UPDATE SET
|
ON CONFLICT(team_id) DO UPDATE SET
|
||||||
team_name=excluded.team_name, code=excluded.code,
|
team_name=excluded.team_name, code=excluded.code,
|
||||||
add_json=excluded.add_json, submitted_at=excluded.submitted_at
|
add_json=excluded.add_json, submitted_at=excluded.submitted_at, site=excluded.site
|
||||||
`)
|
`)
|
||||||
const listSubsStmt = db.prepare(`
|
const listSubsStmt = db.prepare(`
|
||||||
SELECT s.team_id, s.team_name, s.submitted_at,
|
SELECT s.team_id AS teamId, s.team_name AS teamName, s.submitted_at AS submittedAt,
|
||||||
EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
|
EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
|
||||||
FROM submissions s ORDER BY s.submitted_at
|
FROM submissions s ORDER BY s.submitted_at
|
||||||
`)
|
`)
|
||||||
@@ -128,6 +170,21 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
const setPhaseAddStmt = db.prepare(
|
const setPhaseAddStmt = db.prepare(
|
||||||
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
|
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
|
||||||
)
|
)
|
||||||
|
const saveBoardStmt = db.prepare(`
|
||||||
|
INSERT INTO boards (hw_id, kit_id, url, token, claim_code, claimed_by)
|
||||||
|
VALUES (@hw_id, @kit_id, @url, @token, @claim_code, @claimed_by)
|
||||||
|
ON CONFLICT(hw_id) DO UPDATE SET
|
||||||
|
kit_id=excluded.kit_id, url=excluded.url, token=excluded.token,
|
||||||
|
claim_code=excluded.claim_code, claimed_by=excluded.claimed_by
|
||||||
|
`)
|
||||||
|
const listBoardsStmt = db.prepare('SELECT * FROM boards')
|
||||||
|
const upsertInstanceStmt = db.prepare(`
|
||||||
|
INSERT INTO instances (id, name, last_seen)
|
||||||
|
VALUES (@id, @name, @last_seen)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name=excluded.name, last_seen=excluded.last_seen
|
||||||
|
`)
|
||||||
|
const listInstancesStmt = db.prepare('SELECT * FROM instances ORDER BY name')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
upsertTeam(t) {
|
upsertTeam(t) {
|
||||||
@@ -141,6 +198,7 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
stats: JSON.stringify(t.stats),
|
stats: JSON.stringify(t.stats),
|
||||||
device_connected: t.deviceConnected ? 1 : 0,
|
device_connected: t.deviceConnected ? 1 : 0,
|
||||||
updated_at: t.updatedAt,
|
updated_at: t.updatedAt,
|
||||||
|
site: t.site ?? '',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
listTeams() {
|
listTeams() {
|
||||||
@@ -157,6 +215,7 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
code: s.code,
|
code: s.code,
|
||||||
add_json: JSON.stringify(s.add),
|
add_json: JSON.stringify(s.add),
|
||||||
submitted_at: s.submittedAt,
|
submitted_at: s.submittedAt,
|
||||||
|
site: s.site ?? '',
|
||||||
})
|
})
|
||||||
// mark the team's final phase complete if we know the team
|
// mark the team's final phase complete if we know the team
|
||||||
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId)
|
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId)
|
||||||
@@ -202,6 +261,36 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
scoreCount: r.scoreCount,
|
scoreCount: r.scoreCount,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
saveBoard(b) {
|
||||||
|
saveBoardStmt.run({
|
||||||
|
hw_id: b.hwId,
|
||||||
|
kit_id: b.kitId,
|
||||||
|
url: b.url,
|
||||||
|
token: b.token,
|
||||||
|
claim_code: b.claimCode,
|
||||||
|
claimed_by: b.claimedBy,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
listBoards() {
|
||||||
|
return (listBoardsStmt.all() as BoardRow[]).map((r) => ({
|
||||||
|
hwId: r.hw_id,
|
||||||
|
kitId: r.kit_id,
|
||||||
|
url: r.url,
|
||||||
|
token: r.token,
|
||||||
|
claimCode: r.claim_code,
|
||||||
|
claimedBy: r.claimed_by,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
upsertInstance(i) {
|
||||||
|
upsertInstanceStmt.run({ id: i.id, name: i.name, last_seen: i.lastSeen })
|
||||||
|
},
|
||||||
|
listInstances() {
|
||||||
|
return (listInstancesStmt.all() as Array<{ id: string; name: string; last_seen: string }>).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
lastSeen: r.last_seen,
|
||||||
|
}))
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
db.close()
|
db.close()
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ if (!FLEET_SECRET) {
|
|||||||
const store = openStore(DB_PATH)
|
const store = openStore(DB_PATH)
|
||||||
const hub = createHub()
|
const hub = createHub()
|
||||||
const nodes = createNodeBridge({ broadcast: hub.broadcast })
|
const nodes = createNodeBridge({ broadcast: hub.broadcast })
|
||||||
const boards = createBoardRegistry()
|
const boards = createBoardRegistry(store.listBoards(), (b) => store.saveBoard(b))
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
store,
|
store,
|
||||||
broadcast: hub.broadcast,
|
broadcast: hub.broadcast,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ describe('node bridge + /nodes routes', () => {
|
|||||||
send: async (node, message, agent) => {
|
send: async (node, message, agent) => {
|
||||||
sent.push({ node, message, agent })
|
sent.push({ node, message, agent })
|
||||||
},
|
},
|
||||||
|
sendAndWait: async (node, message, agent) => {
|
||||||
|
sent.push({ node, message, agent })
|
||||||
|
return `echo: ${message}`
|
||||||
|
},
|
||||||
subscribe: () => () => {}, // no live SSE in the unit test
|
subscribe: () => () => {}, // no live SSE in the unit test
|
||||||
})
|
})
|
||||||
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes })
|
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes })
|
||||||
@@ -78,6 +82,20 @@ describe('node bridge + /nodes routes', () => {
|
|||||||
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
|
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('say-hi waits for the node reply; 404s an unregistered team', async () => {
|
||||||
|
await request(app).post('/nodes/ghost/say-hi').send({}).expect(404)
|
||||||
|
await request(app)
|
||||||
|
.post('/nodes')
|
||||||
|
.set('x-access-code', ADMIN)
|
||||||
|
.send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
|
||||||
|
.expect(201)
|
||||||
|
const res = await request(app).post('/nodes/t1/say-hi').send({ agent: 'cloud' }).expect(200)
|
||||||
|
expect(res.body.reply).toMatch(/^echo: /)
|
||||||
|
// a default greeting is sent when no message is supplied
|
||||||
|
expect(sent.at(-1)?.message).toMatch(/introduce yourself/i)
|
||||||
|
expect(sent.at(-1)?.agent).toBe('cloud')
|
||||||
|
})
|
||||||
|
|
||||||
it('503s when no bridge is configured', async () => {
|
it('503s when no bridge is configured', async () => {
|
||||||
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
|
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
|
||||||
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503)
|
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503)
|
||||||
|
|||||||
@@ -123,6 +123,22 @@ export async function sendPrompt(node: NodeRef, message: string, agent = 'defaul
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt a node and WAIT for its reply text (the blocking `/webhook` response).
|
||||||
|
* Only for non-flashing turns (e.g. the onboarding greeting) — a flash turn must
|
||||||
|
* stay fire-and-forget via {@link sendPrompt} or it hangs the agent's task.
|
||||||
|
*/
|
||||||
|
export async function promptAndWait(node: NodeRef, message: string, agent = 'default'): Promise<string> {
|
||||||
|
const res = await fetch(`${node.url}/webhook?agent=${encodeURIComponent(agent)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`webhook ${res.status}`)
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { response?: string }
|
||||||
|
return (body.response ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubscribeOptions {
|
export interface SubscribeOptions {
|
||||||
/** Aborts the whole reconnect loop when fired. */
|
/** Aborts the whole reconnect loop when fired. */
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -219,6 +235,9 @@ export interface NodeBridge {
|
|||||||
remove(teamId: string): void
|
remove(teamId: string): void
|
||||||
list(): NodeView[]
|
list(): NodeView[]
|
||||||
prompt(teamId: string, message: string, agent?: string): Promise<boolean>
|
prompt(teamId: string, message: string, agent?: string): Promise<boolean>
|
||||||
|
/** Say-hi: prompt the node and return its reply text (blocking). `null` if the
|
||||||
|
* team has no registered node. Non-flash use only (the greeting). */
|
||||||
|
sayHi(teamId: string, message: string, agent?: string): Promise<string | null>
|
||||||
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
|
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
|
||||||
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
|
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
|
||||||
stopAll(): void
|
stopAll(): void
|
||||||
@@ -230,6 +249,7 @@ export interface NodeBridgeDeps {
|
|||||||
/** Injectable for tests. */
|
/** Injectable for tests. */
|
||||||
ping?: (n: NodeRef) => Promise<boolean>
|
ping?: (n: NodeRef) => Promise<boolean>
|
||||||
send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
|
send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
|
||||||
|
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
|
||||||
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
|
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +263,7 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
|
|||||||
const registry = deps.registry ?? createNodeRegistry()
|
const registry = deps.registry ?? createNodeRegistry()
|
||||||
const ping = deps.ping ?? pingNode
|
const ping = deps.ping ?? pingNode
|
||||||
const send = deps.send ?? sendPrompt
|
const send = deps.send ?? sendPrompt
|
||||||
|
const sendAndWait = deps.sendAndWait ?? promptAndWait
|
||||||
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
|
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
|
||||||
const online = new Map<string, boolean>()
|
const online = new Map<string, boolean>()
|
||||||
const stops = new Map<string, () => void>()
|
const stops = new Map<string, () => void>()
|
||||||
@@ -287,6 +308,11 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
|
|||||||
await send(node, message, agent)
|
await send(node, message, agent)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
async sayHi(teamId, message, agent) {
|
||||||
|
const node = registry.get(teamId)
|
||||||
|
if (!node) return null
|
||||||
|
return sendAndWait(node, message, agent)
|
||||||
|
},
|
||||||
onTeamActivity(teamId, listener) {
|
onTeamActivity(teamId, listener) {
|
||||||
let set = teamListeners.get(teamId)
|
let set = teamListeners.get(teamId)
|
||||||
if (!set) {
|
if (!set) {
|
||||||
|
|||||||
+24
-1
@@ -27,6 +27,9 @@ export interface TeamSnapshot {
|
|||||||
stats: SessionStats
|
stats: SessionStats
|
||||||
deviceConnected: boolean
|
deviceConnected: boolean
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Federation tag: the local instance (site) this team belongs to. '' on a
|
||||||
|
* single-fleet deploy; set by the reporter sidecar on a central deploy. */
|
||||||
|
site?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmissionDTO {
|
export interface SubmissionDTO {
|
||||||
@@ -35,6 +38,18 @@ export interface SubmissionDTO {
|
|||||||
code: string
|
code: string
|
||||||
add: AddLayers
|
add: AddLayers
|
||||||
submittedAt: string
|
submittedAt: string
|
||||||
|
/** Federation tag — see {@link TeamSnapshot.site}. */
|
||||||
|
site?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A running local (edge) stack that has registered with the central control
|
||||||
|
* plane. Identified by a stable `site` id; `lastSeen` drives online/offline.
|
||||||
|
*/
|
||||||
|
export interface InstanceDTO {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lastSeen: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmissionSummary {
|
export interface SubmissionSummary {
|
||||||
@@ -72,7 +87,13 @@ export interface LeaderboardRow {
|
|||||||
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
|
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
|
||||||
|
|
||||||
export type WsEvent =
|
export type WsEvent =
|
||||||
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] }
|
| {
|
||||||
|
type: 'snapshot'
|
||||||
|
teams: TeamSnapshot[]
|
||||||
|
submissions: SubmissionSummary[]
|
||||||
|
unclaimed?: string[]
|
||||||
|
instances?: InstanceDTO[]
|
||||||
|
}
|
||||||
| { type: 'team:update'; team: TeamSnapshot }
|
| { type: 'team:update'; team: TeamSnapshot }
|
||||||
| { type: 'submission:new'; submission: SubmissionSummary }
|
| { type: 'submission:new'; submission: SubmissionSummary }
|
||||||
| { type: 'score:new'; teamId: string; total: number }
|
| { type: 'score:new'; teamId: string; total: number }
|
||||||
@@ -80,3 +101,5 @@ export type WsEvent =
|
|||||||
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
|
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
|
||||||
// Kit ids of boards that have self-registered but aren't claimed yet.
|
// Kit ids of boards that have self-registered but aren't claimed yet.
|
||||||
| { type: 'unclaimed:update'; kits: string[] }
|
| { type: 'unclaimed:update'; kits: string[] }
|
||||||
|
// A local instance (site) registered or heartbeated on the central plane.
|
||||||
|
| { type: 'instance:update'; instance: InstanceDTO }
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ describe('collective WS feed', () => {
|
|||||||
store = openStore(':memory:')
|
store = openStore(':memory:')
|
||||||
const hub = createHub()
|
const hub = createHub()
|
||||||
const boards = createBoardRegistry([
|
const boards = createBoardRegistry([
|
||||||
{ kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null },
|
{ hwId: 'hw-05', kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null },
|
||||||
])
|
])
|
||||||
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
|
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
|
||||||
server = http.createServer(app)
|
server = http.createServer(app)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function attachWs(
|
|||||||
teams: store.listTeams(),
|
teams: store.listTeams(),
|
||||||
submissions: store.listSubmissions(),
|
submissions: store.listSubmissions(),
|
||||||
unclaimed: boards?.unclaimedKits().map((u) => u.kitId) ?? [],
|
unclaimed: boards?.unclaimedKits().map((u) => u.kitId) ?? [],
|
||||||
|
instances: store.listInstances(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
hub.add(ws)
|
hub.add(ws)
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ JUDGE_CODE=jdg-xxxxxxxx
|
|||||||
FLEET_SECRET=change-me
|
FLEET_SECRET=change-me
|
||||||
# Host port for the web UI. Use 8080 if the box can't bind privileged :80.
|
# Host port for the web UI. Use 8080 if the box can't bind privileged :80.
|
||||||
WEB_PORT=80
|
WEB_PORT=80
|
||||||
|
|
||||||
|
# --- Federated mode (optional) ----------------------------------------------
|
||||||
|
# Only needed if you run the reporter sidecar (`--profile federated`) to mirror
|
||||||
|
# this instance UP to the central control plane. Omit for a purely local room.
|
||||||
|
# SITE_ID must be unique per team/instance — it namespaces every id at central.
|
||||||
|
SITE_ID=team-01
|
||||||
|
SITE_NAME=Team 01
|
||||||
|
# Central control-plane API base (its /api origin).
|
||||||
|
CENTRAL_API=https://apess.redclaw.dev/api
|
||||||
|
|||||||
@@ -78,6 +78,31 @@ work): a rebooted board re-announces and the API refreshes its binding; a team
|
|||||||
that lost its browser just re-scans the QR + re-enters the code to resume. For
|
that lost its browser just re-scans the QR + re-enters the code to resume. For
|
||||||
anything stuck, release the kit from `/admin` and let the team re-claim.
|
anything stuck, release the kit from `/admin` and let the team re-claim.
|
||||||
|
|
||||||
|
## 4. Federated mode (optional) — phone home to a central dashboard
|
||||||
|
|
||||||
|
For a multi-team event you can run **one local stack per team** (each with its
|
||||||
|
board) and have every instance mirror its state UP to a **central control
|
||||||
|
plane** — a fleet dashboard where judges see the whole cohort at once. This is
|
||||||
|
outbound-only, so it works from behind the room's NAT.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# in .env, set a UNIQUE SITE_ID per team + the central api base:
|
||||||
|
# SITE_ID=team-07 SITE_NAME="Team 07" CENTRAL_API=https://apess.redclaw.dev/api
|
||||||
|
docker compose --env-file .env -f docker-compose.yml --profile federated up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The **reporter** sidecar joins the local network, subscribes to the local API's
|
||||||
|
WS feed, and replays every `team:update` / submission UP to `CENTRAL_API`,
|
||||||
|
**namespaced by `SITE_ID`** (so ids never collide across instances) and
|
||||||
|
`site`-tagged (so central groups by team/site). It registers the instance and
|
||||||
|
heartbeats on a timer. If the uplink is down, writes queue in an in-memory
|
||||||
|
outbox and backfill on reconnect — the local workshop never blocks on it.
|
||||||
|
|
||||||
|
Central is **observe-only**: it never touches a board (it can't reach the NAT'd
|
||||||
|
boards — only the local stack drives them). Run the central instance from
|
||||||
|
`deploy/docker-compose.yml` with the same `FLEET_SECRET`; the reporter presents
|
||||||
|
it on every federation call.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Data** persists in the `apess-lan-data` volume (`docker compose down` keeps
|
- **Data** persists in the `apess-lan-data` volume (`docker compose down` keeps
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ services:
|
|||||||
# Not published: boards + browsers reach the API through the web's /api proxy.
|
# Not published: boards + browsers reach the API through the web's /api proxy.
|
||||||
networks: [apess-lan]
|
networks: [apess-lan]
|
||||||
|
|
||||||
|
# Optional edge→central reporter. Enable with `--profile federated`; mirrors
|
||||||
|
# this instance's state UP to the central control plane (outbound-only). Needs
|
||||||
|
# SITE_ID + CENTRAL_API set (see .env.example). Omit the profile for a purely
|
||||||
|
# local, offline single-room workshop.
|
||||||
|
reporter:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/lan/reporter/Dockerfile
|
||||||
|
image: apess-reporter:lan
|
||||||
|
container_name: apess-reporter-lan
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [federated]
|
||||||
|
environment:
|
||||||
|
SITE_ID: ${SITE_ID:?set SITE_ID for federated mode}
|
||||||
|
SITE_NAME: ${SITE_NAME:-}
|
||||||
|
CENTRAL_API: ${CENTRAL_API:?set CENTRAL_API for federated mode}
|
||||||
|
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
|
||||||
|
LOCAL_WS: ws://apess-api-lan:3000/ws
|
||||||
|
LOCAL_API: http://apess-api-lan:3000
|
||||||
|
# LOCAL_CODE auths the reporter's read of the local feed — reuse ADMIN_CODE.
|
||||||
|
LOCAL_CODE: ${ADMIN_CODE:?set ADMIN_CODE}
|
||||||
|
depends_on:
|
||||||
|
- apess-api
|
||||||
|
networks: [apess-lan]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
apess-lan:
|
apess-lan:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
Generated
+36
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+67
@@ -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)."
|
||||||
Executable
+66
@@ -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."
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { AddLayerForm } from './AddLayerForm'
|
import { AddLayerForm } from './AddLayerForm'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { askNode } from '@/lib/api'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', async (orig) => ({
|
||||||
|
...(await orig<typeof import('@/lib/api')>()),
|
||||||
|
askNode: vi.fn(),
|
||||||
|
}))
|
||||||
|
const mockAsk = vi.mocked(askNode)
|
||||||
|
|
||||||
describe('AddLayerForm', () => {
|
describe('AddLayerForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
|
mockAsk.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('writes a string layer (L2) to the store as the user types', async () => {
|
it('writes a string layer (L2) to the store as the user types', async () => {
|
||||||
@@ -29,4 +37,21 @@ describe('AddLayerForm', () => {
|
|||||||
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
|
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
|
||||||
expect(useSession.getState().add.L1).toBe('structural resonance')
|
expect(useSession.getState().add.L1).toBe('structural resonance')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Refine is disabled while empty, then reformats the text via the node', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
useSession.getState().setAddLayer('L1', 'rough notes about impact spikes')
|
||||||
|
mockAsk.mockResolvedValue('Domain: impact monitoring.\n\n- Impact spike\n- Sustained sway')
|
||||||
|
render(<AddLayerForm layer="L1" title="Layer 1" />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /refine/i }))
|
||||||
|
await waitFor(() => expect(useSession.getState().add.L1).toMatch(/Impact spike/))
|
||||||
|
// the reformatted text replaced the original notes
|
||||||
|
expect(useSession.getState().add.L1).not.toMatch(/rough notes/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Refine is disabled when the layer is empty', () => {
|
||||||
|
render(<AddLayerForm layer="L1" title="Layer 1" />)
|
||||||
|
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useId } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { useSession, type AddLayers } from '@/store/session'
|
import { useSession, type AddLayers } from '@/store/session'
|
||||||
|
import { askNode } from '@/lib/api'
|
||||||
|
|
||||||
export interface AddLayerFormProps {
|
export interface AddLayerFormProps {
|
||||||
layer: keyof AddLayers
|
layer: keyof AddLayers
|
||||||
@@ -13,13 +15,37 @@ export interface AddLayerFormProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Keystone ADD capture component. Drives one layer of the 5-layer Agent Design
|
* Keystone ADD capture component. Drives one layer of the 5-layer Agent Design
|
||||||
* Document — each layer is free text rendered as a single textarea. All edits
|
* Document — each layer is free text rendered as a single textarea, with a
|
||||||
* flow straight into the session store.
|
* "Refine" wand that asks the team's node to reformat + structure what they wrote
|
||||||
|
* (meaning preserved) so it's submission-ready.
|
||||||
*/
|
*/
|
||||||
export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) {
|
export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) {
|
||||||
const value = useSession((s) => s.add[layer])
|
const value = useSession((s) => s.add[layer])
|
||||||
const setAddLayer = useSession((s) => s.setAddLayer)
|
const setAddLayer = useSession((s) => s.setAddLayer)
|
||||||
|
const teamId = useSession((s) => s.teamId)
|
||||||
const baseId = useId()
|
const baseId = useId()
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const refine = async () => {
|
||||||
|
const text = value.trim()
|
||||||
|
if (!text || busy) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const refined = await askNode(
|
||||||
|
teamId,
|
||||||
|
`Reformat and lightly structure the following notes for the "${title}" section of an Agent Design Document. ` +
|
||||||
|
`Preserve the author's meaning and facts — do NOT invent new content. Improve clarity and grammar, and add ` +
|
||||||
|
`light structure (short paragraphs or bullets) where it helps readability. Return ONLY the improved text, no preamble.\n\nNotes:\n${text}`,
|
||||||
|
)
|
||||||
|
if (refined.trim()) setAddLayer(layer, refined.trim())
|
||||||
|
} catch {
|
||||||
|
setError('Could not reach your node — say hi first, then try again.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -29,15 +55,29 @@ export function AddLayerForm({ layer, title, description, placeholder }: AddLaye
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
<label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
{title}
|
{title}
|
||||||
</label>
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="h-7 px-2.5 font-mono text-[11px] tracking-wider"
|
||||||
|
disabled={!value.trim() || busy}
|
||||||
|
onClick={refine}
|
||||||
|
title="Reformat and structure your notes"
|
||||||
|
>
|
||||||
|
{busy ? 'Refining…' : '🪄 Refine'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={baseId}
|
id={baseId}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setAddLayer(layer, e.target.value)}
|
onChange={(e) => setAddLayer(layer, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{error && <p role="alert" className="text-xs text-red-500 leading-relaxed">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
import { DomainPicker } from './DomainPicker'
|
import { DomainPicker } from './DomainPicker'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { askNode } from '@/lib/api'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', async (orig) => ({
|
||||||
|
...(await orig<typeof import('@/lib/api')>()),
|
||||||
|
askNode: vi.fn(),
|
||||||
|
}))
|
||||||
|
const mockAsk = vi.mocked(askNode)
|
||||||
|
|
||||||
describe('DomainPicker', () => {
|
describe('DomainPicker', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
|
mockAsk.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('binds the input to the session domain', () => {
|
it('binds the input to the session domain', () => {
|
||||||
@@ -22,4 +30,35 @@ describe('DomainPicker', () => {
|
|||||||
expect(screen.getByText(label)).toBeInTheDocument()
|
expect(screen.getByText(label)).toBeInTheDocument()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('disables Refine until a domain is named', () => {
|
||||||
|
render(<DomainPicker />)
|
||||||
|
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
|
||||||
|
fireEvent.change(screen.getByLabelText(/your domain/i), { target: { value: 'air quality' } })
|
||||||
|
expect(screen.getByRole('button', { name: /refine/i })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refine drafts all four dimensions into the store and opens a modal', async () => {
|
||||||
|
mockAsk.mockImplementation(async (_t, prompt) => {
|
||||||
|
const which = /SKILLS/.test(prompt) ? 'skills'
|
||||||
|
: /POLICIES/.test(prompt) ? 'policies'
|
||||||
|
: /HARNESS/.test(prompt) ? 'harness' : 'loops'
|
||||||
|
return `draft for ${which}`
|
||||||
|
})
|
||||||
|
useSession.getState().setDomain('air quality')
|
||||||
|
render(<DomainPicker />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /refine/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(useSession.getState().add.L2).toBe('draft for skills'))
|
||||||
|
expect(useSession.getState().add.L3).toBe('draft for policies')
|
||||||
|
expect(useSession.getState().add.L4).toBe('draft for harness')
|
||||||
|
expect(useSession.getState().add.L5).toBe('draft for loops')
|
||||||
|
|
||||||
|
// the Skills card is now done + clickable → clicking pops the modal
|
||||||
|
const skills = await screen.findByTestId('dim-L2')
|
||||||
|
expect(skills).toHaveAttribute('data-state', 'done')
|
||||||
|
fireEvent.click(skills)
|
||||||
|
expect(screen.getByRole('dialog')).toHaveTextContent(/draft for skills/i)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+125
-17
@@ -1,26 +1,87 @@
|
|||||||
import { useId } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useSession } from '@/store/session'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Modal } from '@/components/ui/modal'
|
||||||
|
import { useSession, type AddLayers } from '@/store/session'
|
||||||
|
import { askNode } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/** Generic, domain-agnostic scaffolding prompts for the four design dimensions
|
type LayerKey = 'L2' | 'L3' | 'L4' | 'L5'
|
||||||
* the team builds next. Deliberately NOT a fixed catalog — just questions. */
|
type GenState = 'idle' | 'running' | 'done'
|
||||||
const DIMENSION_HINTS: { label: string; prompt: string }[] = [
|
|
||||||
{ label: 'Skills', prompt: 'What domain knowledge must it know?' },
|
/** The four design dimensions the domain refine jump-starts, mapped to ADD layers.
|
||||||
{ label: 'Policies', prompt: 'What may it do autonomously vs. need approval?' },
|
* Each prompt is failure-first framed so the drafts seed the real deliverable. */
|
||||||
{ label: 'Harness', prompt: 'When does it decide locally vs. escalate?' },
|
const DIMENSIONS: { key: LayerKey; label: string; hint: string; prompt: (d: string) => string }[] = [
|
||||||
{ label: 'Loops', prompt: 'How often does it check its world + report by exception?' },
|
{
|
||||||
|
key: 'L2',
|
||||||
|
label: 'Skills',
|
||||||
|
hint: 'What domain knowledge must it know?',
|
||||||
|
prompt: (d) =>
|
||||||
|
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe the SKILLS it needs — the domain knowledge and sensing/interpretation capabilities required to understand this domain. Plain prose, no preamble, no heading.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'L3',
|
||||||
|
label: 'Policies',
|
||||||
|
hint: 'What may it do autonomously vs. need approval?',
|
||||||
|
prompt: (d) =>
|
||||||
|
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its POLICIES — what it may do autonomously vs. what needs human approval, and how each thing fails safe (a failure must never read as "nominal"; degrade to unknown/escalate). Plain prose, no preamble, no heading.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'L4',
|
||||||
|
label: 'Harness',
|
||||||
|
hint: 'When does it decide locally vs. escalate?',
|
||||||
|
prompt: (d) =>
|
||||||
|
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its HARNESS — the degradation path (cloud → on-board → fully offline) and what still works with no network at all. Plain prose, no preamble, no heading.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'L5',
|
||||||
|
label: 'Loops',
|
||||||
|
hint: 'How often does it check its world + report by exception?',
|
||||||
|
prompt: (d) =>
|
||||||
|
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its LOOPS — how often it checks its world, how it reports by exception, and what it does when a cycle fails (stale readings, missed ticks, partial data). Plain prose, no preamble, no heading.`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Names the problem domain the team's node is for, plus the events it senses.
|
* Names the problem domain and, via "Refine", asks the team's node to jump-start
|
||||||
* Bound straight to the session store's free-text `domain`. Below the input we
|
* drafts for the four design dimensions (ADD layers L2–L5). Each card generates
|
||||||
* surface generic scaffolding prompts for the next four design dimensions.
|
* behind the scenes, turns green when done, and opens a modal with its draft.
|
||||||
|
* Drafts land in the session store so later modules pick them up.
|
||||||
*/
|
*/
|
||||||
export function DomainPicker() {
|
export function DomainPicker() {
|
||||||
const domain = useSession((s) => s.domain)
|
const domain = useSession((s) => s.domain)
|
||||||
const setDomain = useSession((s) => s.setDomain)
|
const setDomain = useSession((s) => s.setDomain)
|
||||||
|
const add = useSession((s) => s.add)
|
||||||
|
const setAddLayer = useSession((s) => s.setAddLayer)
|
||||||
|
const teamId = useSession((s) => s.teamId)
|
||||||
const inputId = useId()
|
const inputId = useId()
|
||||||
|
|
||||||
|
const [state, setState] = useState<Record<LayerKey, GenState>>({ L2: 'idle', L3: 'idle', L4: 'idle', L5: 'idle' })
|
||||||
|
const [open, setOpen] = useState<LayerKey | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const anyRunning = Object.values(state).some((s) => s === 'running')
|
||||||
|
const doneCount = DIMENSIONS.filter((d) => state[d.key] === 'done').length
|
||||||
|
|
||||||
|
const refine = () => {
|
||||||
|
if (!domain.trim() || anyRunning) return
|
||||||
|
setError(null)
|
||||||
|
for (const dim of DIMENSIONS) {
|
||||||
|
setState((s) => ({ ...s, [dim.key]: 'running' }))
|
||||||
|
askNode(teamId, dim.prompt(domain))
|
||||||
|
.then((text) => {
|
||||||
|
setAddLayer(dim.key as keyof AddLayers, text.trim())
|
||||||
|
setState((s) => ({ ...s, [dim.key]: 'done' }))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setState((s) => ({ ...s, [dim.key]: 'idle' }))
|
||||||
|
setError('Could not reach your node — say hi first, then try again.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = DIMENSIONS.find((d) => d.key === open)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -40,18 +101,65 @@ export function DomainPicker() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
What you’ll design next
|
What you’ll design next
|
||||||
</div>
|
</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">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
{DIMENSION_HINTS.map((d) => (
|
{DIMENSIONS.map((d) => {
|
||||||
<div key={d.label} className="rounded-md border border-border px-3 py-2.5">
|
const st = state[d.key]
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</div>
|
const content = add[d.key]
|
||||||
<div className="text-sm text-muted-foreground leading-snug mt-1">{d.prompt}</div>
|
const clickable = st === 'done' && !!content
|
||||||
|
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>
|
||||||
))}
|
<div className="text-sm text-muted-foreground leading-snug mt-1 line-clamp-2">
|
||||||
|
{st === 'done' && content ? content : d.hint}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,14 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-bold tracking-tight">Open your node →</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl font-bold tracking-tight">Open your node →</span>
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-widest text-teal border border-teal/40 bg-teal/10 rounded px-1.5 py-0.5">
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
|
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
|
||||||
{device.nodeUrl}
|
{device.port ? `${device.port} · ` : ''}{device.nodeUrl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
|
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ScoreInput,
|
ScoreInput,
|
||||||
ScoreDTO,
|
ScoreDTO,
|
||||||
LeaderboardRow,
|
LeaderboardRow,
|
||||||
|
InstanceDTO,
|
||||||
WsEvent,
|
WsEvent,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
@@ -82,6 +83,12 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
|
|||||||
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
|
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Central-mode fleet view: the local instances that have phoned home. Empty on
|
||||||
|
* a single-fleet deploy (the route just returns []). */
|
||||||
|
export async function getInstances(code: string): Promise<InstanceDTO[]> {
|
||||||
|
return asJson(await fetch(`${API_BASE}/instances`, { headers: authHeaders(code) }), 'getInstances')
|
||||||
|
}
|
||||||
|
|
||||||
// --- board onboarding (participant) ---------------------------------------
|
// --- board onboarding (participant) ---------------------------------------
|
||||||
export interface ClaimInput {
|
export interface ClaimInput {
|
||||||
teamId: string
|
teamId: string
|
||||||
@@ -161,6 +168,26 @@ export async function releaseBoard(kit: string, code: string): Promise<{ release
|
|||||||
|
|
||||||
// --- ZeroClaw node (participant) ------------------------------------------
|
// --- ZeroClaw node (participant) ------------------------------------------
|
||||||
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
|
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
|
||||||
|
/** Onboarding "say hi": prompt the team's node and wait for its reply text. */
|
||||||
|
export async function sayHi(teamId: string, agent?: string, message?: string): Promise<string> {
|
||||||
|
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/say-hi`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ agent, message }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||||
|
throw new Error(body.error ?? `sayHi ${res.status}`)
|
||||||
|
}
|
||||||
|
return ((await res.json()) as { reply: string }).reply
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the team's node a one-off prompt and wait for its reply (reuses the
|
||||||
|
* blocking say-hi path). Backs the "Refine" features. Defaults to the cloud agent. */
|
||||||
|
export async function askNode(teamId: string, prompt: string, agent = 'cloud'): Promise<string> {
|
||||||
|
return sayHi(teamId, agent, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
|
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
|
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ const team = (id: string): TeamSnapshot => ({
|
|||||||
updatedAt: '2026-07-27T13:00:00.000Z',
|
updatedAt: '2026-07-27T13:00:00.000Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {}, unclaimed: [] }
|
const empty: CollectiveState = {
|
||||||
|
teams: {},
|
||||||
|
submissions: {},
|
||||||
|
nodes: {},
|
||||||
|
activity: [],
|
||||||
|
counts: {},
|
||||||
|
unclaimed: [],
|
||||||
|
instances: {},
|
||||||
|
}
|
||||||
|
|
||||||
describe('collectiveReducer', () => {
|
describe('collectiveReducer', () => {
|
||||||
it('seeds from a snapshot', () => {
|
it('seeds from a snapshot', () => {
|
||||||
@@ -39,6 +47,31 @@ describe('collectiveReducer', () => {
|
|||||||
expect(next.unclaimed).toEqual(['KIT-07'])
|
expect(next.unclaimed).toEqual(['KIT-07'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('seeds instances from a snapshot and upserts them on instance:update', () => {
|
||||||
|
const seeded = collectiveReducer(empty, {
|
||||||
|
type: 'snapshot',
|
||||||
|
teams: [],
|
||||||
|
submissions: [],
|
||||||
|
instances: [{ id: 'site-a', name: 'Team A', lastSeen: 't0' }],
|
||||||
|
})
|
||||||
|
expect(seeded.instances['site-a'].name).toBe('Team A')
|
||||||
|
const next = collectiveReducer(seeded, {
|
||||||
|
type: 'instance:update',
|
||||||
|
instance: { id: 'site-a', name: 'Team A', lastSeen: 't1' },
|
||||||
|
})
|
||||||
|
expect(next.instances['site-a'].lastSeen).toBe('t1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a snapshot without instances preserves the ones already known', () => {
|
||||||
|
const seeded = collectiveReducer(empty, {
|
||||||
|
type: 'instance:update',
|
||||||
|
instance: { id: 'site-a', name: 'Team A', lastSeen: 't0' },
|
||||||
|
})
|
||||||
|
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
|
||||||
|
expect(next.instances['site-a']).toBeDefined() // not wiped by an instance-less snapshot
|
||||||
|
expect(Object.keys(next.teams)).toEqual(['a'])
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves unclaimed when a snapshot omits it (REST seed)', () => {
|
it('preserves unclaimed when a snapshot omits it (REST seed)', () => {
|
||||||
const seeded = collectiveReducer(empty, { type: 'unclaimed:update', kits: ['KIT-01'] })
|
const seeded = collectiveReducer(empty, { type: 'unclaimed:update', kits: ['KIT-01'] })
|
||||||
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
|
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||||
import { openCollective, getTeams, getSubmissions, getUnclaimed } from './api'
|
import { openCollective, getTeams, getSubmissions, getUnclaimed, getInstances } from './api'
|
||||||
import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types'
|
import type { TeamSnapshot, SubmissionSummary, InstanceDTO, WsEvent, NodeActivityKind } from '@/types'
|
||||||
|
|
||||||
export interface NodeActivityEntry {
|
export interface NodeActivityEntry {
|
||||||
teamId: string
|
teamId: string
|
||||||
@@ -27,10 +27,20 @@ export interface CollectiveState {
|
|||||||
counts: Record<string, NodeCounts>
|
counts: Record<string, NodeCounts>
|
||||||
/** kit ids of powered-on boards no team has claimed yet. */
|
/** kit ids of powered-on boards no team has claimed yet. */
|
||||||
unclaimed: string[]
|
unclaimed: string[]
|
||||||
|
/** siteId → federated local instance (central-mode; empty single-fleet). */
|
||||||
|
instances: Record<string, InstanceDTO>
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ACTIVITY = 40
|
const MAX_ACTIVITY = 40
|
||||||
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {}, unclaimed: [] }
|
const empty: CollectiveState = {
|
||||||
|
teams: {},
|
||||||
|
submissions: {},
|
||||||
|
nodes: {},
|
||||||
|
activity: [],
|
||||||
|
counts: {},
|
||||||
|
unclaimed: [],
|
||||||
|
instances: {},
|
||||||
|
}
|
||||||
|
|
||||||
const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 }
|
const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 }
|
||||||
const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = {
|
const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = {
|
||||||
@@ -46,10 +56,15 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
|
|||||||
event.teams.forEach((t) => (teams[t.id] = t))
|
event.teams.forEach((t) => (teams[t.id] = t))
|
||||||
const submissions: Record<string, SubmissionSummary> = {}
|
const submissions: Record<string, SubmissionSummary> = {}
|
||||||
event.submissions.forEach((s) => (submissions[s.teamId] = s))
|
event.submissions.forEach((s) => (submissions[s.teamId] = s))
|
||||||
return { ...state, teams, submissions, unclaimed: event.unclaimed ?? state.unclaimed }
|
const instances = event.instances
|
||||||
|
? Object.fromEntries(event.instances.map((i) => [i.id, i]))
|
||||||
|
: state.instances
|
||||||
|
return { ...state, teams, submissions, unclaimed: event.unclaimed ?? state.unclaimed, instances }
|
||||||
}
|
}
|
||||||
case 'unclaimed:update':
|
case 'unclaimed:update':
|
||||||
return { ...state, unclaimed: event.kits }
|
return { ...state, unclaimed: event.kits }
|
||||||
|
case 'instance:update':
|
||||||
|
return { ...state, instances: { ...state.instances, [event.instance.id]: event.instance } }
|
||||||
case 'team:update':
|
case 'team:update':
|
||||||
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
|
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
|
||||||
case 'submission:new':
|
case 'submission:new':
|
||||||
@@ -96,6 +111,8 @@ export interface Collective {
|
|||||||
activity: NodeActivityEntry[]
|
activity: NodeActivityEntry[]
|
||||||
counts: Record<string, NodeCounts>
|
counts: Record<string, NodeCounts>
|
||||||
unclaimed: string[]
|
unclaimed: string[]
|
||||||
|
/** federated instances, sorted by name (central-mode; empty single-fleet). */
|
||||||
|
instances: InstanceDTO[]
|
||||||
status: CollectiveStatus
|
status: CollectiveStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +134,10 @@ export function useCollective(code: string): Collective {
|
|||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const [teams, submissions] = await Promise.all([getTeams(code), getSubmissions(code)])
|
const [teams, submissions] = await Promise.all([getTeams(code), getSubmissions(code)])
|
||||||
if (!cancelled && !live.current) dispatch({ type: 'snapshot', teams, submissions })
|
// instances is central-mode only (empty/absent otherwise) — never let its
|
||||||
|
// failure drop the teams/submissions seed.
|
||||||
|
const instances = await getInstances(code).catch(() => undefined)
|
||||||
|
if (!cancelled && !live.current) dispatch({ type: 'snapshot', teams, submissions, instances })
|
||||||
} catch {
|
} catch {
|
||||||
/* the WS snapshot may still arrive; leave state as-is */
|
/* the WS snapshot may still arrive; leave state as-is */
|
||||||
}
|
}
|
||||||
@@ -159,6 +179,7 @@ export function useCollective(code: string): Collective {
|
|||||||
activity: state.activity,
|
activity: state.activity,
|
||||||
counts: state.counts,
|
counts: state.counts,
|
||||||
unclaimed: state.unclaimed,
|
unclaimed: state.unclaimed,
|
||||||
|
instances: Object.values(state.instances).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
status,
|
status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('@/lib/api', () => ({
|
|||||||
getTeams: vi.fn().mockResolvedValue([]),
|
getTeams: vi.fn().mockResolvedValue([]),
|
||||||
getSubmissions: vi.fn().mockResolvedValue([]),
|
getSubmissions: vi.fn().mockResolvedValue([]),
|
||||||
getUnclaimed: vi.fn().mockResolvedValue([]),
|
getUnclaimed: vi.fn().mockResolvedValue([]),
|
||||||
|
getInstances: vi.fn().mockResolvedValue([]),
|
||||||
releaseBoard: vi.fn().mockResolvedValue({ released: true, teamId: null }),
|
releaseBoard: vi.fn().mockResolvedValue({ released: true, teamId: null }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -85,6 +86,30 @@ describe('Admin', () => {
|
|||||||
expect(within(panel).getByText('KIT-09')).toBeInTheDocument()
|
expect(within(panel).getByText('KIT-09')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('groups teams under their instance in central (federated) mode', async () => {
|
||||||
|
renderAdmin()
|
||||||
|
const a = { ...team(1), site: 'site-a' }
|
||||||
|
const b = { ...team(2), site: 'site-b' }
|
||||||
|
act(() =>
|
||||||
|
emit({
|
||||||
|
type: 'snapshot',
|
||||||
|
teams: [a, b],
|
||||||
|
submissions: [],
|
||||||
|
instances: [
|
||||||
|
{ id: 'site-a', name: 'Team A laptop', lastSeen: new Date().toISOString() },
|
||||||
|
{ id: 'site-b', name: 'Team B laptop', lastSeen: new Date().toISOString() },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// fleet view replaces the flat grid; one section per instance
|
||||||
|
await waitFor(() => expect(screen.getByTestId('fleet-view')).toBeInTheDocument())
|
||||||
|
const sections = screen.getAllByTestId('fleet-instance')
|
||||||
|
expect(sections).toHaveLength(2)
|
||||||
|
const siteA = sections.find((s) => s.getAttribute('data-site') === 'site-a')!
|
||||||
|
expect(within(siteA).getByText('Team A laptop')).toBeInTheDocument()
|
||||||
|
expect(within(siteA).getAllByTestId('team-card')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('gates behind the access code when none is stored', () => {
|
it('gates behind the access code when none is stored', () => {
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
renderAdmin()
|
renderAdmin()
|
||||||
|
|||||||
+117
-16
@@ -3,10 +3,15 @@ import { Link } from 'react-router-dom'
|
|||||||
import { AccessGate } from '@/components/AccessGate'
|
import { AccessGate } from '@/components/AccessGate'
|
||||||
import { TeamCard } from '@/components/TeamCard'
|
import { TeamCard } from '@/components/TeamCard'
|
||||||
import { BoardActivity } from '@/components/BoardActivity'
|
import { BoardActivity } from '@/components/BoardActivity'
|
||||||
import { useCollective } from '@/lib/useCollective'
|
import { useCollective, type Collective } from '@/lib/useCollective'
|
||||||
import { useNow } from '@/lib/useNow'
|
import { useNow } from '@/lib/useNow'
|
||||||
import { releaseBoard } from '@/lib/api'
|
import { releaseBoard } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { TeamSnapshot } from '@/types'
|
||||||
|
|
||||||
|
/** A local instance is considered offline if it hasn't heartbeated recently
|
||||||
|
* (heartbeat cadence is 30s; allow ~2.5× before flagging it down). */
|
||||||
|
const INSTANCE_STALE_MS = 75_000
|
||||||
|
|
||||||
/** Release a mis-claimed / reassigned kit back to the pool so another team can
|
/** Release a mis-claimed / reassigned kit back to the pool so another team can
|
||||||
* claim it. The collective WS pushes the refreshed unclaimed list automatically. */
|
* claim it. The collective WS pushes the refreshed unclaimed list automatically. */
|
||||||
@@ -62,8 +67,110 @@ function Stat({ label, value }: { label: string; value: number | string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The team tiles for a set of teams (shared by the flat + grouped views). */
|
||||||
|
function TeamGrid({
|
||||||
|
teams,
|
||||||
|
submissions,
|
||||||
|
counts,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
teams: TeamSnapshot[]
|
||||||
|
submissions: Collective['submissions']
|
||||||
|
counts: Collective['counts']
|
||||||
|
now: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4" data-testid="team-grid">
|
||||||
|
{teams.map((t) => (
|
||||||
|
<TeamCard
|
||||||
|
key={t.id}
|
||||||
|
team={t}
|
||||||
|
submitted={!!submissions[t.id]}
|
||||||
|
scored={!!submissions[t.id]?.scored}
|
||||||
|
counts={counts[t.id]}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central-mode fleet view: teams grouped under the local instance (site) that
|
||||||
|
* reported them, each with an online/offline indicator + last-seen. Teams whose
|
||||||
|
* site matches no known instance fall into an "unlinked" group so nothing is
|
||||||
|
* hidden.
|
||||||
|
*/
|
||||||
|
function FleetView({
|
||||||
|
instances,
|
||||||
|
teams,
|
||||||
|
submissions,
|
||||||
|
counts,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
instances: Collective['instances']
|
||||||
|
teams: TeamSnapshot[]
|
||||||
|
submissions: Collective['submissions']
|
||||||
|
counts: Collective['counts']
|
||||||
|
now: number
|
||||||
|
}) {
|
||||||
|
const bySite = new Map<string, TeamSnapshot[]>()
|
||||||
|
for (const t of teams) {
|
||||||
|
const site = t.site || '—'
|
||||||
|
;(bySite.get(site) ?? bySite.set(site, []).get(site)!).push(t)
|
||||||
|
}
|
||||||
|
// instances first (in name order), then any orphan site buckets
|
||||||
|
const known = new Set(instances.map((i) => i.id))
|
||||||
|
const orphanSites = [...bySite.keys()].filter((s) => !known.has(s)).sort()
|
||||||
|
|
||||||
|
const Section = ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
lastSeen,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lastSeen?: string
|
||||||
|
}) => {
|
||||||
|
const group = bySite.get(id) ?? []
|
||||||
|
const online = lastSeen ? now - new Date(lastSeen).getTime() < INSTANCE_STALE_MS : false
|
||||||
|
const ageS = lastSeen ? Math.round((now - new Date(lastSeen).getTime()) / 1000) : null
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" data-testid="fleet-instance" data-site={id}>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border pb-1">
|
||||||
|
<span
|
||||||
|
className={cn('w-2 h-2 rounded-full shrink-0', lastSeen ? (online ? 'bg-teal animate-pulse' : 'bg-amber') : 'bg-muted-foreground')}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs font-semibold tracking-wide">{name}</span>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
|
{group.length} team{group.length === 1 ? '' : 's'}
|
||||||
|
{ageS !== null && ` · ${online ? 'live' : `${ageS}s ago`}`}
|
||||||
|
{!lastSeen && ' · unlinked'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No teams reported yet.</p>
|
||||||
|
) : (
|
||||||
|
<TeamGrid teams={group} submissions={submissions} counts={counts} now={now} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" data-testid="fleet-view">
|
||||||
|
{instances.map((i) => (
|
||||||
|
<Section key={i.id} id={i.id} name={i.name} lastSeen={i.lastSeen} />
|
||||||
|
))}
|
||||||
|
{orphanSites.map((s) => (
|
||||||
|
<Section key={s} id={s} name={s === '—' ? 'No site' : s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AdminBoard({ code }: { code: string }) {
|
function AdminBoard({ code }: { code: string }) {
|
||||||
const { teams, submissions, nodes, activity, counts, unclaimed, status } = useCollective(code)
|
const { teams, submissions, nodes, activity, counts, unclaimed, instances, status } = useCollective(code)
|
||||||
const now = useNow()
|
const now = useNow()
|
||||||
const connected = teams.filter((t) => t.deviceConnected).length
|
const connected = teams.filter((t) => t.deviceConnected).length
|
||||||
const boardsLive = Object.values(nodes).filter(Boolean).length
|
const boardsLive = Object.values(nodes).filter(Boolean).length
|
||||||
@@ -71,6 +178,8 @@ function AdminBoard({ code }: { code: string }) {
|
|||||||
const submittedCount = Object.keys(submissions).length
|
const submittedCount = Object.keys(submissions).length
|
||||||
const judgedCount = Object.values(submissions).filter((s) => s.scored).length
|
const judgedCount = Object.values(submissions).filter((s) => s.scored).length
|
||||||
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
|
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
|
||||||
|
const federated = instances.length > 0 // central-mode: teams arrive tagged by site
|
||||||
|
const instancesLive = instances.filter((i) => now - new Date(i.lastSeen).getTime() < INSTANCE_STALE_MS).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<main className="min-h-screen bg-background">
|
||||||
@@ -91,7 +200,8 @@ function AdminBoard({ code }: { code: string }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
|
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-7 gap-px bg-border rounded-md overflow-hidden">
|
<div className={cn('grid grid-cols-2 gap-px bg-border rounded-md overflow-hidden', federated ? 'md:grid-cols-8' : 'md:grid-cols-7')}>
|
||||||
|
{federated && <Stat label="Instances" value={`${instancesLive}/${instances.length}`} />}
|
||||||
<Stat label="Teams" value={teams.length} />
|
<Stat label="Teams" value={teams.length} />
|
||||||
<Stat label="Connected" value={connected} />
|
<Stat label="Connected" value={connected} />
|
||||||
<Stat label="Boards live" value={boardsLive} />
|
<Stat label="Boards live" value={boardsLive} />
|
||||||
@@ -131,21 +241,12 @@ function AdminBoard({ code }: { code: string }) {
|
|||||||
<BoardActivity activity={activity} nameFor={nameFor} />
|
<BoardActivity activity={activity} nameFor={nameFor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{teams.length === 0 ? (
|
{teams.length === 0 && !federated ? (
|
||||||
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
|
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
|
||||||
|
) : federated ? (
|
||||||
|
<FleetView instances={instances} teams={teams} submissions={submissions} counts={counts} now={now} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4" data-testid="team-grid">
|
<TeamGrid teams={teams} submissions={submissions} counts={counts} now={now} />
|
||||||
{teams.map((t) => (
|
|
||||||
<TeamCard
|
|
||||||
key={t.id}
|
|
||||||
team={t}
|
|
||||||
submitted={!!submissions[t.id]}
|
|
||||||
scored={!!submissions[t.id]?.scored}
|
|
||||||
counts={counts[t.id]}
|
|
||||||
now={now}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+33
-37
@@ -1,15 +1,15 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { EnvSetup } from './EnvSetup'
|
import { EnvSetup } from './EnvSetup'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
import { getNodeStatus } from '@/lib/api'
|
import { sayHi } from '@/lib/api'
|
||||||
|
|
||||||
vi.mock('@/lib/api', async (orig) => ({
|
vi.mock('@/lib/api', async (orig) => ({
|
||||||
...(await orig<typeof import('@/lib/api')>()),
|
...(await orig<typeof import('@/lib/api')>()),
|
||||||
getNodeStatus: vi.fn(),
|
sayHi: vi.fn(),
|
||||||
}))
|
}))
|
||||||
const mockNodeStatus = vi.mocked(getNodeStatus)
|
const mockSayHi = vi.mocked(sayHi)
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
@@ -21,13 +21,14 @@ function renderPage() {
|
|||||||
|
|
||||||
/** Connect a board with a live node URL — the common precondition. */
|
/** Connect a board with a live node URL — the common precondition. */
|
||||||
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
|
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
|
||||||
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl })
|
useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('EnvSetup — Meet your node', () => {
|
describe('EnvSetup — Meet your node', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
|
mockSayHi.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the phase strip set to setup and the heading', () => {
|
it('renders the phase strip set to setup and the heading', () => {
|
||||||
@@ -42,19 +43,17 @@ describe('EnvSetup — Meet your node', () => {
|
|||||||
it('prompts to claim a board first when not connected', () => {
|
it('prompts to claim a board first when not connected', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeDisabled()
|
||||||
// say-hi is disabled with no device
|
|
||||||
expect(screen.getByRole('button', { name: /say hi \/ confirm online/i })).toBeDisabled()
|
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the Open-your-node CTA to the board url when connected with a nodeUrl', () => {
|
it('shows the node as Connected with the board url when claimed', () => {
|
||||||
connect('http://192.168.1.7:8080')
|
connect('http://192.168.1.7:8080')
|
||||||
renderPage()
|
renderPage()
|
||||||
const link = screen.getByRole('link', { name: /open your node/i })
|
const link = screen.getByRole('link', { name: /open your node/i })
|
||||||
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
|
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
|
||||||
expect(link).toHaveAttribute('target', '_blank')
|
expect(link).toHaveAttribute('target', '_blank')
|
||||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
expect(screen.getByText(/^connected$/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
|
it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
|
||||||
@@ -64,39 +63,34 @@ describe('EnvSetup — Meet your node', () => {
|
|||||||
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a domain even after the board is online', async () => {
|
it('says hi to the agent and shows its reply', async () => {
|
||||||
vi.useFakeTimers()
|
mockSayHi.mockResolvedValue("Hi! I'm your node — I can read your sensor and drive the matrix.")
|
||||||
try {
|
|
||||||
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
|
|
||||||
connect()
|
connect()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i }))
|
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
|
||||||
await act(async () => {
|
expect(await screen.findByTestId('node-reply')).toHaveTextContent(/read your sensor/i)
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
expect(screen.getByRole('button', { name: /your node replied/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
|
|
||||||
|
|
||||||
// online but no domain → still gated
|
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()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
||||||
} finally {
|
|
||||||
vi.useRealTimers()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps Proceed gated when a domain is named but the board is not confirmed online', () => {
|
it('keeps Proceed gated when a domain is named but the agent has not replied', () => {
|
||||||
connect()
|
connect()
|
||||||
useSession.getState().setDomain('air quality')
|
useSession.getState().setDomain('air quality')
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('say hi / online confirmation', () => {
|
it('enables Proceed once the agent has replied AND a domain is named', async () => {
|
||||||
beforeEach(() => vi.useFakeTimers())
|
mockSayHi.mockResolvedValue('hello there')
|
||||||
afterEach(() => vi.useRealTimers())
|
|
||||||
|
|
||||||
it('enables Proceed once online AND a domain is named, without polluting stats', async () => {
|
|
||||||
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
|
|
||||||
connect()
|
connect()
|
||||||
useSession.getState().setDomain('structural stress')
|
useSession.getState().setDomain('structural stress')
|
||||||
renderPage()
|
renderPage()
|
||||||
@@ -104,15 +98,17 @@ describe('EnvSetup — Meet your node', () => {
|
|||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
expect(proceed).toBeDisabled()
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i }))
|
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
|
||||||
await act(async () => {
|
await screen.findByTestId('node-reply')
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
|
|
||||||
expect(proceed).toBeEnabled()
|
expect(proceed).toBeEnabled()
|
||||||
// confirmation polls must NOT be counted as workshop events
|
|
||||||
expect(useSession.getState().stats.calls).toBe(0)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+54
-30
@@ -7,14 +7,10 @@ import { PhaseStrip } from '@/components/PhaseStrip'
|
|||||||
import { OpenYourNode } from '@/components/OpenYourNode'
|
import { OpenYourNode } from '@/components/OpenYourNode'
|
||||||
import { DomainPicker } from '@/components/DomainPicker'
|
import { DomainPicker } from '@/components/DomainPicker'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
import { getNodeStatus } from '@/lib/api'
|
import { sayHi } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type SelfTest = 'idle' | 'running' | 'ok'
|
type HiState = 'idle' | 'running' | 'ok'
|
||||||
|
|
||||||
/** Say-hi confirmation: poll the claimed board's liveness this many times. */
|
|
||||||
const SELFTEST_POLLS = 12
|
|
||||||
const SELFTEST_POLL_MS = 500
|
|
||||||
|
|
||||||
export function EnvSetup() {
|
export function EnvSetup() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -22,27 +18,27 @@ export function EnvSetup() {
|
|||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const domain = useSession((s) => s.domain)
|
const domain = useSession((s) => s.domain)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
const [selfTest, setSelfTest] = useState<SelfTest>('idle')
|
const [hi, setHi] = useState<HiState>('idle')
|
||||||
|
const [reply, setReply] = useState('')
|
||||||
|
const [hiError, setHiError] = useState<string | null>(null)
|
||||||
|
|
||||||
const runSelfTest = async () => {
|
const saidHi = async () => {
|
||||||
setSelfTest('running')
|
if (hi === 'running') return
|
||||||
// Confirm the claimed board is reachable and online.
|
setHi('running')
|
||||||
for (let i = 0; i < SELFTEST_POLLS; i++) {
|
setHiError(null)
|
||||||
|
setReply('')
|
||||||
try {
|
try {
|
||||||
const s = await getNodeStatus(teamId)
|
// Chat with the agent on the team's own board; a reply means it's live.
|
||||||
if (s.online) {
|
const r = await sayHi(teamId, 'cloud')
|
||||||
setSelfTest('ok')
|
setReply(r || '(your node replied)')
|
||||||
return
|
setHi('ok')
|
||||||
|
} catch (e) {
|
||||||
|
setHiError(e instanceof Error ? e.message : 'Could not reach your node.')
|
||||||
|
setHi('idle')
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* board not registered yet / transient — keep polling */
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, SELFTEST_POLL_MS))
|
|
||||||
}
|
|
||||||
setSelfTest('idle') // couldn't confirm — let them retry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = device.connected && selfTest === 'ok' && domain.trim().length > 0
|
const ready = device.connected && hi === 'ok' && domain.trim().length > 0
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
completePhase('setup')
|
completePhase('setup')
|
||||||
@@ -92,26 +88,54 @@ export function EnvSetup() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
Open your node’s dashboard and say hi — it introduces itself and lists the skills
|
Say hi to the agent running on your board. When it replies, your node is live and
|
||||||
it already has. Then confirm it’s online here.
|
listening — and you’re clear to name its domain.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* the exchange */}
|
||||||
|
{(hi !== 'idle' || reply) && (
|
||||||
|
<div className="space-y-2" data-testid="say-hi-chat">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
|
||||||
|
</div>
|
||||||
|
{hi === 'running' && (
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
|
||||||
|
your node is thinking…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reply && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<span className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]" data-testid="node-reply">
|
||||||
|
{reply}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant={hi === 'ok' ? 'outline' : 'default'}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
disabled={!device.connected || selfTest === 'running'}
|
disabled={!device.connected || hi === 'running'}
|
||||||
onClick={runSelfTest}
|
onClick={saidHi}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-2 h-2 rounded-full mr-3',
|
'w-2 h-2 rounded-full mr-3',
|
||||||
selfTest === 'ok' ? 'bg-teal' : selfTest === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
|
hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{selfTest === 'ok' ? 'Online ✓' : selfTest === 'running' ? 'Checking…' : 'Say hi / confirm online'}
|
{hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{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">
|
<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.
|
Bind your board in team registration first.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -13,6 +13,9 @@ export interface TeamSnapshot {
|
|||||||
stats: SessionStats
|
stats: SessionStats
|
||||||
deviceConnected: boolean
|
deviceConnected: boolean
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Federation tag: the local instance (site) this team belongs to. '' on a
|
||||||
|
* single-fleet deploy; set by the reporter sidecar on a central deploy. */
|
||||||
|
site?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmissionDTO {
|
export interface SubmissionDTO {
|
||||||
@@ -21,6 +24,15 @@ export interface SubmissionDTO {
|
|||||||
code: string
|
code: string
|
||||||
add: AddLayers
|
add: AddLayers
|
||||||
submittedAt: string
|
submittedAt: string
|
||||||
|
/** Federation tag — see {@link TeamSnapshot.site}. */
|
||||||
|
site?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A running local (edge) stack registered with the central control plane. */
|
||||||
|
export interface InstanceDTO {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lastSeen: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lightweight queue row for the judge (no full ADD payload). */
|
/** Lightweight queue row for the judge (no full ADD payload). */
|
||||||
@@ -60,7 +72,13 @@ export interface LeaderboardRow {
|
|||||||
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
|
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
|
||||||
|
|
||||||
export type WsEvent =
|
export type WsEvent =
|
||||||
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] }
|
| {
|
||||||
|
type: 'snapshot'
|
||||||
|
teams: TeamSnapshot[]
|
||||||
|
submissions: SubmissionSummary[]
|
||||||
|
unclaimed?: string[]
|
||||||
|
instances?: InstanceDTO[]
|
||||||
|
}
|
||||||
| { type: 'team:update'; team: TeamSnapshot }
|
| { type: 'team:update'; team: TeamSnapshot }
|
||||||
| { type: 'submission:new'; submission: SubmissionSummary }
|
| { type: 'submission:new'; submission: SubmissionSummary }
|
||||||
| { type: 'score:new'; teamId: string; total: number }
|
| { type: 'score:new'; teamId: string; total: number }
|
||||||
@@ -68,3 +86,5 @@ export type WsEvent =
|
|||||||
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
|
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
|
||||||
// Kit ids of boards that have self-registered but aren't claimed yet.
|
// Kit ids of boards that have self-registered but aren't claimed yet.
|
||||||
| { type: 'unclaimed:update'; kits: string[] }
|
| { type: 'unclaimed:update'; kits: string[] }
|
||||||
|
// A local instance (site) registered or heartbeated on the central plane.
|
||||||
|
| { type: 'instance:update'; instance: InstanceDTO }
|
||||||
|
|||||||
Reference in New Issue
Block a user