feat(apess): wire real ZeroClaw nodes + live/sim + cloud-local fallback #1

Merged
osobh merged 3 commits from feat/zeroclaw-node-integration into main 2026-07-03 13:39:31 +00:00
5 changed files with 150 additions and 7 deletions
Showing only changes of commit 28a0965108 - Show all commits
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BoardActivity } from './BoardActivity'
import type { NodeActivityEntry } from '@/lib/useCollective'
const entry = (over: Partial<NodeActivityEntry>): NodeActivityEntry => ({
teamId: 't1',
kind: 'flash',
label: 'Flashed to 0x80F0000',
ts: 'T',
...over,
})
describe('BoardActivity', () => {
it('shows an empty hint when there is no activity', () => {
render(<BoardActivity activity={[]} />)
expect(screen.getByTestId('board-activity-empty')).toBeInTheDocument()
})
it('renders entries newest-first with resolved team names', () => {
render(
<BoardActivity
activity={[entry({ label: 'Flashed to 0x80F0000' }), entry({ kind: 'thinking', label: 'Agent started' })]}
nameFor={(id) => (id === 't1' ? 'Team Rocket' : id)}
/>,
)
const items = screen.getByTestId('board-activity').querySelectorAll('li')
expect(items).toHaveLength(2)
expect(items[0]).toHaveTextContent('Flashed to 0x80F0000')
expect(screen.getAllByText('Team Rocket')).toHaveLength(2)
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { NodeActivityEntry } from '@/lib/useCollective'
import type { NodeActivityKind } from '@/types'
import { cn } from '@/lib/utils'
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
}
export interface BoardActivityProps {
activity: NodeActivityEntry[]
/** Resolve a teamId to a display name (falls back to the id). */
nameFor?: (teamId: string) => string
}
/** Instructor-facing rolling feed of real on-device agent activity across boards. */
export function BoardActivity({ activity, nameFor }: BoardActivityProps) {
if (activity.length === 0) {
return (
<p data-testid="board-activity-empty" className="font-mono text-[11px] text-muted-foreground">
No board activity yet boards report generate / compile / flash here as teams work.
</p>
)
}
return (
<ul data-testid="board-activity" className="space-y-1.5">
{activity.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="text-muted-foreground shrink-0">{nameFor ? nameFor(e.teamId) : e.teamId}</span>
<span
className={cn(
'truncate',
e.kind === 'error' && 'text-destructive',
e.kind === 'flash' && 'text-foreground font-medium',
)}
>
{e.label}
</span>
</li>
))}
</ul>
)
}
+32 -1
View File
@@ -13,7 +13,7 @@ const team = (id: string): TeamSnapshot => ({
updatedAt: '2026-07-27T13:00:00.000Z', updatedAt: '2026-07-27T13:00:00.000Z',
}) })
const empty: CollectiveState = { teams: {}, submissions: {} } const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] }
describe('collectiveReducer', () => { describe('collectiveReducer', () => {
it('seeds from a snapshot', () => { it('seeds from a snapshot', () => {
@@ -46,4 +46,35 @@ describe('collectiveReducer', () => {
const next = collectiveReducer(empty, { type: 'score:new', teamId: 'ghost', total: 1 }) const next = collectiveReducer(empty, { type: 'score:new', teamId: 'ghost', total: 1 })
expect(next).toBe(empty) expect(next).toBe(empty)
}) })
it('tracks per-team board online status on node:status', () => {
const next = collectiveReducer(empty, { type: 'node:status', teamId: 'a', online: true })
expect(next.nodes.a).toBe(true)
const off = collectiveReducer(next, { type: 'node:status', teamId: 'a', online: false })
expect(off.nodes.a).toBe(false)
})
it('prepends node:activity to a bounded, most-recent-first feed', () => {
const first = collectiveReducer(empty, {
type: 'node:activity',
teamId: 'a',
kind: 'thinking',
label: 'Agent started',
ts: 'T1',
})
const second = collectiveReducer(first, {
type: 'node:activity',
teamId: 'a',
kind: 'flash',
label: 'Flashed to 0x80F0000',
ts: 'T2',
})
expect(second.activity.map((e) => e.label)).toEqual(['Flashed to 0x80F0000', 'Agent started'])
})
it('preserves node status/activity across a fresh snapshot', () => {
const withNode = collectiveReducer(empty, { type: 'node:status', teamId: 'a', online: true })
const next = collectiveReducer(withNode, { type: 'snapshot', teams: [team('a')], submissions: [] })
expect(next.nodes.a).toBe(true)
})
}) })
+28 -4
View File
@@ -1,13 +1,25 @@
import { useEffect, useReducer, useRef, useState } from 'react' import { useEffect, useReducer, useRef, useState } from 'react'
import { openCollective, getTeams, getSubmissions } from './api' import { openCollective, getTeams, getSubmissions } from './api'
import type { TeamSnapshot, SubmissionSummary, WsEvent } from '@/types' import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types'
export interface NodeActivityEntry {
teamId: string
kind: NodeActivityKind
label: string
ts: string
}
export interface CollectiveState { export interface CollectiveState {
teams: Record<string, TeamSnapshot> teams: Record<string, TeamSnapshot>
submissions: Record<string, SubmissionSummary> submissions: Record<string, SubmissionSummary>
/** teamId → board online. */
nodes: Record<string, boolean>
/** rolling board-activity feed, most-recent-first, bounded. */
activity: NodeActivityEntry[]
} }
const empty: CollectiveState = { teams: {}, submissions: {} } const MAX_ACTIVITY = 40
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] }
export function collectiveReducer(state: CollectiveState, event: WsEvent): CollectiveState { export function collectiveReducer(state: CollectiveState, event: WsEvent): CollectiveState {
switch (event.type) { switch (event.type) {
@@ -16,7 +28,7 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
event.teams.forEach((t) => (teams[t.id] = t)) event.teams.forEach((t) => (teams[t.id] = t))
const submissions: Record<string, SubmissionSummary> = {} const submissions: Record<string, SubmissionSummary> = {}
event.submissions.forEach((s) => (submissions[s.teamId] = s)) event.submissions.forEach((s) => (submissions[s.teamId] = s))
return { teams, submissions } return { ...state, teams, submissions }
} }
case 'team:update': case 'team:update':
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } } return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
@@ -33,6 +45,16 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
submissions: { ...state.submissions, [event.teamId]: { ...existing, scored: true } }, submissions: { ...state.submissions, [event.teamId]: { ...existing, scored: true } },
} }
} }
case 'node:status':
return { ...state, nodes: { ...state.nodes, [event.teamId]: event.online } }
case 'node:activity':
return {
...state,
activity: [
{ teamId: event.teamId, kind: event.kind, label: event.label, ts: event.ts },
...state.activity,
].slice(0, MAX_ACTIVITY),
}
default: default:
return state return state
} }
@@ -43,6 +65,8 @@ export type CollectiveStatus = 'connecting' | 'live' | 'polling'
export interface Collective { export interface Collective {
teams: TeamSnapshot[] teams: TeamSnapshot[]
submissions: Record<string, SubmissionSummary> submissions: Record<string, SubmissionSummary>
nodes: Record<string, boolean>
activity: NodeActivityEntry[]
status: CollectiveStatus status: CollectiveStatus
} }
@@ -93,5 +117,5 @@ export function useCollective(code: string): Collective {
}, [code]) }, [code])
const teams = Object.values(state.teams).sort((a, b) => a.name.localeCompare(b.name)) const teams = Object.values(state.teams).sort((a, b) => a.name.localeCompare(b.name))
return { teams, submissions: state.submissions, status } return { teams, submissions: state.submissions, nodes: state.nodes, activity: state.activity, status }
} }
+11 -2
View File
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { AccessGate } from '@/components/AccessGate' import { AccessGate } from '@/components/AccessGate'
import { TeamCard } from '@/components/TeamCard' import { TeamCard } from '@/components/TeamCard'
import { BoardActivity } from '@/components/BoardActivity'
import { useCollective } from '@/lib/useCollective' import { useCollective } from '@/lib/useCollective'
import { useNow } from '@/lib/useNow' import { useNow } from '@/lib/useNow'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -15,11 +16,13 @@ function Stat({ label, value }: { label: string; value: number | string }) {
} }
function AdminBoard({ code }: { code: string }) { function AdminBoard({ code }: { code: string }) {
const { teams, submissions, status } = useCollective(code) const { teams, submissions, nodes, activity, status } = useCollective(code)
const now = useNow() const now = useNow()
const connected = teams.filter((t) => t.deviceConnected).length const connected = teams.filter((t) => t.deviceConnected).length
const boardsLive = Object.values(nodes).filter(Boolean).length
const submittedCount = Object.keys(submissions).length const submittedCount = Object.keys(submissions).length
const judgedCount = Object.values(submissions).filter((s) => s.scored).length const judgedCount = Object.values(submissions).filter((s) => s.scored).length
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
return ( return (
<main className="min-h-screen bg-background"> <main className="min-h-screen bg-background">
@@ -40,13 +43,19 @@ function AdminBoard({ code }: { code: string }) {
</header> </header>
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6"> <section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden"> <div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-border rounded-md overflow-hidden">
<Stat label="Teams" value={teams.length} /> <Stat label="Teams" value={teams.length} />
<Stat label="Connected" value={connected} /> <Stat label="Connected" value={connected} />
<Stat label="Boards live" value={boardsLive} />
<Stat label="Submitted" value={submittedCount} /> <Stat label="Submitted" value={submittedCount} />
<Stat label="Judged" value={judgedCount} /> <Stat label="Judged" value={judgedCount} />
</div> </div>
<div className="border border-border rounded-md p-4 space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Board activity</div>
<BoardActivity activity={activity} nameFor={nameFor} />
</div>
{teams.length === 0 ? ( {teams.length === 0 ? (
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p> <p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
) : ( ) : (