feat(apess): wire real ZeroClaw nodes + live/sim + cloud-local fallback #1
+2
-1
@@ -134,7 +134,8 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
if (typeof b.message !== 'string' || !b.message.trim()) {
|
if (typeof b.message !== 'string' || !b.message.trim()) {
|
||||||
return res.status(400).json({ error: 'message is required' })
|
return res.status(400).json({ error: 'message is required' })
|
||||||
}
|
}
|
||||||
const ok = await nodes.prompt(String(req.params.teamId), b.message)
|
const agent = typeof b.agent === 'string' ? b.agent : undefined
|
||||||
|
const ok = await nodes.prompt(String(req.params.teamId), b.message, agent)
|
||||||
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
|
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
|
||||||
res.status(202).json({ accepted: true })
|
res.status(202).json({ accepted: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const store = {} as unknown as Store
|
|||||||
|
|
||||||
describe('node bridge + /nodes routes', () => {
|
describe('node bridge + /nodes routes', () => {
|
||||||
let events: WsEvent[]
|
let events: WsEvent[]
|
||||||
let sent: { node: NodeRef; message: string }[]
|
let sent: { node: NodeRef; message: string; agent?: string }[]
|
||||||
let app: ReturnType<typeof createApp>
|
let app: ReturnType<typeof createApp>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -24,8 +24,8 @@ describe('node bridge + /nodes routes', () => {
|
|||||||
const nodes = createNodeBridge({
|
const nodes = createNodeBridge({
|
||||||
broadcast: (e) => events.push(e),
|
broadcast: (e) => events.push(e),
|
||||||
ping: async () => true, // pretend the node is online
|
ping: async () => true, // pretend the node is online
|
||||||
send: async (node, message) => {
|
send: async (node, message, agent) => {
|
||||||
sent.push({ node, message })
|
sent.push({ node, message, agent })
|
||||||
},
|
},
|
||||||
subscribe: () => () => {}, // no live SSE in the unit test
|
subscribe: () => () => {}, // no live SSE in the unit test
|
||||||
})
|
})
|
||||||
@@ -61,9 +61,10 @@ describe('node bridge + /nodes routes', () => {
|
|||||||
.expect(201)
|
.expect(201)
|
||||||
|
|
||||||
// prompting is public (no code) — matches PUT /teams/:id
|
// prompting is public (no code) — matches PUT /teams/:id
|
||||||
await request(app).post('/nodes/t1/prompt').send({ message: 'scroll HELLO' }).expect(202)
|
await request(app).post('/nodes/t1/prompt').send({ message: 'scroll HELLO', agent: 'local' }).expect(202)
|
||||||
expect(sent).toHaveLength(1)
|
expect(sent).toHaveLength(1)
|
||||||
expect(sent[0].message).toBe('scroll HELLO')
|
expect(sent[0].message).toBe('scroll HELLO')
|
||||||
|
expect(sent[0].agent).toBe('local')
|
||||||
expect(sent[0].node.token).toBe('zc_secret')
|
expect(sent[0].node.token).toBe('zc_secret')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -93,8 +93,6 @@ export function mapNodeEvent(teamId: string, raw: unknown): WsEvent | null {
|
|||||||
// --- I/O bridge to a node's ZeroClaw gateway --------------------------------
|
// --- I/O bridge to a node's ZeroClaw gateway --------------------------------
|
||||||
// (Uses global fetch; exercised by integration against a live node, not units.)
|
// (Uses global fetch; exercised by integration against a live node, not units.)
|
||||||
|
|
||||||
const AGENT = 'default'
|
|
||||||
|
|
||||||
/** Health-ping a node's gateway. */
|
/** Health-ping a node's gateway. */
|
||||||
export async function pingNode(node: NodeRef, timeoutMs = 3000): Promise<boolean> {
|
export async function pingNode(node: NodeRef, timeoutMs = 3000): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -106,9 +104,12 @@ export async function pingNode(node: NodeRef, timeoutMs = 3000): Promise<boolean
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forward a participant prompt to the node's agent (fire-and-return). */
|
/**
|
||||||
export async function sendPrompt(node: NodeRef, message: string): Promise<void> {
|
* Forward a participant prompt to a specific pre-provisioned agent alias on
|
||||||
await fetch(`${node.url}/webhook?agent=${AGENT}`, {
|
* the node (routed via `?agent=`; defaults to `default`). Fire-and-return.
|
||||||
|
*/
|
||||||
|
export async function sendPrompt(node: NodeRef, message: string, agent = 'default'): Promise<void> {
|
||||||
|
await fetch(`${node.url}/webhook?agent=${encodeURIComponent(agent)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ message }),
|
body: JSON.stringify({ message }),
|
||||||
@@ -177,7 +178,7 @@ export interface NodeBridge {
|
|||||||
register(ref: NodeRef): Promise<void>
|
register(ref: NodeRef): Promise<void>
|
||||||
remove(teamId: string): void
|
remove(teamId: string): void
|
||||||
list(): NodeView[]
|
list(): NodeView[]
|
||||||
prompt(teamId: string, message: string): Promise<boolean>
|
prompt(teamId: string, message: string, agent?: string): Promise<boolean>
|
||||||
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
|
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
|
||||||
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
|
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
|
||||||
stopAll(): void
|
stopAll(): void
|
||||||
@@ -188,7 +189,7 @@ export interface NodeBridgeDeps {
|
|||||||
registry?: NodeRegistry
|
registry?: NodeRegistry
|
||||||
/** Injectable for tests. */
|
/** Injectable for tests. */
|
||||||
ping?: (n: NodeRef) => Promise<boolean>
|
ping?: (n: NodeRef) => Promise<boolean>
|
||||||
send?: (n: NodeRef, m: string) => Promise<void>
|
send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
|
||||||
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void) => () => void
|
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +232,10 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
|
|||||||
list() {
|
list() {
|
||||||
return registry.list().map((n) => ({ teamId: n.teamId, url: n.url, online: online.get(n.teamId) ?? false }))
|
return registry.list().map((n) => ({ teamId: n.teamId, url: n.url, online: online.get(n.teamId) ?? false }))
|
||||||
},
|
},
|
||||||
async prompt(teamId, message) {
|
async prompt(teamId, message, agent) {
|
||||||
const node = registry.get(teamId)
|
const node = registry.get(teamId)
|
||||||
if (!node) return false
|
if (!node) return false
|
||||||
await send(node, message)
|
await send(node, message, agent)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
onTeamActivity(teamId, listener) {
|
onTeamActivity(teamId, listener) {
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ describe('BuildFlash', () => {
|
|||||||
|
|
||||||
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
|
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
|
||||||
await user.click(screen.getByRole('button', { name: /working|send/i }))
|
await user.click(screen.getByRole('button', { name: /working|send/i }))
|
||||||
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO')
|
// 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
|
// a flash event streams in over the (mocked) SSE feed
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
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 {
|
||||||
@@ -35,6 +36,7 @@ const isTerminal = (k: NodeActivityKind) => k === 'flash' || k === 'response' ||
|
|||||||
export function BuildFlash() {
|
export function BuildFlash() {
|
||||||
const mode = useSession((s) => s.mode)
|
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[]>([])
|
||||||
@@ -72,7 +74,7 @@ export function BuildFlash() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendPrompt(teamId, msg)
|
await sendPrompt(teamId, msg, harnessToAgent(harness))
|
||||||
} 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)
|
||||||
|
|||||||
+3
-3
@@ -70,12 +70,12 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- ZeroClaw node (participant) ------------------------------------------
|
// --- ZeroClaw node (participant) ------------------------------------------
|
||||||
/** Send a prompt to the team's board (public, keyed by teamId). */
|
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
|
||||||
export async function sendPrompt(teamId: string, message: string): Promise<void> {
|
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
|
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ message }),
|
body: JSON.stringify({ message, agent }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`sendPrompt ${res.status}`)
|
if (!res.ok) throw new Error(`sendPrompt ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { harnessToToml } from './harness'
|
import { harnessToToml, harnessToAgent } from './harness'
|
||||||
import type { Harness } from '@/store/session'
|
import type { Harness } from '@/store/session'
|
||||||
|
|
||||||
const harness: Harness = {
|
const harness: Harness = {
|
||||||
@@ -43,3 +43,17 @@ describe('harnessToToml', () => {
|
|||||||
expect(harnessToToml({ ...harness, provider: 'local', fallbackLocal: true })).not.toContain('fallback')
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -19,3 +19,15 @@ export function harnessToToml(h: Harness): string {
|
|||||||
lines.push('')
|
lines.push('')
|
||||||
return lines.join('\n')
|
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'
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user