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:
co-authored by
Claude Opus 4.8
parent
3be44ec215
commit
6d94833bb9
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (L2–L5) */
|
|
||||||
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 (L2–L5) 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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]} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 & flash</CardTitle>
|
<CardTitle className="text-base">Build & 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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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’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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 board’s `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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 0–10 range', async () => {
|
it('clamps a criterion to the 0–10 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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 & submit</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Module 3 · Harness, Loops & 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
@@ -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
@@ -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’s
|
||||||
provider your agent will escalate to. Then you are clear for Module 1.
|
for — then you’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 & 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 team’s 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’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’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’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’ll revisit this
|
||||||
|
once you’ve designed the agent’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}>
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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 Q’s quad-core Linux side and flashes its on-board STM32 MCU.' },
|
{ icon: '⚙', name: 'Rust on the Uno Q', desc: 'ZeroClaw runs on the Uno Q’s 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">
|
||||||
|
|||||||
@@ -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
@@ -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 Q’s 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 Q’s 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
@@ -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
@@ -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 & 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 & goal"
|
title="ADD · Layer 1 — Domain & 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
@@ -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
@@ -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 & 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’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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user