feat(web): reshape workshop from 'tune a harness' to 'design a domain node'

The board is now a Claude-powered ZeroClaw agent with expert skills that teams
talk to directly (its own dashboard / Telegram / voice). The frontend was built
on the older sensor+harness model; this repoints it.

- State/DTO: AddLayers remapped to the 5 design layers (Domain/Skills/Policies/
  Harness/Loops); added session.domain (+ mirrored through TeamSnapshot, sync,
  and the SQLite store with a guarded migration); dropped Harness/Provider/RunMode;
  persist v2 migrate resets stale state.
- Removed the harness/sim/failover machinery (HarnessProviderSelect, HarnessTuner,
  HarnessTomlPreview, ResiliencePanel, TriggerButtons, harness.ts, useSerial,
  LiveFeed/serial classifier) and every sim-vs-live branch.
- Onboarding: EnvSetup rebuilt into 'Meet your node' — Open-your-node hero (new
  reusable OpenYourNode CTA), say-hi-to-your-agent, a free-text DomainPicker
  (domain drives L1-L5), Telegram/voice pointers, and the open->locked lockdown
  policy step. Domain is required to proceed.
- Modules repointed: M1 Domain & events (L1), M2 Skills & Policies (L2/L3, skills
  reference + actuation-gate framing), M3 Harness (reasoning+tiering) & Loops
  (cadence) (L4/L5). BuildFlash reframed as a guided 'ask your node to build X'
  that hands off to the node dashboard.
- Judge rubric -> Domain fit/Skills/Policies/Harness/Loops; Lecture + Landing
  re-storied to the Claude-node + talk-to-your-node narrative.

Verified: web tsc + 181 tests, api tsc + 58 tests, dead-ref sweep clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-16 15:33:38 -07:00
co-authored by Claude Opus 4.8
parent 3be44ec215
commit 6d94833bb9
52 changed files with 649 additions and 1833 deletions
+2
View File
@@ -44,6 +44,7 @@ export function createApp(opts: AppOptions): Express {
name: typeof b.name === 'string' ? b.name : '', name: typeof b.name === 'string' ? b.name : '',
kit: typeof b.kit === 'string' ? b.kit : '', kit: typeof b.kit === 'string' ? b.kit : '',
members: Array.isArray(b.members) ? b.members : [], members: Array.isArray(b.members) ? b.members : [],
domain: typeof b.domain === 'string' ? b.domain : '',
phases: { ...emptyPhases, ...(b.phases ?? {}) }, phases: { ...emptyPhases, ...(b.phases ?? {}) },
stats: { ...emptyStats, ...(b.stats ?? {}) }, stats: { ...emptyStats, ...(b.stats ?? {}) },
deviceConnected: !!b.deviceConnected, deviceConnected: !!b.deviceConnected,
@@ -197,6 +198,7 @@ export function createApp(opts: AppOptions): Express {
name: pickName ?? '', name: pickName ?? '',
kit: b.kit, kit: b.kit,
members: pickMembers ?? [], members: pickMembers ?? [],
domain: typeof prev?.domain === 'string' ? prev.domain : '',
phases: { ...emptyPhases, ...(prev?.phases ?? {}) }, phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
stats: { ...emptyStats, ...(prev?.stats ?? {}) }, stats: { ...emptyStats, ...(prev?.stats ?? {}) },
deviceConnected: true, deviceConnected: true,
+15 -3
View File
@@ -24,6 +24,7 @@ interface TeamRow {
id: string id: string
name: string name: string
kit: string kit: string
domain: string
members: string members: string
phases: string phases: string
stats: string stats: string
@@ -36,6 +37,7 @@ function rowToTeam(r: TeamRow): TeamSnapshot {
id: r.id, id: r.id,
name: r.name, name: r.name,
kit: r.kit, kit: r.kit,
domain: r.domain ?? '',
members: JSON.parse(r.members), members: JSON.parse(r.members),
phases: JSON.parse(r.phases), phases: JSON.parse(r.phases),
stats: JSON.parse(r.stats), stats: JSON.parse(r.stats),
@@ -53,6 +55,7 @@ export function openStore(path = ':memory:'): Store {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
kit TEXT NOT NULL DEFAULT '', kit TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
members TEXT NOT NULL DEFAULT '[]', members TEXT NOT NULL DEFAULT '[]',
phases TEXT NOT NULL DEFAULT '{}', phases TEXT NOT NULL DEFAULT '{}',
stats TEXT NOT NULL DEFAULT '{}', stats TEXT NOT NULL DEFAULT '{}',
@@ -77,11 +80,19 @@ export function openStore(path = ':memory:'): Store {
); );
`) `)
// Migration for DBs created before the `domain` column existed. CREATE TABLE
// IF NOT EXISTS won't add it to an existing table, so add it defensively.
try {
db.exec(`ALTER TABLE teams ADD COLUMN domain TEXT NOT NULL DEFAULT ''`)
} catch {
/* column already exists — fine */
}
const upsertTeamStmt = db.prepare(` const upsertTeamStmt = db.prepare(`
INSERT INTO teams (id, name, kit, members, phases, stats, device_connected, updated_at) INSERT INTO teams (id, name, kit, domain, members, phases, stats, device_connected, updated_at)
VALUES (@id, @name, @kit, @members, @phases, @stats, @device_connected, @updated_at) VALUES (@id, @name, @kit, @domain, @members, @phases, @stats, @device_connected, @updated_at)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name=excluded.name, kit=excluded.kit, 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
`) `)
@@ -124,6 +135,7 @@ export function openStore(path = ':memory:'): Store {
id: t.id, id: t.id,
name: t.name, name: t.name,
kit: t.kit, kit: t.kit,
domain: t.domain ?? '',
members: JSON.stringify(t.members), members: JSON.stringify(t.members),
phases: JSON.stringify(t.phases), phases: JSON.stringify(t.phases),
stats: JSON.stringify(t.stats), stats: JSON.stringify(t.stats),
+2 -1
View File
@@ -10,7 +10,7 @@ export interface SessionStats {
} }
export interface AddLayers { export interface AddLayers {
L1: unknown | null L1: string
L2: string L2: string
L3: string L3: string
L4: string L4: string
@@ -22,6 +22,7 @@ export interface TeamSnapshot {
name: string name: string
kit: string kit: string
members: string[] members: string[]
domain: string
phases: Record<PhaseKey, boolean> phases: Record<PhaseKey, boolean>
stats: SessionStats stats: SessionStats
deviceConnected: boolean deviceConnected: boolean
+1
View File
@@ -51,6 +51,7 @@ describe('collective WS feed', () => {
id: 't1', id: 't1',
name: 'team t1', name: 'team t1',
kit: 'KIT-01', kit: 'KIT-01',
domain: 'structural stress',
members: [], members: [],
phases: { reg: true, setup: false, m1: false, m2: false, add: false }, phases: { reg: true, setup: false, m1: false, m2: false, add: false },
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 }, stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
+8 -20
View File
@@ -1,6 +1,5 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { harnessToToml } from '@/lib/harness'
function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactNode }) { function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactNode }) {
return ( return (
@@ -20,9 +19,8 @@ function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactN
export function AddDocument() { export function AddDocument() {
const team = useSession((s) => s.team) const team = useSession((s) => s.team)
const add = useSession((s) => s.add) const add = useSession((s) => s.add)
const harness = useSession((s) => s.harness) const domain = useSession((s) => s.domain)
const stats = useSession((s) => s.stats) const stats = useSession((s) => s.stats)
const l1 = (add.L1 as Record<string, string> | null) ?? {}
return ( return (
<article <article
@@ -40,26 +38,16 @@ export function AddDocument() {
</div> </div>
</header> </header>
<LayerBlock <LayerBlock n={1} title="Domain & events" body={add.L1} />
n={1} <LayerBlock n={2} title="Skills" body={add.L2} />
title="Perception & goal" <LayerBlock n={3} title="Policies" body={add.L3} />
body={ <LayerBlock n={4} title="Harness" body={add.L4} />
Object.keys(l1).length <LayerBlock n={5} title="Loops" body={add.L5} />
? Object.entries(l1)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')
: ''
}
/>
<LayerBlock n={2} title="Reasoning policy" body={add.L2} />
<LayerBlock n={3} title="Action contract" body={add.L3} />
<LayerBlock n={4} title="Failure modes" body={add.L4} />
<LayerBlock n={5} title="AI-native redesign" body={add.L5} />
<div className="grid sm:grid-cols-2 gap-4 border-t border-border pt-4 break-inside-avoid"> <div className="grid sm:grid-cols-2 gap-4 border-t border-border pt-4 break-inside-avoid">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Harness</div> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Domain</div>
<pre className="font-mono text-[11px] whitespace-pre">{harnessToToml(harness)}</pre> <div className="font-mono text-[11px]">{domain || '—'}</div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Session</div> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Session</div>
+4 -17
View File
@@ -23,23 +23,10 @@ describe('AddLayerForm', () => {
expect(screen.getByLabelText(/layer 3/i)).toHaveValue('prior text') expect(screen.getByLabelText(/layer 3/i)).toHaveValue('prior text')
}) })
it('writes an object layer (L1) field-by-field', async () => { it('writes the L1 string layer to the store as the user types', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(<AddLayerForm layer="L1" title="Layer 1" placeholder="domain & events" />)
<AddLayerForm await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
layer="L1" expect(useSession.getState().add.L1).toBe('structural resonance')
title="Layer 1"
fields={[
{ key: 'goal', label: 'Goal' },
{ key: 'perception', label: 'Perception' },
]}
/>,
)
await user.type(screen.getByLabelText(/goal/i), 'keep structure safe')
await user.type(screen.getByLabelText(/perception/i), 'imu + acoustic')
expect(useSession.getState().add.L1).toEqual({
goal: 'keep structure safe',
perception: 'imu + acoustic',
})
}) })
}) })
+15 -54
View File
@@ -1,32 +1,22 @@
import { useId } from 'react' import { useId } from 'react'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useSession, type AddLayers } from '@/store/session' import { useSession, type AddLayers } from '@/store/session'
export interface AddLayerFieldSpec {
key: string
label: string
placeholder?: string
multiline?: boolean
}
export interface AddLayerFormProps { export interface AddLayerFormProps {
layer: keyof AddLayers layer: keyof AddLayers
title: string title: string
description?: string description?: string
/** when present, the layer is a structured object keyed by these fields (L1) */ /** placeholder for the single-textarea string layer */
fields?: AddLayerFieldSpec[]
/** placeholder for the single-textarea string layers (L2L5) */
placeholder?: string placeholder?: string
} }
/** /**
* 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: string layers (L2L5) render a single textarea; object layers (L1) * Document — each layer is free text rendered as a single textarea. All edits
* render one input per field. All edits flow straight into the session store. * flow straight into the session store.
*/ */
export function AddLayerForm({ layer, title, description, fields, 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 baseId = useId() const baseId = useId()
@@ -38,46 +28,17 @@ export function AddLayerForm({ layer, title, description, fields, placeholder }:
{description && <p className="text-xs text-muted-foreground leading-relaxed">{description}</p>} {description && <p className="text-xs text-muted-foreground leading-relaxed">{description}</p>}
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{fields ? ( <div className="space-y-2">
fields.map((f) => { <label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
const obj = (value as Record<string, string> | null) ?? {} {title}
const id = `${baseId}-${f.key}` </label>
return ( <Textarea
<div key={f.key} className="space-y-2"> id={baseId}
<label htmlFor={id} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> placeholder={placeholder}
{f.label} value={value}
</label> onChange={(e) => setAddLayer(layer, e.target.value)}
{f.multiline ? ( />
<Textarea </div>
id={id}
placeholder={f.placeholder}
value={obj[f.key] ?? ''}
onChange={(e) => setAddLayer(layer, { ...obj, [f.key]: e.target.value })}
/>
) : (
<Input
id={id}
placeholder={f.placeholder}
value={obj[f.key] ?? ''}
onChange={(e) => setAddLayer(layer, { ...obj, [f.key]: e.target.value })}
/>
)}
</div>
)
})
) : (
<div className="space-y-2">
<label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{title}
</label>
<Textarea
id={baseId}
placeholder={placeholder}
value={(value as string) ?? ''}
onChange={(e) => setAddLayer(layer, e.target.value)}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
) )
+3 -3
View File
@@ -7,7 +7,7 @@ const sub: SubmissionDTO = {
teamId: 't1', teamId: 't1',
teamName: 'team_resonance', teamName: 'team_resonance',
code: 'KIT-03-ABC', code: 'KIT-03-ABC',
add: { L1: { goal: 'stay safe' }, L2: 'reason', L3: 'act', L4: 'fail', L5: 'redesign' }, add: { L1: 'stay safe', L2: 'reason', L3: 'act', L4: 'fail', L5: 'redesign' },
submittedAt: '2026-07-27T18:00:00.000Z', submittedAt: '2026-07-27T18:00:00.000Z',
} }
@@ -28,8 +28,8 @@ describe('AddReview', () => {
expect(review).toHaveTextContent('KIT-03-ABC') expect(review).toHaveTextContent('KIT-03-ABC')
}) })
it('handles a null L1 without crashing', () => { it('handles an empty L1 without crashing', () => {
render(<AddReview submission={{ ...sub, add: { ...sub.add, L1: null } }} />) render(<AddReview submission={{ ...sub, add: { ...sub.add, L1: '' } }} />)
expect(screen.getByTestId('add-review')).toBeInTheDocument() expect(screen.getByTestId('add-review')).toBeInTheDocument()
}) })
}) })
+1 -8
View File
@@ -25,20 +25,13 @@ export function AddReview({ submission }: AddReviewProps) {
if (!submission) { if (!submission) {
return <p className="text-sm text-muted-foreground">Select a submission to review.</p> return <p className="text-sm text-muted-foreground">Select a submission to review.</p>
} }
const l1 = (submission.add.L1 as Record<string, string> | null) ?? {}
return ( return (
<article data-testid="add-review" className="space-y-4"> <article data-testid="add-review" className="space-y-4">
<header> <header>
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div>
<h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3> <h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3>
</header> </header>
<Block <Block n={1} title="Domain & events" body={submission.add.L1} />
n={1}
title="Perception & goal"
body={Object.entries(l1)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')}
/>
{LAYERS.map((l, i) => ( {LAYERS.map((l, i) => (
<Block key={l.key} n={i + 2} title={l.title} body={submission.add[l.key]} /> <Block key={l.key} n={i + 2} title={l.title} body={submission.add[l.key]} />
))} ))}
+23 -54
View File
@@ -1,6 +1,7 @@
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, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { BuildFlash } from './BuildFlash' import { BuildFlash } from './BuildFlash'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import type { WsEvent } from '@/types' import type { WsEvent } from '@/types'
@@ -23,73 +24,41 @@ describe('BuildFlash', () => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
sendPrompt.mockReset() sendPrompt.mockReset()
sendPrompt.mockResolvedValue(undefined)
closeSpy.mockReset() closeSpy.mockReset()
liveOnEvent = null liveOnEvent = null
}) })
it('disables Send until there is a prompt', () => { it('disables Send until there is a prompt', () => {
render(<BuildFlash />) render(<MemoryRouter><BuildFlash /></MemoryRouter>)
expect(screen.getByRole('button', { name: /send/i })).toBeDisabled() expect(screen.getByRole('button', { name: /send/i })).toBeDisabled()
}) })
it('shows the Simulation badge by default and Live when in live mode', () => { it('shows the Live board badge', () => {
const { rerender } = render(<BuildFlash />) render(<MemoryRouter><BuildFlash /></MemoryRouter>)
expect(screen.getByText(/simulation/i)).toBeInTheDocument()
act(() => useSession.getState().setMode('live'))
rerender(<BuildFlash />)
expect(screen.getByText(/live board/i)).toBeInTheDocument() expect(screen.getByText(/live board/i)).toBeInTheDocument()
}) })
describe('simulation mode', () => { it('sends the prompt to the single cloud agent and renders streamed board activity', async () => {
beforeEach(() => vi.useFakeTimers()) const user = userEvent.setup()
afterEach(() => vi.useRealTimers()) render(<MemoryRouter><BuildFlash /></MemoryRouter>)
it('plays a simulated generate→flash sequence without hardware or the api', async () => { await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
render(<BuildFlash />) await user.click(screen.getByRole('button', { name: /working|send/i }))
fireEvent.change(screen.getByLabelText(/prompt your board/i), { expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'cloud')
target: { value: 'scroll GO CLAWS' },
})
fireEvent.click(screen.getByRole('button', { name: /send/i }))
await act(async () => { // a flash event streams in over the (mocked) SSE feed
await vi.advanceTimersByTimeAsync(1400) act(() => {
}) liveOnEvent?.({ type: 'node:activity', teamId: 't', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' })
const log = screen.getByTestId('activity-log')
expect(log).toHaveTextContent(/flashed to 0x80F0000 \(simulated\)/i)
expect(log).toHaveTextContent(/your board is running the sketch/i)
expect(sendPrompt).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: /send/i })).toBeEnabled()
}) })
expect(screen.getByTestId('activity-log')).toHaveTextContent(/flashed to 0x80F0000/i)
// terminal event re-enables Send
expect(screen.getByRole('button', { name: /send/i })).toBeEnabled()
}) })
describe('live mode', () => { it('closes the activity stream on unmount', () => {
beforeEach(() => { const { unmount } = render(<MemoryRouter><BuildFlash /></MemoryRouter>)
useSession.getState().setMode('live') unmount()
}) expect(closeSpy).toHaveBeenCalled()
it('sends the prompt to the api and renders streamed board activity', async () => {
const user = userEvent.setup()
render(<BuildFlash />)
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
await user.click(screen.getByRole('button', { name: /working|send/i }))
// default harness (cloud + fallback) routes to the `default` agent alias
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'default')
// a flash event streams in over the (mocked) SSE feed
act(() => {
liveOnEvent?.({ type: 'node:activity', teamId: 't', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' })
})
expect(screen.getByTestId('activity-log')).toHaveTextContent(/flashed to 0x80F0000/i)
// terminal event re-enables Send
expect(screen.getByRole('button', { name: /send/i })).toBeEnabled()
})
it('closes the activity stream on unmount', () => {
const { unmount } = render(<BuildFlash />)
unmount()
expect(closeSpy).toHaveBeenCalled()
})
}) })
}) })
+15 -35
View File
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { OpenYourNode } from '@/components/OpenYourNode'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api' import { sendPrompt, openTeamActivity } from '@/lib/api'
import { harnessToAgent } from '@/lib/harness'
import type { NodeActivityKind, WsEvent } from '@/types' import type { NodeActivityKind, WsEvent } from '@/types'
interface Entry { interface Entry {
@@ -14,14 +14,6 @@ interface Entry {
label: string label: string
} }
/** Deterministic sequence played back in simulation mode (no hardware). */
const SIM_SEQUENCE: { kind: NodeActivityKind; label: string; delay: number }[] = [
{ kind: 'thinking', label: 'Agent started', delay: 200 },
{ kind: 'tool', label: 'Writing the sketch…', delay: 600 },
{ kind: 'flash', label: 'Flashed to 0x80F0000 (simulated)', delay: 1000 },
{ kind: 'response', label: 'Done — your board is running the sketch', delay: 1300 },
]
const KIND_DOT: Record<NodeActivityKind, string> = { const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground', thinking: 'bg-muted-foreground',
tool: 'bg-amber', tool: 'bg-amber',
@@ -35,28 +27,21 @@ const isTerminal = (k: NodeActivityKind) => k === 'flash' || k === 'response' ||
/** Prompt the team's board and watch it generate → compile → flash, live. */ /** Prompt the team's board and watch it generate → compile → flash, live. */
export function BuildFlash() { export function BuildFlash() {
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId) const teamId = useSession((s) => s.teamId)
const harness = useSession((s) => s.harness)
const [prompt, setPrompt] = useState('') const [prompt, setPrompt] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [entries, setEntries] = useState<Entry[]>([]) const [entries, setEntries] = useState<Entry[]>([])
const timers = useRef<ReturnType<typeof setTimeout>[]>([])
const append = (e: Entry) => setEntries((prev) => [...prev, e]) const append = (e: Entry) => setEntries((prev) => [...prev, e])
// Live mode: stream this team's own board activity. // Stream this team's own board activity.
useEffect(() => { useEffect(() => {
if (mode !== 'live') return
return openTeamActivity(teamId, (ev: WsEvent) => { return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return if (ev.type !== 'node:activity') return
append({ kind: ev.kind, label: ev.label }) append({ kind: ev.kind, label: ev.label })
if (isTerminal(ev.kind)) setBusy(false) if (isTerminal(ev.kind)) setBusy(false)
}) })
}, [mode, teamId]) }, [teamId])
// Clear any pending sim timers on unmount.
useEffect(() => () => timers.current.forEach(clearTimeout), [])
const run = async () => { const run = async () => {
const msg = prompt.trim() const msg = prompt.trim()
@@ -64,18 +49,8 @@ export function BuildFlash() {
setEntries([]) setEntries([])
setBusy(true) setBusy(true)
if (mode === 'sim') {
timers.current = SIM_SEQUENCE.map((s) =>
setTimeout(() => {
append({ kind: s.kind, label: s.label })
if (isTerminal(s.kind) && s.kind !== 'flash') setBusy(false)
}, s.delay),
)
return
}
try { try {
await sendPrompt(teamId, msg, harnessToAgent(harness)) await sendPrompt(teamId, msg, 'cloud')
} catch { } catch {
append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' }) append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' })
setBusy(false) setBusy(false)
@@ -86,14 +61,19 @@ export function BuildFlash() {
<Card> <Card>
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Build &amp; flash</CardTitle> <CardTitle className="text-base">Build &amp; flash</CardTitle>
<Badge <Badge variant="default" className="font-mono text-[10px] uppercase tracking-widest">
variant={mode === 'live' ? 'default' : 'secondary'} Live board
className="font-mono text-[10px] uppercase tracking-widest"
>
{mode === 'live' ? 'Live board' : 'Simulation'}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
Ask your node to build something it uses its skills, writes a sketch, and flashes the MCU.
The conversation lives in the node; watch it happen there while the activity feed streams here.
</p>
<OpenYourNode variant="inline" className="shrink-0 mt-0.5" />
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label="Prompt your board" aria-label="Prompt your board"
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DomainPicker } from './DomainPicker'
import { useSession } from '@/store/session'
describe('DomainPicker', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('binds the input to the session domain', () => {
render(<DomainPicker />)
const input = screen.getByLabelText(/your domain/i)
fireEvent.change(input, { target: { value: 'air quality' } })
expect(useSession.getState().domain).toBe('air quality')
})
it('shows generic scaffolding hints for the four next dimensions', () => {
render(<DomainPicker />)
for (const label of ['Skills', 'Policies', 'Harness', 'Loops']) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
})
+56
View File
@@ -0,0 +1,56 @@
import { useId } from 'react'
import { Input } from '@/components/ui/input'
import { useSession } from '@/store/session'
/** Generic, domain-agnostic scaffolding prompts for the four design dimensions
* the team builds next. Deliberately NOT a fixed catalog — just questions. */
const DIMENSION_HINTS: { label: string; prompt: string }[] = [
{ label: 'Skills', prompt: 'What domain knowledge must it know?' },
{ label: 'Policies', prompt: 'What may it do autonomously vs. need approval?' },
{ label: 'Harness', prompt: 'When does it decide locally vs. escalate?' },
{ label: 'Loops', prompt: 'How often does it check its world + report by exception?' },
]
/**
* Names the problem domain the team's node is for, plus the events it senses.
* Bound straight to the session store's free-text `domain`. Below the input we
* surface generic scaffolding prompts for the next four design dimensions.
*/
export function DomainPicker() {
const domain = useSession((s) => s.domain)
const setDomain = useSession((s) => s.setDomain)
const inputId = useId()
return (
<div className="space-y-5">
<div className="space-y-2">
<label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Your domain
</label>
<Input
id={inputId}
placeholder="e.g. image measurement · structural stress · air quality"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Name the domain your node is for and the events it senses. This frames everything you design next.
</p>
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
What you&rsquo;ll design next
</div>
<div className="grid sm:grid-cols-2 gap-3">
{DIMENSION_HINTS.map((d) => (
<div key={d.label} className="rounded-md border border-border px-3 py-2.5">
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</div>
<div className="text-sm text-muted-foreground leading-snug mt-1">{d.prompt}</div>
</div>
))}
</div>
</div>
</div>
)
}
@@ -1,67 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { HarnessProviderSelect } from './HarnessProviderSelect'
import { useSession } from '@/store/session'
describe('HarnessProviderSelect', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('marks the current provider as pressed', () => {
render(<HarnessProviderSelect />)
expect(screen.getByRole('button', { name: /anthropic/i })).toHaveAttribute('aria-pressed', 'true')
})
it('writes the chosen provider to the store', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
await user.click(screen.getByRole('button', { name: /groq/i }))
expect(useSession.getState().harness.provider).toBe('groq')
})
it('writes the model field to the store', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
const input = screen.getByLabelText(/model/i)
await user.clear(input)
await user.type(input, 'claude-sonnet-4-6')
expect(useSession.getState().harness.model).toBe('claude-sonnet-4-6')
})
it('disables editing when read-only', () => {
render(<HarnessProviderSelect readOnly />)
expect(screen.getByRole('button', { name: /groq/i })).toBeDisabled()
expect(screen.getByLabelText(/model/i)).toHaveAttribute('readonly')
})
it('offers Local as a provider', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
await user.click(screen.getByRole('button', { name: /local/i }))
expect(useSession.getState().harness.provider).toBe('local')
})
it('shows the on-board Qwen fallback toggle for a cloud primary and writes it', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
const toggle = screen.getByRole('checkbox', { name: /fall back to on-board qwen/i })
expect(toggle).toBeChecked() // default on
await user.click(toggle)
expect(useSession.getState().harness.fallbackLocal).toBe(false)
})
it('hides the fallback toggle when the primary is Local', async () => {
const user = userEvent.setup()
render(<HarnessProviderSelect />)
await user.click(screen.getByRole('button', { name: /local/i }))
expect(screen.queryByRole('checkbox', { name: /fall back/i })).not.toBeInTheDocument()
})
it('disables the fallback toggle when read-only', () => {
render(<HarnessProviderSelect readOnly />)
expect(screen.getByRole('checkbox', { name: /fall back/i })).toBeDisabled()
})
})
-78
View File
@@ -1,78 +0,0 @@
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useSession, type Provider } from '@/store/session'
const PROVIDERS: { id: Provider; label: string }[] = [
{ id: 'anthropic', label: 'Anthropic' },
{ id: 'groq', label: 'Groq' },
{ id: 'openai', label: 'OpenAI' },
{ id: 'local', label: 'Local' },
]
export interface HarnessProviderSelectProps {
/** render the model field read-only (e.g. the Module 2 config summary) */
readOnly?: boolean
}
/** Provider toggle group + model field, bound to the session harness. */
export function HarnessProviderSelect({ readOnly }: HarnessProviderSelectProps) {
const provider = useSession((s) => s.harness.provider)
const model = useSession((s) => s.harness.model)
const fallbackLocal = useSession((s) => s.harness.fallbackLocal)
const setHarness = useSession((s) => s.setHarness)
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Reasoning provider">
{PROVIDERS.map((p) => {
const selected = p.id === provider
return (
<button
key={p.id}
type="button"
disabled={readOnly}
aria-pressed={selected}
onClick={() => setHarness({ provider: p.id })}
className={cn(
'font-mono text-xs px-2 py-2 rounded-md border transition disabled:opacity-60',
selected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-card text-muted-foreground border-border hover:border-primary/40',
)}
>
{p.label}
</button>
)
})}
</div>
<div className="space-y-2">
<label htmlFor="harness-model" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Model
</label>
<Input
id="harness-model"
value={model}
readOnly={readOnly}
onChange={(e) => setHarness({ model: e.target.value })}
className="font-mono"
/>
</div>
{provider !== 'local' && (
<label className="flex items-start gap-2 cursor-pointer select-none pt-1" data-testid="fallback-toggle">
<input
type="checkbox"
checked={fallbackLocal}
disabled={readOnly}
onChange={(e) => setHarness({ fallbackLocal: e.target.checked })}
className="mt-0.5 accent-primary"
aria-label="Fall back to on-board Qwen if the cloud is unreachable"
/>
<span className="font-mono text-[10px] leading-relaxed text-muted-foreground">
Fall back to <span className="text-foreground">on-board Qwen</span> if the cloud is unreachable
</span>
</label>
)}
</div>
)
}
@@ -1,24 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { HarnessTomlPreview } from './HarnessTomlPreview'
import { useSession } from '@/store/session'
describe('HarnessTomlPreview', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the live harness as TOML', () => {
render(<HarnessTomlPreview />)
const pre = screen.getByTestId('harness-toml')
expect(pre).toHaveTextContent('[harness]')
expect(pre).toHaveTextContent('threshold_g = 0.8')
})
it('reflects a tuned threshold', () => {
useSession.getState().setHarness({ thresholdG: 1.1 })
render(<HarnessTomlPreview />)
expect(screen.getByTestId('harness-toml')).toHaveTextContent('threshold_g = 1.1')
})
})
-15
View File
@@ -1,15 +0,0 @@
import { useSession } from '@/store/session'
import { harnessToToml } from '@/lib/harness'
/** Read-only `harness.toml` preview that reflects the live tuned config. */
export function HarnessTomlPreview() {
const harness = useSession((s) => s.harness)
return (
<pre
data-testid="harness-toml"
className="font-mono text-xs bg-card border border-border rounded-md p-4 overflow-x-auto whitespace-pre"
>
{harnessToToml(harness)}
</pre>
)
}
-37
View File
@@ -1,37 +0,0 @@
import { Input } from '@/components/ui/input'
import { useSession } from '@/store/session'
const FIELDS: { key: 'thresholdG' | 'thresholdDb' | 'callsPerMinute'; label: string; step: number }[] = [
{ key: 'thresholdG', label: 'Threshold · g', step: 0.05 },
{ key: 'thresholdDb', label: 'Threshold · dB', step: 1 },
{ key: 'callsPerMinute', label: 'Calls / min', step: 1 },
]
/** Numeric tuner bound to the session harness thresholds. */
export function HarnessTuner() {
const harness = useSession((s) => s.harness)
const setHarness = useSession((s) => s.setHarness)
return (
<div className="grid sm:grid-cols-3 gap-4" data-testid="harness-tuner">
{FIELDS.map((f) => (
<div key={f.key} className="space-y-2">
<label htmlFor={`harness-${f.key}`} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{f.label}
</label>
<Input
id={`harness-${f.key}`}
type="number"
step={f.step}
value={harness[f.key]}
onChange={(e) => {
const n = Number(e.target.value)
if (Number.isFinite(n)) setHarness({ [f.key]: n })
}}
className="font-mono"
/>
</div>
))}
</div>
)
}
-30
View File
@@ -1,30 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LiveFeed } from './LiveFeed'
describe('LiveFeed', () => {
it('prompts to start when there is no frame', () => {
render(<LiveFeed last={null} frames={[]} connected={false} />)
expect(screen.getByText(/no frames yet/i)).toBeInTheDocument()
expect(screen.getByTestId('live-feed').querySelector('[data-state="idle"]')).toBeTruthy()
})
it('renders the latest frame axes and magnitude when live', () => {
render(
<LiveFeed
last={{ ax: 0.3, ay: 0.4, az: 0, db: 50, t: 1 }}
frames={[{ ax: 0.3, ay: 0.4, az: 0, db: 50, t: 1 }]}
connected
/>,
)
const feed = screen.getByTestId('live-feed')
expect(feed.querySelector('[data-state="live"]')).toBeTruthy()
expect(feed.querySelector('[data-axis="ax"]')).toHaveTextContent('0.30')
expect(feed.querySelector('[data-axis="mag"]')).toHaveTextContent('0.50')
})
it('marks a simulated connection', () => {
render(<LiveFeed last={null} frames={[]} connected mocked />)
expect(screen.getByText(/simulated/i)).toBeInTheDocument()
})
})
-63
View File
@@ -1,63 +0,0 @@
import type { ImuFrame } from '@/lib/serial'
import { cn } from '@/lib/utils'
export interface LiveFeedProps {
last: ImuFrame | null
frames: ImuFrame[]
connected: boolean
mocked?: boolean
}
function mag(f: ImuFrame): number {
return Math.sqrt(f.ax * f.ax + f.ay * f.ay + f.az * f.az)
}
/**
* Presentational live IMU readout. Pages feed it `useSerial()` output; tests
* seed it with props directly.
*/
export function LiveFeed({ last, frames, connected, mocked }: LiveFeedProps) {
return (
<div data-testid="live-feed" className="border border-border rounded-md bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
data-state={connected ? 'live' : 'idle'}
className={cn('w-2 h-2 rounded-full', connected ? 'bg-teal animate-pulse' : 'bg-muted-foreground')}
/>
<span className="text-sm font-medium">{connected ? 'Live feed' : 'Feed idle'}</span>
</div>
{mocked && (
<span className="font-mono text-[9px] uppercase tracking-widest text-amber">simulated</span>
)}
</div>
{last ? (
<div className="grid grid-cols-4 gap-2 font-mono text-xs">
{(['ax', 'ay', 'az'] as const).map((k) => (
<div key={k} className="space-y-0.5">
<div className="text-[9px] uppercase tracking-widest text-muted-foreground">{k}</div>
<div className="tabular-nums" data-axis={k}>{last[k].toFixed(2)}</div>
</div>
))}
<div className="space-y-0.5">
<div className="text-[9px] uppercase tracking-widest text-muted-foreground">|g|</div>
<div className="tabular-nums" data-axis="mag">{mag(last).toFixed(2)}</div>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">No frames yet start the feed.</p>
)}
<div className="flex gap-0.5 h-8 items-end" aria-hidden="true">
{frames.slice(-32).map((f, i) => (
<span
key={i}
className="flex-1 bg-primary/40 rounded-sm"
style={{ height: `${Math.min(100, mag(f) * 50)}%` }}
/>
))}
</div>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { Link } from 'react-router-dom'
import { useSession } from '@/store/session'
import { cn } from '@/lib/utils'
export interface OpenYourNodeProps {
/** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact
* link for reuse inside later module pages. */
variant?: 'hero' | 'inline'
className?: string
}
/**
* Reusable "Open your node" call-to-action. Opens the board's LAN ZeroClaw
* dashboard (device.nodeUrl) in a new tab. When the board isn't claimed yet it
* shows an amber prompt back to team registration instead.
*/
export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) {
const device = useSession((s) => s.device)
const canOpen = device.connected && !!device.nodeUrl
if (!canOpen) {
return (
<div
className={cn(
'border border-amber/40 bg-amber/5 rounded-md px-4 py-3 text-sm',
className,
)}
>
No node yet <Link to="/workshop" className="underline font-medium">claim your board first</Link>.
</div>
)
}
if (variant === 'inline') {
return (
<a
href={device.nodeUrl!}
target="_blank"
rel="noopener noreferrer"
className={cn(
'inline-flex items-center gap-1 font-mono text-[11px] text-primary hover:underline tracking-widest uppercase',
className,
)}
>
Open your node
</a>
)
}
return (
<a
href={device.nodeUrl!}
target="_blank"
rel="noopener noreferrer"
className={cn(
'group flex items-center justify-between gap-4 rounded-lg border border-primary/40 bg-primary/5 px-6 py-5 transition-colors hover:bg-primary/10',
className,
)}
>
<div>
<div className="text-xl font-bold tracking-tight">Open your node </div>
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
{device.nodeUrl}
</div>
</div>
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
</a>
)
}
-77
View File
@@ -1,77 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ResiliencePanel } from './ResiliencePanel'
import { useSession } from '@/store/session'
import type { WsEvent } from '@/types'
const sendPrompt = vi.fn()
let liveOnEvent: ((e: WsEvent) => void) | null = null
const closeSpy = vi.fn()
vi.mock('@/lib/api', () => ({
sendPrompt: (...args: unknown[]) => sendPrompt(...args),
openTeamActivity: (_teamId: string, onEvent: (e: WsEvent) => void) => {
liveOnEvent = onEvent
return closeSpy
},
}))
describe('ResiliencePanel', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
sendPrompt.mockReset()
closeSpy.mockReset()
liveOnEvent = null
})
describe('simulation mode', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('plays the cloud-outage → on-board-Qwen failover without hardware or the api', async () => {
render(<ResiliencePanel />)
fireEvent.click(screen.getByRole('button', { name: /simulate cloud outage/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(1700)
})
const log = screen.getByTestId('resilience-log')
expect(log).toHaveTextContent(/falling back to on-board qwen/i)
expect(log).toHaveTextContent(/on-board qwen answered/i)
// the "survived" banner appears once a fallback is followed by a response
expect(screen.getByTestId('resilience-recovered')).toBeInTheDocument()
expect(sendPrompt).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: /simulate cloud outage/i })).toBeEnabled()
})
})
describe('live mode', () => {
beforeEach(() => useSession.getState().setMode('live'))
it('routes the outage through the boards `chaos` agent', async () => {
const user = userEvent.setup()
render(<ResiliencePanel />)
await user.click(screen.getByRole('button', { name: /simulate cloud outage/i }))
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, expect.any(String), 'chaos')
})
it('renders a streamed fallback event and marks recovery on the response', () => {
render(<ResiliencePanel />)
act(() => {
liveOnEvent?.({ type: 'node:activity', teamId: 't', kind: 'fallback', label: 'Cloud unreachable — falling back to on-board Qwen', ts: 'T' })
liveOnEvent?.({ type: 'node:activity', teamId: 't', kind: 'response', label: 'On-board Qwen answered', ts: 'T' })
})
expect(screen.getByTestId('resilience-log')).toHaveTextContent(/falling back to on-board qwen/i)
expect(screen.getByTestId('resilience-recovered')).toBeInTheDocument()
})
it('closes the activity stream on unmount', () => {
const { unmount } = render(<ResiliencePanel />)
unmount()
expect(closeSpy).toHaveBeenCalled()
})
})
})
-144
View File
@@ -1,144 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types'
interface Entry {
kind: NodeActivityKind
label: string
}
/** A short reasoning turn — enough to exercise the provider so the failover shows. */
const CHAOS_PROMPT = 'In one short sentence, report the current structural status.'
/** Deterministic demo of graceful degradation for simulation mode (no hardware). */
const SIM_FALLBACK: { kind: NodeActivityKind; label: string; delay: number }[] = [
{ kind: 'thinking', label: 'Agent started — routing to cloud', delay: 200 },
{ kind: 'fallback', label: 'Cloud unreachable — falling back to on-board Qwen', delay: 900 },
{ kind: 'response', label: 'On-board Qwen answered — structure nominal', delay: 1600 },
]
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
fallback: 'bg-rose',
}
const isTerminal = (k: NodeActivityKind) => k === 'response' || k === 'error' || k === 'flash'
/**
* Failure-injection theater — the Uno Q's unique story made visible. Routes a
* prompt through the board's `chaos` agent (a dead cloud endpoint) so the agent
* genuinely fails over to the on-board Qwen, live. This is the concrete evidence
* for ADD Layer 4 (failure modes) and Layer 5 (edge-vs-cloud).
*/
export function ResiliencePanel() {
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId)
const [busy, setBusy] = useState(false)
const [entries, setEntries] = useState<Entry[]>([])
const timers = useRef<ReturnType<typeof setTimeout>[]>([])
const append = (e: Entry) => setEntries((prev) => [...prev, e])
const recovered =
entries.some((e) => e.kind === 'fallback') && entries.some((e) => e.kind === 'response')
// Live mode: stream this team's own board activity.
useEffect(() => {
if (mode !== 'live') return
return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return
append({ kind: ev.kind, label: ev.label })
if (isTerminal(ev.kind)) setBusy(false)
})
}, [mode, teamId])
useEffect(() => () => timers.current.forEach(clearTimeout), [])
const inject = async () => {
if (busy) return
setEntries([])
setBusy(true)
if (mode === 'sim') {
timers.current = SIM_FALLBACK.map((s) =>
setTimeout(() => {
append({ kind: s.kind, label: s.label })
if (isTerminal(s.kind)) setBusy(false)
}, s.delay),
)
return
}
try {
await sendPrompt(teamId, CHAOS_PROMPT, 'chaos')
} catch {
append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' })
setBusy(false)
}
}
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Resilience simulate a cloud outage</CardTitle>
<Badge
variant={mode === 'live' ? 'default' : 'secondary'}
className="font-mono text-[10px] uppercase tracking-widest"
>
{mode === 'live' ? 'Live board' : 'Simulation'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground max-w-xl">
Your agent is cloud-first with an on-board Qwen fallback. Cut the cloud and watch it keep
reasoning on the model running on your own board this is your evidence for ADD Layer 4
(failure modes) and Layer 5 (edge vs cloud).
</p>
<Button variant="destructive" onClick={() => void inject()} disabled={busy}>
{busy ? 'Injecting outage…' : 'Simulate cloud outage'}
</Button>
{recovered && (
<div
data-testid="resilience-recovered"
className="font-mono text-[11px] text-rose font-medium"
>
Survived the cloud went dark and your on-board Qwen took over.
</div>
)}
<ul data-testid="resilience-log" className="space-y-1.5 min-h-[3rem]">
{entries.length === 0 ? (
<li className="font-mono text-[11px] text-muted-foreground">
Inject a cloud outage and watch the agent degrade gracefully to on-device inference.
</li>
) : (
entries.map((e, i) => (
<li key={i} className="flex items-center gap-2 font-mono text-[11px]">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', KIND_DOT[e.kind])} />
<span
className={cn(
e.kind === 'error' && 'text-destructive',
e.kind === 'fallback' && 'text-rose font-medium',
e.kind === 'response' && 'text-foreground',
)}
>
{e.label}
</span>
</li>
))
)}
</ul>
</CardContent>
</Card>
)
}
+13 -13
View File
@@ -9,13 +9,13 @@ describe('ScoreForm', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<ScoreForm onSubmit={onSubmit} />) render(<ScoreForm onSubmit={onSubmit} />)
const perception = screen.getByLabelText(/perception/i) const domain = screen.getByLabelText(/domain fit/i)
await user.clear(perception) await user.clear(domain)
await user.type(perception, '8') await user.type(domain, '8')
const reasoning = screen.getByLabelText(/reasoning/i) const skills = screen.getByLabelText(/skills/i)
await user.clear(reasoning) await user.clear(skills)
await user.type(reasoning, '6') await user.type(skills, '6')
await user.type(screen.getByLabelText(/notes/i), 'solid edge reasoning') await user.type(screen.getByLabelText(/notes/i), 'solid skills design')
expect(screen.getByTestId('score-total')).toHaveTextContent('14') expect(screen.getByTestId('score-total')).toHaveTextContent('14')
await user.click(screen.getByRole('button', { name: /submit score/i })) await user.click(screen.getByRole('button', { name: /submit score/i }))
@@ -23,19 +23,19 @@ describe('ScoreForm', () => {
expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenCalledTimes(1)
const arg = onSubmit.mock.calls[0][0] const arg = onSubmit.mock.calls[0][0]
expect(arg.total).toBe(14) expect(arg.total).toBe(14)
expect(arg.rubric.perception).toBe(8) expect(arg.rubric.domain).toBe(8)
expect(arg.notes).toBe('solid edge reasoning') expect(arg.notes).toBe('solid skills design')
}) })
it('clamps a criterion to the 010 range', async () => { it('clamps a criterion to the 010 range', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<ScoreForm onSubmit={onSubmit} />) render(<ScoreForm onSubmit={onSubmit} />)
const action = screen.getByLabelText(/action/i) const policies = screen.getByLabelText(/policies/i)
await user.clear(action) await user.clear(policies)
await user.type(action, '99') await user.type(policies, '99')
await user.click(screen.getByRole('button', { name: /submit score/i })) await user.click(screen.getByRole('button', { name: /submit score/i }))
expect(onSubmit.mock.calls[0][0].rubric.action).toBe(10) expect(onSubmit.mock.calls[0][0].rubric.policies).toBe(10)
}) })
it('honours the disabled prop', () => { it('honours the disabled prop', () => {
+5 -5
View File
@@ -4,11 +4,11 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
const CRITERIA: { key: string; label: string }[] = [ const CRITERIA: { key: string; label: string }[] = [
{ key: 'perception', label: 'Perception (L1)' }, { key: 'domain', label: 'Domain fit (L1)' },
{ key: 'reasoning', label: 'Reasoning (L2)' }, { key: 'skills', label: 'Skills (L2)' },
{ key: 'action', label: 'Action (L3)' }, { key: 'policies', label: 'Policies (L3)' },
{ key: 'failure', label: 'Failure modes (L4)' }, { key: 'harness', label: 'Harness (L4)' },
{ key: 'redesign', label: 'Redesign (L5)' }, { key: 'loops', label: 'Loops (L5)' },
] ]
const MAX = 10 const MAX = 10
-27
View File
@@ -1,27 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TriggerButtons } from './TriggerButtons'
describe('TriggerButtons', () => {
it('fires an impact frame with high g and dB', async () => {
const user = userEvent.setup()
const onFire = vi.fn()
render(<TriggerButtons onFire={onFire} />)
await user.click(screen.getByRole('button', { name: /impact/i }))
expect(onFire).toHaveBeenCalledTimes(1)
const frame = onFire.mock.calls[0][0]
expect(frame.ax).toBeGreaterThan(0.8)
expect(frame.db).toBeGreaterThan(65)
})
it('fires a calm frame below thresholds', async () => {
const user = userEvent.setup()
const onFire = vi.fn()
render(<TriggerButtons onFire={onFire} />)
await user.click(screen.getByRole('button', { name: /calm/i }))
const frame = onFire.mock.calls[0][0]
expect(frame.ax).toBeLessThan(0.8)
expect(frame.db).toBeLessThan(65)
})
})
-31
View File
@@ -1,31 +0,0 @@
import { Button } from '@/components/ui/button'
import type { ImuFrame } from '@/lib/serial'
const TRIGGERS: { kind: string; label: string; frame: Omit<ImuFrame, 't'> }[] = [
{ kind: 'calm', label: 'Calm', frame: { ax: 0.05, ay: 0.02, az: 0.05, db: 38 } },
{ kind: 'shake', label: 'Shake', frame: { ax: 1.0, ay: 0.3, az: 0.2, db: 52 } },
{ kind: 'impact', label: 'Impact', frame: { ax: 1.6, ay: 0.8, az: 0.5, db: 82 } },
]
export interface TriggerButtonsProps {
onFire: (frame: ImuFrame) => void
}
/** Inject synthetic frames to exercise the just-tuned classifier. */
export function TriggerButtons({ onFire }: TriggerButtonsProps) {
return (
<div className="flex gap-2" data-testid="trigger-buttons">
{TRIGGERS.map((t) => (
<Button
key={t.kind}
variant="outline"
size="sm"
data-trigger={t.kind}
onClick={() => onFire({ ...t.frame, t: 0 })}
>
{t.label}
</Button>
))}
</div>
)
}
-59
View File
@@ -1,59 +0,0 @@
import { describe, it, expect } from 'vitest'
import { harnessToToml, harnessToAgent } from './harness'
import type { Harness } from '@/store/session'
const harness: Harness = {
thresholdG: 0.8,
thresholdDb: 65,
callsPerMinute: 8,
provider: 'anthropic',
model: 'claude-haiku-4-5',
fallbackLocal: true,
}
describe('harnessToToml', () => {
it('renders a [harness] section with the tuned thresholds', () => {
const toml = harnessToToml(harness)
expect(toml).toContain('[harness]')
expect(toml).toContain('threshold_g = 0.8')
expect(toml).toContain('threshold_db = 65')
expect(toml).toContain('calls_per_minute = 8')
})
it('renders a [provider] section with quoted string values', () => {
const toml = harnessToToml(harness)
expect(toml).toContain('[provider]')
expect(toml).toContain('name = "anthropic"')
expect(toml).toContain('model = "claude-haiku-4-5"')
})
it('reflects live edits to the threshold values', () => {
expect(harnessToToml({ ...harness, thresholdG: 1.25 })).toContain('threshold_g = 1.25')
})
it('adds a fallback to local-qwen when a cloud primary has fallback enabled', () => {
expect(harnessToToml(harness)).toContain('fallback = ["local-qwen"]')
})
it('omits the fallback line when fallback is disabled', () => {
expect(harnessToToml({ ...harness, fallbackLocal: false })).not.toContain('fallback')
})
it('omits the fallback line when the primary is already local', () => {
expect(harnessToToml({ ...harness, provider: 'local', fallbackLocal: true })).not.toContain('fallback')
})
})
describe('harnessToAgent', () => {
it('routes a local primary to the local agent', () => {
expect(harnessToAgent({ ...harness, provider: 'local' })).toBe('local')
})
it('routes a cloud primary with fallback to the default (cloud+fallback) agent', () => {
expect(harnessToAgent({ ...harness, provider: 'anthropic', fallbackLocal: true })).toBe('default')
})
it('routes a cloud primary without fallback to the cloud-only agent', () => {
expect(harnessToAgent({ ...harness, provider: 'groq', fallbackLocal: false })).toBe('cloud')
})
})
-33
View File
@@ -1,33 +0,0 @@
import type { Harness } from '@/store/session'
/** Render the live harness config as a `harness.toml`-style document. */
export function harnessToToml(h: Harness): string {
const lines = [
'[harness]',
`threshold_g = ${h.thresholdG}`,
`threshold_db = ${h.thresholdDb}`,
`calls_per_minute = ${h.callsPerMinute}`,
'',
'[provider]',
`name = "${h.provider}"`,
`model = "${h.model}"`,
]
// Cloud-first, local-if-it-fails: only a cloud primary can fall back to Qwen.
if (h.provider !== 'local' && h.fallbackLocal) {
lines.push('fallback = ["local-qwen"]')
}
lines.push('')
return lines.join('\n')
}
/**
* Map a harness to the ZeroClaw agent alias a board should handle the request
* with (routed per-request via `?agent=`). Boards are provisioned with:
* - `local` — on-board Qwen only
* - `cloud` — cloud provider, no fallback
* - `default` — cloud provider with on-board Qwen fallback
*/
export function harnessToAgent(h: Harness): string {
if (h.provider === 'local') return 'local'
return h.fallbackLocal ? 'default' : 'cloud'
}
-74
View File
@@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { serialSupported, parseLine, classifyFrame, requestPort } from './serial'
describe('serialSupported', () => {
it('is false when navigator.serial is absent (jsdom)', () => {
expect(serialSupported()).toBe(false)
})
})
describe('parseLine', () => {
it('parses a 4-field CSV frame', () => {
expect(parseLine('0.10,0.00,0.98,42')).toEqual({ ax: 0.1, ay: 0, az: 0.98, db: 42, t: 0 })
})
it('tolerates surrounding whitespace and trailing newline', () => {
expect(parseLine(' 1,2,3,4 \r')).toEqual({ ax: 1, ay: 2, az: 3, db: 4, t: 0 })
})
it('returns null for junk, partial, or non-numeric lines', () => {
expect(parseLine('garbage')).toBeNull()
expect(parseLine('1,2,3')).toBeNull()
expect(parseLine('1,2,3,x')).toBeNull()
expect(parseLine('')).toBeNull()
})
})
describe('classifyFrame', () => {
const h = { thresholdG: 0.8, thresholdDb: 65 }
const frame = (ax: number, db: number) => ({ ax, ay: 0, az: 0, db, t: 0 })
it('is nominal below both thresholds', () => {
expect(classifyFrame(frame(0.2, 40), h)).toBe('nominal')
})
it('is anomalous when only the g-magnitude exceeds threshold', () => {
expect(classifyFrame(frame(1.5, 40), h)).toBe('anomalous')
})
it('is anomalous when only the dB level exceeds threshold', () => {
expect(classifyFrame(frame(0.2, 80), h)).toBe('anomalous')
})
it('is critical when both g-magnitude and dB exceed their thresholds', () => {
expect(classifyFrame(frame(1.5, 80), h)).toBe('critical')
})
})
describe('requestPort (mock fallback)', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('returns a mocked connection when Web Serial is unsupported', async () => {
const conn = await requestPort()
expect(conn.mocked).toBe(true)
expect(conn.port).toBe('mock-serial://uno-q')
await conn.close()
})
it('emits synthetic frames on a timer and stops on unsubscribe/close', async () => {
const conn = await requestPort()
const seen: number[] = []
const unsub = conn.onFrame((f) => seen.push(f.t))
await vi.advanceTimersByTimeAsync(1200)
expect(seen.length).toBeGreaterThan(0)
const afterUnsub = seen.length
unsub()
await vi.advanceTimersByTimeAsync(1200)
expect(seen.length).toBe(afterUnsub)
await conn.close()
})
})
-181
View File
@@ -1,181 +0,0 @@
// USB bridge to the team's Arduino Uno Q, with a deterministic mock fallback so
// the workshop flow + tests run without hardware (or in browsers that lack the
// Web Serial API). Modules 1 & 2 consume `requestPort` via the `useSerial` hook;
// the classifier is pure so it is trivially unit-tested.
export interface ImuFrame {
ax: number
ay: number
az: number
db: number
t: number
}
export type SerialEvent = 'nominal' | 'anomalous' | 'critical'
export interface HarnessThresholds {
thresholdG: number
thresholdDb: number
}
export interface SerialConn {
port: string
mocked: boolean
onFrame(cb: (f: ImuFrame) => void): () => void
close(): Promise<void>
}
const MOCK_PORT = 'mock-serial://uno-q'
const BAUD_RATE = 115200
export function serialSupported(): boolean {
return typeof navigator !== 'undefined' && 'serial' in navigator
}
/** Parse one line of the firmware wire format: `ax,ay,az,db` (CSV). */
export function parseLine(line: string): ImuFrame | null {
const parts = line.trim().split(',')
if (parts.length !== 4) return null
const nums = parts.map((p) => Number(p.trim()))
if (nums.some((n) => !Number.isFinite(n))) return null
const [ax, ay, az, db] = nums
return { ax, ay, az, db, t: 0 }
}
/**
* Map a frame to a verdict against the tuned harness.
* Rule (pending firmware sign-off): magnitude is the raw acceleration vector
* length; critical requires BOTH the g-magnitude and the dB level to exceed
* their thresholds, anomalous requires either, otherwise nominal.
*/
export function classifyFrame(f: ImuFrame, h: HarnessThresholds): SerialEvent {
const mag = Math.sqrt(f.ax * f.ax + f.ay * f.ay + f.az * f.az)
const overG = mag > h.thresholdG
const overDb = f.db > h.thresholdDb
if (overG && overDb) return 'critical'
if (overG || overDb) return 'anomalous'
return 'nominal'
}
interface MockOptions {
intervalMs?: number
// deterministic synthetic frame generator keyed by tick count
frameAt?: (tick: number) => ImuFrame
}
function createMockConn(opts: MockOptions = {}): SerialConn {
const intervalMs = opts.intervalMs ?? 500
const frameAt =
opts.frameAt ??
((tick: number): ImuFrame => {
// Frames are gravity-compensated (linear) acceleration, so rest sits near
// zero. Mostly-nominal wander with a periodic event every 7th tick — a
// lively-but-deterministic demo stream when no board is attached.
const spike = tick % 7 === 0
const wobble = Math.sin(tick / 3) * 0.08
return {
ax: spike ? 1.4 : 0.06 + wobble,
ay: spike ? 0.5 : wobble / 2,
az: spike ? 0.3 : 0.04 + wobble / 4,
db: spike ? 72 : 40 + (tick % 4),
t: tick,
}
})
const subs = new Set<(f: ImuFrame) => void>()
let tick = 0
const timer = setInterval(() => {
const f = frameAt(++tick)
subs.forEach((cb) => cb(f))
}, intervalMs)
return {
port: MOCK_PORT,
mocked: true,
onFrame(cb) {
subs.add(cb)
return () => subs.delete(cb)
},
async close() {
clearInterval(timer)
subs.clear()
},
}
}
async function createRealConn(): Promise<SerialConn> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serial = (navigator as any).serial
const port = await serial.requestPort()
await port.open({ baudRate: BAUD_RATE })
const subs = new Set<(f: ImuFrame) => void>()
let closed = false
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let reader: any = null
const pump = async () => {
const decoder = new TextDecoderStream()
const readable = port.readable.pipeThrough(decoder)
reader = readable.getReader()
let buf = ''
try {
while (!closed) {
const { value, done } = await reader.read()
if (done) break
buf += value
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const frame = parseLine(line)
if (frame) {
frame.t = Date.now()
subs.forEach((cb) => cb(frame))
}
}
}
} catch {
// read loop ended (port closed / unplugged) — surfaced via close()
}
}
void pump()
return {
port: typeof port.getInfo === 'function' ? `usb-serial://uno-q` : 'usb-serial',
mocked: false,
onFrame(cb) {
subs.add(cb)
return () => subs.delete(cb)
},
async close() {
closed = true
subs.clear()
try {
await reader?.cancel()
} catch {
/* ignore */
}
try {
await port.close()
} catch {
/* ignore */
}
},
}
}
/**
* Open a serial connection. Uses the real Web Serial API when available
* (prompts the user to pick the board), otherwise returns a deterministic
* mock connection so tests and unsupported browsers still drive the flow.
*/
export async function requestPort(opts?: MockOptions): Promise<SerialConn> {
if (!serialSupported()) return createMockConn(opts)
try {
return await createRealConn()
} catch {
// user cancelled the port picker or open failed → fall back to mock
return createMockConn(opts)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { makeSubmissionCode } from './submission'
import type { Team, AddLayers } from '@/store/session' import type { Team, AddLayers } from '@/store/session'
const team: Team = { name: 'team_resonance', members: ['A', 'B'], kit: 'KIT-07' } const team: Team = { name: 'team_resonance', members: ['A', 'B'], kit: 'KIT-07' }
const add: AddLayers = { L1: { goal: 'x' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' } const add: AddLayers = { L1: 'one', L2: 'two', L3: 'three', L4: 'four', L5: 'five' }
describe('makeSubmissionCode', () => { describe('makeSubmissionCode', () => {
it('is prefixed with the team kit', () => { it('is prefixed with the team kit', () => {
+1
View File
@@ -12,6 +12,7 @@ export function projectSnapshot(s: SessionState): TeamSnapshot {
name: s.team.name, name: s.team.name,
kit: s.team.kit, kit: s.team.kit,
members: s.team.members, members: s.team.members,
domain: s.domain,
phases: s.phases, phases: s.phases,
stats: s.stats, stats: s.stats,
deviceConnected: s.device.connected, deviceConnected: s.device.connected,
-45
View File
@@ -1,45 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useSerial } from './useSerial'
import { useSession } from '@/store/session'
describe('useSerial', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
vi.useFakeTimers()
})
afterEach(() => vi.useRealTimers())
it('connects via the mock fallback and reports mocked state', async () => {
const { result } = renderHook(() => useSerial())
expect(result.current.supported).toBe(false)
await act(async () => {
await result.current.connect()
})
expect(result.current.connected).toBe(true)
expect(result.current.mocked).toBe(true)
})
it('streams frames into stats via recordEvent', async () => {
const { result } = renderHook(() => useSerial())
await act(async () => {
await result.current.connect()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
expect(useSession.getState().stats.calls).toBeGreaterThan(0)
expect(result.current.last).not.toBeNull()
})
it('inject() classifies against the live tuned harness', async () => {
const { result } = renderHook(() => useSerial())
useSession.getState().setHarness({ thresholdG: 0.5, thresholdDb: 50 })
act(() => {
result.current.inject({ ax: 2, ay: 0, az: 0, db: 90, t: 1 })
})
expect(useSession.getState().stats.critical).toBe(1)
expect(useSession.getState().stats.calls).toBe(1)
})
})
-84
View File
@@ -1,84 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSession } from '@/store/session'
import {
requestPort,
classifyFrame,
serialSupported,
type SerialConn,
type ImuFrame,
} from './serial'
const FRAME_BUFFER = 50
export interface UseSerial {
supported: boolean
connected: boolean
mocked: boolean
last: ImuFrame | null
frames: ImuFrame[]
connect: () => Promise<SerialConn>
disconnect: () => Promise<void>
/** push a synthetic frame through the same classify→record path (test triggers) */
inject: (f: ImuFrame) => void
}
/**
* React binding over the serial bridge. Opens a connection on demand, keeps a
* rolling frame buffer, and records each frame's verdict into the session
* `stats` using the *live* harness thresholds (read at emit time, so Module 2's
* tuning takes effect immediately). Pages consume this; never `navigator.serial`.
*/
export function useSerial(): UseSerial {
const recordEvent = useSession((s) => s.recordEvent)
const connRef = useRef<SerialConn | null>(null)
const [connected, setConnected] = useState(false)
const [mocked, setMocked] = useState(false)
const [last, setLast] = useState<ImuFrame | null>(null)
const [frames, setFrames] = useState<ImuFrame[]>([])
const handleFrame = useCallback(
(f: ImuFrame) => {
setLast(f)
setFrames((prev) => [...prev.slice(-(FRAME_BUFFER - 1)), f])
// read the harness fresh so tuned thresholds apply without re-subscribing
recordEvent(classifyFrame(f, useSession.getState().harness))
},
[recordEvent],
)
const connect = useCallback(async () => {
if (connRef.current) return connRef.current
const conn = await requestPort()
connRef.current = conn
conn.onFrame(handleFrame)
setConnected(true)
setMocked(conn.mocked)
return conn
}, [handleFrame])
const disconnect = useCallback(async () => {
await connRef.current?.close()
connRef.current = null
setConnected(false)
}, [])
const inject = useCallback((f: ImuFrame) => handleFrame(f), [handleFrame])
useEffect(() => {
return () => {
void connRef.current?.close()
connRef.current = null
}
}, [])
return {
supported: serialSupported(),
connected,
mocked,
last,
frames,
connect,
disconnect,
inject,
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ function renderPage() {
function seedFullAdd() { function seedFullAdd() {
const s = useSession.getState() const s = useSession.getState()
s.setTeam({ name: 'team_resonance', members: ['A'], kit: 'KIT-03' }) s.setTeam({ name: 'team_resonance', members: ['A'], kit: 'KIT-03' })
s.setAddLayer('L1', { goal: 'stay safe' }) s.setAddLayer('L1', 'stay safe')
s.setAddLayer('L2', 'reason') s.setAddLayer('L2', 'reason')
s.setAddLayer('L3', 'act') s.setAddLayer('L3', 'act')
} }
@@ -29,7 +29,7 @@ describe('AddBuilder', () => {
it('renders the phase strip set to add and the heading', () => { it('renders the phase strip set to add and the heading', () => {
renderPage() renderPage()
expect(screen.getByRole('heading', { name: /ADD builder/i })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /harness.*loops/i })).toBeInTheDocument()
expect( expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="add"]'), screen.getByTestId('phase-strip').querySelector('[data-phase="add"]'),
).toHaveAttribute('data-state', 'active') ).toHaveAttribute('data-state', 'active')
+14 -11
View File
@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { AddLayerForm } from '@/components/AddLayerForm' import { AddLayerForm } from '@/components/AddLayerForm'
import { AddDocument } from '@/components/AddDocument' import { AddDocument } from '@/components/AddDocument'
import { OpenYourNode } from '@/components/OpenYourNode'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { makeSubmissionCode } from '@/lib/submission' import { makeSubmissionCode } from '@/lib/submission'
@@ -15,9 +16,8 @@ export function AddBuilder() {
const setSubmission = useSession((s) => s.setSubmission) const setSubmission = useSession((s) => s.setSubmission)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const l1 = add.L1 as { goal?: string } | null
const complete = const complete =
!!l1?.goal?.trim() && add.L1.trim().length > 0 &&
add.L2.trim().length > 0 && add.L2.trim().length > 0 &&
add.L3.trim().length > 0 && add.L3.trim().length > 0 &&
add.L4.trim().length > 0 && add.L4.trim().length > 0 &&
@@ -55,25 +55,28 @@ export function AddBuilder() {
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2"> <Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 5 of 5 · ~90 min · deadline 19:00 Phase 5 of 5 · ~90 min · deadline 19:00
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">ADD builder &amp; submit</h1> <h1 className="text-3xl font-bold tracking-tight">Module 3 · Harness, Loops &amp; submit</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Finish Layers 4 and 5, review the assembled Agent Design Document, export a PDF, and submit Finish Layers 4 and 5 how your node reasons and how it runs over time review the assembled
before the deadline. Agent Design Document, export a PDF, and submit before the deadline.
</p> </p>
<div className="mt-3">
<OpenYourNode variant="inline" />
</div>
</div> </div>
<div className="grid lg:grid-cols-2 gap-6 print:hidden"> <div className="grid lg:grid-cols-2 gap-6 print:hidden">
<AddLayerForm <AddLayerForm
layer="L4" layer="L4"
title="ADD · Layer 4 — Failure modes" title="ADD · Layer 4 — Harness (reasoning + tiering)"
description="Name a way the agent fails, and the degradation you designed for it." description="How it reasons — Claude via the Max token, on-board Qwen as the offline fallback — and when it escalates to a home hub, phone, or cloud."
placeholder="Sensor drift reads as calm → cross-check acoustic; stale frame → hold last critical, never assume nominal." placeholder="Reason locally with on-board Qwen; escalate ambiguous calls to Claude via the Max token; fall back to the home hub when offline."
/> />
<AddLayerForm <AddLayerForm
layer="L5" layer="L5"
title="ADD · Layer 5 — AI-native redesign" title="ADD · Layer 5 — Loops (autonomous cadence)"
description="Redraw the edge/cloud boundary on purpose, justified by your failure modes." description="How it monitors its domain over time — cron / heartbeat — and reports by exception."
placeholder="Keep the safety verdict on-device for outage survival; batch a cloud summary hourly." placeholder="Heartbeat every 30s; sample the IMU each minute; report only on anomaly; nightly cron summary."
/> />
</div> </div>
+57 -92
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react' import { render, screen, fireEvent, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
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'
@@ -20,7 +19,12 @@ function renderPage() {
) )
} }
describe('EnvSetup', () => { /** Connect a board with a live node URL — the common precondition. */
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl })
}
describe('EnvSetup — Meet your node', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
@@ -29,52 +33,23 @@ describe('EnvSetup', () => {
it('renders the phase strip set to setup and the heading', () => { it('renders the phase strip set to setup and the heading', () => {
renderPage() renderPage()
expect(screen.getByTestId('phase-strip')).toBeInTheDocument() expect(screen.getByTestId('phase-strip')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /environment setup/i })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /meet your node/i })).toBeInTheDocument()
expect( expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="setup"]'), screen.getByTestId('phase-strip').querySelector('[data-phase="setup"]'),
).toHaveAttribute('data-state', 'active') ).toHaveAttribute('data-state', 'active')
}) })
it('warns and disables Proceed when the device is 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.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument()
// say-hi is disabled with no device
expect(screen.getByRole('button', { name: /say hi \/ confirm online/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
it('writes the chosen provider to the harness', async () => { it('renders the Open-your-node CTA to the board url when connected with a nodeUrl', () => {
const user = userEvent.setup() connect('http://192.168.1.7:8080')
renderPage()
await user.click(screen.getByRole('button', { name: /groq/i }))
expect(useSession.getState().harness.provider).toBe('groq')
})
describe('self-test', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('passes once the board reports online, enabling Proceed without polluting stats', async () => {
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 })
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /run self-test/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
expect(screen.getByText(/self-test ok/i)).toBeInTheDocument()
expect(proceed).toBeEnabled()
// self-test frames must NOT be counted as workshop events
expect(useSession.getState().stats.calls).toBe(0)
})
})
it('shows an "Open your node" link to the board url when connected with a nodeUrl', () => {
useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl: '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')
@@ -82,72 +57,62 @@ describe('EnvSetup', () => {
expect(link).toHaveAttribute('rel', 'noopener noreferrer') expect(link).toHaveAttribute('rel', 'noopener noreferrer')
}) })
it('hides the "Open your node" link when there is no nodeUrl', () => { it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
useSession.getState().setMode('live') connect(null)
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl: null })
renderPage() renderPage()
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
}) })
it('keeps Proceed gated when connected but self-test has not run', () => { it('requires a domain even after the board is online', async () => {
useSession.getState().setMode('live') vi.useFakeTimers()
useSession.getState().setDevice({ connected: true, port: 'x', uptimeS: 0 }) try {
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
// online but no domain → still gated
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
} finally {
vi.useRealTimers()
}
})
it('keeps Proceed gated when a domain is named but the board is not confirmed online', () => {
connect()
useSession.getState().setDomain('air quality')
renderPage() renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
describe('run mode (live / simulation)', () => { describe('say hi / online confirmation', () => {
it('defaults to simulation and shows the virtual board', () => { beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('enables Proceed once online AND a domain is named, without polluting stats', async () => {
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
connect()
useSession.getState().setDomain('structural stress')
renderPage() renderPage()
expect(useSession.getState().mode).toBe('sim')
expect(screen.getByRole('button', { name: /simulation/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByTestId('device-sim')).toBeInTheDocument()
})
it('toggles to live board (device required) and back to simulation', async () => { const proceed = screen.getByRole('button', { name: /proceed/i })
const user = userEvent.setup() expect(proceed).toBeDisabled()
renderPage()
await user.click(screen.getByRole('button', { name: /live board/i }))
expect(useSession.getState().mode).toBe('live')
expect(screen.queryByTestId('device-sim')).not.toBeInTheDocument()
// no device in live mode → self-test disabled, Proceed gated
expect(screen.getByRole('button', { name: /run self-test/i })).toBeDisabled()
await user.click(screen.getByRole('button', { name: /simulation/i })) fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i }))
expect(useSession.getState().mode).toBe('sim') await act(async () => {
}) await vi.advanceTimersByTimeAsync(2000)
describe('with fake timers', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('runs a virtual self-test in simulation and enables Proceed with no hardware', async () => {
renderPage()
expect(useSession.getState().device.connected).toBe(false)
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /run self-test/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.getByText(/self-test ok/i)).toBeInTheDocument()
expect(proceed).toBeEnabled()
expect(useSession.getState().stats.calls).toBe(0)
}) })
it('invalidates a passed self-test when the mode changes', async () => { expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
renderPage() expect(proceed).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: /run self-test/i })) // confirmation polls must NOT be counted as workshop events
await act(async () => { expect(useSession.getState().stats.calls).toBe(0)
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.getByText(/self-test ok/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /live board/i }))
expect(screen.queryByText(/self-test ok/i)).not.toBeInTheDocument()
})
}) })
}) })
}) })
+101 -126
View File
@@ -4,14 +4,15 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { HarnessProviderSelect } from '@/components/HarnessProviderSelect' import { OpenYourNode } from '@/components/OpenYourNode'
import { useSession, type RunMode } from '@/store/session' import { DomainPicker } from '@/components/DomainPicker'
import { useSession } from '@/store/session'
import { getNodeStatus } from '@/lib/api' import { getNodeStatus } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type SelfTest = 'idle' | 'running' | 'ok' type SelfTest = 'idle' | 'running' | 'ok'
/** Live self-test: poll the claimed board's liveness this many times. */ /** Say-hi confirmation: poll the claimed board's liveness this many times. */
const SELFTEST_POLLS = 12 const SELFTEST_POLLS = 12
const SELFTEST_POLL_MS = 500 const SELFTEST_POLL_MS = 500
@@ -19,29 +20,13 @@ export function EnvSetup() {
const navigate = useNavigate() const navigate = useNavigate()
const teamId = useSession((s) => s.teamId) const teamId = useSession((s) => s.teamId)
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const mode = useSession((s) => s.mode) const domain = useSession((s) => s.domain)
const setMode = useSession((s) => s.setMode)
const harness = useSession((s) => s.harness)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const [selfTest, setSelfTest] = useState<SelfTest>('idle') const [selfTest, setSelfTest] = useState<SelfTest>('idle')
// Switching mode invalidates any prior self-test result.
const onSetMode = (m: RunMode) => {
if (m !== mode) {
setMode(m)
setSelfTest('idle')
}
}
const runSelfTest = async () => { const runSelfTest = async () => {
setSelfTest('running') setSelfTest('running')
if (mode === 'sim') { // Confirm the claimed board is reachable and online.
// Simulated sense path — no hardware required.
await new Promise((r) => setTimeout(r, 400))
setSelfTest('ok')
return
}
// Live: confirm the claimed board is reachable and online.
for (let i = 0; i < SELFTEST_POLLS; i++) { for (let i = 0; i < SELFTEST_POLLS; i++) {
try { try {
const s = await getNodeStatus(teamId) const s = await getNodeStatus(teamId)
@@ -57,8 +42,7 @@ export function EnvSetup() {
setSelfTest('idle') // couldn't confirm — let them retry setSelfTest('idle') // couldn't confirm — let them retry
} }
const deviceReady = mode === 'sim' || device.connected const ready = device.connected && selfTest === 'ok' && domain.trim().length > 0
const ready = deviceReady && selfTest === 'ok' && !!harness.provider && !!harness.model
const onProceed = () => { const onProceed = () => {
completePhase('setup') completePhase('setup')
@@ -79,121 +63,112 @@ export function EnvSetup() {
<PhaseStrip active="setup" /> <PhaseStrip active="setup" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6"> <section className="px-8 py-10 max-w-3xl mx-auto space-y-6">
<div> <div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2"> <Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 2 of 5 · ~15 min Phase 2 of 5 · ~15 min
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Environment setup</h1> <h1 className="text-3xl font-bold tracking-tight">Meet your node</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Confirm the board is alive, prove the sense path with a self-test, and choose the reasoning Your board is a node running its own agent. Open it, say hi, name the domain it&rsquo;s
provider your agent will escalate to. Then you are clear for Module 1. for then you&rsquo;re clear for Module 1.
</p> </p>
</div> </div>
<div className="grid lg:grid-cols-2 gap-6"> {/* 1 — Open your node (hero) */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Board &amp; sense path</CardTitle> <CardTitle className="text-base">Open your node</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent>
<div className="space-y-2"> <OpenYourNode variant="hero" />
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Run mode</div> </CardContent>
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Run mode"> </Card>
{(['sim', 'live'] as RunMode[]).map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => onSetMode(m)}
className={cn(
'font-mono text-xs px-2 py-2 rounded-md border transition',
mode === m
? 'bg-primary text-primary-foreground border-primary'
: 'bg-card text-muted-foreground border-border hover:border-primary/40',
)}
>
{m === 'sim' ? 'Simulation' : 'Live board'}
</button>
))}
</div>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
{mode === 'sim'
? 'No hardware needed — a virtual board runs the sense path and agent.'
: 'Drives your teams Arduino Uno Q through its on-board ZeroClaw agent.'}
</p>
</div>
<div className="space-y-2"> {/* 2 — Say hi to your agent */}
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Device</div> <Card>
{mode === 'sim' ? ( <CardHeader>
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="device-sim"> <CardTitle className="text-base">Say hi to your agent</CardTitle>
<div className="flex items-center gap-2"> </CardHeader>
<span className="w-2 h-2 rounded-full bg-teal" /> <CardContent className="space-y-4">
<span className="text-sm font-medium">Simulation ready</span> <p className="text-sm text-muted-foreground leading-relaxed">
</div> Open your node&rsquo;s dashboard and say hi it introduces itself and lists the skills
<div className="font-mono text-[10px] text-muted-foreground mt-1">virtual board · no hardware</div> it already has. Then confirm it&rsquo;s online here.
</div> </p>
) : device.connected ? ( <Button
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3"> variant="outline"
<div className="flex items-center gap-2"> className="w-full justify-start"
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" /> disabled={!device.connected || selfTest === 'running'}
<span className="text-sm font-medium">Connected</span> onClick={runSelfTest}
</div> >
<div className="font-mono text-[10px] text-muted-foreground mt-1">{device.port}</div> <span
{device.nodeUrl && ( className={cn(
<a 'w-2 h-2 rounded-full mr-3',
href={device.nodeUrl} selfTest === 'ok' ? 'bg-teal' : selfTest === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-[10px] text-primary hover:underline mt-2"
>
Open your node
</a>
)}
</div>
) : (
<div className="border border-amber/40 bg-amber/5 rounded-md px-4 py-3 text-sm">
No device. Go back to{' '}
<Link to="/workshop" className="underline">team registration</Link> and connect the board,
or switch to Simulation.
</div>
)} )}
</div> />
{selfTest === 'ok' ? 'Online ✓' : selfTest === 'running' ? 'Checking…' : 'Say hi / confirm online'}
</Button>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Confirms the board is reachable and live. Does not count toward your session stats.
</p>
</CardContent>
</Card>
<div className="space-y-2"> {/* 3 — Pick your domain */}
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Self-test</div> <Card>
<Button <CardHeader>
variant="outline" <CardTitle className="text-base">Pick your domain</CardTitle>
className="w-full justify-start" </CardHeader>
disabled={!deviceReady || selfTest === 'running'} <CardContent>
onClick={runSelfTest} <DomainPicker />
> </CardContent>
<span </Card>
className={cn(
'w-2 h-2 rounded-full mr-3',
selfTest === 'ok' ? 'bg-teal' : selfTest === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)}
/>
{selfTest === 'ok' ? 'Self-test OK' : selfTest === 'running' ? 'Reading frames…' : 'Run self-test'}
</Button>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Reads a few live frames to confirm the sense path before Module 1. Does not count toward your
session stats.
</p>
</div>
</CardContent>
</Card>
<Card> {/* 4 — Extra channels (optional) */}
<CardHeader> <Card>
<CardTitle className="text-base">Reasoning harness</CardTitle> <CardHeader>
</CardHeader> <CardTitle className="text-base">
<CardContent> Extra channels <span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
<HarnessProviderSelect /> </CardTitle>
</CardContent> </CardHeader>
</Card> <CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
</div> <p>
Your dashboard is the main way in, but you can reach your node other ways too:
</p>
<ul className="space-y-2">
<li>
<span className="font-medium text-foreground">Telegram</span> make a bot with{' '}
<span className="font-mono text-xs">@BotFather</span>, then paste its token in the node
dashboard Config channels.
</li>
<li>
<span className="font-medium text-foreground">Voice</span> use the mic button in the
node dashboard to talk to it directly.
</li>
</ul>
</CardContent>
</Card>
{/* 5 — Lock it down (policy moment) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lock it down</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Your board boots <span className="font-medium text-foreground">open</span> so setup is
frictionless anyone on the LAN can reach it right now. That&rsquo;s your first{' '}
<span className="font-medium text-foreground">policy</span> decision: when setup is done,
harden it so only your group can talk to it.
</p>
<p>
Run <span className="font-mono text-xs">zeroclaw-lockdown.sh</span> on the board it mints
a pair code your group uses to reconnect. Leave it open for now; you&rsquo;ll revisit this
once you&rsquo;ve designed the agent&rsquo;s policies.
</p>
</CardContent>
</Card>
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}> <Button size="lg" disabled={!ready} onClick={onProceed}>
+4 -4
View File
@@ -20,7 +20,7 @@ const dto: SubmissionDTO = {
teamId: 'a', teamId: 'a',
teamName: 'Alpha', teamName: 'Alpha',
code: 'KIT-01-AAA', code: 'KIT-01-AAA',
add: { L1: { goal: 'g' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' }, add: { L1: 'one', L2: 'two', L3: 'three', L4: 'four', L5: 'five' },
submittedAt: 'x', submittedAt: 'x',
} }
@@ -73,9 +73,9 @@ describe('Judge', () => {
await user.click(within(screen.getByTestId('submission-list')).getByText('Alpha')) await user.click(within(screen.getByTestId('submission-list')).getByText('Alpha'))
await waitFor(() => expect(screen.getByTestId('score-form')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('score-form')).toBeInTheDocument())
const perception = screen.getByLabelText(/perception/i) const domain = screen.getByLabelText(/domain fit/i)
await user.clear(perception) await user.clear(domain)
await user.type(perception, '7') await user.type(domain, '7')
await user.click(screen.getByRole('button', { name: /submit score/i })) await user.click(screen.getByRole('button', { name: /submit score/i }))
await waitFor(() => expect(api.postScore).toHaveBeenCalled()) await waitFor(() => expect(api.postScore).toHaveBeenCalled())
+1 -1
View File
@@ -14,7 +14,7 @@ function renderLanding() {
describe('Landing', () => { describe('Landing', () => {
it('renders the workshop headline and date', () => { it('renders the workshop headline and date', () => {
renderLanding() renderLanding()
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/on-device agentic systems/i) expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/design a domain node/i)
expect(screen.getByText(/FabLab Torino/i)).toBeInTheDocument() expect(screen.getByText(/FabLab Torino/i)).toBeInTheDocument()
expect(screen.getByText(/July 27, 2026/i)).toBeInTheDocument() expect(screen.getByText(/July 27, 2026/i)).toBeInTheDocument()
}) })
+12 -11
View File
@@ -12,17 +12,17 @@ interface ProgrammeRow {
const PROGRAMME: ProgrammeRow[] = [ const PROGRAMME: ProgrammeRow[] = [
{ time: '13:00', title: 'Arrival & kit pickup', desc: 'Teams collect Arduino Uno Q boards + sensor kits.', tags: ['setup'] }, { time: '13:00', title: 'Arrival & kit pickup', desc: 'Teams collect Arduino Uno Q boards + sensor kits.', tags: ['setup'] },
{ time: '14:00', title: 'Lecture · 5 movements', desc: 'Agency, perception loop, ZeroClaw, failure modes, edge vs cloud.', tags: ['lecture'] }, { time: '14:00', title: 'Lecture · 5 movements', desc: 'Domain & events, skills, policies, harness, loops.', tags: ['lecture'] },
{ time: '15:00', title: 'Module 1 · Sense → Reason', desc: 'Live IMU feed, actor map, Layer-1 ADD draft.', tags: ['build'] }, { time: '15:00', title: 'Module 1 · Domain & Skills', desc: 'Pick a domain, name its events, draft the skill library — Layers 1 + 2.', tags: ['build'] },
{ time: '16:15', title: 'Module 2 · Harness engineering', desc: 'Tune harness.toml, fire test triggers, Layers 2 + 3.', tags: ['build'] }, { time: '16:15', title: 'Module 2 · Policies & Harness', desc: 'Set the actuation gate, tier the reasoning, talk to your node — Layers 3 + 4.', tags: ['build'] },
{ time: '17:45', title: 'ADD builder & submit', desc: 'Failure mode + AI-native redesign, PDF export, submission.', tags: ['add'] }, { time: '17:45', title: 'ADD builder & submit', desc: 'Design the autonomous loops, PDF export, submission — Layer 5.', tags: ['add'] },
{ time: '19:00', title: 'Judging & award', desc: 'Demartino panel reviews ADDs; RedClaw Systems award announced.', tags: ['judge'] }, { time: '19:00', title: 'Judging & award', desc: 'Demartino panel reviews ADDs; RedClaw Systems award announced.', tags: ['judge'] },
] ]
const STACK = [ const STACK = [
{ icon: '⚙', name: 'Rust on the Uno Q', desc: 'ZeroClaw runs on the Uno Qs quad-core Linux side and flashes its on-board STM32 MCU.' }, { icon: '⚙', name: 'Rust on the Uno Q', desc: 'ZeroClaw runs on the Uno Qs quad-core Linux side and self-flashes its on-board STM32 MCU.' },
{ icon: '⌬', name: 'Local + cloud reasoning', desc: 'An on-board Qwen model decides locally; ambiguous calls escalate to a per-team rate-limited cloud proxy.' }, { icon: '⌬', name: 'Claude on the edge', desc: 'Each node reasons with Claude via a Max token, with an on-board Qwen model as an offline fallback.' },
{ icon: '⚡', name: 'On-board agent', desc: 'Each board hosts its own ZeroClaw agent and local LLM — decisions happen on the device, offline-capable.' }, { icon: '⚡', name: 'Talk to your node', desc: 'Load expert skills, then converse with the board through its own dashboard, Telegram, or voice.' },
] ]
const PREREQS = [ const PREREQS = [
@@ -75,13 +75,14 @@ export function Landing() {
FabLab Torino · July 27, 2026 FabLab Torino · July 27, 2026
</Badge> </Badge>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight leading-[1.05]"> <h1 className="text-5xl md:text-6xl font-bold tracking-tight leading-[1.05]">
On-device agentic systems Design a domain node
<br /> <br />
<span className="text-primary">for structural intelligence</span> <span className="text-primary">a Claude agent on the edge</span>
</h1> </h1>
<p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto"> <p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
Build a ZeroClaw agent that senses, reasons, and responds autonomously on an Arduino Uno Q. Pick a domain, then engineer the skills, policies, harness, and loops of a Claude-powered ZeroClaw node
Complete a five-layer Agent Design Document in a single 5.5-hour session. on an Arduino Uno Q one you talk to and one that works on its own. Complete a five-layer Agent Design
Document in a single 5.5-hour session.
</p> </p>
<div className="flex flex-wrap gap-3 justify-center pt-4"> <div className="flex flex-wrap gap-3 justify-center pt-4">
<Button asChild size="lg"> <Button asChild size="lg">
+6 -6
View File
@@ -28,18 +28,18 @@ describe('Lecture', () => {
.getAllByRole('heading', { level: 2 }) .getAllByRole('heading', { level: 2 })
.map((h) => h.textContent) .map((h) => h.textContent)
expect(titles).toEqual([ expect(titles).toEqual([
'Agency', 'Domain & events',
'The perception loop', 'Skills',
'ZeroClaw', 'Policies',
'Failure modes', 'Harness',
'Edge vs cloud', 'Loops',
]) ])
}) })
it('orders the movements 1 through 5 by slug', () => { it('orders the movements 1 through 5 by slug', () => {
renderLecture() renderLecture()
const slugs = screen.getAllByTestId('movement').map((el) => el.getAttribute('data-movement')) const slugs = screen.getAllByTestId('movement').map((el) => el.getAttribute('data-movement'))
expect(slugs).toEqual(['agency', 'perception', 'zeroclaw', 'failure-modes', 'edge-vs-cloud']) expect(slugs).toEqual(['domain', 'skills', 'policies', 'harness', 'loops'])
}) })
it('links back to landing and into the workshop', () => { it('links back to landing and into the workshop', () => {
+50 -50
View File
@@ -16,81 +16,81 @@ interface Movement {
const MOVEMENTS: Movement[] = [ const MOVEMENTS: Movement[] = [
{ {
n: 1, n: 1,
id: 'agency', id: 'domain',
title: 'Agency', title: 'Domain & events',
thesis: 'An agent is a system that closes the loop between sensing the world and acting on it — without a human in the middle.', thesis: 'An agent is a system that closes the loop between sensing the world and acting on it — without a human in the middle. First you choose the world.',
body: [ body: [
'Most embedded software is reactive plumbing: read a sensor, threshold it, toggle a pin. An agent is different in kind, not degree — it holds a goal, forms a belief about its environment, and chooses an action it expects to advance that goal.', 'Most embedded software is reactive plumbing: read a sensor, threshold it, toggle a pin. An agent is different in kind, not degree — it holds a goal, forms a belief about its environment, and chooses an action it expects to advance that goal.',
'Today you build the smallest honest version of that: a board that decides, on its own, whether a structure is behaving nominally, anomalously, or critically — and is accountable for the call it makes.', 'Today you design a domain node: pick a domain — a workshop, a greenhouse, a stairwell, a bike — and name the events it must notice and answer for. The board becomes an expert in that world, and everything downstream — its skills, its policies, its cadence — is justified by the events you name here.',
], ],
takeaways: [ takeaways: [
'Agency = goal + perception + decision + action, closed in a loop', 'Agency = goal + perception + decision + action, closed in a loop',
'The interesting engineering is in the decision, not the wiring', 'Pick a domain, then name the events that matter in it',
'Autonomy is a spectrum; pick the least you need to be useful', 'The node is only as good as the world it is accountable for',
], ],
tags: ['concept'], tags: ['concept'],
}, },
{ {
n: 2, n: 2,
id: 'perception', id: 'skills',
title: 'The perception loop', title: 'Skills',
thesis: 'Sense → reason → act, repeated fast enough that the world has not changed underneath you.', thesis: 'A skill is expertise the node can load on demand — a SKILL.md and the references it points to.',
body: [ body: [
'The loop is the heartbeat of the agent. Sense produces a frame — here, IMU acceleration and an acoustic level. Reason maps that frame to a verdict against your tuned thresholds. Act emits the verdict and, where wired, drives an output.', 'The board is a Claude-powered ZeroClaw agent, not a threshold table. You make it an expert by writing skills: each is a SKILL.md that tells the agent when the skill applies and how to act, plus the reference material it can pull in when it does. Good skills are the difference between a chatbot and a node that actually knows your domain.',
'Loop rate is a design parameter, not an afterthought. Too slow and you miss the event; too fast and you drown the reasoner in noise and burn your call budget. You will feel this tension directly when you tune the harness.', 'You talk to the node — through its own dashboard, Telegram, or voice — and it reaches for the right skill for the situation. Designing that library, and the boundaries between skills, is the core of Layer 2.',
], ],
takeaways: [ takeaways: [
'A frame is the unit of perception — keep it small and typed', 'A skill = SKILL.md (when + how) + the references it cites',
'Latency budget = sense + reason + act must beat the event', 'Load expertise on demand; do not stuff it all in one prompt',
'Rate-limit reasoning deliberately; more calls is not more intelligence', 'You converse with the node — it selects the skill to fit the moment',
], ],
tags: ['concept', 'build'], tags: ['concept', 'build'],
}, },
{ {
n: 3, n: 3,
id: 'zeroclaw', id: 'policies',
title: 'ZeroClaw', title: 'Policies',
thesis: 'A Rust agent runtime that lives on the Uno Qs Linux side, hosts a local model, and routes reasoning off-device only when it must.', thesis: 'Policies are the actuation gate — what the node is allowed to do on its own, and what it must ask about first.',
body: [ body: [
'ZeroClaw runs on the Uno Qs quad-core Cortex-A53 Linux side, hosting a local Qwen model and driving the on-board STM32U585 MCU it flashes over SWD. It owns the perception loop on-device, classifies frames locally against your harness, and escalates only the ambiguous cases to a rate-limited cloud proxy.', 'Reasoning is cheap to imagine and expensive to get wrong once it drives hardware. A policy is the rule that stands between a decision and an action: this actuation is autonomous, that one needs confirmation, this one is never permitted. The node can self-flash sketches to its own MCU — policies decide when that is allowed.',
'This is the edge-agent pattern in miniature: cheap, fast, private decisions stay local; expensive judgement is borrowed sparingly. The harness you tune today is the contract between those two worlds.', 'Layer 3 asks you to write those rules explicitly. The grade is in the honesty of the gate: an agent that can do anything is not trustworthy, and an agent that can do nothing is not useful.',
], ],
takeaways: [ takeaways: [
'On-device first: classify locally, escalate the unsure', 'A policy gates actuation: autonomous, confirm-first, or forbidden',
'The harness is the local/remote contract', 'Write the gate before you hand the node a lever',
'Small binaries are a feature — they fit where the structure is', 'Trust comes from the boundary, not from raw capability',
],
tags: ['hardware'],
},
{
n: 4,
id: 'failure-modes',
title: 'Failure modes',
thesis: 'An autonomous system is defined by how it fails, not how it succeeds on a good day.',
body: [
'Sensors drift, links drop, models hallucinate, and budgets run dry mid-event. A serious agent has a defined behaviour for each: a stale frame is not a calm frame, a dropped link is not a clean bill of health, an exhausted call budget falls back to the local verdict rather than going silent.',
'Layer 4 of your Agent Design Document is exactly this exercise — name the failure, then design the degradation. The grade is in the honesty of that analysis.',
],
takeaways: [
'Absence of signal is information — never read it as "fine"',
'Every dependency needs a defined degradation path',
'Design the fallback before the happy path ships',
], ],
tags: ['concept'], tags: ['concept'],
}, },
{ {
n: 5, n: 4,
id: 'edge-vs-cloud', id: 'harness',
title: 'Edge vs cloud', title: 'Harness',
thesis: 'The question is never edge or cloud — it is which decision belongs where.', thesis: 'The harness is how the node reasons — and where each decision runs: on the board, on a hub, on a phone, or in the cloud.',
body: [ body: [
'Latency, privacy, cost, and availability pull the boundary in different directions. A structural-safety call that must survive a network outage belongs on the edge; a once-an-hour summary that benefits from a large model belongs in the cloud.', 'The node reasons with Claude via a Max token, with an on-board Qwen model as an offline fallback. The harness is the design of that reasoning: how prompts are assembled, and how work is tiered across the board, a room hub, a paired phone, and the cloud by latency, privacy, cost, and availability.',
'The AI-native redesign in Layer 5 asks you to redraw that boundary on purpose, justified by the failure modes you just named. That is the whole craft: placing intelligence where it is accountable.', 'A call that must survive a network outage belongs on the edge; a once-an-hour summary that benefits from the largest model belongs in the cloud. Layer 4 is where you draw that tiering on purpose, justified by the events and policies you already named.',
], ],
takeaways: [ takeaways: [
'Place each decision by its latency, privacy, cost, and availability', 'Claude via a Max token; on-board Qwen is the offline fallback',
'Survive-the-outage decisions live at the edge', 'Tier each decision across board, hub, phone, cloud',
'A good architecture is a defensible boundary, not a default', 'Survive-the-outage reasoning lives at the edge',
],
tags: ['concept', 'build'],
},
{
n: 5,
id: 'loops',
title: 'Loops',
thesis: 'A node earns its keep when it acts without being asked — on a cadence you design.',
body: [
'Talking to the node is one mode; the other is autonomy. Loops are the node running on its own schedule — a cron sweep, a heartbeat check — so it notices and answers events while no one is watching. The cadence is a design parameter: too slow and you miss the event, too fast and you burn budget on nothing.',
'Layer 5 asks you to set that rhythm deliberately, justified by the failure modes it must catch. That is the whole craft: an autonomous node that is accountable for what it does between conversations.',
],
takeaways: [
'Loops = autonomous cadence, driven by cron or a heartbeat',
'Set the rhythm to the event, not to a default',
'The node must be accountable between conversations, not only during them',
], ],
tags: ['concept', 'build'], tags: ['concept', 'build'],
}, },
@@ -120,13 +120,13 @@ export function Lecture() {
Lecture · ~60 min Lecture · ~60 min
</Badge> </Badge>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight leading-[1.05]"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight leading-[1.05]">
On-device agency Design a domain node
<br /> <br />
<span className="text-primary">in five movements</span> <span className="text-primary">in five movements</span>
</h1> </h1>
<p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto"> <p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
The conceptual spine of the workshop from what an agent is, through the perception loop and the The conceptual spine of the workshop from choosing a domain, through the skills and policies that
ZeroClaw runtime, to how these systems fail and where their intelligence should live. make the node an expert, to the harness it reasons with and the loops that keep it working on its own.
</p> </p>
</div> </div>
</section> </section>
+24 -55
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { render, screen, act, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { Module1 } from './Module1' import { Module1 } from './Module1'
@@ -32,76 +32,45 @@ describe('Module1', () => {
it('renders the phase strip set to m1 and the heading', () => { it('renders the phase strip set to m1 and the heading', () => {
renderPage() renderPage()
expect(screen.getByRole('heading', { name: /sense.*reason/i })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /domain.*events/i })).toBeInTheDocument()
expect( expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="m1"]'), screen.getByTestId('phase-strip').querySelector('[data-phase="m1"]'),
).toHaveAttribute('data-state', 'active') ).toHaveAttribute('data-state', 'active')
}) })
it('renders the actor map and a live feed', () => { it('renders the actor map and the live board feed', () => {
renderPage() renderPage()
expect(screen.getByTestId('actor-map')).toBeInTheDocument() expect(screen.getByTestId('actor-map')).toBeInTheDocument()
expect(screen.getByTestId('live-feed')).toBeInTheDocument() expect(screen.getByTestId('live-board-feed')).toBeInTheDocument()
}) })
it('gates Proceed until frames are observed and L1 is filled', async () => { it('streams real board activity into the feed', async () => {
renderPage()
act(() =>
emit({ type: 'node:activity', teamId: 'x', kind: 'flash', label: 'Flashing sketch to the MCU', ts: '' }),
)
await waitFor(() =>
expect(screen.getByTestId('live-board-activity')).toHaveTextContent(/flashing sketch/i),
)
})
it('gates Proceed until the board is online and L1 is filled', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i }) const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
await user.type(screen.getByLabelText(/goal/i), 'keep the structure safe') await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
expect(proceed).toBeDisabled() // still no frames expect(proceed).toBeDisabled() // board not online yet
act(() => emit({ type: 'node:status', teamId: 'x', online: true }))
await waitFor(() => expect(proceed).toBeEnabled())
}) })
describe('with the feed running', () => { it('persists the Layer-1 domain text to the store as a string', async () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('streams frames into stats and enables Proceed when L1 is set', async () => {
useSession.getState().setAddLayer('L1', { goal: 'keep safe' })
renderPage()
fireEvent.click(screen.getByRole('button', { name: /start feed/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
expect(useSession.getState().stats.calls).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
})
})
describe('live mode (real node feed)', () => {
it('shows the live board feed instead of the sim IMU feed', async () => {
useSession.getState().setMode('live')
renderPage()
expect(await screen.findByTestId('live-board-feed')).toBeInTheDocument()
expect(screen.queryByTestId('live-feed')).toBeNull()
// real board activity streams in from the node
act(() =>
emit({ type: 'node:activity', teamId: 'x', kind: 'flash', label: 'Flashing sketch to the MCU', ts: '' }),
)
await waitFor(() =>
expect(screen.getByTestId('live-board-activity')).toHaveTextContent(/flashing sketch/i),
)
})
it('enables Proceed once the board is online and L1 is set', async () => {
useSession.getState().setMode('live')
useSession.getState().setAddLayer('L1', { goal: 'keep safe' })
renderPage()
const proceed = await screen.findByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
act(() => emit({ type: 'node:status', teamId: 'x', online: true }))
await waitFor(() => expect(proceed).toBeEnabled())
})
})
it('persists the Layer-1 goal to the store', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/goal/i), 'detect resonance') await user.type(screen.getByLabelText(/layer 1/i), 'detect resonance')
expect((useSession.getState().add.L1 as { goal: string }).goal).toBe('detect resonance') expect(useSession.getState().add.L1).toBe('detect resonance')
}) })
}) })
+36 -38
View File
@@ -2,30 +2,28 @@ import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { LiveFeed } from '@/components/LiveFeed'
import { LiveBoardFeed } from '@/components/LiveBoardFeed' import { LiveBoardFeed } from '@/components/LiveBoardFeed'
import { StatsTally } from '@/components/StatsTally'
import { ActorMap } from '@/components/ActorMap' import { ActorMap } from '@/components/ActorMap'
import { AddLayerForm } from '@/components/AddLayerForm' import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial' import { OpenYourNode } from '@/components/OpenYourNode'
import { useNodeFeed } from '@/lib/useNodeFeed' import { useNodeFeed } from '@/lib/useNodeFeed'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function Module1() { export function Module1() {
const navigate = useNavigate() const navigate = useNavigate()
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId) const teamId = useSession((s) => s.teamId)
const serial = useSerial() const feed = useNodeFeed(teamId, true)
const feed = useNodeFeed(teamId, mode === 'live') const l1 = useSession((s) => s.add.L1)
const stats = useSession((s) => s.stats) const domain = useSession((s) => s.domain)
const l1 = useSession((s) => s.add.L1) as { goal?: string } | null const setDomain = useSession((s) => s.setDomain)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
// Sim: a classified frame proves the loop. Live: the board's own loop runs // The board's own loop runs on-device, so an online board (or any activity
// on-device, so an online board (or any activity from it) is the proof. // from it) is the proof that the sense→reason loop is live.
const sensed = mode === 'live' ? feed.online || feed.activity.length > 0 : stats.calls > 0 const sensed = feed.online || feed.activity.length > 0
const ready = sensed && !!l1?.goal?.trim() const ready = sensed && l1.trim().length > 0
const onProceed = () => { const onProceed = () => {
completePhase('m1') completePhase('m1')
@@ -51,33 +49,37 @@ export function Module1() {
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2"> <Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 3 of 5 · ~75 min Phase 3 of 5 · ~75 min
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 1 · Sense Reason</h1> <h1 className="text-3xl font-bold tracking-tight">Module 1 · Domain &amp; events</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Watch the perception loop run live, then draft Layer 1 of your Agent Design Document what the Define the domain your node is for and the events it must sense and act on. You seeded the
agent senses, the actors it must track, and the goal it pursues. domain during setup refine it here, then draft Layer 1 of your Agent Design Document.
Open your node to explore what it already senses.
</p> </p>
</div> </div>
<div className="rounded-md border border-border bg-card px-5 py-4 space-y-3">
<div className="flex items-center justify-between gap-4">
<label htmlFor="domain-refine" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Your domain
</label>
<OpenYourNode variant="inline" />
</div>
<Input
id="domain-refine"
placeholder="e.g. structural resonance monitoring"
value={domain}
onChange={(e) => setDomain(e.target.value)}
className="font-mono text-sm"
/>
</div>
<div className="grid lg:grid-cols-2 gap-6"> <div className="grid lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader>
<CardTitle className="text-base">{mode === 'live' ? 'Live board feed' : 'Live IMU feed'}</CardTitle> <CardTitle className="text-base">Live board feed</CardTitle>
{mode === 'sim' &&
(!serial.connected ? (
<Button size="sm" onClick={() => void serial.connect()}>Start feed</Button>
) : (
<Button size="sm" variant="outline" onClick={() => void serial.disconnect()}>Stop</Button>
))}
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{mode === 'live' ? ( <LiveBoardFeed feed={feed} />
<LiveBoardFeed feed={feed} />
) : (
<>
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected} mocked={serial.mocked} />
<StatsTally />
</>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -93,13 +95,9 @@ export function Module1() {
<AddLayerForm <AddLayerForm
layer="L1" layer="L1"
title="ADD · Layer 1 — Perception &amp; goal" title="ADD · Layer 1 — Domain &amp; events"
description="Who and what does the agent perceive, and what is it trying to achieve?" description="What domain does your node operate in, and what events must it notice?"
fields={[ placeholder="Domain: structural resonance monitoring. Events: an impact spike, a sustained sway, a stale sensor."
{ key: 'goal', label: 'Goal', placeholder: 'keep the structure within safe resonance' },
{ key: 'perception', label: 'Perception inputs', placeholder: 'tri-axis IMU, acoustic level' },
{ key: 'actors', label: 'Actors / entities', placeholder: 'the structure, the operator, the agent' },
]}
/> />
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
+13 -47
View File
@@ -14,6 +14,7 @@ vi.mock('@/lib/api', async (orig) => ({
return () => {} return () => {}
}, },
getNodeStatus: vi.fn().mockResolvedValue({ teamId: 't', online: false }), getNodeStatus: vi.fn().mockResolvedValue({ teamId: 't', online: false }),
sendPrompt: vi.fn().mockResolvedValue(undefined),
})) }))
function renderPage() { function renderPage() {
@@ -32,63 +33,28 @@ describe('Module2', () => {
it('renders the phase strip set to m2 and the heading', () => { it('renders the phase strip set to m2 and the heading', () => {
renderPage() renderPage()
expect(screen.getByRole('heading', { name: /harness engineering/i })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /skills.*policies/i })).toBeInTheDocument()
expect( expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="m2"]'), screen.getByTestId('phase-strip').querySelector('[data-phase="m2"]'),
).toHaveAttribute('data-state', 'active') ).toHaveAttribute('data-state', 'active')
}) })
it('tunes a threshold and reflects it in the TOML preview', async () => { it('shows the live board feed and the build & flash panel', () => {
const user = userEvent.setup()
renderPage() renderPage()
const input = screen.getByLabelText(/threshold · g/i) expect(screen.getByTestId('live-board-feed')).toBeInTheDocument()
await user.clear(input) expect(screen.getByTestId('activity-log')).toBeInTheDocument()
await user.type(input, '1.2')
expect(useSession.getState().harness.thresholdG).toBe(1.2)
expect(screen.getByTestId('harness-toml')).toHaveTextContent('threshold_g = 1.2')
}) })
it('fires a trigger that classifies against the tuned threshold', async () => { it('gates Proceed until the board acted and L2 + L3 are filled', async () => {
const user = userEvent.setup() useSession.getState().setAddLayer('L2', 'escalate on critical')
renderPage() useSession.getState().setAddLayer('L3', 'drive damper on critical')
// tune low so the impact frame is unambiguously critical
const g = screen.getByLabelText(/threshold · g/i)
await user.clear(g)
await user.type(g, '0.5')
await user.click(screen.getByRole('button', { name: /impact/i }))
expect(useSession.getState().stats.critical).toBe(1)
})
it('gates Proceed until a trigger fired and L2 + L3 are filled', async () => {
const user = userEvent.setup()
renderPage() renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i }) const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
await user.type(screen.getByLabelText(/layer 2/i), 'escalate on critical') // the board acts (via Build & flash) → activity streams from the node
await user.type(screen.getByLabelText(/layer 3/i), 'drive damper on critical') act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' }))
expect(proceed).toBeDisabled() // no trigger fired yet await waitFor(() => expect(proceed).toBeEnabled())
await user.click(screen.getByRole('button', { name: /shake/i }))
expect(proceed).toBeEnabled()
})
describe('live mode (real node feed)', () => {
it('replaces triggers with the live board feed and gates on real activity', async () => {
useSession.getState().setMode('live')
useSession.getState().setAddLayer('L2', 'escalate on critical')
useSession.getState().setAddLayer('L3', 'drive damper on critical')
renderPage()
expect(await screen.findByTestId('live-board-feed')).toBeInTheDocument()
expect(screen.queryByTestId('trigger-buttons')).toBeNull() // no synthetic triggers in live mode
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
// the board acts (via Build & flash) → activity streams from the node
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' }))
await waitFor(() => expect(proceed).toBeEnabled())
})
}) })
it('marks m2 complete on Proceed', async () => { it('marks m2 complete on Proceed', async () => {
@@ -96,8 +62,8 @@ describe('Module2', () => {
renderPage() renderPage()
await user.type(screen.getByLabelText(/layer 2/i), 'L2 text') await user.type(screen.getByLabelText(/layer 2/i), 'L2 text')
await user.type(screen.getByLabelText(/layer 3/i), 'L3 text') await user.type(screen.getByLabelText(/layer 3/i), 'L3 text')
await user.click(screen.getByRole('button', { name: /impact/i })) act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' }))
await user.click(screen.getByRole('button', { name: /proceed/i })) await user.click(await screen.findByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.m2).toBe(true) expect(useSession.getState().phases.m2).toBe(true)
}) })
}) })
+42 -60
View File
@@ -1,43 +1,26 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { HarnessTuner } from '@/components/HarnessTuner'
import { HarnessTomlPreview } from '@/components/HarnessTomlPreview'
import { TriggerButtons } from '@/components/TriggerButtons'
import { LiveFeed } from '@/components/LiveFeed'
import { LiveBoardFeed } from '@/components/LiveBoardFeed' import { LiveBoardFeed } from '@/components/LiveBoardFeed'
import { StatsTally } from '@/components/StatsTally'
import { BuildFlash } from '@/components/BuildFlash' import { BuildFlash } from '@/components/BuildFlash'
import { ResiliencePanel } from '@/components/ResiliencePanel'
import { AddLayerForm } from '@/components/AddLayerForm' import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial' import { OpenYourNode } from '@/components/OpenYourNode'
import { useNodeFeed } from '@/lib/useNodeFeed' import { useNodeFeed } from '@/lib/useNodeFeed'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function Module2() { export function Module2() {
const navigate = useNavigate() const navigate = useNavigate()
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId) const teamId = useSession((s) => s.teamId)
const serial = useSerial() const feed = useNodeFeed(teamId, true)
const feed = useNodeFeed(teamId, mode === 'live')
const l2 = useSession((s) => s.add.L2) const l2 = useSession((s) => s.add.L2)
const l3 = useSession((s) => s.add.L3) const l3 = useSession((s) => s.add.L3)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
// gate on a trigger fired on *this* screen, so prior-module frames don't count // The board acting on a prompt (via Build & flash below) shows up in its
const [fired, setFired] = useState(false) // activity feed — that's proof the loop was exercised.
const exercised = feed.activity.length > 0
const onFire = (frame: Parameters<typeof serial.inject>[0]) => {
serial.inject(frame)
setFired(true)
}
// Sim: a fired trigger crossing the threshold. Live: the board acting on a
// prompt (via Build & flash below) shows up in its activity feed.
const exercised = mode === 'live' ? feed.activity.length > 0 : fired
const ready = exercised && l2.trim().length > 0 && l3.trim().length > 0 const ready = exercised && l2.trim().length > 0 && l3.trim().length > 0
const onProceed = () => { const onProceed = () => {
@@ -64,58 +47,57 @@ export function Module2() {
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2"> <Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 4 of 5 · ~90 min Phase 4 of 5 · ~90 min
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 2 · Harness engineering</h1> <h1 className="text-3xl font-bold tracking-tight">Module 2 · Skills &amp; policies</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Tune the harness, fire test triggers, and watch a frame cross your threshold. Then capture Your node ships with expert skills already. Decide which domain skills it needs, try them in the
Layers 2 and 3 how the agent reasons and how it acts. node dashboard, then capture Layers 2 and 3 the skills it can invoke and the actuation gate
that governs them.
</p> </p>
<div className="mt-3">
<OpenYourNode variant="inline" />
</div>
</div> </div>
<div className="grid lg:grid-cols-2 gap-6"> <Card>
<Card> <CardHeader>
<CardHeader> <CardTitle className="text-base">Skills reference</CardTitle>
<CardTitle className="text-base">Tune the harness</CardTitle> </CardHeader>
</CardHeader> <CardContent className="space-y-3">
<CardContent className="space-y-4"> <p className="text-sm text-muted-foreground leading-relaxed">
<HarnessTuner /> The node already ships with expert skills <span className="font-mono text-xs text-foreground">uno-q-hardware</span>,{' '}
<HarnessTomlPreview /> <span className="font-mono text-xs text-foreground">bridge</span>,{' '}
</CardContent> <span className="font-mono text-xs text-foreground">led-matrix</span>,{' '}
</Card> <span className="font-mono text-xs text-foreground">flashing</span>, and more. Layer 2 is not
about re-implementing those: it&rsquo;s deciding which <em>domain</em> skills your node needs, then
trying them live in the dashboard.
</p>
<OpenYourNode variant="inline" />
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">{mode === 'live' ? 'Live board feed' : 'Fire test triggers'}</CardTitle> <CardTitle className="text-base">Live board feed</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{mode === 'live' ? ( <LiveBoardFeed feed={feed} />
<LiveBoardFeed feed={feed} /> </CardContent>
) : ( </Card>
<>
<TriggerButtons onFire={onFire} />
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected || fired} mocked={serial.mocked} />
<StatsTally />
</>
)}
</CardContent>
</Card>
</div>
<BuildFlash /> <BuildFlash />
<ResiliencePanel />
<div className="grid lg:grid-cols-2 gap-6"> <div className="grid lg:grid-cols-2 gap-6">
<AddLayerForm <AddLayerForm
layer="L2" layer="L2"
title="ADD · Layer 2 — Reasoning policy" title="ADD · Layer 2 — Skills"
description="How does the agent decide a verdict from a frame?" description="What skills can the node invoke to act on its domain?"
placeholder="Classify each frame; escalate ambiguous cases to the cloud within the call budget." placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
/> />
<AddLayerForm <AddLayerForm
layer="L3" layer="L3"
title="ADD · Layer 3 — Action contract" title="ADD · Layer 3 — Policies"
description="What does the agent do for each verdict?" description="The actuation gate: what may it do autonomously vs. need approval, and where is the e-stop?"
placeholder="nominal → log; anomalous → alert; critical → drive damper + alert operator." placeholder="Autonomous: log + alert. Needs approval: drive the damper. E-stop: operator can halt actuation at any time."
/> />
</div> </div>
+4 -4
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { TeamRegistration } from './TeamRegistration' import { TeamRegistration } from './TeamRegistration'
@@ -56,8 +56,8 @@ describe('TeamRegistration', () => {
await user.type(memberInput, 'A. Rossi{Enter}') await user.type(memberInput, 'A. Rossi{Enter}')
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
// the simulator escape hatch satisfies the board requirement without hardware // a claimed board satisfies the device requirement
await user.click(screen.getByRole('button', { name: /use the simulator/i })) act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
expect(proceed).toBeEnabled() expect(proceed).toBeEnabled()
}) })
@@ -98,7 +98,7 @@ describe('TeamRegistration', () => {
renderPage() renderPage()
await user.type(screen.getByLabelText(/team name/i), 'team_x') await user.type(screen.getByLabelText(/team name/i), 'team_x')
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}') await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}')
await user.click(screen.getByRole('button', { name: /use the simulator/i })) act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
await user.click(screen.getByRole('button', { name: /proceed/i })) await user.click(screen.getByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.reg).toBe(true) expect(useSession.getState().phases.reg).toBe(true)
}) })
-11
View File
@@ -33,8 +33,6 @@ export function TeamRegistration() {
navigate('/workshop/setup') navigate('/workshop/setup')
} }
const useSimulator = () => setDevice({ connected: true, port: 'simulator · no board', uptimeS: 0 })
return ( return (
<main className="min-h-screen bg-background"> <main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between"> <header className="px-8 py-5 border-b border-border flex items-center justify-between">
@@ -131,15 +129,6 @@ export function TeamRegistration() {
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null }) setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null })
}} }}
/> />
{!device.connected && (
<button
type="button"
onClick={useSimulator}
className="font-mono text-[10px] text-muted-foreground hover:text-foreground underline"
>
No board yet? Use the simulator instead
</button>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+23 -32
View File
@@ -5,11 +5,6 @@ export type PhaseKey = 'reg' | 'setup' | 'm1' | 'm2' | 'add'
export const PHASE_ORDER: PhaseKey[] = ['reg', 'setup', 'm1', 'm2', 'add'] export const PHASE_ORDER: PhaseKey[] = ['reg', 'setup', 'm1', 'm2', 'add']
export type Provider = 'anthropic' | 'groq' | 'openai' | 'local'
/** Whether the workshop drives a real ZeroClaw node ('live') or the simulator ('sim'). */
export type RunMode = 'live' | 'sim'
export interface Team { export interface Team {
name: string name: string
members: string[] members: string[]
@@ -25,16 +20,6 @@ export interface Device {
nodeUrl: string | null nodeUrl: string | null
} }
export interface Harness {
thresholdG: number
thresholdDb: number
callsPerMinute: number
provider: Provider
model: string
/** Fall back to the board's on-board Qwen when the cloud provider is unreachable. */
fallbackLocal: boolean
}
export interface SessionStats { export interface SessionStats {
calls: number calls: number
nominal: number nominal: number
@@ -42,8 +27,16 @@ export interface SessionStats {
critical: number critical: number
} }
/**
* The five layers of the Agent Design Document. All free-text.
* L1 = Domain & events
* L2 = Skills
* L3 = Policies
* L4 = Harness
* L5 = Loops
*/
export interface AddLayers { export interface AddLayers {
L1: unknown | null L1: string
L2: string L2: string
L3: string L3: string
L4: string L4: string
@@ -65,17 +58,16 @@ export interface SessionState {
teamId: string teamId: string
team: Team team: Team
device: Device device: Device
mode: RunMode /** The team's free-text problem domain, e.g. "image measurement". */
domain: string
phases: Record<PhaseKey, boolean> phases: Record<PhaseKey, boolean>
harness: Harness
stats: SessionStats stats: SessionStats
add: AddLayers add: AddLayers
submission: Submission submission: Submission
setTeam: (patch: Partial<Team>) => void setTeam: (patch: Partial<Team>) => void
setDevice: (patch: Partial<Device>) => void setDevice: (patch: Partial<Device>) => void
setMode: (mode: RunMode) => void setDomain: (d: string) => void
completePhase: (phase: PhaseKey) => void completePhase: (phase: PhaseKey) => void
setHarness: (patch: Partial<Harness>) => void
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void
setSubmission: (s: Submission) => void setSubmission: (s: Submission) => void
@@ -96,18 +88,10 @@ const initial = {
teamId: genTeamId(), teamId: genTeamId(),
team: { name: '', members: [] as string[], kit: 'KIT-01' }, team: { name: '', members: [] as string[], kit: 'KIT-01' },
device: { connected: false, port: null, uptimeS: 0, nodeUrl: null }, device: { connected: false, port: null, uptimeS: 0, nodeUrl: null },
mode: 'sim' as RunMode, domain: '',
phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>, phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>,
harness: {
thresholdG: 0.8,
thresholdDb: 65,
callsPerMinute: 8,
provider: 'anthropic' as Provider,
model: 'claude-haiku-4-5',
fallbackLocal: true,
},
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 }, stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
add: { L1: null, L2: '', L3: '', L4: '', L5: '' } as AddLayers, add: { L1: '', L2: '', L3: '', L4: '', L5: '' } as AddLayers,
submission: { code: null, submittedAt: null } as Submission, submission: { code: null, submittedAt: null } as Submission,
} }
@@ -117,10 +101,9 @@ export const useSession = create<SessionState>()(
...initial, ...initial,
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })), setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })), setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
setMode: (mode) => set({ mode }), setDomain: (domain) => set({ domain }),
completePhase: (phase) => completePhase: (phase) =>
set((s) => ({ phases: { ...s.phases, [phase]: true } })), set((s) => ({ phases: { ...s.phases, [phase]: true } })),
setHarness: (patch) => set((s) => ({ harness: { ...s.harness, ...patch } })),
recordEvent: (kind) => recordEvent: (kind) =>
set((s) => ({ set((s) => ({
stats: { ...s.stats, calls: s.stats.calls + 1, [kind]: s.stats[kind] + 1 }, stats: { ...s.stats, calls: s.stats.calls + 1, [kind]: s.stats[kind] + 1 },
@@ -140,6 +123,14 @@ export const useSession = create<SessionState>()(
{ {
name: 'apess_state', name: 'apess_state',
storage: createJSONStorage(() => sessionStorage), storage: createJSONStorage(() => sessionStorage),
version: 2,
// v1 held a different shape (add.L1 was an object, no `domain`). Rather than
// patch a stale blob field-by-field, drop any older/absent version back to a
// fresh initial state so the app can never crash on a legacy snapshot.
migrate: (_persisted, version) => {
if (version < 2) return { ...initial }
return _persisted as SessionState
},
}, },
), ),
) )
+1
View File
@@ -8,6 +8,7 @@ export interface TeamSnapshot {
name: string name: string
kit: string kit: string
members: string[] members: string[]
domain: string
phases: Record<PhaseKey, boolean> phases: Record<PhaseKey, boolean>
stats: SessionStats stats: SessionStats
deviceConnected: boolean deviceConnected: boolean