mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
12 Commits
fix/evals-
...
fix/dev-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd7c985e3 | ||
|
|
548c9dd996 | ||
|
|
d38b01a8c7 | ||
|
|
ff36c8412b | ||
|
|
fd5aba249b | ||
|
|
492f3fcdf2 | ||
|
|
cb0c0dd0c1 | ||
|
|
8712f89f18 | ||
|
|
ba60bf466f | ||
|
|
26afb826c6 | ||
|
|
b2340c8afa | ||
|
|
790a270f47 |
176
.github/workflows/publish-vm-agent-cache.yml
vendored
176
.github/workflows/publish-vm-agent-cache.yml
vendored
@@ -1,176 +0,0 @@
|
||||
name: Publish VM Agent Cache
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
agent:
|
||||
description: "Agent name from bundle.json"
|
||||
required: true
|
||||
type: string
|
||||
default: openclaw
|
||||
publish:
|
||||
description: "Upload to R2 and merge manifest slice"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/browseros-agent/packages/build-tools/**"
|
||||
- ".github/workflows/publish-vm-agent-cache.yml"
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.6"
|
||||
PKG_DIR: packages/browseros-agent/packages/build-tools
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools typecheck
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools test
|
||||
|
||||
build:
|
||||
needs: check
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- arch: x64
|
||||
runner: ubuntu-24.04
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build tarball
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
OUT: ${{ github.workspace }}/dist/images
|
||||
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
|
||||
path: dist/images/
|
||||
retention-days: 7
|
||||
|
||||
smoke:
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- arch: x64
|
||||
runner: ubuntu-24.04
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
|
||||
path: dist/images
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Smoke test tarball
|
||||
timeout-minutes: 10
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-${{ matrix.arch }}.tar.gz" -print -quit)"
|
||||
if [ -z "$tarball" ]; then
|
||||
echo "missing ${{ matrix.arch }} tarball artifact for ${AGENT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
checksum="${tarball}.sha256"
|
||||
if [ ! -f "$checksum" ]; then
|
||||
echo "missing checksum sidecar: $checksum" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "smoke-testing $tarball"
|
||||
ls -lh "$tarball" "$checksum"
|
||||
(cd "$(dirname "$tarball")" && sha256sum -c "$(basename "$checksum")")
|
||||
timeout --verbose --kill-after=30s 8m bun run smoke:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --tarball "$tarball"
|
||||
|
||||
publish:
|
||||
needs: [build, smoke]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: release
|
||||
concurrency:
|
||||
group: r2-manifest-publish
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: tarball-*
|
||||
path: dist/images
|
||||
merge-multiple: true
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Upload tarballs to R2
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
|
||||
base="$(basename "$file")"
|
||||
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
|
||||
done
|
||||
- name: Merge agent slice into manifest
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/images
|
||||
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
|
||||
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
|
||||
bun run emit-manifest -- \
|
||||
--slice "agents:${AGENT}" \
|
||||
--dist-dir dist \
|
||||
--merge-from dist/baseline-manifest.json \
|
||||
--out dist/manifest.json
|
||||
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -63,15 +63,15 @@ jobs:
|
||||
junit_path: test-results/server-root.xml
|
||||
needs_browser: false
|
||||
- suite: agent
|
||||
command: bun run test:agent
|
||||
command: (cd apps/agent && bun run test)
|
||||
junit_path: test-results/agent.xml
|
||||
needs_browser: false
|
||||
- suite: eval
|
||||
command: bun run test:eval
|
||||
command: (cd apps/eval && bun run test)
|
||||
junit_path: test-results/eval.xml
|
||||
needs_browser: false
|
||||
- suite: build
|
||||
command: bun run test:build
|
||||
command: bun run ./scripts/run-bun-test.ts ./scripts/build
|
||||
junit_path: test-results/build.xml
|
||||
needs_browser: false
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -188,6 +188,21 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
|
||||
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) — BrowserOS uses some patches for enhanced privacy. Thanks to everyone behind this project!
|
||||
- [The Chromium Project](https://www.chromium.org/) — at the core of BrowserOS, making it possible to exist in the first place.
|
||||
|
||||
## Citation
|
||||
|
||||
If you use BrowserOS in your research or project, please cite:
|
||||
|
||||
```bibtex
|
||||
@software{browseros2025,
|
||||
author = {Nithin Sonti and Nikhil Sonti and {BrowserOS-team}},
|
||||
title = {BrowserOS: The open-source Agentic browser},
|
||||
url = {https://github.com/browseros-ai/BrowserOS},
|
||||
year = {2025},
|
||||
publisher = {GitHub},
|
||||
license = {AGPL-3.0},
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
|
||||
|
||||
@@ -79,14 +79,15 @@ cp apps/server/.env.example apps/server/.env.development
|
||||
cp apps/agent/.env.example apps/agent/.env.development
|
||||
cp apps/server/.env.production.example apps/server/.env.production
|
||||
|
||||
# Install deps, generate agent code, and sync the VM cache
|
||||
# Install deps and generate agent code
|
||||
bun run dev:setup
|
||||
|
||||
# Start the full dev environment
|
||||
bun run dev:watch
|
||||
```
|
||||
|
||||
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
|
||||
`dev:watch` starts the server immediately. OpenClaw VM/image prewarm runs from
|
||||
the server startup path and pulls the configured GHCR image on demand.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -156,9 +157,14 @@ bun run build:server # Build production server resource artifacts and u
|
||||
bun run build:agent # Build agent extension
|
||||
|
||||
# Test
|
||||
bun run test # Run standard tests
|
||||
bun run test:cdp # Run CDP-based tests
|
||||
bun run test:integration # Run integration tests
|
||||
bun run test # Run all tests
|
||||
bun run test:all # Run all tests
|
||||
bun run test:main # Run key server tools and integration tests
|
||||
|
||||
# App-specific test groups (from packages/browseros-agent)
|
||||
cd apps/server && bun run test:tools
|
||||
cd apps/server && bun run test:cdp
|
||||
cd apps/server && bun run test:integration
|
||||
|
||||
# Quality
|
||||
bun run lint # Check with Biome
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Bot, Loader2, Wrench } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: AgentCardData
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp?: number): string {
|
||||
if (!timestamp) return 'No activity yet'
|
||||
const diff = Date.now() - timestamp
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
function getStatusLabel(status: AgentCardData['status']): string {
|
||||
if (status === 'working') return 'Working'
|
||||
if (status === 'error') return 'Error'
|
||||
return 'Ready'
|
||||
}
|
||||
|
||||
function getStatusTone(status: AgentCardData['status']): string {
|
||||
if (status === 'working') return 'bg-amber-500'
|
||||
if (status === 'error') return 'bg-destructive'
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
if (usd < 0.005) return `$${usd.toFixed(4)}`
|
||||
return `$${usd.toFixed(2)}`
|
||||
}
|
||||
|
||||
export const AgentCardExpanded: FC<AgentCardProps> = ({
|
||||
agent,
|
||||
onClick,
|
||||
active,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group flex min-h-32 w-full min-w-0 flex-col rounded-2xl border p-4 text-left shadow-sm transition-all duration-200',
|
||||
active
|
||||
? 'border-border/80 bg-card shadow-md ring-1 ring-[var(--accent-orange)]/20'
|
||||
: 'border-border/60 bg-card/85 hover:border-border hover:bg-card hover:shadow-md',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-xl',
|
||||
active
|
||||
? 'bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-sm">{agent.name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{agent.model ?? 'OpenClaw agent'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-[11px] text-muted-foreground">
|
||||
<span
|
||||
className={cn('size-2 rounded-full', getStatusTone(agent.status))}
|
||||
/>
|
||||
<span>{getStatusLabel(agent.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex-1">
|
||||
<p className="line-clamp-2 text-foreground/90 text-sm">
|
||||
{agent.lastMessage ??
|
||||
'Start a conversation to see recent work and summaries.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1.5 text-muted-foreground text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{formatTimestamp(agent.lastMessageTimestamp)}</span>
|
||||
{agent.costUsd ? (
|
||||
<span className="tabular-nums opacity-70">
|
||||
{formatCost(agent.costUsd)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{agent.status === 'working' && agent.currentTool ? (
|
||||
<div className="flex items-center gap-1.5 text-[var(--accent-orange)]/70">
|
||||
<Loader2 className="size-3 shrink-0 animate-spin" />
|
||||
<span className="truncate">{agent.currentTool}</span>
|
||||
</div>
|
||||
) : agent.activitySummary ? (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground/60">
|
||||
<Wrench className="size-3 shrink-0" />
|
||||
<span className="truncate">{agent.activitySummary}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
export const AgentCardCompact: FC<AgentCardProps> = ({
|
||||
agent,
|
||||
onClick,
|
||||
active,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors',
|
||||
active
|
||||
? 'border-border bg-card shadow-sm ring-1 ring-[var(--accent-orange)]/20'
|
||||
: 'border-border/60 bg-card/85 text-foreground hover:border-border hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
active ? 'bg-[var(--accent-orange)]' : getStatusTone(agent.status),
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</button>
|
||||
)
|
||||
@@ -1,70 +1,71 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAdapterHealth,
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentCardCompact, AgentCardExpanded } from './AgentCard'
|
||||
import { HomeAgentCard } from './HomeAgentCard'
|
||||
|
||||
interface AgentCardDockProps {
|
||||
agents: AgentCardData[]
|
||||
agents: HarnessAgent[]
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
activeAgentId?: string
|
||||
onSelectAgent: (agentId: string) => void
|
||||
onCreateAgent?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function CreateAgentButton({
|
||||
compact,
|
||||
onCreateAgent,
|
||||
}: {
|
||||
compact?: boolean
|
||||
onCreateAgent: () => void
|
||||
}) {
|
||||
function CreateAgentButton({ onCreateAgent }: { onCreateAgent: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateAgent}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center gap-2 border border-dashed text-muted-foreground transition-colors hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
|
||||
compact
|
||||
? 'rounded-full px-3 py-2 text-sm'
|
||||
: 'min-h-32 rounded-2xl px-5 py-4',
|
||||
'flex min-h-32 shrink-0 items-center justify-center gap-2 rounded-2xl border border-dashed px-5 py-4 text-muted-foreground transition-colors',
|
||||
'hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
|
||||
)}
|
||||
>
|
||||
<Plus className={compact ? 'size-3.5' : 'size-5'} />
|
||||
<span>{compact ? 'New' : 'Create agent'}</span>
|
||||
<Plus className="size-5" />
|
||||
<span>Create agent</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-column grid of HomeAgentCards plus a trailing "Create agent"
|
||||
* tile. The previous `compact` mode (rendered a horizontal pill rail)
|
||||
* had no callers and was dropped along with the legacy AgentCard.
|
||||
*/
|
||||
export const AgentCardDock: FC<AgentCardDockProps> = ({
|
||||
agents,
|
||||
adapters,
|
||||
activeAgentId,
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
compact,
|
||||
}) => {
|
||||
if (agents.length === 0 && !onCreateAgent) return null
|
||||
|
||||
const Card = compact ? AgentCardCompact : AgentCardExpanded
|
||||
const adapterHealth = new Map<HarnessAgentAdapter, HarnessAdapterHealth>()
|
||||
for (const descriptor of adapters) {
|
||||
if (descriptor.health) adapterHealth.set(descriptor.id, descriptor.health)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
compact
|
||||
? 'flex items-center gap-2 overflow-x-auto pb-1'
|
||||
: 'grid gap-4 md:grid-cols-3',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.agentId}
|
||||
<HomeAgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
active={agent.agentId === activeAgentId}
|
||||
onClick={() => onSelectAgent(agent.agentId)}
|
||||
adapter={agent.adapter}
|
||||
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
|
||||
active={agent.id === activeAgentId}
|
||||
onClick={() => onSelectAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
{onCreateAgent ? (
|
||||
<CreateAgentButton compact={compact} onCreateAgent={onCreateAgent} />
|
||||
<CreateAgentButton onCreateAgent={onCreateAgent} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,12 @@ import { ArrowLeft, Bot, Home } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
cancelHarnessTurn,
|
||||
useEnqueueHarnessMessage,
|
||||
useHarnessAgents,
|
||||
useRemoveHarnessQueuedMessage,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
@@ -15,6 +21,7 @@ import {
|
||||
filterTurnsPersistedInHistory,
|
||||
flattenHistoryPages,
|
||||
} from './claw-chat-types'
|
||||
import { QueuePanel } from './QueuePanel'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
||||
|
||||
@@ -212,15 +219,33 @@ function AgentConversationController({
|
||||
[historyMessages],
|
||||
)
|
||||
|
||||
// Listing query feeds queue + active-turn state for this agent. We
|
||||
// already poll it every 5s for the rail; reusing the same cache
|
||||
// keeps cross-tab queue state in sync without a second poll.
|
||||
const { harnessAgents } = useHarnessAgents()
|
||||
const harnessAgent = harnessAgents.find((entry) => entry.id === agentId)
|
||||
const queue = harnessAgent?.queue ?? []
|
||||
const activeTurnId = harnessAgent?.activeTurnId ?? null
|
||||
|
||||
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||
runtime: 'agent-harness',
|
||||
sessionKey: null,
|
||||
history: chatHistory,
|
||||
activeTurnId,
|
||||
onComplete: () => {
|
||||
void harnessHistoryQuery.refetch()
|
||||
},
|
||||
onSessionKeyChange: () => {},
|
||||
})
|
||||
const enqueueMessage = useEnqueueHarnessMessage()
|
||||
const removeQueuedMessage = useRemoveHarnessQueuedMessage()
|
||||
|
||||
const handleStop = () => {
|
||||
void cancelHarnessTurn(agentId, {
|
||||
turnId: activeTurnId ?? undefined,
|
||||
reason: 'user pressed stop',
|
||||
})
|
||||
}
|
||||
const visibleTurns = useMemo(
|
||||
() => filterTurnsPersistedInHistory(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
@@ -281,7 +306,15 @@ function AgentConversationController({
|
||||
/>
|
||||
|
||||
<div className="border-border/50 border-t bg-background/88 px-4 py-3 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mx-auto max-w-3xl space-y-3">
|
||||
{queue.length > 0 ? (
|
||||
<QueuePanel
|
||||
queue={queue}
|
||||
onRemove={(messageId) =>
|
||||
removeQueuedMessage.mutate({ agentId, messageId })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<ConversationInput
|
||||
variant="conversation"
|
||||
agents={agents}
|
||||
@@ -296,14 +329,31 @@ function AgentConversationController({
|
||||
name: a.name,
|
||||
dataUrl: a.dataUrl,
|
||||
}))
|
||||
// When the agent already has an in-flight turn, route
|
||||
// the new message into the durable queue instead of
|
||||
// starting a parallel turn. Drains automatically as
|
||||
// soon as the active turn ends.
|
||||
if (streaming || activeTurnId) {
|
||||
enqueueMessage.mutate({
|
||||
agentId,
|
||||
message: input.text,
|
||||
attachments,
|
||||
})
|
||||
return
|
||||
}
|
||||
void send({ text: input.text, attachments, attachmentPreviews })
|
||||
}}
|
||||
onCreateAgent={() => navigate(createAgentPath)}
|
||||
onStop={handleStop}
|
||||
streaming={streaming}
|
||||
disabled={disabled}
|
||||
status="running"
|
||||
attachmentsEnabled={true}
|
||||
placeholder={`Message ${agentName}...`}
|
||||
placeholder={
|
||||
streaming
|
||||
? `Type to queue another message for ${agentName}...`
|
||||
: `Message ${agentName}...`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgent,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import {
|
||||
useAgentAdapters,
|
||||
useHarnessAgents,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
||||
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
||||
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import { AgentCardDock } from './AgentCardDock'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import { buildAgentCardData } from './useAgentCardData'
|
||||
import { orderHomeAgents } from './home-agent-card.helpers'
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
@@ -38,11 +45,13 @@ function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
function RecentThreads({
|
||||
activeAgentId,
|
||||
agents,
|
||||
adapters,
|
||||
onOpenAgents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId?: string | null
|
||||
agents: AgentCardData[]
|
||||
agents: HarnessAgent[]
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
onOpenAgents: () => void
|
||||
onSelectAgent: (agentId: string) => void
|
||||
}) {
|
||||
@@ -68,6 +77,7 @@ function RecentThreads({
|
||||
</div>
|
||||
<AgentCardDock
|
||||
agents={agents}
|
||||
adapters={adapters}
|
||||
activeAgentId={activeAgentId ?? undefined}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onOpenAgents}
|
||||
@@ -79,25 +89,32 @@ function RecentThreads({
|
||||
export const AgentCommandHome: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const activeHint = useActiveHint()
|
||||
const { agents, status } = useAgentCommandData()
|
||||
// The conversation input still consumes the merged AgentEntry list
|
||||
// from the layout context (handles legacy /claw/agents entries that
|
||||
// haven't yet been backfilled into the harness store). The Recent
|
||||
// Agents grid below reads the richer harness payload directly.
|
||||
const { agents: legacyAgents, status } = useAgentCommandData()
|
||||
const { harnessAgents } = useHarnessAgents()
|
||||
const { adapters } = useAgentAdapters()
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||
const cardData = buildAgentCardData(agents, status?.status, undefined)
|
||||
|
||||
const orderedAgents = useMemo(
|
||||
() => orderHomeAgents(harnessAgents),
|
||||
[harnessAgents],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
if (selectedAgentId) {
|
||||
setSelectedAgentId(null)
|
||||
}
|
||||
if (legacyAgents.length === 0) {
|
||||
if (selectedAgentId) setSelectedAgentId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!selectedAgentId ||
|
||||
!agents.some((agent) => agent.agentId === selectedAgentId)
|
||||
!legacyAgents.some((agent) => agent.agentId === selectedAgentId)
|
||||
) {
|
||||
setSelectedAgentId(agents[0].agentId)
|
||||
setSelectedAgentId(legacyAgents[0].agentId)
|
||||
}
|
||||
}, [agents, selectedAgentId])
|
||||
}, [legacyAgents, selectedAgentId])
|
||||
|
||||
const handleSend = (input: { text: string }) => {
|
||||
if (!selectedAgentId) return
|
||||
@@ -110,7 +127,7 @@ export const AgentCommandHome: FC = () => {
|
||||
setSelectedAgentId(agent.agentId)
|
||||
}
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
const selectedAgent = legacyAgents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
const selectedAgentReady = selectedAgent
|
||||
@@ -118,13 +135,15 @@ export const AgentCommandHome: FC = () => {
|
||||
: false
|
||||
const selectedAgentStatus =
|
||||
selectedAgent?.source === 'agent-harness' ? 'running' : status?.status
|
||||
const selectedCard =
|
||||
cardData.find((agent) => agent.agentId === selectedAgentId) ?? cardData[0]
|
||||
const selectedAgentName =
|
||||
selectedAgent?.name ?? orderedAgents[0]?.name ?? 'your agent'
|
||||
|
||||
const hasAgents = legacyAgents.length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-full px-4 py-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
|
||||
{cardData.length > 0 ? (
|
||||
{hasAgents ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
@@ -140,7 +159,7 @@ export const AgentCommandHome: FC = () => {
|
||||
<div className="w-full max-w-3xl">
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
agents={legacyAgents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
@@ -151,7 +170,7 @@ export const AgentCommandHome: FC = () => {
|
||||
attachmentsEnabled={false}
|
||||
placeholder={
|
||||
selectedAgentReady
|
||||
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
|
||||
? `Ask ${selectedAgentName} to handle a task...`
|
||||
: 'Agent runtime is not running...'
|
||||
}
|
||||
/>
|
||||
@@ -162,7 +181,8 @@ export const AgentCommandHome: FC = () => {
|
||||
|
||||
<RecentThreads
|
||||
activeAgentId={selectedAgentId}
|
||||
agents={cardData}
|
||||
agents={orderedAgents}
|
||||
adapters={adapters}
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
/>
|
||||
|
||||
@@ -54,25 +54,40 @@ interface ConversationInputProps {
|
||||
placeholder?: string
|
||||
attachmentsEnabled?: boolean
|
||||
variant?: 'home' | 'conversation'
|
||||
/**
|
||||
* When set, a Stop button surfaces to the left of the voice mic
|
||||
* while `streaming === true`. Click cancels the active turn
|
||||
* server-side via the chat-cancel endpoint. Absent → no Stop
|
||||
* button (legacy behaviour for the home composer).
|
||||
*/
|
||||
onStop?: () => void
|
||||
}
|
||||
|
||||
function InputActionButton({
|
||||
disabled,
|
||||
onClick,
|
||||
streaming,
|
||||
hasContent,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
streaming: boolean
|
||||
hasContent: boolean
|
||||
}) {
|
||||
// Show the spinner while streaming only when there's nothing to
|
||||
// send — once the user types something, the icon flips back to the
|
||||
// paper-plane so it reads as "queue this message" instead of
|
||||
// "still working".
|
||||
const showSpinner = streaming && !hasContent
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
title={streaming && hasContent ? 'Queue message' : undefined}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{streaming ? (
|
||||
{showSpinner ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
@@ -81,6 +96,22 @@ function InputActionButton({
|
||||
)
|
||||
}
|
||||
|
||||
function StopButton({ onStop }: { onStop: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onStop}
|
||||
title="Stop current turn — queued messages will start next."
|
||||
aria-label="Stop current turn"
|
||||
className="h-8 w-8 flex-shrink-0 rounded-lg bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15 hover:text-destructive"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function VoiceButton({
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
@@ -299,6 +330,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
placeholder,
|
||||
attachmentsEnabled = true,
|
||||
variant = 'conversation',
|
||||
onStop,
|
||||
}) => {
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
@@ -379,10 +411,17 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
|
||||
const hasContent = input.trim().length > 0 || attachments.length > 0
|
||||
// Queue-aware composers (the conversation panel passes `onStop`)
|
||||
// accept input while streaming — the parent decides whether the
|
||||
// submission opens a new turn or enqueues onto the active one.
|
||||
// Surfaces without a Stop hook (home) keep the legacy behaviour
|
||||
// and block input until the current turn finishes.
|
||||
const queueAware = Boolean(onStop)
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim()
|
||||
if (disabled || isStaging || streaming) return
|
||||
if (disabled || isStaging) return
|
||||
if (streaming && !queueAware) return
|
||||
if (!text && attachments.length === 0) return
|
||||
onSend({ text, attachments })
|
||||
setInput('')
|
||||
@@ -512,6 +551,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{streaming && onStop ? <StopButton onStop={onStop} /> : null}
|
||||
<VoiceButton
|
||||
isRecording={voice.isRecording}
|
||||
isTranscribing={voice.isTranscribing}
|
||||
@@ -529,12 +569,13 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
!!disabled ||
|
||||
voice.isRecording ||
|
||||
voice.isTranscribing ||
|
||||
streaming
|
||||
(streaming && !queueAware)
|
||||
}
|
||||
onClick={handleSend}
|
||||
// Spinner stays the user-facing "agent is busy" hint; with the
|
||||
// queue active we still spin while a turn is in flight.
|
||||
streaming={streaming}
|
||||
hasContent={hasContent}
|
||||
/>
|
||||
</div>
|
||||
{voice.error ? (
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Quote, TriangleAlert } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { adapterLabel } from '@/entrypoints/app/agents/AdapterIcon'
|
||||
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
|
||||
import type {
|
||||
HarnessAdapterHealth,
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { AgentTile } from '@/entrypoints/app/agents/agent-row/AgentTile'
|
||||
import {
|
||||
firstNonBlankLine,
|
||||
truncate,
|
||||
} from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
|
||||
import type { AgentLiveness } from '@/entrypoints/app/agents/LivenessDot'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HomeAgentCardProps {
|
||||
agent: HarnessAgent
|
||||
adapter: HarnessAgentAdapter | 'unknown'
|
||||
/** Per-adapter health snapshot, shared across cards rendering the
|
||||
* same adapter. `null` when the /adapters response hasn't surfaced
|
||||
* health yet (we treat that as healthy until proven otherwise). */
|
||||
adapterHealth: HarnessAdapterHealth | null
|
||||
/** Highlights the card with an accent ring; tells the user which
|
||||
* agent the conversation input is bound to. */
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const PREVIEW_CHARS = 100
|
||||
|
||||
/**
|
||||
* Grid-shaped card for the /home Recent agents section. Composition
|
||||
* mirrors the rail's `AgentRowCard` but the layout is a vertical
|
||||
* column sized for a 1/3-width tile rather than a full-width row.
|
||||
*
|
||||
* Reuses `<AgentTile>`, `<LivenessDot>`, `livenessDetail`,
|
||||
* `formatRelativeTime`, `firstNonBlankLine`, `truncate`, and the
|
||||
* inline `Unavailable` chip pattern so the visual language is
|
||||
* continuous between rail and grid.
|
||||
*/
|
||||
export const HomeAgentCard: FC<HomeAgentCardProps> = ({
|
||||
agent,
|
||||
adapter,
|
||||
adapterHealth,
|
||||
active,
|
||||
onClick,
|
||||
}) => {
|
||||
const status = agent.status ?? 'unknown'
|
||||
const lastUsedAt = agent.lastUsedAt ?? null
|
||||
const isWorking = status === 'working'
|
||||
const isAsleep = status === 'asleep'
|
||||
const isError = status === 'error'
|
||||
const hasActiveTurn = Boolean(agent.activeTurnId)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group flex min-h-32 w-full min-w-0 flex-col rounded-2xl border bg-card p-4 text-left shadow-sm transition-colors',
|
||||
active && 'ring-1 ring-[var(--accent-orange)]/30',
|
||||
isWorking
|
||||
? 'border-[var(--accent-orange)]/40'
|
||||
: isError
|
||||
? 'border-destructive/30'
|
||||
: 'border-border/60 hover:border-[var(--accent-orange)]/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentTile adapter={adapter} status={status} lastUsedAt={lastUsedAt} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-sm">
|
||||
{displayName(agent)}
|
||||
</span>
|
||||
{isWorking && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto bg-amber-50 text-amber-900 hover:bg-amber-50"
|
||||
>
|
||||
Working
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<SummaryLine
|
||||
adapter={adapter}
|
||||
modelId={agent.modelId ?? null}
|
||||
reasoningEffort={agent.reasoningEffort ?? null}
|
||||
adapterHealth={adapterHealth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LastMessage message={agent.lastUserMessage ?? null} />
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2 text-muted-foreground text-xs">
|
||||
<span>{statusFootnote(status, lastUsedAt)}</span>
|
||||
{hasActiveTurn ? (
|
||||
<ResumeChip />
|
||||
) : isAsleep ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Asleep
|
||||
</Badge>
|
||||
) : isError ? (
|
||||
<ErrorChip lastError={agent.lastError ?? null} />
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const SummaryLine: FC<{
|
||||
adapter: HarnessAgentAdapter | 'unknown'
|
||||
modelId: string | null
|
||||
reasoningEffort: string | null
|
||||
adapterHealth: HarnessAdapterHealth | null
|
||||
}> = ({ adapter, modelId, reasoningEffort, adapterHealth }) => {
|
||||
const parts = [adapterLabel(adapter)]
|
||||
if (modelId) parts.push(modelId)
|
||||
if (reasoningEffort) parts.push(reasoningEffort)
|
||||
const unhealthy = adapterHealth?.healthy === false
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex items-center gap-1.5 text-muted-foreground text-xs',
|
||||
unhealthy && 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{parts.join(' · ')}</span>
|
||||
{unhealthy && (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 cursor-default gap-1 border-amber-500/40 bg-amber-50 px-1.5 text-amber-900 hover:bg-amber-50"
|
||||
>
|
||||
<TriangleAlert className="size-2.5" />
|
||||
<span className="font-normal">Unavailable</span>
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" className="w-72 text-sm">
|
||||
<div className="font-medium">
|
||||
{adapterLabel(adapter)} CLI not available
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
{adapterHealth?.reason ??
|
||||
'Adapter binary missing on $PATH. Install it from the adapter docs to use this agent.'}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LastMessage: FC<{ message: string | null }> = ({ message }) => {
|
||||
if (!message) {
|
||||
return (
|
||||
<p className="mt-3 flex-1 text-muted-foreground/70 text-xs italic">
|
||||
No messages yet — start a chat
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p className="mt-3 line-clamp-2 flex flex-1 items-start gap-1.5 text-foreground/85 text-sm italic leading-snug">
|
||||
<Quote
|
||||
className="mt-1 size-3 shrink-0 text-muted-foreground/60"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="line-clamp-2">
|
||||
{truncate(firstNonBlankLine(message), PREVIEW_CHARS)}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const ResumeChip: FC = () => (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent-orange)] px-2.5 py-0.5 font-medium text-[11px] text-white shadow-sm">
|
||||
<span className="relative flex size-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white/70 opacity-75" />
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-white" />
|
||||
</span>
|
||||
Resume
|
||||
</span>
|
||||
)
|
||||
|
||||
const ErrorChip: FC<{ lastError: string | null }> = ({ lastError }) => {
|
||||
if (!lastError) {
|
||||
return <Badge variant="destructive">Attention</Badge>
|
||||
}
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="destructive" className="cursor-default">
|
||||
Attention
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="left"
|
||||
className="max-w-xs whitespace-pre-wrap font-mono text-xs"
|
||||
>
|
||||
{lastError}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer left side: relative time on every state EXCEPT working,
|
||||
* which shows `now` (the dot is already pulsing — restating it as
|
||||
* "Working" would duplicate the pill in the title row).
|
||||
*/
|
||||
function statusFootnote(
|
||||
status: AgentLiveness,
|
||||
lastUsedAt: number | null,
|
||||
): string {
|
||||
if (status === 'working') return 'now'
|
||||
return formatRelativeTime(lastUsedAt)
|
||||
}
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const OC_UUID_PATTERN =
|
||||
/^oc-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
function displayName(agent: HarnessAgent): string {
|
||||
const name = agent.name?.trim()
|
||||
const id = agent.id
|
||||
if (!name || name === id) {
|
||||
if (OC_UUID_PATTERN.test(id)) return id.slice(0, 11)
|
||||
if (UUID_PATTERN.test(id)) return id.slice(0, 8)
|
||||
return id
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ListPlus, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
Queue,
|
||||
QueueItem,
|
||||
QueueItemAction,
|
||||
QueueItemActions,
|
||||
QueueItemAttachment,
|
||||
QueueItemContent,
|
||||
QueueItemFile,
|
||||
QueueItemImage,
|
||||
QueueList,
|
||||
QueueSection,
|
||||
QueueSectionContent,
|
||||
QueueSectionLabel,
|
||||
QueueSectionTrigger,
|
||||
} from '@/components/ai-elements/queue'
|
||||
import type {
|
||||
HarnessQueuedMessage,
|
||||
HarnessQueuedMessageAttachment,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { firstNonBlankLine } from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
|
||||
|
||||
interface QueuePanelProps {
|
||||
queue: HarnessQueuedMessage[]
|
||||
onRemove: (messageId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the agent's pending message queue using the shared AI
|
||||
* Elements `Queue` primitives. Caller is expected to gate render on
|
||||
* `queue.length > 0` — when empty, this returns null so the panel
|
||||
* disappears cleanly between turns.
|
||||
*/
|
||||
export const QueuePanel: FC<QueuePanelProps> = ({ queue, onRemove }) => {
|
||||
if (queue.length === 0) return null
|
||||
return (
|
||||
<Queue>
|
||||
<QueueSection>
|
||||
<QueueSectionTrigger>
|
||||
<QueueSectionLabel
|
||||
count={queue.length}
|
||||
label={queue.length === 1 ? 'queued message' : 'queued messages'}
|
||||
icon={<ListPlus className="size-3.5" />}
|
||||
/>
|
||||
</QueueSectionTrigger>
|
||||
<QueueSectionContent>
|
||||
<QueueList>
|
||||
{queue.map((entry) => (
|
||||
<QueueItem key={entry.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<QueueItemContent>
|
||||
{firstNonBlankLine(entry.message)}
|
||||
</QueueItemContent>
|
||||
<QueueItemActions>
|
||||
<QueueItemAction
|
||||
aria-label="Remove from queue"
|
||||
onClick={() => onRemove(entry.id)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</QueueItemAction>
|
||||
</QueueItemActions>
|
||||
</div>
|
||||
{entry.attachments && entry.attachments.length > 0 ? (
|
||||
<QueueItemAttachment>
|
||||
{entry.attachments.map((attachment, idx) =>
|
||||
renderAttachment(entry.id, attachment, idx),
|
||||
)}
|
||||
</QueueItemAttachment>
|
||||
) : null}
|
||||
</QueueItem>
|
||||
))}
|
||||
</QueueList>
|
||||
</QueueSectionContent>
|
||||
</QueueSection>
|
||||
</Queue>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAttachment(
|
||||
messageId: string,
|
||||
attachment: HarnessQueuedMessageAttachment,
|
||||
idx: number,
|
||||
) {
|
||||
if (attachment.mediaType.startsWith('image/')) {
|
||||
const src = `data:${attachment.mediaType};base64,${attachment.data}`
|
||||
return <QueueItemImage key={`${messageId}-${idx}`} src={src} />
|
||||
}
|
||||
return (
|
||||
<QueueItemFile key={`${messageId}-${idx}`}>
|
||||
{attachment.mediaType}
|
||||
</QueueItemFile>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { orderHomeAgents } from './home-agent-card.helpers'
|
||||
|
||||
function agent(overrides: Partial<HarnessAgent>): HarnessAgent {
|
||||
return {
|
||||
id: overrides.id ?? 'agent-x',
|
||||
name: overrides.name ?? overrides.id ?? 'agent-x',
|
||||
adapter: overrides.adapter ?? 'codex',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: `agent:${overrides.id ?? 'agent-x'}:main`,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('orderHomeAgents', () => {
|
||||
it('places active-turn agents before everyone else', () => {
|
||||
const sorted = orderHomeAgents([
|
||||
agent({ id: 'a', lastUsedAt: 5000 }),
|
||||
agent({ id: 'b', lastUsedAt: 9000, activeTurnId: 'turn-1' }),
|
||||
agent({ id: 'c', lastUsedAt: 7000 }),
|
||||
])
|
||||
expect(sorted.map((a) => a.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('orders non-active agents by lastUsedAt desc', () => {
|
||||
const sorted = orderHomeAgents([
|
||||
agent({ id: 'old', lastUsedAt: 1000 }),
|
||||
agent({ id: 'new', lastUsedAt: 9000 }),
|
||||
agent({ id: 'mid', lastUsedAt: 5000 }),
|
||||
])
|
||||
expect(sorted.map((a) => a.id)).toEqual(['new', 'mid', 'old'])
|
||||
})
|
||||
|
||||
it('puts the gateway `main` seed agent above other never-used agents', () => {
|
||||
const sorted = orderHomeAgents([
|
||||
agent({ id: 'oc-aaaaaa', lastUsedAt: null }),
|
||||
agent({ id: 'main', lastUsedAt: null }),
|
||||
agent({ id: 'oc-bbbbbb', lastUsedAt: null }),
|
||||
])
|
||||
expect(sorted.map((a) => a.id)).toEqual(['main', 'oc-aaaaaa', 'oc-bbbbbb'])
|
||||
})
|
||||
|
||||
it('sends never-used agents to the bottom even when `main` is among them', () => {
|
||||
const sorted = orderHomeAgents([
|
||||
agent({ id: 'main', lastUsedAt: null }),
|
||||
agent({ id: 'used', lastUsedAt: 5000 }),
|
||||
])
|
||||
expect(sorted.map((a) => a.id)).toEqual(['used', 'main'])
|
||||
})
|
||||
|
||||
it('does NOT sort by pinned — pinned agents are treated like any other', () => {
|
||||
const sorted = orderHomeAgents([
|
||||
agent({ id: 'unpinned-recent', lastUsedAt: 9000, pinned: false }),
|
||||
agent({ id: 'pinned-old', lastUsedAt: 1000, pinned: true }),
|
||||
])
|
||||
expect(sorted.map((a) => a.id)).toEqual(['unpinned-recent', 'pinned-old'])
|
||||
})
|
||||
|
||||
it('falls back to id-stable ordering when lastUsedAt ties', () => {
|
||||
const sorted = orderHomeAgents([
|
||||
agent({ id: 'b', lastUsedAt: 5000 }),
|
||||
agent({ id: 'a', lastUsedAt: 5000 }),
|
||||
])
|
||||
expect(sorted.map((a) => a.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
|
||||
/**
|
||||
* Order for the /home Recent agents grid.
|
||||
*
|
||||
* 1. Active turn first — agents mid-turn float to the top so the
|
||||
* Resume affordance is the first thing the user sees on /home.
|
||||
* 2. The protected gateway-side `main` agent stays pinned-to-top in
|
||||
* the never-used group on a fresh install (mirrors the rail).
|
||||
* 3. Recency (`lastUsedAt` desc).
|
||||
* 4. `id` tiebreaker for stability so the grid doesn't reshuffle on
|
||||
* every 5-second poll.
|
||||
*
|
||||
* Pin is NOT a sort key. The home grid is action-oriented and trusts
|
||||
* recency + active-turn to surface the right agent; pinning is an
|
||||
* organisation tool that lives on the rail at /agents.
|
||||
*/
|
||||
export function orderHomeAgents(agents: HarnessAgent[]): HarnessAgent[] {
|
||||
return [...agents].sort((a, b) => {
|
||||
const aActive = a.activeTurnId != null
|
||||
const bActive = b.activeTurnId != null
|
||||
if (aActive !== bActive) return aActive ? -1 : 1
|
||||
|
||||
// Recency wins outright. Never-used agents (`lastUsedAt == null`)
|
||||
// both fall to the same `-Infinity` bucket and the seed/id rules
|
||||
// below decide their order — but a used agent always beats any
|
||||
// never-used agent regardless of id.
|
||||
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
|
||||
// Inside the never-used (or exact-tie) group: pin the gateway
|
||||
// `main` seed to the top of the group on a fresh install, then
|
||||
// fall back to id-stable order so the grid doesn't reshuffle on
|
||||
// every poll.
|
||||
const aSeed = a.id === 'main' && a.lastUsedAt == null
|
||||
const bSeed = b.id === 'main' && b.lastUsedAt == null
|
||||
if (aSeed !== bSeed) return aSeed ? -1 : 1
|
||||
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
type OpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import type { AgentOverview } from './useAgentDashboard'
|
||||
|
||||
function resolveAgentStatus(
|
||||
gatewayStatus: OpenClawStatus['status'] | undefined,
|
||||
liveStatus: AgentOverview['status'] | undefined,
|
||||
): AgentCardData['status'] {
|
||||
// Gateway-level errors take precedence
|
||||
if (gatewayStatus === 'error') return 'error'
|
||||
if (gatewayStatus === 'starting') return 'working'
|
||||
|
||||
// Per-agent live status from the WS observer
|
||||
if (liveStatus === 'working') return 'working'
|
||||
if (liveStatus === 'error') return 'error'
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent card display data by merging the raw agent entries from
|
||||
* the gateway with enriched overview data from the dashboard API.
|
||||
*
|
||||
* Pure function — no hooks, no IndexedDB, no async.
|
||||
*/
|
||||
export function buildAgentCardData(
|
||||
agents: AgentEntry[],
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
dashboard: AgentOverview[] | undefined,
|
||||
): AgentCardData[] {
|
||||
return agents.map((agent) => {
|
||||
const overview = dashboard?.find((d) => d.agentId === agent.agentId)
|
||||
|
||||
return {
|
||||
agentId: agent.agentId,
|
||||
name: agent.name,
|
||||
model: getModelDisplayName(agent.model),
|
||||
status:
|
||||
agent.source === 'agent-harness'
|
||||
? 'idle'
|
||||
: resolveAgentStatus(status, overview?.status),
|
||||
lastMessage: overview?.latestMessage?.slice(0, 200) ?? undefined,
|
||||
lastMessageTimestamp: overview?.latestMessageAt ?? undefined,
|
||||
activitySummary: overview?.activitySummary ?? undefined,
|
||||
currentTool: overview?.currentTool ?? undefined,
|
||||
costUsd: overview?.totalCostUsd ?? undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -36,6 +36,15 @@ interface UseAgentConversationOptions {
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
onComplete?: () => void
|
||||
onSessionKeyChange?: (sessionKey: string) => void
|
||||
/**
|
||||
* Server-side active turn id, surfaced via the listing query. When
|
||||
* this changes from null/<id> to a different non-null id while we
|
||||
* aren't already streaming (e.g. the server just popped a queued
|
||||
* message and started a new turn), the hook reattaches via
|
||||
* /chat/active so the chat panel picks up the live stream without
|
||||
* waiting for a remount.
|
||||
*/
|
||||
activeTurnId?: string | null
|
||||
}
|
||||
|
||||
export function useAgentConversation(
|
||||
@@ -211,31 +220,46 @@ export function useAgentConversation(
|
||||
}
|
||||
processEventRef.current = processAgentHarnessStreamEvent
|
||||
|
||||
// On mount (and whenever the agent changes), check whether the
|
||||
// server has an in-flight turn for this agent and reattach to it.
|
||||
// This is what makes the chat resilient across tab close/reopen,
|
||||
// refresh, and navigation: the runtime call kept running on the
|
||||
// server while we were away. Effect only depends on `agentId` —
|
||||
// the event handler is read off a ref so this doesn't re-subscribe
|
||||
// every render.
|
||||
const activeTurnIdDep = options.activeTurnId ?? null
|
||||
|
||||
// On mount, on agent change, and whenever the listing reports a
|
||||
// *new* active turn id, check whether the server has an in-flight
|
||||
// turn for this agent and reattach to it. This catches three
|
||||
// cases at once: the chat resilience flow (tab close/reopen),
|
||||
// navigation between agents, AND queue drain (the server starts a
|
||||
// new turn from a queued message → activeTurnId flips → attach).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const abortController = new AbortController()
|
||||
// Reference the dep inside the body so biome's exhaustive-deps
|
||||
// rule sees it consumed; the value is just an "any non-null
|
||||
// active turn id" trigger — the actual id we attach to comes
|
||||
// from the fresh fetchActiveHarnessTurn call below.
|
||||
void activeTurnIdDep
|
||||
|
||||
const attemptResume = async () => {
|
||||
// Track whether *we* started a stream in this run. When the
|
||||
// early-return paths fire (no active turn, or a `send()` /
|
||||
// earlier resume already owns `streamAbortRef`), the finally
|
||||
// block must NOT touch streaming/turnIdRef/lastSeqRef —
|
||||
// otherwise we clobber the in-flight stream's state and the
|
||||
// Stop button drops out mid-turn while events keep arriving.
|
||||
let weStartedStream = false
|
||||
try {
|
||||
const active = await fetchActiveHarnessTurn(agentId)
|
||||
if (cancelled || !active || active.status !== 'running') return
|
||||
if (streamAbortRef.current) return // a fresh send already in flight
|
||||
if (streamAbortRef.current) return // someone else already owns the stream
|
||||
|
||||
// Stage a placeholder turn so the streamed events have a row
|
||||
// to render into. We don't have the user message text on
|
||||
// resume; the assistant turn is what we're catching up on.
|
||||
// to render into. The server now persists the kicking-off
|
||||
// prompt on the active turn, so we render it as the user
|
||||
// bubble immediately — no empty-bubble flicker when a queued
|
||||
// message starts running.
|
||||
setTurns((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
userText: '',
|
||||
userText: active.prompt ?? '',
|
||||
parts: [],
|
||||
done: false,
|
||||
timestamp: active.startedAt,
|
||||
@@ -247,6 +271,7 @@ export function useAgentConversation(
|
||||
lastSeqRef.current = null
|
||||
streamAbortRef.current = abortController
|
||||
setStreaming(true)
|
||||
weStartedStream = true
|
||||
|
||||
const response = await attachToHarnessTurn(agentId, {
|
||||
turnId: active.turnId,
|
||||
@@ -265,10 +290,20 @@ export function useAgentConversation(
|
||||
// Resume is best-effort; transient errors fall back to the
|
||||
// user starting a new turn manually.
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
// Always release `streamAbortRef` if we owned it — even when
|
||||
// the effect was cancelled mid-stream (a listing poll
|
||||
// captured the next queue-drain turn id, for example). If we
|
||||
// don't, the next effect run hits `if (streamAbortRef.current)
|
||||
// return` against our now-aborted controller and never
|
||||
// reattaches, leaving `streaming === true` with no live stream.
|
||||
if (weStartedStream && streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
// The other state (streaming flag, turn id, lastSeq) is the
|
||||
// *current run's* lifecycle: only reset it on a clean exit.
|
||||
// When `cancelled` is true the next run will set these
|
||||
// itself, so resetting here would only cause a brief flicker.
|
||||
if (!cancelled && weStartedStream) {
|
||||
turnIdRef.current = null
|
||||
lastSeqRef.current = null
|
||||
setStreaming(false)
|
||||
@@ -281,7 +316,7 @@ export function useAgentConversation(
|
||||
cancelled = true
|
||||
abortController.abort()
|
||||
}
|
||||
}, [agentId])
|
||||
}, [agentId, activeTurnIdDep])
|
||||
|
||||
const send = async (input: string | SendInput) => {
|
||||
const normalized: SendInput =
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export interface AgentOverview {
|
||||
agentId: string
|
||||
status: 'working' | 'idle' | 'error' | 'unknown'
|
||||
latestMessage: string | null
|
||||
latestMessageAt: number | null
|
||||
activitySummary: string | null
|
||||
currentTool: string | null
|
||||
totalCostUsd: number
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
agents: AgentOverview[]
|
||||
summary: {
|
||||
totalAgents: number
|
||||
totalCostUsd: number
|
||||
}
|
||||
}
|
||||
|
||||
interface StatusEvent {
|
||||
agentId: string
|
||||
status: AgentOverview['status']
|
||||
currentTool: string | null
|
||||
error: string | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const DASHBOARD_QUERY_KEY = ['claw', 'dashboard']
|
||||
|
||||
export function useAgentDashboard(enabled: boolean) {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
const ready = enabled && Boolean(baseUrl) && !urlLoading
|
||||
|
||||
// Initial data load + periodic refresh as fallback
|
||||
const query = useQuery<DashboardResponse>({
|
||||
queryKey: [...DASHBOARD_QUERY_KEY, baseUrl],
|
||||
queryFn: async () => {
|
||||
const url = new URL('/claw/dashboard', baseUrl as string)
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) throw new Error('Failed to fetch dashboard')
|
||||
return response.json()
|
||||
},
|
||||
enabled: ready,
|
||||
})
|
||||
|
||||
// SSE subscription for real-time status patches
|
||||
useEffect(() => {
|
||||
if (!ready || !baseUrl) return
|
||||
|
||||
const streamUrl = new URL('/claw/dashboard/stream', baseUrl)
|
||||
const eventSource = new EventSource(streamUrl.toString())
|
||||
|
||||
eventSource.addEventListener('snapshot', (event) => {
|
||||
try {
|
||||
const dashboard = JSON.parse(event.data) as DashboardResponse
|
||||
queryClient.setQueryData([...DASHBOARD_QUERY_KEY, baseUrl], dashboard)
|
||||
} catch {}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('status', (event) => {
|
||||
try {
|
||||
const status = JSON.parse(event.data) as StatusEvent
|
||||
queryClient.setQueryData<DashboardResponse>(
|
||||
[...DASHBOARD_QUERY_KEY, baseUrl],
|
||||
(prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
agents: prev.agents.map((agent) =>
|
||||
agent.agentId === status.agentId
|
||||
? {
|
||||
...agent,
|
||||
status: status.status,
|
||||
currentTool: status.currentTool,
|
||||
}
|
||||
: agent,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch {}
|
||||
})
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}, [ready, baseUrl, queryClient])
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -2,67 +2,87 @@ import { Loader2 } from 'lucide-react'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import { AgentRowCard } from './AgentRowCard'
|
||||
import { AgentsEmptyState } from './AgentsEmptyState'
|
||||
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type {
|
||||
AgentAdapterHealth,
|
||||
AgentRowData,
|
||||
} from './agent-row/agent-row.types'
|
||||
import type { AgentListItem } from './agents-page-types'
|
||||
import type { AgentLiveness } from './LivenessDot'
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AgentListItem[]
|
||||
/**
|
||||
* Optional per-agent activity metadata. Keyed by `agentId`. Missing
|
||||
* entries fall back to status='unknown' / lastUsedAt=null and the
|
||||
* row renders an "unknown" dot. The server will populate this once
|
||||
* the activity tracker ships; the page works without it.
|
||||
*/
|
||||
/** Optional per-agent activity metadata, keyed by `agentId`. */
|
||||
activity?: Record<
|
||||
string,
|
||||
{ status: AgentLiveness; lastUsedAt: number | null }
|
||||
>
|
||||
/**
|
||||
* Lookup table from harness agent id → adapter + reasoning effort,
|
||||
* sourced from `useHarnessAgents`. Lets the row card render the
|
||||
* correct adapter icon and chips for harness agents (legacy
|
||||
* /claw/agents entries fall back to inferring from `runtimeLabel`).
|
||||
*/
|
||||
/** Lookup table from harness id → enriched agent record. */
|
||||
harnessAgentLookup?: Map<string, HarnessAgent>
|
||||
/** Adapter catalog (carries per-adapter health). */
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
loading: boolean
|
||||
deletingAgentKey: string | null
|
||||
onCreateAgent: () => void
|
||||
onDeleteAgent: (agent: AgentListItem) => void
|
||||
onPinToggle: (agent: AgentListItem, next: boolean) => void
|
||||
}
|
||||
|
||||
export const AgentList: FC<AgentListProps> = ({
|
||||
agents,
|
||||
activity,
|
||||
harnessAgentLookup,
|
||||
adapters,
|
||||
loading,
|
||||
deletingAgentKey,
|
||||
onCreateAgent,
|
||||
onDeleteAgent,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
// Sort by recency: most recently used first; never-used agents drop
|
||||
// to the bottom in id-stable order so the list doesn't reshuffle on
|
||||
// every refresh. The pinned exception is the gateway's `main` agent
|
||||
// when it's never been touched — keep it at the top so a fresh
|
||||
// install has an obvious starting point.
|
||||
const adapterHealth = useMemo(() => {
|
||||
const map = new Map<HarnessAgentAdapter, AgentAdapterHealth>()
|
||||
for (const adapter of adapters) {
|
||||
if (adapter.health) {
|
||||
map.set(adapter.id, {
|
||||
healthy: adapter.health.healthy,
|
||||
reason: adapter.health.reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [adapters])
|
||||
|
||||
// Sort: pinned rows first, then most recently used, then never-used
|
||||
// agents in id-stable order. The gateway's `main` agent stays
|
||||
// pinned-to-top when never touched so a fresh install has an
|
||||
// obvious starting point.
|
||||
const ordered = useMemo(() => {
|
||||
const withScore = agents.map((agent) => {
|
||||
const lastUsedAt = activity?.[agent.agentId]?.lastUsedAt ?? null
|
||||
return { agent, lastUsedAt }
|
||||
const withMeta = agents.map((agent) => {
|
||||
const harness = harnessAgentLookup?.get(agent.agentId)
|
||||
return {
|
||||
agent,
|
||||
pinned: harness?.pinned ?? false,
|
||||
lastUsedAt: activity?.[agent.agentId]?.lastUsedAt ?? null,
|
||||
}
|
||||
})
|
||||
return withScore
|
||||
return withMeta
|
||||
.sort((a, b) => {
|
||||
const aPinned = a.agent.agentId === 'main' && a.lastUsedAt === null
|
||||
const bPinned = b.agent.agentId === 'main' && b.lastUsedAt === null
|
||||
if (aPinned && !bPinned) return -1
|
||||
if (!aPinned && bPinned) return 1
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
const aSeed = a.agent.agentId === 'main' && a.lastUsedAt === null
|
||||
const bSeed = b.agent.agentId === 'main' && b.lastUsedAt === null
|
||||
if (aSeed && !bSeed) return -1
|
||||
if (!aSeed && bSeed) return 1
|
||||
const aValue = a.lastUsedAt ?? -Infinity
|
||||
const bValue = b.lastUsedAt ?? -Infinity
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
return a.agent.agentId.localeCompare(b.agent.agentId)
|
||||
})
|
||||
.map((entry) => entry.agent)
|
||||
}, [activity, agents])
|
||||
}, [activity, agents, harnessAgentLookup])
|
||||
|
||||
if (loading && agents.length === 0) {
|
||||
return (
|
||||
@@ -80,18 +100,23 @@ export const AgentList: FC<AgentListProps> = ({
|
||||
<div className="grid gap-3">
|
||||
{ordered.map((agent) => {
|
||||
const harness = harnessAgentLookup?.get(agent.agentId)
|
||||
const adapter: HarnessAgentAdapter | undefined =
|
||||
const adapter: HarnessAgentAdapter | 'unknown' =
|
||||
harness?.adapter ?? inferAdapterFromLabel(agent.runtimeLabel)
|
||||
const data = buildRowData({
|
||||
agent,
|
||||
adapter,
|
||||
harness,
|
||||
activity: activity?.[agent.agentId],
|
||||
adapterHealth:
|
||||
adapterHealth.get(adapter as HarnessAgentAdapter) ?? null,
|
||||
})
|
||||
return (
|
||||
<AgentRowCard
|
||||
key={agent.key}
|
||||
agent={agent}
|
||||
status={activity?.[agent.agentId]?.status}
|
||||
lastUsedAt={activity?.[agent.agentId]?.lastUsedAt}
|
||||
adapter={adapter}
|
||||
reasoningEffort={harness?.reasoningEffort ?? null}
|
||||
onDelete={onDeleteAgent}
|
||||
data={data}
|
||||
deleting={deletingAgentKey === agent.key}
|
||||
onDelete={onDeleteAgent}
|
||||
onPinToggle={onPinToggle}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -99,10 +124,53 @@ export const AgentList: FC<AgentListProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
function inferAdapterFromLabel(label: string): HarnessAgentAdapter | undefined {
|
||||
function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
|
||||
const lower = label?.toLowerCase()
|
||||
if (lower === 'claude code') return 'claude'
|
||||
if (lower === 'codex') return 'codex'
|
||||
if (lower === 'openclaw') return 'openclaw'
|
||||
return undefined
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const ZERO_BUCKETS = (): number[] => Array.from({ length: 14 }, () => 0)
|
||||
|
||||
function buildRowData(input: {
|
||||
agent: AgentListItem
|
||||
adapter: HarnessAgentAdapter | 'unknown'
|
||||
harness: HarnessAgent | undefined
|
||||
activity: { status: AgentLiveness; lastUsedAt: number | null } | undefined
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
}): AgentRowData {
|
||||
const { agent, adapter, harness, activity, adapterHealth } = input
|
||||
return {
|
||||
agent,
|
||||
adapter,
|
||||
modelLabel: deriveModelLabel(agent, harness),
|
||||
reasoningEffort: harness?.reasoningEffort ?? null,
|
||||
status: activity?.status ?? 'unknown',
|
||||
lastUsedAt: activity?.lastUsedAt ?? harness?.lastUsedAt ?? null,
|
||||
pinned: harness?.pinned ?? false,
|
||||
cwd: harness?.cwd ?? null,
|
||||
lastUserMessage: harness?.lastUserMessage ?? null,
|
||||
tokens: harness?.tokens ?? null,
|
||||
turnsByDay: harness?.turnsByDay ?? ZERO_BUCKETS(),
|
||||
failedByDay: harness?.failedByDay ?? ZERO_BUCKETS(),
|
||||
lastError: harness?.lastError ?? null,
|
||||
lastErrorAt: harness?.lastErrorAt ?? null,
|
||||
activeTurnId: harness?.activeTurnId ?? null,
|
||||
adapterHealth,
|
||||
}
|
||||
}
|
||||
|
||||
function deriveModelLabel(
|
||||
agent: AgentListItem,
|
||||
harness: HarnessAgent | undefined,
|
||||
): string | null {
|
||||
// Prefer the agent rail's modelLabel when meaningful; harness's
|
||||
// modelId is a stable identifier but the rail's `modelLabel`
|
||||
// already maps to a friendly display string.
|
||||
if (agent.modelLabel && agent.modelLabel !== 'default') {
|
||||
return agent.modelLabel
|
||||
}
|
||||
return harness?.modelId ?? null
|
||||
}
|
||||
|
||||
@@ -1,270 +1,99 @@
|
||||
import {
|
||||
Copy,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AdapterIcon, adapterLabel } from './AdapterIcon'
|
||||
import {
|
||||
canDelete as canDeleteAgent,
|
||||
canRename as canRenameAgent,
|
||||
displayName,
|
||||
formatRelativeTime,
|
||||
workspaceLabel,
|
||||
} from './agent-display.helpers'
|
||||
import type { HarnessAgentAdapter } from './agent-harness-types'
|
||||
import type { AgentListItem } from './agents-page-types'
|
||||
import { type AgentLiveness, LivenessDot } from './LivenessDot'
|
||||
import { AgentActions } from './agent-row/AgentActions'
|
||||
import { AgentErrorPanel } from './agent-row/AgentErrorPanel'
|
||||
import { AgentLastMessage } from './agent-row/AgentLastMessage'
|
||||
import { AgentMetaRow } from './agent-row/AgentMetaRow'
|
||||
import { AgentSummaryChips } from './agent-row/AgentSummaryChips'
|
||||
import { AgentTile } from './agent-row/AgentTile'
|
||||
import { AgentTitleRow } from './agent-row/AgentTitleRow'
|
||||
import type {
|
||||
AgentRowCallbacks,
|
||||
AgentRowData,
|
||||
} from './agent-row/agent-row.types'
|
||||
|
||||
interface AgentRowCardProps {
|
||||
agent: AgentListItem
|
||||
/**
|
||||
* Per-agent extras the listing surface provides on top of the
|
||||
* minimal `AgentListItem` shape. `lastUsedAt` survives server
|
||||
* restart (sourced from acpx session record); `status` is in-memory
|
||||
* server-side.
|
||||
*/
|
||||
status?: AgentLiveness
|
||||
lastUsedAt?: number | null
|
||||
/** Adapter the agent belongs to. Drives icon + label. */
|
||||
adapter?: HarnessAgentAdapter
|
||||
/** Reasoning effort chip (claude/codex/openclaw catalog). */
|
||||
reasoningEffort?: string | null
|
||||
/** Modeled directly off the inbound delete handler so the parent owns the dialog. */
|
||||
onDelete: (agent: AgentListItem) => void
|
||||
/** Whether THIS agent is mid-delete; renders a spinner in place of the trash icon. */
|
||||
interface AgentRowCardProps extends AgentRowCallbacks {
|
||||
data: AgentRowData
|
||||
/** Whether THIS agent is mid-delete; renders a spinner in the menu. */
|
||||
deleting?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition shell for the agent rail. Owns no state; sub-components
|
||||
* each handle their own micro-state (error-panel collapse, etc.) and
|
||||
* emit callbacks (delete, pin/unpin) for the page to act on.
|
||||
*
|
||||
* The whole card carries state — not just the tile — so the row's
|
||||
* border subtly tells the user what's going on at a glance:
|
||||
* working → accent-orange border with a soft glow
|
||||
* error → destructive border
|
||||
* idle → muted border, lifts on hover
|
||||
*/
|
||||
export const AgentRowCard: FC<AgentRowCardProps> = ({
|
||||
agent,
|
||||
status = 'unknown',
|
||||
lastUsedAt,
|
||||
adapter,
|
||||
reasoningEffort,
|
||||
onDelete,
|
||||
data,
|
||||
deleting,
|
||||
onDelete,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const adapterId = adapter ?? inferAdapterFromListItem(agent)
|
||||
const workspace = workspaceLabel(agent)
|
||||
const lastUsedLabel = formatRelativeTime(lastUsedAt ?? null)
|
||||
const allowDelete = canDeleteAgent(agent)
|
||||
const allowRename = canRenameAgent(agent)
|
||||
|
||||
const handleChat = () => navigate(`/agents/${agent.agentId}`)
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(agent.agentId)
|
||||
toast.success('Agent id copied')
|
||||
} catch {
|
||||
toast.error('Could not copy agent id')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-xl border border-border bg-card p-4 shadow-sm transition-all',
|
||||
'hover:border-[var(--accent-orange)]/50 hover:shadow-sm',
|
||||
// Layout-stable hover. No translate, no shadow change — both
|
||||
// visibly perturb neighbouring rows. Only the border tint
|
||||
// shifts on hover, and the rail's vertical rhythm stays
|
||||
// exactly the same in every state.
|
||||
'group rounded-xl border bg-card p-4 shadow-sm transition-colors',
|
||||
data.status === 'working'
|
||||
? 'border-[var(--accent-orange)]/40'
|
||||
: data.status === 'error'
|
||||
? 'border-destructive/40'
|
||||
: 'border-border hover:border-[var(--accent-orange)]/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Adapter tile + liveness dot in the corner. */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<AdapterIcon adapter={adapterId} className="h-6 w-6" />
|
||||
</div>
|
||||
<LivenessDot
|
||||
status={status}
|
||||
detail={livenessDetail(status, lastUsedAt)}
|
||||
className="absolute -right-0.5 -bottom-0.5"
|
||||
/>
|
||||
</div>
|
||||
<AgentTile
|
||||
adapter={data.adapter}
|
||||
status={data.status}
|
||||
lastUsedAt={data.lastUsedAt}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="truncate font-semibold">{displayName(agent)}</span>
|
||||
{status === 'working' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-amber-50 text-amber-900 hover:bg-amber-50"
|
||||
>
|
||||
Working
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'asleep' && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Asleep
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Badge variant="destructive">Attention</Badge>
|
||||
)}
|
||||
</div>
|
||||
<AgentTitleRow
|
||||
agent={data.agent}
|
||||
status={data.status}
|
||||
pinned={data.pinned}
|
||||
turnsByDay={data.turnsByDay}
|
||||
failedByDay={data.failedByDay}
|
||||
onPinToggle={(next) => onPinToggle(data.agent, next)}
|
||||
/>
|
||||
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{adapterLabel(adapterId)}
|
||||
</Badge>
|
||||
{agent.modelLabel && agent.modelLabel !== 'default' && (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{agent.modelLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{reasoningEffort && reasoningEffort !== 'medium' && (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{reasoningEffort}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<AgentSummaryChips
|
||||
adapter={data.adapter}
|
||||
modelLabel={data.modelLabel}
|
||||
reasoningEffort={data.reasoningEffort}
|
||||
adapterHealth={data.adapterHealth}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>Last used {lastUsedLabel}</span>
|
||||
{workspace && (
|
||||
<>
|
||||
<span aria-hidden>•</span>
|
||||
<span className="truncate font-mono" title={workspace}>
|
||||
{workspace}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AgentLastMessage message={data.lastUserMessage} />
|
||||
|
||||
<AgentMetaRow lastUsedAt={data.lastUsedAt} tokens={data.tokens} />
|
||||
|
||||
{data.status === 'error' && data.lastError && (
|
||||
<AgentErrorPanel
|
||||
agentId={data.agent.agentId}
|
||||
message={data.lastError}
|
||||
errorAt={data.lastErrorAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleChat}>
|
||||
<MessageSquare className="mr-1.5 h-3 w-3" />
|
||||
Chat
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`More actions for ${displayName(agent)}`}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={() => void handleCopyId()}>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Copy id
|
||||
</DropdownMenuItem>
|
||||
<RenameMenuItem disabled={!allowRename} />
|
||||
<ResetHistoryMenuItem />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete(agent)}
|
||||
disabled={!allowDelete || deleting}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<AgentActions
|
||||
agent={data.agent}
|
||||
activeTurnId={data.activeTurnId}
|
||||
deleting={deleting}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenameMenuItem: FC<{ disabled: boolean }> = ({ disabled }) => {
|
||||
const item = (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
if (!disabled) return item
|
||||
// Disabled but with a hint so users know it's coming, not broken.
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block w-full">{item}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Rename coming soon
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const ResetHistoryMenuItem: FC = () => {
|
||||
const item = (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
Reset history
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block w-full">{item}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Reset history coming soon
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function inferAdapterFromListItem(
|
||||
agent: AgentListItem,
|
||||
): HarnessAgentAdapter | 'unknown' {
|
||||
const label = agent.runtimeLabel?.toLowerCase()
|
||||
if (label?.includes('claude')) return 'claude'
|
||||
if (label?.includes('codex')) return 'codex'
|
||||
if (label?.includes('openclaw')) return 'openclaw'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function livenessDetail(
|
||||
status: AgentLiveness,
|
||||
lastUsedAt: number | null | undefined,
|
||||
): string | undefined {
|
||||
if (lastUsedAt == null) return undefined
|
||||
const diffMin = Math.floor((Date.now() - lastUsedAt) / 60_000)
|
||||
if (status === 'idle') return `Idle for ${Math.max(0, diffMin)} min`
|
||||
if (status === 'asleep') {
|
||||
if (diffMin < 60) return `Asleep — quiet for ${diffMin} min`
|
||||
const hr = Math.floor(diffMin / 60)
|
||||
return `Asleep — quiet for ${hr} hr`
|
||||
}
|
||||
if (status === 'working') return 'Working on a turn'
|
||||
if (status === 'error') return 'Attention — last turn failed'
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
useCreateHarnessAgent,
|
||||
useDeleteHarnessAgent,
|
||||
useHarnessAgents,
|
||||
useUpdateHarnessAgent,
|
||||
} from './useAgents'
|
||||
import { useOpenClawAgents, useOpenClawMutations } from './useOpenClaw'
|
||||
|
||||
@@ -76,6 +77,7 @@ export const AgentsPage: FC = () => {
|
||||
} = useOpenClawAgents(openClawAgentsEnabled)
|
||||
const createHarnessAgent = useCreateHarnessAgent()
|
||||
const deleteHarnessAgent = useDeleteHarnessAgent()
|
||||
const updateHarnessAgent = useUpdateHarnessAgent()
|
||||
const {
|
||||
setupOpenClaw,
|
||||
createAgent: createOpenClawAgent,
|
||||
@@ -342,12 +344,24 @@ export const AgentsPage: FC = () => {
|
||||
agents={agentListItems}
|
||||
activity={agentActivity}
|
||||
harnessAgentLookup={harnessAgentLookup}
|
||||
adapters={adapters}
|
||||
loading={agentsLoading}
|
||||
deletingAgentKey={deletingAgent ? deletingAgentKey : null}
|
||||
onCreateAgent={() => setCreateOpen(true)}
|
||||
onDeleteAgent={(agent) => {
|
||||
void handleDelete(agent)
|
||||
}}
|
||||
onPinToggle={(agent, next) => {
|
||||
// Optimistic mutation; harness-only — gateway-original
|
||||
// OpenClaw entries are gated server-side via the harness
|
||||
// backfill, so we only fire when the row maps to a
|
||||
// harness agent record.
|
||||
if (!harnessAgentLookup.has(agent.agentId)) return
|
||||
updateHarnessAgent.mutate({
|
||||
agentId: agent.agentId,
|
||||
patch: { pinned: next },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<SetupOpenClawDialog
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentListItem } from './agents-page-types'
|
||||
import type { AgentLiveness } from './LivenessDot'
|
||||
|
||||
/**
|
||||
* Display rules for the redesigned agent rows. Pure helpers — no React,
|
||||
@@ -82,3 +83,25 @@ export function formatRelativeTime(epochMs: number | null): string {
|
||||
const d = Math.floor(diff / ONE_DAY)
|
||||
return d === 1 ? '1 day ago' : `${d} days ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip-friendly description of a row's current liveness state.
|
||||
* Returns `undefined` when the state has nothing extra to add (e.g.
|
||||
* `unknown` with no timestamp).
|
||||
*/
|
||||
export function livenessDetail(
|
||||
status: AgentLiveness,
|
||||
lastUsedAt: number | null | undefined,
|
||||
): string | undefined {
|
||||
if (lastUsedAt == null) return undefined
|
||||
const diffMin = Math.floor((Date.now() - lastUsedAt) / 60_000)
|
||||
if (status === 'idle') return `Idle for ${Math.max(0, diffMin)} min`
|
||||
if (status === 'asleep') {
|
||||
if (diffMin < 60) return `Asleep — quiet for ${diffMin} min`
|
||||
const hr = Math.floor(diffMin / 60)
|
||||
return `Asleep — quiet for ${hr} hr`
|
||||
}
|
||||
if (status === 'working') return 'Working on a turn'
|
||||
if (status === 'error') return 'Attention — last turn failed'
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -56,6 +56,43 @@ export interface HarnessAgent {
|
||||
* agents. Drives the recency sort and the "Last used X min ago" copy.
|
||||
*/
|
||||
lastUsedAt?: number | null
|
||||
/** Pinned agents float to the top of the list. Defaults to `false`. */
|
||||
pinned?: boolean
|
||||
/** First non-blank line of the most recent user message; null if none. */
|
||||
lastUserMessage?: string | null
|
||||
/** Working directory the agent runs in; null when no session record yet. */
|
||||
cwd?: string | null
|
||||
/** Cumulative + 7-day rolling token usage; null when no record. */
|
||||
tokens?: {
|
||||
last7d: { input: number; output: number; requestCount: number }
|
||||
cumulative: { input: number; output: number }
|
||||
} | null
|
||||
turnsByDay?: number[]
|
||||
failedByDay?: number[]
|
||||
lastError?: string | null
|
||||
lastErrorAt?: number | null
|
||||
/** When non-null, an in-flight turn this row can be resumed from. */
|
||||
activeTurnId?: string | null
|
||||
/** Persistent FIFO queue of messages waiting for this agent. */
|
||||
queue?: HarnessQueuedMessage[]
|
||||
}
|
||||
|
||||
export interface HarnessQueuedMessageAttachment {
|
||||
mediaType: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface HarnessQueuedMessage {
|
||||
id: string
|
||||
createdAt: number
|
||||
message: string
|
||||
attachments?: ReadonlyArray<HarnessQueuedMessageAttachment>
|
||||
}
|
||||
|
||||
export interface HarnessAdapterHealth {
|
||||
healthy: boolean
|
||||
reason?: string
|
||||
checkedAt: number
|
||||
}
|
||||
|
||||
export interface HarnessAdapterDescriptor {
|
||||
@@ -66,6 +103,7 @@ export interface HarnessAdapterDescriptor {
|
||||
modelControl: 'runtime-supported' | 'best-effort'
|
||||
models: Array<{ id: string; label: string; recommended?: boolean }>
|
||||
reasoningEfforts: Array<{ id: string; label: string; recommended?: boolean }>
|
||||
health?: HarnessAdapterHealth
|
||||
}
|
||||
|
||||
export interface CreateHarnessAgentInput {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
Copy,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
canDelete as canDeleteAgent,
|
||||
canRename as canRenameAgent,
|
||||
displayName,
|
||||
} from '../agent-display.helpers'
|
||||
import type { AgentListItem } from '../agents-page-types'
|
||||
|
||||
interface AgentActionsProps {
|
||||
agent: AgentListItem
|
||||
activeTurnId: string | null
|
||||
deleting?: boolean
|
||||
onDelete: (agent: AgentListItem) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Single primary CTA per row: `Resume` (filled, accent-orange, with a
|
||||
* pulsing dot) when an active turn exists; otherwise `Chat` (outline).
|
||||
* Both navigate to the same place — the chat hook auto-attaches via
|
||||
* `/chat/active` when there's a live turn — but the row signals which
|
||||
* action the user is actually taking.
|
||||
*/
|
||||
export const AgentActions: FC<AgentActionsProps> = ({
|
||||
agent,
|
||||
activeTurnId,
|
||||
deleting,
|
||||
onDelete,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const allowDelete = canDeleteAgent(agent)
|
||||
const allowRename = canRenameAgent(agent)
|
||||
|
||||
const handleChat = () => navigate(`/agents/${agent.agentId}`)
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(agent.agentId)
|
||||
toast.success('Agent id copied')
|
||||
} catch {
|
||||
toast.error('Could not copy agent id')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{activeTurnId ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleChat}
|
||||
className="gap-2 bg-[var(--accent-orange)] text-white shadow-sm hover:bg-[var(--accent-orange)]/90"
|
||||
>
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white/70 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-white" />
|
||||
</span>
|
||||
Resume
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={handleChat}>
|
||||
<MessageSquare className="mr-1.5 size-3" />
|
||||
Chat
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`More actions for ${displayName(agent)}`}
|
||||
className="size-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={() => void handleCopyId()}>
|
||||
<Copy className="mr-2 size-3.5" />
|
||||
Copy id
|
||||
</DropdownMenuItem>
|
||||
<ComingSoonItem
|
||||
icon={Pencil}
|
||||
label="Rename"
|
||||
disabled={!allowRename}
|
||||
/>
|
||||
<ComingSoonItem icon={RotateCcw} label="Reset history" disabled />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete(agent)}
|
||||
disabled={!allowDelete || deleting}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="mr-2 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
)}
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComingSoonItemProps {
|
||||
icon: typeof Pencil
|
||||
label: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const ComingSoonItem: FC<ComingSoonItemProps> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
disabled,
|
||||
}) => {
|
||||
const item = (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
<Icon className="mr-2 size-3.5" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
if (!disabled) return item
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block w-full">{item}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{label} coming soon
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AlertTriangle, ChevronDown } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { truncate } from './agent-row.helpers'
|
||||
|
||||
interface AgentErrorPanelProps {
|
||||
agentId: string
|
||||
message: string
|
||||
errorAt: number | null
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'agent-row:lastErrorSeenAt:'
|
||||
const PREVIEW_CHARS = 200
|
||||
|
||||
export const AgentErrorPanel: FC<AgentErrorPanelProps> = ({
|
||||
agentId,
|
||||
message,
|
||||
errorAt,
|
||||
}) => {
|
||||
const storageKey = `${STORAGE_PREFIX}${agentId}`
|
||||
// Open if we've never seen this `errorAt` for this agent. Once the
|
||||
// user collapses the panel (or refreshes after seeing it), we mark
|
||||
// it seen so it doesn't re-pop on every poll.
|
||||
const [open, setOpen] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined' || !errorAt) return true
|
||||
const seen = Number(window.localStorage.getItem(storageKey) ?? 0)
|
||||
return !Number.isFinite(seen) || errorAt > seen
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && errorAt && typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(storageKey, String(errorAt))
|
||||
}
|
||||
}, [open, errorAt, storageKey])
|
||||
|
||||
const preview = truncate(message, PREVIEW_CHARS)
|
||||
const truncated = preview.length < message.length
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3">
|
||||
<div className="flex items-center justify-between rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-2 font-medium text-destructive text-xs">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
Last error
|
||||
</div>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-muted-foreground"
|
||||
>
|
||||
<span className="text-xs">{open ? 'hide' : 'show'}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-1 size-3 transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 rounded-md border-destructive/30 border-x border-b bg-destructive/5 px-3 pb-2 text-xs">
|
||||
{truncated ? (
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="cursor-default font-mono text-foreground/80">
|
||||
{preview}…
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="bottom"
|
||||
className="max-w-md whitespace-pre-wrap font-mono text-xs"
|
||||
>
|
||||
{message}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<span className="font-mono text-foreground/80">{message}</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Quote } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { firstNonBlankLine, truncate } from './agent-row.helpers'
|
||||
|
||||
interface AgentLastMessageProps {
|
||||
message: string | null
|
||||
}
|
||||
|
||||
const PREVIEW_CHARS = 110
|
||||
|
||||
/**
|
||||
* Inline preview of the most recent user message. Renders as a quoted,
|
||||
* italic line so the row reads like a conversation snippet rather than
|
||||
* a label-and-value pair. No hover-card — opening the agent's chat is
|
||||
* the canonical way to read the full message.
|
||||
*/
|
||||
export const AgentLastMessage: FC<AgentLastMessageProps> = ({ message }) => {
|
||||
if (!message) {
|
||||
return (
|
||||
<p className="mt-1 text-muted-foreground/70 text-xs italic">
|
||||
No messages yet — start a chat
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const preview = truncate(firstNonBlankLine(message), PREVIEW_CHARS)
|
||||
return (
|
||||
<p className="mt-1.5 flex items-start gap-1.5 text-foreground/85 text-sm italic leading-snug">
|
||||
<Quote
|
||||
className="mt-1 size-3 shrink-0 text-muted-foreground/60"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{preview}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FC } from 'react'
|
||||
import { formatRelativeTime } from '../agent-display.helpers'
|
||||
import { AgentTokenSummary } from './AgentTokenSummary'
|
||||
import type { AgentTokenUsage } from './agent-row.types'
|
||||
|
||||
interface AgentMetaRowProps {
|
||||
lastUsedAt: number | null
|
||||
tokens: AgentTokenUsage | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-of-row meta line. Intentionally sparse — last activity time
|
||||
* and lifetime tokens. CWD is no longer surfaced here because the path
|
||||
* the server happens to be running from isn't actionable; if a future
|
||||
* surface needs the cwd (chat panel, debug view) it reads from the
|
||||
* listing payload directly.
|
||||
*/
|
||||
export const AgentMetaRow: FC<AgentMetaRowProps> = ({ lastUsedAt, tokens }) => {
|
||||
const lastUsedLabel = formatRelativeTime(lastUsedAt)
|
||||
const tokensTotal =
|
||||
(tokens?.cumulative.input ?? 0) + (tokens?.cumulative.output ?? 0)
|
||||
const showTokens = tokensTotal > 0
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 text-muted-foreground text-xs">
|
||||
<span>{lastUsedLabel}</span>
|
||||
{showTokens && (
|
||||
<>
|
||||
<span aria-hidden className="text-muted-foreground/50">
|
||||
·
|
||||
</span>
|
||||
<AgentTokenSummary tokens={tokens} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatLocalDate, ROW_BAR_COUNT } from './agent-row.helpers'
|
||||
|
||||
interface AgentSparklineProps {
|
||||
/** 14 entries, oldest → newest. Today's bucket is the last index. */
|
||||
turnsByDay: number[]
|
||||
/** Same length, same order. Failed turns counted separately. */
|
||||
failedByDay: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MIN_BAR_HEIGHT_PX = 2
|
||||
const MAX_BAR_HEIGHT_PX = 18
|
||||
|
||||
export const AgentSparkline: FC<AgentSparklineProps> = ({
|
||||
turnsByDay,
|
||||
failedByDay,
|
||||
className,
|
||||
}) => {
|
||||
if (turnsByDay.length === 0 || turnsByDay.every((n) => n === 0)) return null
|
||||
const max = Math.max(1, ...turnsByDay)
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={250}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`Last ${ROW_BAR_COUNT} days of activity`}
|
||||
className={cn('flex h-5 items-end gap-px', className)}
|
||||
>
|
||||
{turnsByDay.map((count, idx) => {
|
||||
const ratio = count / max
|
||||
const height = Math.max(
|
||||
MIN_BAR_HEIGHT_PX,
|
||||
Math.round(ratio * MAX_BAR_HEIGHT_PX),
|
||||
)
|
||||
const isToday = idx === ROW_BAR_COUNT - 1
|
||||
const failed = failedByDay[idx] ?? 0
|
||||
return (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length sparkline buckets keyed by day position
|
||||
key={`bar-${idx}`}
|
||||
className={cn(
|
||||
'w-1.5 rounded-sm',
|
||||
count === 0
|
||||
? 'bg-muted-foreground/15'
|
||||
: failed > 0
|
||||
? 'bg-destructive/50'
|
||||
: 'bg-[var(--accent-orange)]/50',
|
||||
isToday && 'ring-1 ring-foreground/30',
|
||||
)}
|
||||
style={{ height }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="left" className="w-56 text-xs">
|
||||
<div className="mb-2 font-medium text-sm">Last 14 days</div>
|
||||
<ul className="space-y-0.5">
|
||||
{turnsByDay.map((count, idx) => {
|
||||
const failed = failedByDay[idx] ?? 0
|
||||
const dayLabel = formatLocalDate(idx)
|
||||
return (
|
||||
<li
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length list keyed by day position
|
||||
key={`day-${idx}`}
|
||||
className="flex items-center justify-between text-muted-foreground"
|
||||
>
|
||||
<span>{dayLabel}</span>
|
||||
<span>
|
||||
{count}
|
||||
{failed > 0 && (
|
||||
<span className="ml-1 text-destructive">
|
||||
({failed} failed)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { adapterLabel } from '../AdapterIcon'
|
||||
import type { HarnessAgentAdapter } from '../agent-harness-types'
|
||||
import type { AgentAdapterHealth } from './agent-row.types'
|
||||
|
||||
interface AgentSummaryChipsProps {
|
||||
adapter: HarnessAgentAdapter | 'unknown'
|
||||
modelLabel: string | null
|
||||
reasoningEffort: string | null
|
||||
/** When unhealthy, the adapter label dims and a warning chip appears. */
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter / model / reasoning summary line. Always rendered (so OpenClaw
|
||||
* rows that fall back to defaults still expose what they're set up to do)
|
||||
* and surfaces adapter-health *only when unhealthy* — keeping the calm
|
||||
* default state silent and reserving visual noise for things the user
|
||||
* needs to act on.
|
||||
*/
|
||||
export const AgentSummaryChips: FC<AgentSummaryChipsProps> = ({
|
||||
adapter,
|
||||
modelLabel,
|
||||
reasoningEffort,
|
||||
adapterHealth,
|
||||
}) => {
|
||||
const parts = [adapterLabel(adapter)]
|
||||
if (modelLabel) parts.push(modelLabel)
|
||||
if (reasoningEffort) parts.push(reasoningEffort)
|
||||
const unhealthy = adapterHealth?.healthy === false
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-muted-foreground text-xs',
|
||||
unhealthy && 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{parts.join(' · ')}</span>
|
||||
{unhealthy && adapterHealth && (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 cursor-default gap-1 border-amber-500/40 bg-amber-50 px-1.5 text-amber-900 hover:bg-amber-50"
|
||||
>
|
||||
<TriangleAlert className="size-2.5" />
|
||||
<span className="font-normal">Unavailable</span>
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" className="w-72 text-sm">
|
||||
<div className="font-medium">
|
||||
{adapterLabel(adapter)} CLI not available
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
{adapterHealth.reason ??
|
||||
'Adapter binary missing on $PATH. Install it from the adapter docs to use this agent.'}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AdapterIcon } from '../AdapterIcon'
|
||||
import { livenessDetail } from '../agent-display.helpers'
|
||||
import type { HarnessAgentAdapter } from '../agent-harness-types'
|
||||
import { type AgentLiveness, LivenessDot } from '../LivenessDot'
|
||||
|
||||
export interface AgentTileProps {
|
||||
adapter: HarnessAgentAdapter | 'unknown'
|
||||
status: AgentLiveness
|
||||
lastUsedAt: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter glyph + a single liveness dot. Adapter health is no longer
|
||||
* surfaced here — it lives as an inline pill inside `AgentSummaryChips`
|
||||
* so the user isn't asked to disambiguate two dots on the same tile.
|
||||
*/
|
||||
export const AgentTile: FC<AgentTileProps> = ({
|
||||
adapter,
|
||||
status,
|
||||
lastUsedAt,
|
||||
}) => (
|
||||
<div className="relative shrink-0">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<AdapterIcon adapter={adapter} className="h-6 w-6" />
|
||||
</div>
|
||||
<LivenessDot
|
||||
status={status}
|
||||
detail={livenessDetail(status, lastUsedAt)}
|
||||
className={cn(
|
||||
'absolute -right-0.5 -bottom-0.5',
|
||||
status === 'working' && 'animate-pulse',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { displayName } from '../agent-display.helpers'
|
||||
import type { AgentListItem } from '../agents-page-types'
|
||||
import type { AgentLiveness } from '../LivenessDot'
|
||||
import { AgentSparkline } from './AgentSparkline'
|
||||
import { PinToggle } from './PinToggle'
|
||||
|
||||
interface AgentTitleRowProps {
|
||||
agent: AgentListItem
|
||||
status: AgentLiveness
|
||||
pinned: boolean
|
||||
turnsByDay: number[]
|
||||
failedByDay: number[]
|
||||
onPinToggle: (next: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Title strip: name + status badge + (right-aligned) sparkline. The
|
||||
* pin toggle sits trailing the title so the title always flushes left
|
||||
* regardless of pin state — moving the star left of the title indents
|
||||
* the row's first line off-axis from the model/preview/meta lines
|
||||
* below it. When unpinned and not hovered, the toggle is removed from
|
||||
* layout entirely so it reserves no space at all.
|
||||
*/
|
||||
export const AgentTitleRow: FC<AgentTitleRowProps> = ({
|
||||
agent,
|
||||
status,
|
||||
pinned,
|
||||
turnsByDay,
|
||||
failedByDay,
|
||||
onPinToggle,
|
||||
}) => (
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="truncate font-semibold">{displayName(agent)}</span>
|
||||
{status === 'working' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-amber-50 text-amber-900 hover:bg-amber-50"
|
||||
>
|
||||
Working
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'asleep' && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Asleep
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'error' && <Badge variant="destructive">Attention</Badge>}
|
||||
<PinToggle pinned={pinned} onToggle={onPinToggle} />
|
||||
<div className="ml-auto">
|
||||
<AgentSparkline turnsByDay={turnsByDay} failedByDay={failedByDay} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { formatTokens } from './agent-row.helpers'
|
||||
import type { AgentTokenUsage } from './agent-row.types'
|
||||
|
||||
interface AgentTokenSummaryProps {
|
||||
tokens: AgentTokenUsage | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline token total + a HoverCard breakdown. Surfaces lifetime tokens
|
||||
* (the only window we can compute reliably from the session record).
|
||||
* Per-window stats land in a follow-up once the activity ledger ships.
|
||||
*/
|
||||
export const AgentTokenSummary: FC<AgentTokenSummaryProps> = ({ tokens }) => {
|
||||
if (!tokens) return null
|
||||
const { input, output } = tokens.cumulative
|
||||
const total = input + output
|
||||
if (total === 0) return null
|
||||
const inputPct = (input / total) * 100
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="cursor-default text-muted-foreground tabular-nums transition-colors hover:text-foreground">
|
||||
{formatTokens(total)} tokens
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="end" className="w-72 text-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium">Lifetime tokens</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatTokens(total)} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<span className="tabular-nums">{formatTokens(input)}</span>
|
||||
</div>
|
||||
<Progress value={inputPct} className="h-1.5" />
|
||||
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<span className="tabular-nums">{formatTokens(output)}</span>
|
||||
</div>
|
||||
<Progress value={100 - inputPct} className="h-1.5" />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 border-t pt-2 text-muted-foreground text-xs leading-snug">
|
||||
Cumulative across every turn this agent has run. Per-window stats
|
||||
arrive in a future release.
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Star } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PinToggleProps {
|
||||
pinned: boolean
|
||||
onToggle: (next: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing star toggle. The button is *always rendered* — only its
|
||||
* opacity changes between pinned/unpinned/hover states — so the title
|
||||
* row's height is constant. Hiding the slot via `display: none` would
|
||||
* collapse the row's vertical metrics on hover and shift every card
|
||||
* below in the rail.
|
||||
*
|
||||
* Placement is trailing the title (after the status badge) so the
|
||||
* title itself flushes left regardless of pin state — leading the
|
||||
* row with the star would indent the title relative to the model /
|
||||
* preview / meta lines beneath it.
|
||||
*/
|
||||
export const PinToggle: FC<PinToggleProps> = ({ pinned, onToggle }) => (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'size-6 text-muted-foreground transition-opacity hover:text-foreground',
|
||||
pinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
)}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? 'Unpin agent' : 'Pin agent'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onToggle(!pinned)
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'size-3.5',
|
||||
pinned && 'fill-amber-400 text-amber-500',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{pinned ? 'Unpin' : 'Pin to top'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
firstNonBlankLine,
|
||||
formatLocalDate,
|
||||
formatTokens,
|
||||
ROW_BAR_COUNT,
|
||||
truncate,
|
||||
} from './agent-row.helpers'
|
||||
|
||||
describe('formatTokens', () => {
|
||||
it('renders zero / NaN as "0"', () => {
|
||||
expect(formatTokens(0)).toBe('0')
|
||||
expect(formatTokens(Number.NaN)).toBe('0')
|
||||
})
|
||||
|
||||
it('renders sub-1K as integer', () => {
|
||||
expect(formatTokens(142)).toBe('142')
|
||||
})
|
||||
|
||||
it('renders K with one decimal under 10', () => {
|
||||
expect(formatTokens(8_400)).toBe('8.4K')
|
||||
})
|
||||
|
||||
it('drops the decimal at >=10K', () => {
|
||||
expect(formatTokens(120_000)).toBe('120K')
|
||||
})
|
||||
|
||||
it('renders M with one decimal under 10', () => {
|
||||
expect(formatTokens(1_200_000)).toBe('1.2M')
|
||||
})
|
||||
})
|
||||
|
||||
describe('firstNonBlankLine', () => {
|
||||
it('returns the first non-blank line', () => {
|
||||
expect(firstNonBlankLine('\n\nhello\nworld')).toBe('hello')
|
||||
})
|
||||
|
||||
it('skips USER_QUERY envelope tags', () => {
|
||||
expect(firstNonBlankLine('<USER_QUERY>\nfix tests\n</USER_QUERY>')).toBe(
|
||||
'fix tests',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the trimmed input when nothing matches', () => {
|
||||
expect(firstNonBlankLine(' single ')).toBe('single')
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns input unchanged when within limit', () => {
|
||||
expect(truncate('hello', 10)).toBe('hello')
|
||||
})
|
||||
|
||||
it('appends an ellipsis when over limit', () => {
|
||||
expect(truncate('hello world', 6)).toBe('hello…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocalDate', () => {
|
||||
const today = new Date('2026-04-30T12:00:00Z')
|
||||
|
||||
it('labels today and yesterday explicitly', () => {
|
||||
expect(formatLocalDate(ROW_BAR_COUNT - 1, today)).toBe('today')
|
||||
expect(formatLocalDate(ROW_BAR_COUNT - 2, today)).toBe('yesterday')
|
||||
})
|
||||
|
||||
it('returns a "Mon D" format for older days', () => {
|
||||
const label = formatLocalDate(0, today)
|
||||
// "Apr 17" or "Apr 17," depending on locale; just assert it
|
||||
// contains a month abbreviation and a day number.
|
||||
expect(label).toMatch(/[A-Za-z]+ \d+/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Pure formatters consumed by row sub-components. Kept distinct from
|
||||
* `agent-display.helpers.ts` (page-level helpers) so the row internals
|
||||
* have an obvious single home.
|
||||
*/
|
||||
|
||||
const TOKEN_THRESHOLDS: Array<[number, string]> = [
|
||||
[1_000_000, 'M'],
|
||||
[1_000, 'K'],
|
||||
]
|
||||
|
||||
/** `1.2M`, `820K`, `8.4K`, `142`, `0`. */
|
||||
export function formatTokens(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0'
|
||||
for (const [threshold, suffix] of TOKEN_THRESHOLDS) {
|
||||
if (n >= threshold) {
|
||||
const value = n / threshold
|
||||
const decimal = value < 10 ? value.toFixed(1) : value.toFixed(0)
|
||||
return `${decimal}${suffix}`
|
||||
}
|
||||
}
|
||||
return String(Math.round(n))
|
||||
}
|
||||
|
||||
const USER_QUERY_OPEN = /^<USER_QUERY>$/i
|
||||
const USER_QUERY_CLOSE = /^<\/USER_QUERY>$/i
|
||||
|
||||
/**
|
||||
* First non-blank line, with the BrowserOS user-system-prompt
|
||||
* `<USER_QUERY>` envelope tags stripped so previews don't show
|
||||
* structural noise.
|
||||
*/
|
||||
export function firstNonBlankLine(text: string): string {
|
||||
const lines = text.split('\n').map((line) => line.trim())
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
if (USER_QUERY_OPEN.test(line) || USER_QUERY_CLOSE.test(line)) continue
|
||||
return line
|
||||
}
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
export function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text
|
||||
return `${text.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
const SPARKLINE_DAYS = 14
|
||||
|
||||
/**
|
||||
* "today" / "yesterday" / "Apr 17" — given an index 0..13 from
|
||||
* oldest → newest. `today` defaults to `new Date()` so callers don't
|
||||
* have to thread a clock through.
|
||||
*/
|
||||
export function formatLocalDate(idx: number, today: Date = new Date()): string {
|
||||
if (idx === SPARKLINE_DAYS - 1) return 'today'
|
||||
if (idx === SPARKLINE_DAYS - 2) return 'yesterday'
|
||||
const offset = SPARKLINE_DAYS - 1 - idx
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - offset)
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export const ROW_BAR_COUNT = SPARKLINE_DAYS
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { HarnessAgentAdapter } from '../agent-harness-types'
|
||||
import type { AgentListItem } from '../agents-page-types'
|
||||
import type { AgentLiveness } from '../LivenessDot'
|
||||
|
||||
/**
|
||||
* Window-bounded token usage. Server returns `null` when no session
|
||||
* record exists yet for the agent.
|
||||
*/
|
||||
export interface AgentTokenUsage {
|
||||
last7d: { input: number; output: number; requestCount: number }
|
||||
cumulative: { input: number; output: number }
|
||||
}
|
||||
|
||||
export interface AgentAdapterHealth {
|
||||
healthy: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything an `AgentRowCard` needs to render. Mirrors the shape
|
||||
* `useHarnessAgents` exposes; the page assembles one entry per row in
|
||||
* `AgentList` and passes it down. Sub-components only see slices of
|
||||
* this object — no prop drilling beyond two levels.
|
||||
*/
|
||||
export interface AgentRowData {
|
||||
agent: AgentListItem
|
||||
adapter: HarnessAgentAdapter | 'unknown'
|
||||
modelLabel: string | null
|
||||
reasoningEffort: string | null
|
||||
status: AgentLiveness
|
||||
lastUsedAt: number | null
|
||||
pinned: boolean
|
||||
cwd: string | null
|
||||
lastUserMessage: string | null
|
||||
tokens: AgentTokenUsage | null
|
||||
/** 14 entries, oldest → newest. Today is the last index. */
|
||||
turnsByDay: number[]
|
||||
/** Same length and ordering as `turnsByDay`. */
|
||||
failedByDay: number[]
|
||||
lastError: string | null
|
||||
lastErrorAt: number | null
|
||||
/** When non-null, an in-flight turn this row can be resumed from. */
|
||||
activeTurnId: string | null
|
||||
/** Adapter-level health, shared across rows for the same adapter. */
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
}
|
||||
|
||||
export interface AgentRowCallbacks {
|
||||
onDelete: (agent: AgentListItem) => void
|
||||
onPinToggle: (agent: AgentListItem, next: boolean) => void
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type HarnessAdapterDescriptor,
|
||||
type HarnessAgent,
|
||||
type HarnessAgentHistoryPage,
|
||||
type HarnessQueuedMessage,
|
||||
mapHarnessAgentToEntry,
|
||||
} from './agent-harness-types'
|
||||
import type { OpenClawStatus } from './useOpenClaw'
|
||||
@@ -135,6 +136,63 @@ export function useCreateHarnessAgent() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a partial update to a harness agent. Used by the pin-toggle
|
||||
* star and (eventually) the inline rename UI. Optimistically writes
|
||||
* the patch into the listing query cache so the row updates instantly,
|
||||
* then rolls back if the server rejects the change.
|
||||
*/
|
||||
export function useUpdateHarnessAgent() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: {
|
||||
agentId: string
|
||||
patch: { name?: string; pinned?: boolean }
|
||||
}) => {
|
||||
if (!baseUrl || urlLoading) {
|
||||
throw new Error('BrowserOS agent server URL is not ready')
|
||||
}
|
||||
const data = await agentsFetch<{ agent: HarnessAgent }>(
|
||||
baseUrl,
|
||||
`/${encodeURIComponent(input.agentId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input.patch),
|
||||
},
|
||||
)
|
||||
return data.agent
|
||||
},
|
||||
onMutate: async ({ agentId, patch }) => {
|
||||
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
|
||||
if (!previous) return { previous: undefined }
|
||||
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
|
||||
...previous,
|
||||
agents: previous.agents.map((agent) =>
|
||||
agent.id === agentId ? { ...agent, ...patch } : agent,
|
||||
),
|
||||
})
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (!context?.previous) return
|
||||
queryClient.setQueryData(
|
||||
[AGENT_QUERY_KEYS.agents, baseUrl],
|
||||
context.previous,
|
||||
)
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteHarnessAgent() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -206,6 +264,8 @@ export interface HarnessActiveTurnInfo {
|
||||
lastSeq: number
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
/** User message that kicked off the turn; null when not captured. */
|
||||
prompt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,3 +320,145 @@ export async function fetchHarnessAgentHistory(
|
||||
`/${encodeURIComponent(agentId)}/sessions/main/history`,
|
||||
)
|
||||
}
|
||||
|
||||
export interface EnqueueMessageInput {
|
||||
message: string
|
||||
attachments?: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
export async function enqueueHarnessMessage(
|
||||
agentId: string,
|
||||
input: EnqueueMessageInput,
|
||||
): Promise<HarnessQueuedMessage> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(
|
||||
`${baseUrl}/agents/${encodeURIComponent(agentId)}/queue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: input.message,
|
||||
...(input.attachments && input.attachments.length > 0
|
||||
? { attachments: input.attachments }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
let message = `Request failed with status ${response.status}`
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
const body = (await response.json()) as { queued: HarnessQueuedMessage }
|
||||
return body.queued
|
||||
}
|
||||
|
||||
export async function removeHarnessQueuedMessage(
|
||||
agentId: string,
|
||||
messageId: string,
|
||||
): Promise<{ removed: boolean }> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(
|
||||
`${baseUrl}/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(
|
||||
messageId,
|
||||
)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!response.ok) return { removed: false }
|
||||
return (await response.json()) as { removed: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic enqueue: writes the new queued message into the listing
|
||||
* cache immediately so the queue panel reflects the change without
|
||||
* waiting for the next poll. Rolls back if the server rejects.
|
||||
*/
|
||||
export function useEnqueueHarnessMessage() {
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: { agentId: string } & EnqueueMessageInput) =>
|
||||
enqueueHarnessMessage(input.agentId, input),
|
||||
onMutate: async (input) => {
|
||||
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
|
||||
if (!previous) return { previous: undefined }
|
||||
const optimistic: HarnessQueuedMessage = {
|
||||
id: `optimistic-${Math.random().toString(36).slice(2, 10)}`,
|
||||
createdAt: Date.now(),
|
||||
message: input.message,
|
||||
}
|
||||
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
|
||||
...previous,
|
||||
agents: previous.agents.map((agent) =>
|
||||
agent.id === input.agentId
|
||||
? { ...agent, queue: [...(agent.queue ?? []), optimistic] }
|
||||
: agent,
|
||||
),
|
||||
})
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (!context?.previous) return
|
||||
queryClient.setQueryData(
|
||||
[AGENT_QUERY_KEYS.agents, baseUrl],
|
||||
context.previous,
|
||||
)
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic queue removal mirror of `useEnqueueHarnessMessage`.
|
||||
*/
|
||||
export function useRemoveHarnessQueuedMessage() {
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: { agentId: string; messageId: string }) =>
|
||||
removeHarnessQueuedMessage(input.agentId, input.messageId),
|
||||
onMutate: async (input) => {
|
||||
const queryKey = [AGENT_QUERY_KEYS.agents, baseUrl]
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previous = queryClient.getQueryData<HarnessAgentsResponse>(queryKey)
|
||||
if (!previous) return { previous: undefined }
|
||||
queryClient.setQueryData<HarnessAgentsResponse>(queryKey, {
|
||||
...previous,
|
||||
agents: previous.agents.map((agent) =>
|
||||
agent.id === input.agentId
|
||||
? {
|
||||
...agent,
|
||||
queue: (agent.queue ?? []).filter(
|
||||
(entry) => entry.id !== input.messageId,
|
||||
),
|
||||
}
|
||||
: agent,
|
||||
),
|
||||
})
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (!context?.previous) return
|
||||
queryClient.setQueryData(
|
||||
[AGENT_QUERY_KEYS.agents, baseUrl],
|
||||
context.previous,
|
||||
)
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,15 +59,3 @@ export interface AgentConversation {
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AgentCardData {
|
||||
agentId: string
|
||||
name: string
|
||||
model?: string
|
||||
status: 'idle' | 'working' | 'error'
|
||||
lastMessage?: string
|
||||
lastMessageTimestamp?: number
|
||||
activitySummary?: string
|
||||
currentTool?: string
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"build": "bun run codegen && wxt build",
|
||||
"build:dev": "bun --env-file=.env.development wxt build --mode development",
|
||||
"zip": "wxt zip",
|
||||
"test": "bun run ../../scripts/run-bun-test.ts ./apps/agent",
|
||||
"compile": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",
|
||||
"lint": "bunx biome check",
|
||||
"typecheck": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",
|
||||
|
||||
32
packages/browseros-agent/apps/eval/README.md
vendored
32
packages/browseros-agent/apps/eval/README.md
vendored
@@ -181,6 +181,8 @@ export EVAL_R2_BUCKET=browseros-eval
|
||||
export EVAL_R2_CDN_BASE_URL=https://eval.browseros.com
|
||||
```
|
||||
|
||||
`EVAL_R2_CDN_BASE_URL` must be a public R2 custom domain, `r2.dev` URL, or Worker URL. Do not set it to the private `*.r2.cloudflarestorage.com` S3 API endpoint.
|
||||
|
||||
Published runs are available at `EVAL_R2_CDN_BASE_URL/viewer.html?run=<run-id>`.
|
||||
|
||||
### BrowserOS infrastructure
|
||||
@@ -253,7 +255,35 @@ results/
|
||||
summary.json # Aggregate pass rates
|
||||
```
|
||||
|
||||
R2 publishing preserves the same task files under `runs/<run-id>/...`, writes `runs/<run-id>/manifest.json`, and uploads `viewer.html` at the bucket root. The viewer URL is `EVAL_R2_CDN_BASE_URL/viewer.html?run=<run-id>`.
|
||||
R2 publishing preserves the task files under `runs/<run-id>/...`, writes `runs/<run-id>/manifest.json`, and uploads `viewer.html` at the bucket root. The viewer URL is `EVAL_R2_CDN_BASE_URL/viewer.html?run=<run-id>`.
|
||||
|
||||
### R2 viewer manifest
|
||||
|
||||
`runs/<run-id>/manifest.json` is the source of truth for the public viewer. New manifests include `schemaVersion: 2` and each task includes explicit artifact paths:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"runId": "agisdk-real-smoke-2026-04-30-0000",
|
||||
"tasks": [
|
||||
{
|
||||
"queryId": "agisdk-dashdish-10",
|
||||
"paths": {
|
||||
"metadata": "tasks/agisdk-dashdish-10/metadata.json",
|
||||
"messages": "tasks/agisdk-dashdish-10/messages.jsonl",
|
||||
"grades": "tasks/agisdk-dashdish-10/grades.json",
|
||||
"trace": "tasks/agisdk-dashdish-10/trace.jsonl",
|
||||
"screenshots": "tasks/agisdk-dashdish-10/screenshots",
|
||||
"graderArtifacts": "tasks/agisdk-dashdish-10/grader-artifacts"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The static viewer uses `task.paths` when present. Older uploaded runs without `schemaVersion` or `task.paths` still work through the legacy inferred layout: `runs/<run-id>/<task-id>/metadata.json`, `messages.jsonl`, and `screenshots/<n>.png`.
|
||||
|
||||
Manifest paths are stable artifact locations, not a guarantee that every optional artifact exists for every task. For example, `attempt.json`, `trace.jsonl`, or grader artifact directories may be absent when that artifact was not produced by the run.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eval": "bun --env-file=.env.development run src/index.ts",
|
||||
"test": "bun run ../../scripts/run-bun-test.ts ./apps/eval/tests",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -24,45 +24,11 @@ import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
interface ManifestTask {
|
||||
queryId: string
|
||||
query: string
|
||||
status: string
|
||||
durationMs: number
|
||||
screenshotCount: number
|
||||
graderResults: Record<string, { pass: boolean; score: number }>
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
runId: string
|
||||
uploadedAt: string
|
||||
agentConfig?: { type?: string; model?: string }
|
||||
dataset?: string
|
||||
summary?: { passRate?: number; avgDurationMs?: number }
|
||||
tasks: ManifestTask[]
|
||||
}
|
||||
|
||||
interface RunSummary {
|
||||
runId: string
|
||||
configName: string
|
||||
date: string
|
||||
avgScore: number
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
timeout: number
|
||||
avgDurationMs: number
|
||||
model: string
|
||||
dataset: string
|
||||
agentType: string
|
||||
}
|
||||
|
||||
const PASS_FAIL_GRADER_ORDER = [
|
||||
'agisdk_state_diff',
|
||||
'infinity_state',
|
||||
'performance_grader',
|
||||
]
|
||||
import {
|
||||
buildRunSummaries,
|
||||
type ReportManifest,
|
||||
type RunSummary,
|
||||
} from '../src/reporting/run-summary'
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name]
|
||||
@@ -87,7 +53,7 @@ const client = new S3Client({
|
||||
// Step 1: List all manifest.json files in runs/
|
||||
console.log('Scanning R2 for eval runs...')
|
||||
|
||||
const manifests: Manifest[] = []
|
||||
const manifests: ReportManifest[] = []
|
||||
let continuationToken: string | undefined
|
||||
|
||||
do {
|
||||
@@ -127,64 +93,9 @@ if (manifests.length === 0) {
|
||||
}
|
||||
|
||||
// Step 2: Build run summaries
|
||||
const runs: RunSummary[] = manifests
|
||||
.map((m) => {
|
||||
const total = m.tasks.length
|
||||
const completed = m.tasks.filter((t) => t.status === 'completed').length
|
||||
const failed = m.tasks.filter((t) => t.status === 'failed').length
|
||||
const timeout = m.tasks.filter((t) => t.status === 'timeout').length
|
||||
|
||||
let scoredCount = 0
|
||||
let scoreSum = 0
|
||||
for (const task of m.tasks) {
|
||||
if (!task.graderResults) continue
|
||||
for (const name of PASS_FAIL_GRADER_ORDER) {
|
||||
if (task.graderResults[name]) {
|
||||
scoredCount++
|
||||
scoreSum += task.graderResults[name].score ?? 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const avgScore = scoredCount > 0 ? (scoreSum / scoredCount) * 100 : 0
|
||||
const durations = m.tasks
|
||||
.filter((t) => t.durationMs > 0)
|
||||
.map((t) => t.durationMs)
|
||||
const avgDurationMs =
|
||||
durations.length > 0
|
||||
? durations.reduce((a, b) => a + b, 0) / durations.length
|
||||
: 0
|
||||
|
||||
const date = m.uploadedAt
|
||||
? `${m.uploadedAt.split('T')[0]} ${m.uploadedAt.split('T')[1]?.slice(0, 5) || ''}`
|
||||
: m.runId.slice(0, 15)
|
||||
|
||||
const model = m.agentConfig?.model || 'unknown'
|
||||
const dataset = m.dataset || m.runId
|
||||
const agentType = m.agentConfig?.type || 'unknown'
|
||||
|
||||
const configName = extractConfigName(m.runId)
|
||||
return {
|
||||
runId: m.runId,
|
||||
configName,
|
||||
date,
|
||||
avgScore,
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
timeout,
|
||||
avgDurationMs,
|
||||
model,
|
||||
dataset,
|
||||
agentType,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
const runs: RunSummary[] = buildRunSummaries(manifests)
|
||||
|
||||
// Step 3: Identify unique config groups
|
||||
// runId can be "ci-weekly" (old) or "ci-weekly-2026-03-21-1730" (timestamped)
|
||||
// Extract config name by stripping the date-time suffix pattern
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
@@ -193,12 +104,6 @@ function escHtml(s: string): string {
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function extractConfigName(runId: string): string {
|
||||
// "browseros-agent-weekly-2026-03-21-1730" → "browseros-agent-weekly"
|
||||
// "ci-weekly" → "ci-weekly" (no timestamp, old format)
|
||||
return runId.replace(/-\d{4}-\d{2}-\d{2}-\d{4}$/, '')
|
||||
}
|
||||
|
||||
const configGroups = [...new Set(runs.map((r) => r.configName))]
|
||||
const defaultConfig = configGroups.includes('ci-weekly')
|
||||
? 'ci-weekly'
|
||||
|
||||
@@ -685,6 +685,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Test harness note: these ASCII section markers are used by r2-viewer-compat.test.ts.
|
||||
// -- Artifact path resolution
|
||||
function taskKey(task) {
|
||||
return task.queryId || task.id || 'unknown-task';
|
||||
}
|
||||
|
||||
function legacyArtifactPath(task, artifact) {
|
||||
const id = taskKey(task);
|
||||
switch (artifact) {
|
||||
case 'attempt':
|
||||
return `${id}/attempt.json`;
|
||||
case 'metadata':
|
||||
return `${id}/metadata.json`;
|
||||
case 'messages':
|
||||
return `${id}/messages.jsonl`;
|
||||
case 'trace':
|
||||
return `${id}/trace.jsonl`;
|
||||
case 'grades':
|
||||
return `${id}/grades.json`;
|
||||
case 'screenshots':
|
||||
return `${id}/screenshots`;
|
||||
case 'graderArtifacts':
|
||||
return `${id}/grader-artifacts`;
|
||||
default:
|
||||
return `${id}/${artifact}`;
|
||||
}
|
||||
}
|
||||
|
||||
function artifactPath(task, artifact) {
|
||||
const manifestPath = task.paths && task.paths[artifact];
|
||||
if (typeof manifestPath === 'string' && manifestPath.length > 0) {
|
||||
return manifestPath.replace(/^\/+/, '');
|
||||
}
|
||||
return legacyArtifactPath(task, artifact);
|
||||
}
|
||||
|
||||
function artifactUrl(task, artifact) {
|
||||
return `${basePath}/${artifactPath(task, artifact)}`;
|
||||
}
|
||||
|
||||
function metadataUrl(task) {
|
||||
return artifactUrl(task, 'metadata');
|
||||
}
|
||||
|
||||
function messagesUrl(task) {
|
||||
return artifactUrl(task, 'messages');
|
||||
}
|
||||
|
||||
function screenshotUrl(task, n) {
|
||||
return `${artifactUrl(task, 'screenshots')}/${n}.png`;
|
||||
}
|
||||
|
||||
// -- Task selection
|
||||
// ── Task selection ─────────────────────────────────────────────
|
||||
function selectTask(task) {
|
||||
stopAutoplay();
|
||||
@@ -716,6 +769,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// -- Center panel
|
||||
// ── Center panel: screenshot viewer ────────────────────────────
|
||||
function renderCenterPanel(task) {
|
||||
const panel = document.getElementById('center-panel');
|
||||
@@ -763,10 +817,6 @@
|
||||
updateControls();
|
||||
}
|
||||
|
||||
function screenshotUrl(task, n) {
|
||||
return `${basePath}/${task.queryId || task.id}/screenshots/${n}.png`;
|
||||
}
|
||||
|
||||
function goToStep(n) {
|
||||
if (!selectedTask || n < 1 || n > totalSteps) return;
|
||||
currentStep = n;
|
||||
@@ -914,7 +964,7 @@
|
||||
body.innerHTML = '<div class="placeholder"><div class="ph-text" style="color: #6e7681;">Loading messages...</div></div>';
|
||||
countEl.textContent = '';
|
||||
|
||||
const msgUrl = `${basePath}/${task.queryId || task.id}/messages.jsonl`;
|
||||
const msgUrl = messagesUrl(task);
|
||||
|
||||
fetch(msgUrl)
|
||||
.then((res) => {
|
||||
@@ -1075,7 +1125,7 @@
|
||||
|
||||
// ── Load task metadata for rich grader details ──────────────────
|
||||
function loadTaskMetadata(task) {
|
||||
const metaUrl = `${basePath}/${task.queryId || task.id}/metadata.json`;
|
||||
const metaUrl = metadataUrl(task);
|
||||
fetch(metaUrl)
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((meta) => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
ViewerManifest,
|
||||
ViewerManifestTask,
|
||||
} from '../viewer/viewer-manifest'
|
||||
|
||||
export interface R2UploadConfig {
|
||||
accountId: string
|
||||
accessKeyId: string
|
||||
@@ -6,27 +11,9 @@ export interface R2UploadConfig {
|
||||
cdnBaseUrl: string
|
||||
}
|
||||
|
||||
export interface R2ManifestTask {
|
||||
queryId: string
|
||||
query: string
|
||||
startUrl: string
|
||||
status: string
|
||||
durationMs: number
|
||||
screenshotCount: number
|
||||
graderResults: Record<string, unknown>
|
||||
}
|
||||
export type R2ManifestTask = ViewerManifestTask
|
||||
|
||||
export interface R2RunManifest {
|
||||
runId: string
|
||||
uploadedAt: string
|
||||
agentConfig?: Record<string, unknown>
|
||||
dataset?: string
|
||||
summary?: {
|
||||
passRate?: unknown
|
||||
avgDurationMs?: unknown
|
||||
}
|
||||
tasks: R2ManifestTask[]
|
||||
}
|
||||
export type R2RunManifest = ViewerManifest
|
||||
|
||||
export interface R2PublishRunResult {
|
||||
runId: string
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import {
|
||||
buildViewerManifest,
|
||||
type ViewerManifestTaskInput,
|
||||
} from '../viewer/viewer-manifest'
|
||||
import type {
|
||||
R2ManifestTask,
|
||||
R2PublishPathResult,
|
||||
R2PublishRunResult,
|
||||
R2RunManifest,
|
||||
@@ -43,7 +46,6 @@ interface UploadJob {
|
||||
interface TaskDirEntry {
|
||||
taskId: string
|
||||
taskPath: string
|
||||
canonicalLayout: boolean
|
||||
}
|
||||
|
||||
export function contentTypeForPath(filePath: string): string {
|
||||
@@ -129,7 +131,6 @@ async function findTaskDirs(runDir: string): Promise<TaskDirEntry[]> {
|
||||
legacyTasks.push({
|
||||
taskId: entry.name,
|
||||
taskPath,
|
||||
canonicalLayout: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,7 +147,6 @@ async function findTaskDirs(runDir: string): Promise<TaskDirEntry[]> {
|
||||
canonicalTasks.push({
|
||||
taskId: entry.name,
|
||||
taskPath,
|
||||
canonicalLayout: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,7 @@ export class R2Publisher {
|
||||
throw new Error(`No task subdirectories in ${runId}`)
|
||||
}
|
||||
|
||||
const manifestTasks: R2ManifestTask[] = []
|
||||
const manifestTasks: ViewerManifestTaskInput[] = []
|
||||
const jobs: UploadJob[] = (await collectRunRootFiles(runDir)).map(
|
||||
(job) => ({
|
||||
...job,
|
||||
@@ -289,22 +289,23 @@ export class R2Publisher {
|
||||
if (relative.startsWith('screenshots/') && extname(file) === '.png') {
|
||||
screenshotCount++
|
||||
}
|
||||
// Keep legacy keys during the manifest v2 rollout so cached viewers and
|
||||
// old manifests can still resolve task artifacts.
|
||||
jobs.push({
|
||||
key: `runs/${runId}/${taskId}/${relative}`,
|
||||
filePath: file,
|
||||
contentType: contentTypeForPath(file),
|
||||
})
|
||||
if (taskDirEntry.canonicalLayout) {
|
||||
jobs.push({
|
||||
key: `runs/${runId}/tasks/${taskId}/${relative}`,
|
||||
filePath: file,
|
||||
contentType: contentTypeForPath(file),
|
||||
})
|
||||
}
|
||||
jobs.push({
|
||||
key: `runs/${runId}/tasks/${taskId}/${relative}`,
|
||||
filePath: file,
|
||||
contentType: contentTypeForPath(file),
|
||||
})
|
||||
}
|
||||
|
||||
manifestTasks.push({
|
||||
queryId: (meta.query_id as string | undefined) || taskId,
|
||||
artifactId: taskId,
|
||||
query: (meta.query as string | undefined) || '',
|
||||
startUrl: (meta.start_url as string | undefined) || '',
|
||||
status: statusFromMetadata(meta),
|
||||
@@ -312,7 +313,8 @@ export class R2Publisher {
|
||||
screenshotCount:
|
||||
(meta.screenshot_count as number | undefined) || screenshotCount,
|
||||
graderResults:
|
||||
(meta.grader_results as Record<string, unknown> | undefined) || {},
|
||||
(meta.grader_results as ViewerManifestTaskInput['graderResults']) ||
|
||||
{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,7 +349,7 @@ export class R2Publisher {
|
||||
return {
|
||||
runId,
|
||||
uploadedFiles: uploaded + 2,
|
||||
viewerUrl: `${this.config.cdnBaseUrl}/viewer.html?run=${runId}`,
|
||||
viewerUrl: `${this.config.cdnBaseUrl}/viewer.html?run=${encodeURIComponent(runId)}`,
|
||||
manifest,
|
||||
}
|
||||
}
|
||||
@@ -369,7 +371,7 @@ export class R2Publisher {
|
||||
runId: string,
|
||||
agentConfig: Record<string, unknown> | undefined,
|
||||
dataset: string | undefined,
|
||||
tasks: R2ManifestTask[],
|
||||
tasks: ViewerManifestTaskInput[],
|
||||
): Promise<R2RunManifest> {
|
||||
let summaryData: Record<string, unknown> | undefined
|
||||
try {
|
||||
@@ -378,7 +380,7 @@ export class R2Publisher {
|
||||
) as Record<string, unknown>
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
return buildViewerManifest({
|
||||
runId,
|
||||
uploadedAt: this.now().toISOString(),
|
||||
agentConfig,
|
||||
@@ -390,7 +392,7 @@ export class R2Publisher {
|
||||
}
|
||||
: undefined,
|
||||
tasks,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async uploadFile(job: UploadJob): Promise<void> {
|
||||
|
||||
104
packages/browseros-agent/apps/eval/src/reporting/run-summary.ts
vendored
Normal file
104
packages/browseros-agent/apps/eval/src/reporting/run-summary.ts
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
export interface ReportManifestTask {
|
||||
queryId: string
|
||||
query?: string
|
||||
status: string
|
||||
durationMs: number
|
||||
screenshotCount?: number
|
||||
paths?: Record<string, string>
|
||||
graderResults?: Record<string, { pass?: boolean; score?: number }>
|
||||
}
|
||||
|
||||
export interface ReportManifest {
|
||||
schemaVersion?: number
|
||||
runId: string
|
||||
uploadedAt?: string
|
||||
agentConfig?: { type?: string; model?: string }
|
||||
dataset?: string
|
||||
summary?: { passRate?: number; avgDurationMs?: number }
|
||||
tasks?: ReportManifestTask[]
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
runId: string
|
||||
configName: string
|
||||
date: string
|
||||
avgScore: number
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
timeout: number
|
||||
avgDurationMs: number
|
||||
model: string
|
||||
dataset: string
|
||||
agentType: string
|
||||
}
|
||||
|
||||
// Report score uses the primary pass/fail grader so mixed-grader runs keep
|
||||
// the same precedence as the eval summary.
|
||||
const PASS_FAIL_GRADER_ORDER = [
|
||||
'agisdk_state_diff',
|
||||
'infinity_state',
|
||||
'performance_grader',
|
||||
]
|
||||
|
||||
export function extractConfigName(runId: string): string {
|
||||
return runId.replace(/-\d{4}-\d{2}-\d{2}-\d{4}$/, '')
|
||||
}
|
||||
|
||||
function reportDate(manifest: ReportManifest): string {
|
||||
if (!manifest.uploadedAt) return 'unknown'
|
||||
const [date, time] = manifest.uploadedAt.split('T')
|
||||
return `${date} ${time?.slice(0, 5) || ''}`
|
||||
}
|
||||
|
||||
function primaryScore(task: ReportManifestTask): number | null {
|
||||
if (!task.graderResults) return null
|
||||
for (const name of PASS_FAIL_GRADER_ORDER) {
|
||||
const result = task.graderResults[name]
|
||||
if (result) return result.score ?? 0
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildRunSummaries(manifests: ReportManifest[]): RunSummary[] {
|
||||
return manifests
|
||||
.map((manifest) => {
|
||||
const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : []
|
||||
const total = tasks.length
|
||||
const completed = tasks.filter((t) => t.status === 'completed').length
|
||||
const failed = tasks.filter((t) => t.status === 'failed').length
|
||||
const timeout = tasks.filter((t) => t.status === 'timeout').length
|
||||
|
||||
let scoredCount = 0
|
||||
let scoreSum = 0
|
||||
for (const task of tasks) {
|
||||
const score = primaryScore(task)
|
||||
if (score === null) continue
|
||||
scoredCount++
|
||||
scoreSum += score
|
||||
}
|
||||
|
||||
const durations = tasks
|
||||
.filter((t) => t.durationMs > 0)
|
||||
.map((t) => t.durationMs)
|
||||
|
||||
return {
|
||||
runId: manifest.runId,
|
||||
configName: extractConfigName(manifest.runId),
|
||||
date: reportDate(manifest),
|
||||
avgScore: scoredCount > 0 ? (scoreSum / scoredCount) * 100 : 0,
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
timeout,
|
||||
avgDurationMs:
|
||||
durations.length > 0
|
||||
? durations.reduce((a, b) => a + b, 0) / durations.length
|
||||
: 0,
|
||||
model: manifest.agentConfig?.model || 'unknown',
|
||||
dataset: manifest.dataset || manifest.runId,
|
||||
agentType: manifest.agentConfig?.type || 'unknown',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { GraderResult } from '../types'
|
||||
|
||||
export const VIEWER_MANIFEST_SCHEMA_VERSION = 2
|
||||
|
||||
export interface ViewerManifestTaskPaths {
|
||||
attempt: string
|
||||
metadata: string
|
||||
messages: string
|
||||
trace: string
|
||||
grades: string
|
||||
screenshots: string
|
||||
graderArtifacts: string
|
||||
}
|
||||
|
||||
export interface ViewerManifestTaskInput {
|
||||
queryId: string
|
||||
artifactId?: string
|
||||
query: string
|
||||
startUrl?: string
|
||||
status: string
|
||||
@@ -10,57 +23,67 @@ export interface ViewerManifestTaskInput {
|
||||
graderResults: Record<string, GraderResult>
|
||||
}
|
||||
|
||||
export interface ViewerManifestTask extends ViewerManifestTaskInput {
|
||||
paths: {
|
||||
attempt: string
|
||||
metadata: string
|
||||
messages: string
|
||||
trace: string
|
||||
grades: string
|
||||
screenshots: string
|
||||
graderArtifacts: string
|
||||
}
|
||||
export interface ViewerManifestTask
|
||||
extends Omit<ViewerManifestTaskInput, 'artifactId'> {
|
||||
startUrl: string
|
||||
paths: ViewerManifestTaskPaths
|
||||
}
|
||||
|
||||
export interface ViewerManifest {
|
||||
schemaVersion: typeof VIEWER_MANIFEST_SCHEMA_VERSION
|
||||
runId: string
|
||||
suiteId: string
|
||||
variantId: string
|
||||
suiteId?: string
|
||||
variantId?: string
|
||||
uploadedAt?: string
|
||||
summary: Record<string, unknown>
|
||||
agentConfig?: Record<string, unknown>
|
||||
dataset?: string
|
||||
summary?: Record<string, unknown>
|
||||
tasks: ViewerManifestTask[]
|
||||
}
|
||||
|
||||
export interface BuildViewerManifestInput {
|
||||
runId: string
|
||||
suiteId: string
|
||||
variantId: string
|
||||
suiteId?: string
|
||||
variantId?: string
|
||||
uploadedAt?: string
|
||||
summary: Record<string, unknown>
|
||||
agentConfig?: Record<string, unknown>
|
||||
dataset?: string
|
||||
summary?: Record<string, unknown>
|
||||
tasks: ViewerManifestTaskInput[]
|
||||
}
|
||||
|
||||
function taskPaths(queryId: string): ViewerManifestTaskPaths {
|
||||
return {
|
||||
attempt: `tasks/${queryId}/attempt.json`,
|
||||
metadata: `tasks/${queryId}/metadata.json`,
|
||||
messages: `tasks/${queryId}/messages.jsonl`,
|
||||
trace: `tasks/${queryId}/trace.jsonl`,
|
||||
grades: `tasks/${queryId}/grades.json`,
|
||||
screenshots: `tasks/${queryId}/screenshots`,
|
||||
graderArtifacts: `tasks/${queryId}/grader-artifacts`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the compact JSON index consumed by the static R2 viewer. */
|
||||
export function buildViewerManifest(
|
||||
input: BuildViewerManifestInput,
|
||||
): ViewerManifest {
|
||||
return {
|
||||
schemaVersion: VIEWER_MANIFEST_SCHEMA_VERSION,
|
||||
runId: input.runId,
|
||||
suiteId: input.suiteId,
|
||||
variantId: input.variantId,
|
||||
uploadedAt: input.uploadedAt,
|
||||
summary: input.summary,
|
||||
tasks: input.tasks.map((task) => ({
|
||||
...task,
|
||||
paths: {
|
||||
attempt: `tasks/${task.queryId}/attempt.json`,
|
||||
metadata: `tasks/${task.queryId}/metadata.json`,
|
||||
messages: `tasks/${task.queryId}/messages.jsonl`,
|
||||
trace: `tasks/${task.queryId}/trace.jsonl`,
|
||||
grades: `tasks/${task.queryId}/grades.json`,
|
||||
screenshots: `tasks/${task.queryId}/screenshots`,
|
||||
graderArtifacts: `tasks/${task.queryId}/grader-artifacts`,
|
||||
},
|
||||
})),
|
||||
...(input.suiteId ? { suiteId: input.suiteId } : {}),
|
||||
...(input.variantId ? { variantId: input.variantId } : {}),
|
||||
...(input.uploadedAt ? { uploadedAt: input.uploadedAt } : {}),
|
||||
...(input.agentConfig ? { agentConfig: input.agentConfig } : {}),
|
||||
...(input.dataset ? { dataset: input.dataset } : {}),
|
||||
...(input.summary ? { summary: input.summary } : {}),
|
||||
tasks: input.tasks.map((task) => {
|
||||
const { artifactId, ...publicTask } = task
|
||||
return {
|
||||
...publicTask,
|
||||
startUrl: publicTask.startUrl ?? '',
|
||||
paths: taskPaths(artifactId ?? publicTask.queryId),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ async function writeRunFixture(
|
||||
root: string,
|
||||
configName = 'browseros-agent-weekly',
|
||||
timestamp = '2026-04-29-1200',
|
||||
options: { queryId?: string } = {},
|
||||
): Promise<{ runDir: string; runId: string }> {
|
||||
const runDir = join(root, configName, timestamp)
|
||||
const taskDir = join(runDir, 'task-1')
|
||||
@@ -33,7 +34,7 @@ async function writeRunFixture(
|
||||
await writeFile(
|
||||
join(taskDir, 'metadata.json'),
|
||||
JSON.stringify({
|
||||
query_id: 'task-1',
|
||||
query_id: options.queryId ?? 'task-1',
|
||||
dataset: 'webbench',
|
||||
query: 'Find pricing',
|
||||
start_url: 'https://example.test',
|
||||
@@ -94,6 +95,15 @@ describe('R2Publisher', () => {
|
||||
expect(
|
||||
byKey.get(`runs/${runId}/task-1/screenshots/1.png`)?.ContentType,
|
||||
).toBe('image/png')
|
||||
expect(
|
||||
byKey.get(`runs/${runId}/tasks/task-1/metadata.json`)?.ContentType,
|
||||
).toBe('application/json')
|
||||
expect(
|
||||
byKey.get(`runs/${runId}/tasks/task-1/messages.jsonl`)?.ContentType,
|
||||
).toBe('application/x-ndjson')
|
||||
expect(
|
||||
byKey.get(`runs/${runId}/tasks/task-1/screenshots/1.png`)?.ContentType,
|
||||
).toBe('image/png')
|
||||
expect(byKey.get(`runs/${runId}/manifest.json`)?.ContentType).toBe(
|
||||
'application/json',
|
||||
)
|
||||
@@ -111,8 +121,10 @@ describe('R2Publisher', () => {
|
||||
).toString('utf-8'),
|
||||
)
|
||||
expect(manifest).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
runId,
|
||||
uploadedAt: '2026-04-29T12:00:00.000Z',
|
||||
agentConfig: { type: 'single', model: 'kimi' },
|
||||
dataset: 'webbench',
|
||||
summary: { passRate: 1, avgDurationMs: 1200 },
|
||||
tasks: [
|
||||
@@ -120,11 +132,86 @@ describe('R2Publisher', () => {
|
||||
queryId: 'task-1',
|
||||
status: 'completed',
|
||||
screenshotCount: 1,
|
||||
paths: {
|
||||
attempt: 'tasks/task-1/attempt.json',
|
||||
metadata: 'tasks/task-1/metadata.json',
|
||||
messages: 'tasks/task-1/messages.jsonl',
|
||||
trace: 'tasks/task-1/trace.jsonl',
|
||||
grades: 'tasks/task-1/grades.json',
|
||||
screenshots: 'tasks/task-1/screenshots',
|
||||
graderArtifacts: 'tasks/task-1/grader-artifacts',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses task directory ids for canonical paths when metadata query ids differ', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'eval-r2-path-id-'))
|
||||
const { runDir, runId } = await writeRunFixture(
|
||||
dir,
|
||||
'weekly',
|
||||
'2026-04-29-1200',
|
||||
{ queryId: 'query-id-from-metadata' },
|
||||
)
|
||||
const viewerPath = join(dir, 'viewer.html')
|
||||
await writeFile(viewerPath, '<html>viewer</html>')
|
||||
const client = new FakeR2Client()
|
||||
|
||||
await new R2Publisher({
|
||||
client,
|
||||
viewerPath,
|
||||
config: {
|
||||
accountId: 'acct',
|
||||
accessKeyId: 'key',
|
||||
secretAccessKey: 'secret',
|
||||
bucket: 'bucket',
|
||||
cdnBaseUrl: 'https://eval.example.test',
|
||||
},
|
||||
now: () => new Date('2026-04-29T12:00:00.000Z'),
|
||||
}).publishRun(runDir, runId)
|
||||
|
||||
const byKey = new Map(client.puts.map((put) => [put.Key, put]))
|
||||
const manifest = JSON.parse(
|
||||
Buffer.from(
|
||||
byKey.get(`runs/${runId}/manifest.json`)?.Body as Buffer,
|
||||
).toString('utf-8'),
|
||||
)
|
||||
|
||||
expect(byKey.has(`runs/${runId}/tasks/task-1/metadata.json`)).toBe(true)
|
||||
expect(manifest.tasks[0]).toMatchObject({
|
||||
queryId: 'query-id-from-metadata',
|
||||
paths: {
|
||||
metadata: 'tasks/task-1/metadata.json',
|
||||
screenshots: 'tasks/task-1/screenshots',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('encodes run ids in returned viewer urls', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'eval-r2-viewer-url-'))
|
||||
const { runDir } = await writeRunFixture(dir)
|
||||
const viewerPath = join(dir, 'viewer.html')
|
||||
await writeFile(viewerPath, '<html>viewer</html>')
|
||||
const client = new FakeR2Client()
|
||||
|
||||
const result = await new R2Publisher({
|
||||
client,
|
||||
viewerPath,
|
||||
config: {
|
||||
accountId: 'acct',
|
||||
accessKeyId: 'key',
|
||||
secretAccessKey: 'secret',
|
||||
bucket: 'bucket',
|
||||
cdnBaseUrl: 'https://eval.example.test',
|
||||
},
|
||||
}).publishRun(runDir, 'run with spaces')
|
||||
|
||||
expect(result.viewerUrl).toBe(
|
||||
'https://eval.example.test/viewer.html?run=run%20with%20spaces',
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes unuploaded runs from a config results directory', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'eval-r2-config-'))
|
||||
const first = await writeRunFixture(dir, 'weekly', '2026-04-29-1200')
|
||||
@@ -186,8 +273,27 @@ describe('R2Publisher', () => {
|
||||
}).publishPath(runDir)
|
||||
|
||||
const keys = client.puts.map((put) => put.Key)
|
||||
const byKey = new Map(client.puts.map((put) => [put.Key, put]))
|
||||
const manifest = JSON.parse(
|
||||
Buffer.from(
|
||||
byKey.get(`runs/${runId}/manifest.json`)?.Body as Buffer,
|
||||
).toString('utf-8'),
|
||||
)
|
||||
|
||||
expect(result.uploadedRuns.map((run) => run.runId)).toEqual([runId])
|
||||
expect(keys).toContain(`runs/${runId}/task-1/metadata.json`)
|
||||
expect(keys).toContain(`runs/${runId}/tasks/task-1/metadata.json`)
|
||||
expect(manifest).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
tasks: [
|
||||
{
|
||||
queryId: 'task-1',
|
||||
paths: {
|
||||
metadata: 'tasks/task-1/metadata.json',
|
||||
screenshots: 'tasks/task-1/screenshots',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
130
packages/browseros-agent/apps/eval/tests/publishing/r2-viewer-compat.test.ts
vendored
Normal file
130
packages/browseros-agent/apps/eval/tests/publishing/r2-viewer-compat.test.ts
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
interface ViewerPathResolvers {
|
||||
artifactUrl(task: Record<string, unknown>, artifact: string): string
|
||||
metadataUrl(task: Record<string, unknown>): string
|
||||
messagesUrl(task: Record<string, unknown>): string
|
||||
screenshotUrl(task: Record<string, unknown>, step: number): string
|
||||
}
|
||||
|
||||
async function loadViewerPathResolvers(): Promise<ViewerPathResolvers> {
|
||||
const html = await readFile(
|
||||
join(import.meta.dir, '..', '..', 'src', 'dashboard', 'viewer.html'),
|
||||
'utf-8',
|
||||
)
|
||||
const start = html.indexOf('// -- Artifact path resolution')
|
||||
const end = html.indexOf('// -- Task selection', start)
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
|
||||
const block = html.slice(start, end)
|
||||
const createResolvers = new Function(
|
||||
`
|
||||
const basePath = 'runs/run-1';
|
||||
${block}
|
||||
return { artifactUrl, metadataUrl, messagesUrl, screenshotUrl };
|
||||
`,
|
||||
) as () => ViewerPathResolvers
|
||||
return createResolvers()
|
||||
}
|
||||
|
||||
async function runAutoSelectFromHash(hash: string): Promise<unknown> {
|
||||
const html = await readFile(
|
||||
join(import.meta.dir, '..', '..', 'src', 'dashboard', 'viewer.html'),
|
||||
'utf-8',
|
||||
)
|
||||
const start = html.indexOf('function autoSelectFromHash()')
|
||||
const end = html.indexOf('// -- Center panel', start)
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
|
||||
const block = html.slice(start, end)
|
||||
const runAutoSelect = new Function(
|
||||
`
|
||||
const window = { location: { hash: ${JSON.stringify(hash)} } };
|
||||
const manifest = {
|
||||
tasks: [
|
||||
{ queryId: 'legacy-task' },
|
||||
{ queryId: 'new-task', paths: { metadata: 'tasks/new-task/metadata.json' } },
|
||||
],
|
||||
};
|
||||
let selected = null;
|
||||
function selectTask(task) { selected = task; }
|
||||
${block}
|
||||
autoSelectFromHash();
|
||||
return selected;
|
||||
`,
|
||||
) as () => unknown
|
||||
return runAutoSelect()
|
||||
}
|
||||
|
||||
describe('R2 viewer artifact path compatibility', () => {
|
||||
it('uses explicit manifest paths for new uploaded runs', async () => {
|
||||
const resolvers = await loadViewerPathResolvers()
|
||||
const task = {
|
||||
queryId: 'task-1',
|
||||
paths: {
|
||||
metadata: 'tasks/task-1/metadata.json',
|
||||
messages: 'tasks/task-1/messages.jsonl',
|
||||
grades: 'tasks/task-1/grades.json',
|
||||
trace: 'tasks/task-1/trace.jsonl',
|
||||
screenshots: 'tasks/task-1/screenshots',
|
||||
graderArtifacts: 'tasks/task-1/grader-artifacts',
|
||||
},
|
||||
}
|
||||
|
||||
expect(resolvers.metadataUrl(task)).toBe(
|
||||
'runs/run-1/tasks/task-1/metadata.json',
|
||||
)
|
||||
expect(resolvers.messagesUrl(task)).toBe(
|
||||
'runs/run-1/tasks/task-1/messages.jsonl',
|
||||
)
|
||||
expect(resolvers.artifactUrl(task, 'grades')).toBe(
|
||||
'runs/run-1/tasks/task-1/grades.json',
|
||||
)
|
||||
expect(resolvers.artifactUrl(task, 'trace')).toBe(
|
||||
'runs/run-1/tasks/task-1/trace.jsonl',
|
||||
)
|
||||
expect(resolvers.artifactUrl(task, 'graderArtifacts')).toBe(
|
||||
'runs/run-1/tasks/task-1/grader-artifacts',
|
||||
)
|
||||
expect(resolvers.screenshotUrl(task, 7)).toBe(
|
||||
'runs/run-1/tasks/task-1/screenshots/7.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to legacy inferred paths for old uploaded runs', async () => {
|
||||
const resolvers = await loadViewerPathResolvers()
|
||||
const task = { queryId: 'legacy-task' }
|
||||
|
||||
expect(resolvers.metadataUrl(task)).toBe(
|
||||
'runs/run-1/legacy-task/metadata.json',
|
||||
)
|
||||
expect(resolvers.messagesUrl(task)).toBe(
|
||||
'runs/run-1/legacy-task/messages.jsonl',
|
||||
)
|
||||
expect(resolvers.artifactUrl(task, 'grades')).toBe(
|
||||
'runs/run-1/legacy-task/grades.json',
|
||||
)
|
||||
expect(resolvers.artifactUrl(task, 'trace')).toBe(
|
||||
'runs/run-1/legacy-task/trace.jsonl',
|
||||
)
|
||||
expect(resolvers.artifactUrl(task, 'graderArtifacts')).toBe(
|
||||
'runs/run-1/legacy-task/grader-artifacts',
|
||||
)
|
||||
expect(resolvers.screenshotUrl(task, 3)).toBe(
|
||||
'runs/run-1/legacy-task/screenshots/3.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps hash-based task selection independent of artifact layout', async () => {
|
||||
expect(await runAutoSelectFromHash('#new-task')).toMatchObject({
|
||||
queryId: 'new-task',
|
||||
})
|
||||
expect(await runAutoSelectFromHash('#legacy-task')).toMatchObject({
|
||||
queryId: 'legacy-task',
|
||||
})
|
||||
})
|
||||
})
|
||||
105
packages/browseros-agent/apps/eval/tests/reporting/run-summary.test.ts
vendored
Normal file
105
packages/browseros-agent/apps/eval/tests/reporting/run-summary.test.ts
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
buildRunSummaries,
|
||||
extractConfigName,
|
||||
} from '../../src/reporting/run-summary'
|
||||
|
||||
describe('report run summaries', () => {
|
||||
it('summarizes schema v2 manifests without depending on artifact paths', () => {
|
||||
const [summary] = buildRunSummaries([
|
||||
{
|
||||
schemaVersion: 2,
|
||||
runId: 'agisdk-real-smoke-2026-04-30-0000',
|
||||
uploadedAt: '2026-04-30T01:03:59.663Z',
|
||||
agentConfig: { type: 'single', model: 'moonshotai/kimi-k2.5' },
|
||||
dataset: 'agisdk-real',
|
||||
tasks: [
|
||||
{
|
||||
queryId: 'task-1',
|
||||
query: 'Do task 1',
|
||||
status: 'completed',
|
||||
durationMs: 1000,
|
||||
screenshotCount: 1,
|
||||
paths: { metadata: 'tasks/task-1/metadata.json' },
|
||||
graderResults: {
|
||||
agisdk_state_diff: { score: 1, pass: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
queryId: 'task-2',
|
||||
query: 'Do task 2',
|
||||
status: 'timeout',
|
||||
durationMs: 3000,
|
||||
screenshotCount: 0,
|
||||
paths: { metadata: 'tasks/task-2/metadata.json' },
|
||||
graderResults: {
|
||||
agisdk_state_diff: { score: 0, pass: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
runId: 'agisdk-real-smoke-2026-04-30-0000',
|
||||
configName: 'agisdk-real-smoke',
|
||||
date: '2026-04-30 01:03',
|
||||
avgScore: 50,
|
||||
total: 2,
|
||||
completed: 1,
|
||||
timeout: 1,
|
||||
avgDurationMs: 2000,
|
||||
model: 'moonshotai/kimi-k2.5',
|
||||
dataset: 'agisdk-real',
|
||||
agentType: 'single',
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes legacy manifests without schema version or paths', () => {
|
||||
const [summary] = buildRunSummaries([
|
||||
{
|
||||
runId: 'browseros-agent-weekly-2026-04-29-1430',
|
||||
uploadedAt: '2026-04-29T14:30:00.000Z',
|
||||
agentConfig: { type: 'orchestrator-executor', model: 'kimi' },
|
||||
dataset: 'webbench',
|
||||
tasks: [
|
||||
{
|
||||
queryId: 'legacy-task',
|
||||
query: 'Do the old task',
|
||||
status: 'failed',
|
||||
durationMs: 0,
|
||||
screenshotCount: 0,
|
||||
graderResults: {
|
||||
performance_grader: { score: 0.25, pass: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
runId: 'browseros-agent-weekly-2026-04-29-1430',
|
||||
configName: 'browseros-agent-weekly',
|
||||
avgScore: 25,
|
||||
total: 1,
|
||||
completed: 0,
|
||||
failed: 1,
|
||||
avgDurationMs: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legacy config names when run ids have no timestamp suffix', () => {
|
||||
expect(extractConfigName('ci-weekly')).toBe('ci-weekly')
|
||||
})
|
||||
|
||||
it('uses an explicit unknown date when uploadedAt is missing', () => {
|
||||
const [summary] = buildRunSummaries([
|
||||
{
|
||||
runId: 'ci-weekly',
|
||||
tasks: [],
|
||||
},
|
||||
])
|
||||
|
||||
expect(summary.date).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { R2RunManifest } from '../../src/publishing/r2-manifest'
|
||||
import { buildViewerManifest } from '../../src/viewer/viewer-manifest'
|
||||
|
||||
describe('buildViewerManifest', () => {
|
||||
@@ -22,12 +23,15 @@ describe('buildViewerManifest', () => {
|
||||
score: 0,
|
||||
pass: false,
|
||||
reasoning: 'Missing checkout item',
|
||||
details: { missing: ['checkout item'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const publishManifest: R2RunManifest = manifest
|
||||
expect(publishManifest.schemaVersion).toBe(2)
|
||||
expect(manifest.tasks[0].paths.messages).toBe(
|
||||
'tasks/agisdk-dashdish-4/messages.jsonl',
|
||||
)
|
||||
@@ -37,5 +41,72 @@ describe('buildViewerManifest', () => {
|
||||
expect(manifest.tasks[0].paths.graderArtifacts).toBe(
|
||||
'tasks/agisdk-dashdish-4/grader-artifacts',
|
||||
)
|
||||
expect(manifest.tasks[0].graderResults.agisdk_state_diff.details).toEqual({
|
||||
missing: ['checkout item'],
|
||||
})
|
||||
})
|
||||
|
||||
it('builds stable paths when optional task fields are missing', () => {
|
||||
const manifest = buildViewerManifest({
|
||||
runId: 'run-2',
|
||||
uploadedAt: '2026-04-29T06:00:00.000Z',
|
||||
tasks: [
|
||||
{
|
||||
queryId: 'task-with-minimal-fields',
|
||||
query: 'Do the task',
|
||||
status: 'completed',
|
||||
durationMs: 10,
|
||||
screenshotCount: 0,
|
||||
graderResults: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(manifest).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
runId: 'run-2',
|
||||
uploadedAt: '2026-04-29T06:00:00.000Z',
|
||||
tasks: [
|
||||
{
|
||||
queryId: 'task-with-minimal-fields',
|
||||
startUrl: '',
|
||||
paths: {
|
||||
attempt: 'tasks/task-with-minimal-fields/attempt.json',
|
||||
metadata: 'tasks/task-with-minimal-fields/metadata.json',
|
||||
messages: 'tasks/task-with-minimal-fields/messages.jsonl',
|
||||
trace: 'tasks/task-with-minimal-fields/trace.jsonl',
|
||||
grades: 'tasks/task-with-minimal-fields/grades.json',
|
||||
screenshots: 'tasks/task-with-minimal-fields/screenshots',
|
||||
graderArtifacts: 'tasks/task-with-minimal-fields/grader-artifacts',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('can separate display query ids from artifact path ids', () => {
|
||||
const manifest = buildViewerManifest({
|
||||
runId: 'run-3',
|
||||
tasks: [
|
||||
{
|
||||
queryId: 'metadata-query-id',
|
||||
artifactId: 'task-dir-id',
|
||||
query: 'Do the task',
|
||||
status: 'completed',
|
||||
durationMs: 10,
|
||||
screenshotCount: 0,
|
||||
graderResults: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(manifest.tasks[0]).toMatchObject({
|
||||
queryId: 'metadata-query-id',
|
||||
paths: {
|
||||
metadata: 'tasks/task-dir-id/metadata.json',
|
||||
screenshots: 'tasks/task-dir-id/screenshots',
|
||||
},
|
||||
})
|
||||
expect('artifactId' in manifest.tasks[0]).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,11 +7,6 @@ BROWSEROS_EXTENSION_PORT=9300
|
||||
# BROWSEROS_RESOURCES_DIR=./resources
|
||||
# BROWSEROS_EXECUTION_DIR=./out
|
||||
|
||||
# VM cache (optional - runtime downloads published agent cache in background)
|
||||
# Set prefetch=false to skip startup warmup; VM/OpenClaw startup still syncs on demand.
|
||||
BROWSEROS_VM_CACHE_PREFETCH=true
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
|
||||
|
||||
# BrowserOS config
|
||||
BROWSEROS_CONFIG_URL=https://llm.browseros.com/api/browseros-server/config
|
||||
BROWSEROS_VERSION=
|
||||
|
||||
@@ -5,9 +5,6 @@ CODEGEN_SERVICE_URL=
|
||||
POSTHOG_API_KEY=
|
||||
SENTRY_DSN=
|
||||
|
||||
BROWSEROS_VM_CACHE_PREFETCH=true
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
|
||||
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"klavis": "^2.15.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"puppeteer-core": "24.23.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
@@ -117,6 +118,7 @@
|
||||
"@types/bun": "1.3.5",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^24.3.3",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"async-mutex": "^0.5.0",
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ActiveTurnInfo,
|
||||
TurnFrame,
|
||||
} from '../../lib/agents/active-turn-registry'
|
||||
import { AdapterHealthChecker } from '../../lib/agents/adapter-health'
|
||||
import {
|
||||
AGENT_ADAPTER_CATALOG,
|
||||
isAgentAdapter,
|
||||
@@ -34,8 +35,11 @@ import {
|
||||
type AgentDefinitionWithActivity,
|
||||
AgentHarnessService,
|
||||
type GatewayStatusSnapshot,
|
||||
InvalidAgentUpdateError,
|
||||
MessageQueueFullError,
|
||||
type OpenClawProvisioner,
|
||||
OpenClawProvisionerUnavailableError,
|
||||
type QueuedMessage,
|
||||
TurnAlreadyActiveError,
|
||||
UnknownAgentError,
|
||||
} from '../services/agents/agent-harness-service'
|
||||
@@ -60,6 +64,10 @@ type AgentRouteService = {
|
||||
}): Promise<AgentDefinition>
|
||||
getAgent(agentId: string): Promise<AgentDefinition | null>
|
||||
deleteAgent(agentId: string): Promise<boolean>
|
||||
updateAgent(
|
||||
agentId: string,
|
||||
patch: { name?: string; pinned?: boolean },
|
||||
): Promise<AgentDefinition | null>
|
||||
getHistory(agentId: string): Promise<AgentHistoryPage>
|
||||
startTurn(input: {
|
||||
agentId: string
|
||||
@@ -77,6 +85,16 @@ type AgentRouteService = {
|
||||
turnId?: string
|
||||
reason?: string
|
||||
}): boolean
|
||||
enqueueMessage(input: {
|
||||
agentId: string
|
||||
message: string
|
||||
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
|
||||
}): Promise<QueuedMessage>
|
||||
removeQueuedMessage(input: {
|
||||
agentId: string
|
||||
messageId: string
|
||||
}): Promise<boolean>
|
||||
listQueuedMessages(agentId: string): Promise<QueuedMessage[]>
|
||||
}
|
||||
|
||||
type AgentRouteDeps = {
|
||||
@@ -101,6 +119,8 @@ type AgentRouteDeps = {
|
||||
* gateway side. Without this, openclaw create requests fail with 503.
|
||||
*/
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
/** Optional override; defaults to a fresh in-memory checker. */
|
||||
adapterHealth?: AdapterHealthChecker
|
||||
}
|
||||
|
||||
type SidepanelAgentChatRequest = {
|
||||
@@ -122,9 +142,20 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
openclawProvisioner: deps.openclawProvisioner,
|
||||
})
|
||||
// One checker per route mount. Cached probes refresh every 5min;
|
||||
// tests can swap in an alternate via deps if needed.
|
||||
const adapterHealth = deps.adapterHealth ?? new AdapterHealthChecker()
|
||||
|
||||
return new Hono<Env>()
|
||||
.get('/adapters', (c) => c.json({ adapters: AGENT_ADAPTER_CATALOG }))
|
||||
.get('/adapters', async (c) => {
|
||||
const adapters = await Promise.all(
|
||||
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
|
||||
...descriptor,
|
||||
health: await adapterHealth.getHealth(descriptor.id),
|
||||
})),
|
||||
)
|
||||
return c.json({ adapters })
|
||||
})
|
||||
.get('/', async (c) => {
|
||||
// Single round-trip the agents page consumes: enriched agents
|
||||
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
|
||||
@@ -243,6 +274,20 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.patch('/:agentId', async (c) => {
|
||||
const parsed = await parseAgentPatchBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const agent = await service.updateAgent(
|
||||
c.req.param('agentId'),
|
||||
parsed.patch,
|
||||
)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
@@ -320,6 +365,40 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
const cancelled = service.cancelTurn({ agentId, turnId, reason })
|
||||
return c.json({ cancelled })
|
||||
})
|
||||
.get('/:agentId/queue', async (c) => {
|
||||
try {
|
||||
const queue = await service.listQueuedMessages(c.req.param('agentId'))
|
||||
return c.json({ queue })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/queue', async (c) => {
|
||||
const parsed = await parseEnqueueBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const queued = await service.enqueueMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
return c.json({ queued })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId/queue/:messageId', async (c) => {
|
||||
try {
|
||||
const removed = await service.removeQueuedMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
messageId: c.req.param('messageId'),
|
||||
})
|
||||
if (!removed) return c.json({ error: 'Queued message not found' }, 404)
|
||||
return c.json({ removed })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function turnFramesToAgentEvents(
|
||||
@@ -518,6 +597,27 @@ const ALLOWED_IMAGE_MEDIA_TYPES = new Set([
|
||||
'image/gif',
|
||||
])
|
||||
|
||||
/**
|
||||
* Body parser for `POST /agents/:id/queue`. Mirrors `parseChatBody`'s
|
||||
* shape (message + attachments) but adds an upper bound on the
|
||||
* message text size so a runaway client can't fill the queue file
|
||||
* with multi-megabyte payloads.
|
||||
*/
|
||||
async function parseEnqueueBody(
|
||||
c: Context<Env>,
|
||||
): Promise<
|
||||
{ message: string; attachments: InboundImageAttachment[] } | { error: string }
|
||||
> {
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return parsed
|
||||
if (parsed.message.length > AGENT_HARNESS_LIMITS.QUEUE_MESSAGE_MAX_BYTES) {
|
||||
return {
|
||||
error: `Message exceeds ${AGENT_HARNESS_LIMITS.QUEUE_MESSAGE_MAX_BYTES} bytes`,
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function parseChatBody(
|
||||
c: Context<Env>,
|
||||
): Promise<
|
||||
@@ -670,9 +770,40 @@ function handleAgentRouteError(c: Context<Env>, err: unknown) {
|
||||
if (err instanceof UnknownAgentError) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
if (err instanceof InvalidAgentUpdateError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (err instanceof MessageQueueFullError) {
|
||||
return c.json({ error: err.message }, 429)
|
||||
}
|
||||
if (err instanceof OpenClawProvisionerUnavailableError) {
|
||||
return c.json({ error: err.message }, 503)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
|
||||
async function parseAgentPatchBody(
|
||||
c: Context<Env>,
|
||||
): Promise<{ patch: { name?: string; pinned?: boolean } } | { error: string }> {
|
||||
const body = await readJsonBody(c)
|
||||
if ('error' in body) return body
|
||||
const record = body.value
|
||||
const patch: { name?: string; pinned?: boolean } = {}
|
||||
if ('name' in record) {
|
||||
if (typeof record.name !== 'string') {
|
||||
return { error: 'Name must be a string' }
|
||||
}
|
||||
patch.name = record.name
|
||||
}
|
||||
if ('pinned' in record) {
|
||||
if (typeof record.pinned !== 'boolean') {
|
||||
return { error: 'Pinned must be a boolean' }
|
||||
}
|
||||
patch.pinned = record.pinned
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return { error: 'No editable fields supplied' }
|
||||
}
|
||||
return { patch }
|
||||
}
|
||||
|
||||
@@ -18,8 +18,21 @@ import {
|
||||
type CreateAgentInput,
|
||||
FileAgentStore,
|
||||
} from '../../../lib/agents/file-agent-store'
|
||||
import {
|
||||
FileMessageQueue,
|
||||
type QueuedMessage,
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
|
||||
export {
|
||||
MessageQueueFullError,
|
||||
type QueuedMessage,
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentRowSnapshot,
|
||||
AgentRuntime,
|
||||
AgentStreamEvent,
|
||||
} from '../../../lib/agents/types'
|
||||
@@ -37,8 +50,32 @@ export interface AgentActivity {
|
||||
export interface AgentDefinitionWithActivity extends AgentDefinition {
|
||||
status: AgentLiveness
|
||||
lastUsedAt: number | null
|
||||
/** First non-blank line of the most recent user message; null if none. */
|
||||
lastUserMessage: string | null
|
||||
/** Working directory the agent runs in; null when no session record yet. */
|
||||
cwd: string | null
|
||||
/** Cumulative + 7-day rolling token usage; null when no record. */
|
||||
tokens: AgentRowSnapshot['tokens']
|
||||
/**
|
||||
* Last 14 days of completed turns, oldest → newest. Zero-filled in
|
||||
* this release until the activity ledger ships in a follow-up.
|
||||
*/
|
||||
turnsByDay: number[]
|
||||
/** Same shape as `turnsByDay`; counts of failed turns. */
|
||||
failedByDay: number[]
|
||||
/** Last error message when status === 'error'; null otherwise. */
|
||||
lastError: string | null
|
||||
lastErrorAt: number | null
|
||||
/** When non-null, an in-flight turn this row can be resumed from. */
|
||||
activeTurnId: string | null
|
||||
/** Persistent FIFO queue of messages waiting to run for this agent. */
|
||||
queue: QueuedMessage[]
|
||||
}
|
||||
|
||||
const SPARKLINE_DAYS = 14
|
||||
const ZERO_BUCKETS = (): number[] =>
|
||||
Array.from({ length: SPARKLINE_DAYS }, () => 0)
|
||||
|
||||
/**
|
||||
* `idle` downgrades to `asleep` after this many ms of no activity. Read at
|
||||
* enrichment time; no timer cleanup necessary.
|
||||
@@ -119,6 +156,7 @@ export class AgentHarnessService {
|
||||
private readonly runtime: AgentRuntime
|
||||
private readonly openclawProvisioner: OpenClawProvisioner | null
|
||||
private readonly turnRegistry: TurnRegistry
|
||||
private readonly messageQueue: FileMessageQueue
|
||||
private inFlightReconcile: Promise<void> | null = null
|
||||
// In-memory liveness tracker. Lost on server restart (acceptable —
|
||||
// `lastUsedAt` survives via the acpx session record's `lastUsedAt`,
|
||||
@@ -138,6 +176,7 @@ export class AgentHarnessService {
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
turnRegistry?: TurnRegistry
|
||||
messageQueue?: FileMessageQueue
|
||||
} = {},
|
||||
) {
|
||||
this.agentStore = deps.agentStore ?? new FileAgentStore()
|
||||
@@ -150,6 +189,25 @@ export class AgentHarnessService {
|
||||
})
|
||||
this.openclawProvisioner = deps.openclawProvisioner ?? null
|
||||
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
|
||||
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
|
||||
// Drain any agents whose queue file survived a restart. The check
|
||||
// for `getActiveFor` inside `maybeStartNextFromQueue` guards
|
||||
// against double-firing if the in-memory turn registry happens to
|
||||
// have something (it won't post-restart, but the guard is cheap).
|
||||
void this.drainOnBoot()
|
||||
}
|
||||
|
||||
private async drainOnBoot(): Promise<void> {
|
||||
try {
|
||||
const pending = await this.messageQueue.agentsWithPendingMessages()
|
||||
for (const agentId of pending) {
|
||||
void this.maybeStartNextFromQueue(agentId)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Message queue boot drain failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async listAgents(): Promise<AgentDefinition[]> {
|
||||
@@ -166,15 +224,31 @@ export class AgentHarnessService {
|
||||
*/
|
||||
async listAgentsWithActivity(): Promise<AgentDefinitionWithActivity[]> {
|
||||
const agents = await this.listAgents()
|
||||
const lastUsedMap = await this.collectLastUsed(agents)
|
||||
const [snapshots, queueSnapshot] = await Promise.all([
|
||||
this.collectRowSnapshots(agents),
|
||||
this.messageQueue.snapshotAll(),
|
||||
])
|
||||
const now = Date.now()
|
||||
return agents.map((agent) => {
|
||||
const live = this.activity.get(agent.id)
|
||||
const lastUsedAt = lastUsedMap.get(agent.id) ?? null
|
||||
const snapshot = snapshots.get(agent.id) ?? null
|
||||
const lastUsedAt = snapshot?.lastUsedAt ?? null
|
||||
const activeTurn = this.turnRegistry.getActiveFor(agent.id, 'main')
|
||||
return {
|
||||
...agent,
|
||||
pinned: agent.pinned ?? false,
|
||||
status: deriveStatus(live, lastUsedAt, now),
|
||||
lastUsedAt,
|
||||
lastUserMessage: snapshot?.lastUserMessage ?? null,
|
||||
cwd: snapshot?.cwd ?? null,
|
||||
tokens: snapshot?.tokens ?? null,
|
||||
turnsByDay: ZERO_BUCKETS(),
|
||||
failedByDay: ZERO_BUCKETS(),
|
||||
lastError: live?.status === 'error' ? (live.lastError ?? null) : null,
|
||||
lastErrorAt:
|
||||
live?.status === 'error' ? (live.lastEventAt ?? null) : null,
|
||||
activeTurnId: activeTurn?.turnId ?? null,
|
||||
queue: queueSnapshot[agent.id] ?? [],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -199,27 +273,20 @@ export class AgentHarnessService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read each agent's `lastUsedAt` from the acpx session record (the
|
||||
* runtime exposes it through `getHistory` indirectly, but we don't
|
||||
* need history items here — only the timestamp). Loads in parallel
|
||||
* and tolerates per-agent failures (agents that have never had a
|
||||
* turn won't have a record yet).
|
||||
* Pull one snapshot per agent in parallel. Falls back to a
|
||||
* lastUsedAt-only snapshot when the runtime doesn't implement
|
||||
* `getRowSnapshot` (test fakes, future runtimes), so the listing
|
||||
* stays robust during migration.
|
||||
*/
|
||||
private async collectLastUsed(
|
||||
private async collectRowSnapshots(
|
||||
agents: AgentDefinition[],
|
||||
): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>()
|
||||
): Promise<Map<string, AgentRowSnapshot>> {
|
||||
const out = new Map<string, AgentRowSnapshot>()
|
||||
await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
try {
|
||||
const page = await this.runtime.getHistory({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
})
|
||||
const last = page.items.at(-1)?.createdAt
|
||||
if (typeof last === 'number' && Number.isFinite(last)) {
|
||||
out.set(agent.id, last)
|
||||
}
|
||||
const snapshot = await this.fetchRowSnapshot(agent)
|
||||
if (snapshot) out.set(agent.id, snapshot)
|
||||
} catch {
|
||||
// No record yet — treat as never-used.
|
||||
}
|
||||
@@ -228,6 +295,24 @@ export class AgentHarnessService {
|
||||
return out
|
||||
}
|
||||
|
||||
private async fetchRowSnapshot(
|
||||
agent: AgentDefinition,
|
||||
): Promise<AgentRowSnapshot | null> {
|
||||
if (typeof this.runtime.getRowSnapshot === 'function') {
|
||||
return this.runtime.getRowSnapshot({ agent, sessionId: 'main' })
|
||||
}
|
||||
// Legacy fallback: derive only `lastUsedAt` from the history page.
|
||||
const page = await this.runtime.getHistory({ agent, sessionId: 'main' })
|
||||
const last = page.items.at(-1)?.createdAt
|
||||
if (typeof last !== 'number' || !Number.isFinite(last)) return null
|
||||
return {
|
||||
cwd: null,
|
||||
lastUsedAt: last,
|
||||
lastUserMessage: null,
|
||||
tokens: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark `agentId` as actively running a turn. */
|
||||
notifyTurnStarted(agentId: string): void {
|
||||
this.activity.set(agentId, { status: 'working', lastEventAt: Date.now() })
|
||||
@@ -244,11 +329,101 @@ export class AgentHarnessService {
|
||||
lastEventAt: Date.now(),
|
||||
lastError: outcome.error,
|
||||
})
|
||||
} else {
|
||||
// Successful turn — drop the in-memory entry. Liveness will be
|
||||
// derived from the session record's `lastUsedAt` on next read.
|
||||
this.activity.delete(agentId)
|
||||
}
|
||||
// The queue drain runs on every turn-end (success or failure) so
|
||||
// a queued message is the next thing to run. Fire-and-forget; any
|
||||
// failure inside `maybeStartNextFromQueue` requeues the message
|
||||
// and logs.
|
||||
void this.maybeStartNextFromQueue(agentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the oldest queued message for `agentId` and start a turn from
|
||||
* it. Fires from `notifyTurnEnded` (covers natural completion +
|
||||
* cancel) and on server boot to drain queue files that survived a
|
||||
* restart. No-ops when the queue is empty or another turn is
|
||||
* already running for the agent.
|
||||
*/
|
||||
private async maybeStartNextFromQueue(agentId: string): Promise<void> {
|
||||
const next = await this.messageQueue.popOldest(agentId)
|
||||
if (!next) return
|
||||
// Race guard: a turn may have started between `popOldest` and now
|
||||
// (e.g. the user typed and clicked Send directly between cancel
|
||||
// and the drain). Put the message back at the head and let the
|
||||
// next turn-end retry.
|
||||
if (this.turnRegistry.getActiveFor(agentId, 'main')) {
|
||||
await this.messageQueue.pushFront(agentId, next)
|
||||
return
|
||||
}
|
||||
// Successful turn — drop the in-memory entry. Liveness will be
|
||||
// derived from the session record's `lastUsedAt` on next read.
|
||||
this.activity.delete(agentId)
|
||||
try {
|
||||
await this.startTurn({
|
||||
agentId,
|
||||
message: next.message,
|
||||
attachments: next.attachments,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn('Queue drain failed; requeued message', {
|
||||
agentId,
|
||||
queuedId: next.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
try {
|
||||
await this.messageQueue.pushFront(agentId, next)
|
||||
} catch (requeueErr) {
|
||||
logger.error('Queue requeue after drain failure also failed', {
|
||||
agentId,
|
||||
queuedId: next.id,
|
||||
error:
|
||||
requeueErr instanceof Error
|
||||
? requeueErr.message
|
||||
: String(requeueErr),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a message to the agent's queue. Returns the new queued
|
||||
* record. Throws `UnknownAgentError` for unknown agents and
|
||||
* `MessageQueueFullError` when the per-agent cap is reached.
|
||||
*/
|
||||
async enqueueMessage(input: {
|
||||
agentId: string
|
||||
message: string
|
||||
attachments?: ReadonlyArray<QueuedMessageAttachment>
|
||||
}): Promise<QueuedMessage> {
|
||||
const agent = await this.requireAgent(input.agentId)
|
||||
const queued = await this.messageQueue.append(agent.id, {
|
||||
message: input.message,
|
||||
attachments: input.attachments,
|
||||
})
|
||||
// Defensive drain: if the agent has no active turn at enqueue
|
||||
// time (e.g. the user enqueued during the brief window between
|
||||
// turns), pop it back off and start it directly. Avoids the
|
||||
// queue sitting idle while the agent is also idle.
|
||||
if (!this.turnRegistry.getActiveFor(agent.id, 'main')) {
|
||||
void this.maybeStartNextFromQueue(agent.id)
|
||||
}
|
||||
return queued
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a queued message. Returns true if the message was
|
||||
* removed, false if the agent or message was unknown.
|
||||
*/
|
||||
async removeQueuedMessage(input: {
|
||||
agentId: string
|
||||
messageId: string
|
||||
}): Promise<boolean> {
|
||||
return this.messageQueue.remove(input.agentId, input.messageId)
|
||||
}
|
||||
|
||||
async listQueuedMessages(agentId: string): Promise<QueuedMessage[]> {
|
||||
return this.messageQueue.list(agentId)
|
||||
}
|
||||
|
||||
private ensureGatewayReconciled(): Promise<void> {
|
||||
@@ -388,6 +563,35 @@ export class AgentHarnessService {
|
||||
return this.agentStore.delete(agentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a partial update to an agent record. Currently used by the
|
||||
* pin-toggle mutation; rename will land here too. Returns null if
|
||||
* the agent doesn't exist; throws on validation failure so the
|
||||
* route layer can surface a 400.
|
||||
*/
|
||||
async updateAgent(
|
||||
agentId: string,
|
||||
patch: { name?: string; pinned?: boolean },
|
||||
): Promise<AgentDefinition | null> {
|
||||
if (patch.name !== undefined) {
|
||||
const trimmed = patch.name.trim()
|
||||
if (!trimmed) {
|
||||
throw new InvalidAgentUpdateError('Name is required')
|
||||
}
|
||||
// Mirror the create-time validation for length consistency.
|
||||
const { AGENT_HARNESS_LIMITS } = await import(
|
||||
'@browseros/shared/constants/limits'
|
||||
)
|
||||
if (trimmed.length > AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS) {
|
||||
throw new InvalidAgentUpdateError(
|
||||
`Name must be ${AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS} characters or fewer`,
|
||||
)
|
||||
}
|
||||
patch = { ...patch, name: trimmed }
|
||||
}
|
||||
return this.agentStore.update(agentId, patch)
|
||||
}
|
||||
|
||||
getAgent(agentId: string): Promise<AgentDefinition | null> {
|
||||
return this.agentStore.get(agentId)
|
||||
}
|
||||
@@ -418,7 +622,9 @@ export class AgentHarnessService {
|
||||
throw new TurnAlreadyActiveError(agent.id, existing.turnId)
|
||||
}
|
||||
|
||||
const turn = this.turnRegistry.register(agent.id, 'main')
|
||||
const turn = this.turnRegistry.register(agent.id, 'main', {
|
||||
prompt: input.message,
|
||||
})
|
||||
this.notifyTurnStarted(agent.id)
|
||||
|
||||
// Kick off the runtime call in the background. The per-turn
|
||||
@@ -628,6 +834,17 @@ export class OpenClawProvisionerUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an `updateAgent` call carries a payload that fails
|
||||
* validation (e.g., empty/oversized name). Route layer maps to 400.
|
||||
*/
|
||||
export class InvalidAgentUpdateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvalidAgentUpdateError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when `startTurn` is called for an agent that already has an
|
||||
* in-flight turn. The route layer maps this to 409 + the existing
|
||||
|
||||
@@ -10,19 +10,12 @@ import { getBrowserosDir } from '../../../lib/browseros-dir'
|
||||
import { ContainerCli, ImageLoader } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
detectArch,
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import {
|
||||
ensureVmCacheAvailable,
|
||||
ensureVmCacheSynced,
|
||||
type VmCacheSyncOptions,
|
||||
} from '../../../lib/vm/cache-sync'
|
||||
import { readCachedManifest } from '../../../lib/vm/manifest'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
|
||||
import { ContainerRuntime } from './container-runtime'
|
||||
|
||||
@@ -34,13 +27,6 @@ export interface ContainerRuntimeFactoryInput {
|
||||
projectDir: string
|
||||
browserosRoot?: string
|
||||
platform?: NodeJS.Platform
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}
|
||||
|
||||
export interface VmCacheRuntimeConfig
|
||||
extends Pick<VmCacheSyncOptions, 'manifestUrl'> {
|
||||
ensureAvailable?: () => Promise<void>
|
||||
ensureSynced?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export function buildContainerRuntime(
|
||||
@@ -77,16 +63,9 @@ export function buildContainerRuntime(
|
||||
? resolveBundledLimaTemplate(input.resourcesDir)
|
||||
: undefined,
|
||||
browserosRoot,
|
||||
ensureCacheAvailable:
|
||||
input.vmCache?.ensureAvailable ??
|
||||
(() =>
|
||||
ensureVmCacheAvailable({
|
||||
browserosRoot,
|
||||
manifestUrl: input.vmCache?.manifestUrl,
|
||||
})),
|
||||
})
|
||||
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
|
||||
const loader = new DeferredImageLoader(shell, browserosRoot, input.vmCache)
|
||||
const loader = new ImageLoader(shell)
|
||||
|
||||
return new ContainerRuntime({
|
||||
vm,
|
||||
@@ -122,49 +101,6 @@ function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
|
||||
})
|
||||
}
|
||||
|
||||
class DeferredImageLoader {
|
||||
constructor(
|
||||
private readonly shell: ContainerCli,
|
||||
private readonly browserosRoot: string,
|
||||
private readonly vmCache?: VmCacheRuntimeConfig,
|
||||
) {}
|
||||
|
||||
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
|
||||
const loader = await this.buildLoader()
|
||||
await loader.ensureImageLoaded(ref, onLog)
|
||||
}
|
||||
|
||||
async ensureAgentImageLoaded(
|
||||
name: string,
|
||||
onLog?: (msg: string) => void,
|
||||
): Promise<string> {
|
||||
const loader = await this.buildLoader()
|
||||
return loader.ensureAgentImageLoaded(name, onLog)
|
||||
}
|
||||
|
||||
private async buildLoader(): Promise<ImageLoader> {
|
||||
await this.ensureCacheSynced()
|
||||
const manifest = await readCachedManifest(this.browserosRoot)
|
||||
return new ImageLoader(
|
||||
this.shell,
|
||||
manifest,
|
||||
detectArch(),
|
||||
this.browserosRoot,
|
||||
)
|
||||
}
|
||||
|
||||
private async ensureCacheSynced(): Promise<void> {
|
||||
if (this.vmCache?.ensureSynced) {
|
||||
await this.vmCache.ensureSynced()
|
||||
return
|
||||
}
|
||||
await ensureVmCacheSynced({
|
||||
browserosRoot: this.browserosRoot,
|
||||
manifestUrl: this.vmCache?.manifestUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
|
||||
constructor(projectDir: string) {
|
||||
super({
|
||||
@@ -197,6 +133,14 @@ class UnsupportedPlatformTestRuntime extends ContainerRuntime {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async prewarmGatewayImage(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async isGatewayCurrent(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async startGateway(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
@@ -8,24 +8,33 @@ import {
|
||||
OPENCLAW_AGENT_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import type {
|
||||
ContainerCli,
|
||||
ContainerCommandResult,
|
||||
ContainerSpec,
|
||||
LogFn,
|
||||
WaitForContainerNameReleaseOptions,
|
||||
} from '../../../lib/container'
|
||||
import { isContainerNameInUse } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
hostPathToGuest,
|
||||
type VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import { ContainerNameInUseError } from '../../../lib/vm/errors'
|
||||
|
||||
const GATEWAY_CONTAINER_HOME = '/home/node'
|
||||
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
|
||||
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
|
||||
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
|
||||
const OPENCLAW_NAME_RELEASE_WAIT: WaitForContainerNameReleaseOptions = {
|
||||
timeoutMs: 10_000,
|
||||
intervalMs: 100,
|
||||
}
|
||||
// Prepend user-installed bin so tools like `claude` / `gemini` CLI that
|
||||
// are installed via npm into the mounted home are discoverable by
|
||||
// OpenClaw's child-process spawns (no login shell is involved).
|
||||
@@ -95,14 +104,34 @@ export class ContainerRuntime {
|
||||
await this.loader.ensureImageLoaded(image, onLog)
|
||||
}
|
||||
|
||||
/** Warm the gateway image in containerd without creating or starting containers. */
|
||||
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
|
||||
await this.ensureGatewayImageLoaded(onLog)
|
||||
}
|
||||
|
||||
/** Report whether the existing gateway container was created from the target image. */
|
||||
async isGatewayCurrent(): Promise<boolean> {
|
||||
const image = await this.shell.containerImageRef(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
const expected = this.expectedGatewayImageRef()
|
||||
const current = imageMatchesExpectedRef(image, expected)
|
||||
if (!current) {
|
||||
logger.info('OpenClaw gateway image is not current', {
|
||||
actualImageRef: image,
|
||||
expectedImageRef: expected,
|
||||
})
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
async startGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
const image = await this.ensureGatewayImageLoaded(onLog)
|
||||
const container = await this.buildGatewayContainerSpec(input, image)
|
||||
await this.shell.createContainer(container, onLog)
|
||||
await this.createContainerWithNameReconcile(container, onLog)
|
||||
await this.shell.startContainer(container.name)
|
||||
}
|
||||
|
||||
@@ -186,10 +215,11 @@ export class ContainerRuntime {
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
|
||||
await this.removeContainerAndWait(setupContainerName, onLog)
|
||||
const image = await this.ensureGatewayImageLoaded(onLog)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
const createResult = await this.shell.runCommand(
|
||||
const createResult = await this.runSetupCreateWithNameReconcile(
|
||||
setupContainerName,
|
||||
[
|
||||
'create',
|
||||
'--name',
|
||||
@@ -230,10 +260,74 @@ export class ContainerRuntime {
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
|
||||
await this.shell.removeContainer(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
onLog,
|
||||
await this.removeContainerAndWait(OPENCLAW_GATEWAY_CONTAINER_NAME, onLog)
|
||||
}
|
||||
|
||||
/** Create the fixed-name gateway after reconciling stale nerdctl name ownership. */
|
||||
private async createContainerWithNameReconcile(
|
||||
container: ContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
await this.removeContainerAndWait(container.name, onLog)
|
||||
try {
|
||||
await this.shell.createContainer(container, onLog)
|
||||
return
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof ContainerNameInUseError) ||
|
||||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
logger.warn('OpenClaw container name still in use; retrying create', {
|
||||
containerName: container.name,
|
||||
attempt,
|
||||
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
|
||||
})
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runSetupCreateWithNameReconcile(
|
||||
setupContainerName: string,
|
||||
createArgs: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
const result = await this.shell.runCommand(createArgs, onLog)
|
||||
if (
|
||||
result.exitCode === 0 ||
|
||||
!isContainerNameInUse(result.stderr) ||
|
||||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
|
||||
) {
|
||||
return result
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'OpenClaw setup container name still in use; retrying create',
|
||||
{
|
||||
containerName: setupContainerName,
|
||||
attempt,
|
||||
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
|
||||
},
|
||||
)
|
||||
await this.removeContainerAndWait(setupContainerName, onLog)
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
|
||||
private async removeContainerAndWait(
|
||||
containerName: string,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.shell.removeContainer(containerName, { force: true }, onLog)
|
||||
await this.shell.waitForContainerNameRelease(
|
||||
containerName,
|
||||
OPENCLAW_NAME_RELEASE_WAIT,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,7 +390,7 @@ export class ContainerRuntime {
|
||||
}
|
||||
|
||||
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
|
||||
// Local image testing can bypass the synced VM manifest with OPENCLAW_IMAGE.
|
||||
// Local image testing can override the pinned GHCR image with OPENCLAW_IMAGE.
|
||||
const override = process.env.OPENCLAW_IMAGE?.trim()
|
||||
if (override) {
|
||||
await this.loader.ensureImageLoaded(override, onLog)
|
||||
@@ -305,6 +399,10 @@ export class ContainerRuntime {
|
||||
return this.loader.ensureAgentImageLoaded(OPENCLAW_AGENT_NAME, onLog)
|
||||
}
|
||||
|
||||
private expectedGatewayImageRef(): string {
|
||||
return process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
|
||||
}
|
||||
|
||||
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
|
||||
return {
|
||||
HOME: GATEWAY_CONTAINER_HOME,
|
||||
@@ -330,3 +428,12 @@ export class ContainerRuntime {
|
||||
return hostPathToGuest(path)
|
||||
}
|
||||
}
|
||||
|
||||
function imageMatchesExpectedRef(
|
||||
actual: string | null,
|
||||
expected: string,
|
||||
): boolean {
|
||||
return (
|
||||
actual === expected || actual?.startsWith(`${expected}@sha256:`) === true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { withProcessLock } from '../../../lib/process-lock'
|
||||
import {
|
||||
type AgentLiveStatus,
|
||||
type AgentSessionState,
|
||||
@@ -26,10 +29,7 @@ import type {
|
||||
ContainerRuntime,
|
||||
GatewayContainerSpec,
|
||||
} from './container-runtime'
|
||||
import {
|
||||
buildContainerRuntime,
|
||||
type VmCacheRuntimeConfig,
|
||||
} from './container-runtime-factory'
|
||||
import { buildContainerRuntime } from './container-runtime-factory'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
@@ -135,7 +135,6 @@ export interface OpenClawServiceConfig {
|
||||
browserosServerPort?: number
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}
|
||||
|
||||
export type OpenClawSessionSource =
|
||||
@@ -267,7 +266,6 @@ export class OpenClawService {
|
||||
private browserosServerPort: number
|
||||
private resourcesDir: string | null
|
||||
private browserosDir: string | undefined
|
||||
private vmCache: VmCacheRuntimeConfig | undefined
|
||||
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
|
||||
private lastGatewayError: string | null = null
|
||||
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
|
||||
@@ -282,7 +280,6 @@ export class OpenClawService {
|
||||
resourcesDir: config.resourcesDir,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: config.browserosDir,
|
||||
vmCache: config.vmCache,
|
||||
})
|
||||
this.token = crypto.randomUUID()
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
@@ -295,7 +292,6 @@ export class OpenClawService {
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
this.browserosDir = config.browserosDir
|
||||
this.vmCache = config.vmCache
|
||||
}
|
||||
|
||||
configure(config: OpenClawServiceConfig): void {
|
||||
@@ -318,13 +314,6 @@ export class OpenClawService {
|
||||
this.browserosDir = config.browserosDir
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (
|
||||
config.vmCache !== undefined &&
|
||||
!sameVmCacheRuntimeConfig(config.vmCache, this.vmCache)
|
||||
) {
|
||||
this.vmCache = config.vmCache
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (runtimeChanged) {
|
||||
this.rebuildRuntimeClients()
|
||||
}
|
||||
@@ -361,6 +350,23 @@ export class OpenClawService {
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
/** Warm the VM and gateway image so later setup/start avoids registry work. */
|
||||
async prewarm(onLog?: (msg: string) => void): Promise<void> {
|
||||
return this.withLifecycleLock('prewarm', async () => {
|
||||
const imageRef = process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
|
||||
const logProgress = (message: string) => {
|
||||
// Startup prewarm runs outside a user request, so keep phase logs visible without streaming command progress.
|
||||
logger.info(message)
|
||||
onLog?.(message)
|
||||
}
|
||||
logProgress('OpenClaw prewarm: ensuring BrowserOS VM is ready')
|
||||
await this.runtime.ensureReady()
|
||||
logProgress(`OpenClaw prewarm: ensuring image ${imageRef} is available`)
|
||||
await this.runtime.prewarmGatewayImage()
|
||||
logProgress('OpenClaw prewarm: ready')
|
||||
})
|
||||
}
|
||||
|
||||
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
|
||||
return this.withLifecycleLock('setup', async () => {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
@@ -478,7 +484,7 @@ export class OpenClawService {
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
if (await this.isGatewayAvailable(this.hostPort)) {
|
||||
if (await this.isCurrentGatewayAvailable(this.hostPort)) {
|
||||
this.startGatewayLogTail()
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
@@ -873,7 +879,7 @@ export class OpenClawService {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
|
||||
if (!(await this.isGatewayAvailable(this.hostPort))) {
|
||||
if (!(await this.isCurrentGatewayAvailable(this.hostPort))) {
|
||||
await this.ensureGatewayPortAllocated()
|
||||
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
|
||||
const ready = await this.runtime.waitForReady(
|
||||
@@ -987,7 +993,6 @@ export class OpenClawService {
|
||||
resourcesDir: this.resourcesDir ?? undefined,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: this.browserosDir,
|
||||
vmCache: this.vmCache,
|
||||
})
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
@@ -1009,10 +1014,16 @@ export class OpenClawService {
|
||||
if (persistedPort !== null) {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
if (await this.isGatewayAvailable(this.hostPort)) {
|
||||
const currentPortReady = await this.isGatewayPortReady(this.hostPort)
|
||||
if (
|
||||
currentPortReady &&
|
||||
(await this.isGatewayAuthenticated(this.hostPort))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const hostPort = await allocateGatewayPort(this.openclawDir)
|
||||
const hostPort = await allocateGatewayPort(this.openclawDir, {
|
||||
excludePort: currentPortReady ? this.hostPort : undefined,
|
||||
})
|
||||
if (hostPort !== this.hostPort) {
|
||||
logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`)
|
||||
logger.info('Allocated OpenClaw gateway host port', { hostPort })
|
||||
@@ -1022,7 +1033,10 @@ export class OpenClawService {
|
||||
|
||||
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
|
||||
if (!(await this.isGatewayPortReady(hostPort))) return false
|
||||
return this.isGatewayAuthenticated(hostPort)
|
||||
}
|
||||
|
||||
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
|
||||
if (!this.tokenLoaded) {
|
||||
logger.debug(
|
||||
'OpenClaw gateway port is ready before auth token is loaded',
|
||||
@@ -1046,6 +1060,11 @@ export class OpenClawService {
|
||||
return authenticated
|
||||
}
|
||||
|
||||
private async isCurrentGatewayAvailable(hostPort: number): Promise<boolean> {
|
||||
if (!(await this.isGatewayAvailable(hostPort))) return false
|
||||
return this.runtime.isGatewayCurrent()
|
||||
}
|
||||
|
||||
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
|
||||
if (await this.runtime.isReady(hostPort)) return true
|
||||
|
||||
@@ -1504,8 +1523,14 @@ export class OpenClawService {
|
||||
})
|
||||
await previous.catch(() => undefined)
|
||||
try {
|
||||
logger.debug('OpenClaw lifecycle operation started', { operation })
|
||||
return await fn()
|
||||
return await withProcessLock(
|
||||
'openclaw-lifecycle',
|
||||
{ lockDir: join(this.openclawDir, '.locks') },
|
||||
async () => {
|
||||
logger.debug('OpenClaw lifecycle operation started', { operation })
|
||||
return await fn()
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
@@ -1529,7 +1554,6 @@ export function configureOpenClawService(
|
||||
export function configureVmRuntime(config: {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}): OpenClawService {
|
||||
return configureOpenClawService(config)
|
||||
}
|
||||
@@ -1538,14 +1562,3 @@ export function getOpenClawService(): OpenClawService {
|
||||
if (!service) service = new OpenClawService()
|
||||
return service
|
||||
}
|
||||
|
||||
function sameVmCacheRuntimeConfig(
|
||||
left: VmCacheRuntimeConfig | undefined,
|
||||
right: VmCacheRuntimeConfig | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
left?.manifestUrl === right?.manifestUrl &&
|
||||
left?.ensureAvailable === right?.ensureAvailable &&
|
||||
left?.ensureSynced === right?.ensureSynced
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/ope
|
||||
import { getOpenClawStateDir } from './openclaw-env'
|
||||
|
||||
const RUNTIME_STATE_FILE = 'runtime-state.json'
|
||||
const MAX_TCP_PORT = 65_535
|
||||
|
||||
interface RuntimeState {
|
||||
gatewayPort: number
|
||||
@@ -26,7 +27,7 @@ function readForcedGatewayPort(): number | null {
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_TCP_PORT) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
@@ -49,7 +50,7 @@ export async function readPersistedGatewayPort(
|
||||
typeof parsed.gatewayPort === 'number' &&
|
||||
Number.isInteger(parsed.gatewayPort) &&
|
||||
parsed.gatewayPort > 0 &&
|
||||
parsed.gatewayPort <= 65535
|
||||
parsed.gatewayPort <= MAX_TCP_PORT
|
||||
) {
|
||||
return parsed.gatewayPort
|
||||
}
|
||||
@@ -82,14 +83,26 @@ function isPortAvailable(port: number): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
async function findAvailablePort(
|
||||
startPort: number,
|
||||
excludePort?: number,
|
||||
): Promise<number> {
|
||||
let port = startPort
|
||||
while (!(await isPortAvailable(port))) {
|
||||
while (port === excludePort || !(await isPortAvailable(port))) {
|
||||
port++
|
||||
if (port > MAX_TCP_PORT) {
|
||||
throw new Error(
|
||||
`No available OpenClaw gateway port found from ${startPort}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
export interface AllocateGatewayPortOptions {
|
||||
excludePort?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a host port for the gateway container and persist it. Prefers the
|
||||
* previously persisted port when it's still bindable; otherwise scans
|
||||
@@ -97,6 +110,7 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
||||
*/
|
||||
export async function allocateGatewayPort(
|
||||
openclawDir: string,
|
||||
opts: AllocateGatewayPortOptions = {},
|
||||
): Promise<number> {
|
||||
const forcedPort = readForcedGatewayPort()
|
||||
if (forcedPort !== null) {
|
||||
@@ -105,10 +119,17 @@ export async function allocateGatewayPort(
|
||||
}
|
||||
|
||||
const persisted = await readPersistedGatewayPort(openclawDir)
|
||||
if (persisted !== null && (await isPortAvailable(persisted))) {
|
||||
if (
|
||||
persisted !== null &&
|
||||
persisted !== opts.excludePort &&
|
||||
(await isPortAvailable(persisted))
|
||||
) {
|
||||
return persisted
|
||||
}
|
||||
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
const port = await findAvailablePort(
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
opts.excludePort,
|
||||
)
|
||||
await writePersistedGatewayPort(openclawDir, port)
|
||||
return port
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { Command, InvalidArgumentError } from 'commander'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -31,8 +30,6 @@ export const ServerConfigSchema = z.object({
|
||||
instanceBrowserosVersion: z.string().optional(),
|
||||
instanceChromiumVersion: z.string().optional(),
|
||||
aiSdkDevtoolsEnabled: z.boolean(),
|
||||
vmCachePrefetch: z.boolean(),
|
||||
vmCacheManifestUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export type ServerConfig = z.infer<typeof ServerConfigSchema>
|
||||
@@ -229,11 +226,6 @@ function parseConfigFile(filePath?: string): ConfigResult<PartialConfig> {
|
||||
cfg.flags?.allow_remote_in_mcp === true ? true : undefined,
|
||||
aiSdkDevtoolsEnabled:
|
||||
cfg.flags?.ai_sdk_devtools === true ? true : undefined,
|
||||
vmCachePrefetch:
|
||||
typeof cfg.vm_cache?.prefetch === 'boolean'
|
||||
? cfg.vm_cache.prefetch
|
||||
: undefined,
|
||||
vmCacheManifestUrl: parseTrimmedString(cfg.vm_cache?.manifest_url),
|
||||
instanceClientId:
|
||||
typeof cfg.instance?.client_id === 'string'
|
||||
? cfg.instance.client_id
|
||||
@@ -280,10 +272,6 @@ function parseRuntimeEnv(): PartialConfig {
|
||||
instanceClientId: process.env.BROWSEROS_CLIENT_ID,
|
||||
aiSdkDevtoolsEnabled:
|
||||
process.env.BROWSEROS_AI_SDK_DEVTOOLS === 'true' ? true : undefined,
|
||||
vmCachePrefetch: parseBooleanEnv(process.env.BROWSEROS_VM_CACHE_PREFETCH),
|
||||
vmCacheManifestUrl: parseTrimmedString(
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -317,8 +305,6 @@ function getDefaults(cwd: string): PartialConfig {
|
||||
executionDir: cwd,
|
||||
mcpAllowRemote: false,
|
||||
aiSdkDevtoolsEnabled: false,
|
||||
vmCachePrefetch: true,
|
||||
vmCacheManifestUrl: EXTERNAL_URLS.VM_CACHE_MANIFEST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,18 +325,6 @@ function safeParseInt(value: string): number | undefined {
|
||||
return Number.isNaN(num) ? undefined : num
|
||||
}
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
function omitUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, v]) => v !== undefined),
|
||||
|
||||
@@ -19,8 +19,6 @@ export const INLINED_ENV = {
|
||||
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
|
||||
BROWSEROS_VM_CACHE_PREFETCH: process.env.BROWSEROS_VM_CACHE_PREFETCH,
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL: process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
|
||||
SKILLS_CATALOG_URL: process.env.SKILLS_CATALOG_URL,
|
||||
} as const
|
||||
|
||||
@@ -29,6 +27,4 @@ export const REQUIRED_FOR_PRODUCTION = [
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'BROWSEROS_CONFIG_URL',
|
||||
'BROWSEROS_VM_CACHE_PREFETCH',
|
||||
'BROWSEROS_VM_CACHE_MANIFEST_URL',
|
||||
] as const satisfies readonly (keyof typeof INLINED_ENV)[]
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentPromptInput,
|
||||
AgentRowSnapshot,
|
||||
AgentRuntime,
|
||||
AgentSession,
|
||||
AgentStatus,
|
||||
@@ -135,6 +136,33 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
return mapAcpxSessionRecordToHistory(input.agent, input.sessionId, record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight read of the session record's row-level fields. Returns
|
||||
* `null` for never-used agents so the harness can fill in nulls
|
||||
* without throwing. Token bucketing for `last7d` lives outside the
|
||||
* session record (no per-message timestamps); a follow-up activity
|
||||
* ledger will populate that field — for now we return zeros.
|
||||
*/
|
||||
async getRowSnapshot(input: {
|
||||
agent: AgentPromptInput['agent']
|
||||
sessionId: 'main'
|
||||
}): Promise<AgentRowSnapshot | null> {
|
||||
const record = await this.sessionStore.load(input.agent.sessionKey)
|
||||
if (!record) return null
|
||||
return {
|
||||
cwd: record.cwd ?? null,
|
||||
lastUsedAt: parseRecordTimestamp(record) || null,
|
||||
lastUserMessage: extractLastUserMessage(record),
|
||||
tokens: {
|
||||
cumulative: {
|
||||
input: record.cumulative_token_usage?.input_tokens ?? 0,
|
||||
output: record.cumulative_token_usage?.output_tokens ?? 0,
|
||||
},
|
||||
last7d: { input: 0, output: 0, requestCount: 0 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async send(
|
||||
input: AgentPromptInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
@@ -573,6 +601,26 @@ function stringifyToolError(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk messages newest-to-oldest and return the first user-role text.
|
||||
* Returns null when the record has no user messages (rare — a session
|
||||
* always starts with one — but possible mid-load).
|
||||
*/
|
||||
function extractLastUserMessage(record: AcpSessionRecord): string | null {
|
||||
for (let i = record.messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = record.messages[i]
|
||||
if (message === 'Resume') continue
|
||||
if (!('User' in message)) continue
|
||||
const text = message.User.content
|
||||
.map((block) => userContentToText(block))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
if (text) return text
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseRecordTimestamp(record: AcpSessionRecord): number {
|
||||
const parsed = Date.parse(record.updated_at || record.lastUsedAt)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ActiveTurnInfo {
|
||||
lastSeq: number
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
/** User message that kicked off the turn; null when not captured. */
|
||||
prompt: string | null
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
@@ -43,6 +45,8 @@ interface ActiveTurn {
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
retainUntil?: number
|
||||
/** User message that kicked off the turn (when known). */
|
||||
prompt: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_BUFFER_CAPACITY = 5000
|
||||
@@ -136,7 +140,11 @@ export class TurnRegistry {
|
||||
* Register a new turn. The caller is responsible for kicking off the
|
||||
* runtime call and pumping its events into `pushEvent` until done.
|
||||
*/
|
||||
register(agentId: string, sessionId: 'main' = 'main'): ActiveTurn {
|
||||
register(
|
||||
agentId: string,
|
||||
sessionId: 'main' = 'main',
|
||||
options: { prompt?: string | null } = {},
|
||||
): ActiveTurn {
|
||||
const turn: ActiveTurn = {
|
||||
turnId: randomUUID(),
|
||||
agentId,
|
||||
@@ -146,6 +154,7 @@ export class TurnRegistry {
|
||||
subscribers: new Set(),
|
||||
abortController: new AbortController(),
|
||||
startedAt: Date.now(),
|
||||
prompt: options.prompt ?? null,
|
||||
}
|
||||
this.turns.set(turn.turnId, turn)
|
||||
this.ensureSweeper()
|
||||
@@ -187,6 +196,7 @@ export class TurnRegistry {
|
||||
lastSeq: turn.buffer.lastSeq,
|
||||
startedAt: turn.startedAt,
|
||||
endedAt: turn.endedAt,
|
||||
prompt: turn.prompt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { logger } from '../logger'
|
||||
import type { AgentAdapter } from './agent-types'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export interface AdapterHealth {
|
||||
healthy: boolean
|
||||
/** Human-readable explanation when unhealthy; absent on success. */
|
||||
reason?: string
|
||||
/** Wall-clock ms when this probe completed. */
|
||||
checkedAt: number
|
||||
}
|
||||
|
||||
interface CachedHealth extends AdapterHealth {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache of adapter binary availability. Probed lazily on
|
||||
* first read and refreshed every `cacheTtlMs`. The probe is one
|
||||
* `<binary> --version` invocation per adapter with a hard 2s timeout
|
||||
* so a hung CLI doesn't block the listing endpoint.
|
||||
*
|
||||
* OpenClaw isn't probed here — its health derives from the gateway
|
||||
* lifecycle snapshot already exposed via `getGatewayStatus()`.
|
||||
*/
|
||||
export class AdapterHealthChecker {
|
||||
private readonly cache = new Map<AgentAdapter, CachedHealth>()
|
||||
private readonly cacheTtlMs: number
|
||||
private readonly probeTimeoutMs: number
|
||||
private readonly inflight = new Map<AgentAdapter, Promise<AdapterHealth>>()
|
||||
|
||||
constructor(options: { cacheTtlMs?: number; probeTimeoutMs?: number } = {}) {
|
||||
this.cacheTtlMs = options.cacheTtlMs ?? 5 * 60 * 1000
|
||||
this.probeTimeoutMs = options.probeTimeoutMs ?? 2_000
|
||||
}
|
||||
|
||||
async getHealth(adapter: AgentAdapter): Promise<AdapterHealth> {
|
||||
if (adapter === 'openclaw') {
|
||||
// OpenClaw health is derived from the gateway snapshot the
|
||||
// harness service already returns; the row component reads
|
||||
// that path. Surface a permissive default so the dot doesn't
|
||||
// spuriously light up red.
|
||||
return { healthy: true, checkedAt: Date.now() }
|
||||
}
|
||||
const now = Date.now()
|
||||
const cached = this.cache.get(adapter)
|
||||
if (cached && cached.expiresAt > now) return cached
|
||||
|
||||
const inflight = this.inflight.get(adapter)
|
||||
if (inflight) return inflight
|
||||
|
||||
const probe = this.runProbe(adapter)
|
||||
.then((result) => {
|
||||
const cacheEntry: CachedHealth = {
|
||||
...result,
|
||||
expiresAt: Date.now() + this.cacheTtlMs,
|
||||
}
|
||||
this.cache.set(adapter, cacheEntry)
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
this.inflight.delete(adapter)
|
||||
})
|
||||
this.inflight.set(adapter, probe)
|
||||
return probe
|
||||
}
|
||||
|
||||
private async runProbe(adapter: AgentAdapter): Promise<AdapterHealth> {
|
||||
const command = ADAPTER_HEALTH_COMMANDS[adapter]
|
||||
if (!command) {
|
||||
return {
|
||||
healthy: false,
|
||||
reason: 'No health probe defined',
|
||||
checkedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
try {
|
||||
await execAsync(command, { timeout: this.probeTimeoutMs })
|
||||
return { healthy: true, checkedAt: Date.now() }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.debug('Adapter health probe failed', { adapter, error: message })
|
||||
return {
|
||||
healthy: false,
|
||||
reason: friendlyProbeFailure(adapter, message),
|
||||
checkedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes are deliberately conservative — `--version` exits zero on
|
||||
* any installed CLI and won't trigger network calls or auth flows.
|
||||
*/
|
||||
const ADAPTER_HEALTH_COMMANDS: Partial<Record<AgentAdapter, string>> = {
|
||||
claude: 'claude --version',
|
||||
codex: 'codex --version',
|
||||
}
|
||||
|
||||
function friendlyProbeFailure(adapter: AgentAdapter, raw: string): string {
|
||||
if (/command not found|not recognized|ENOENT/i.test(raw)) {
|
||||
return `${ADAPTER_HEALTH_COMMANDS[adapter]} failed: command not found`
|
||||
}
|
||||
if (/timed out|ETIMEDOUT/i.test(raw)) {
|
||||
return `${ADAPTER_HEALTH_COMMANDS[adapter]} did not respond within timeout`
|
||||
}
|
||||
return raw.split('\n')[0]?.slice(0, 200) ?? raw
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface AgentDefinition {
|
||||
sessionKey: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
/** Pinned agents float to the top of the rail. Defaulted on read for legacy records. */
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
export interface AgentAdapterDescriptor {
|
||||
|
||||
@@ -148,6 +148,42 @@ export class FileAgentStore {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a partial update to an agent record. Returns the updated
|
||||
* record, or `null` if no agent matches `id`. Atomic via the same
|
||||
* temp-file + rename + write-queue rules as `create`. Bumps
|
||||
* `updatedAt` so the rail's recency sort reflects the change.
|
||||
*
|
||||
* Currently consumed by the pin-toggle mutation; the rename UI will
|
||||
* use the same patch surface.
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
patch: Partial<Pick<AgentDefinition, 'name' | 'pinned'>>,
|
||||
): Promise<AgentDefinition | null> {
|
||||
return this.withWriteLock(async () => {
|
||||
const file = await this.read()
|
||||
const index = file.agents.findIndex((agent) => agent.id === id)
|
||||
if (index < 0) return null
|
||||
const current = file.agents[index]
|
||||
const next: AgentDefinition = {
|
||||
...current,
|
||||
...(patch.name !== undefined ? { name: patch.name.trim() } : {}),
|
||||
...(patch.pinned !== undefined ? { pinned: patch.pinned } : {}),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
const agents = [...file.agents]
|
||||
agents[index] = next
|
||||
await this.write({ ...file, agents })
|
||||
logger.info('Agent harness store updated agent', {
|
||||
agentId: id,
|
||||
patchedFields: Object.keys(patch),
|
||||
filePath: this.filePath,
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
return this.withWriteLock(async () => {
|
||||
const file = await this.read()
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { AGENT_HARNESS_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
|
||||
export interface QueuedMessageAttachment {
|
||||
mediaType: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string
|
||||
createdAt: number
|
||||
message: string
|
||||
attachments?: ReadonlyArray<QueuedMessageAttachment>
|
||||
}
|
||||
|
||||
interface MessageQueueFile {
|
||||
version: 1
|
||||
queues: Record<string, QueuedMessage[]>
|
||||
}
|
||||
|
||||
export class MessageQueueFullError extends Error {
|
||||
constructor(
|
||||
readonly agentId: string,
|
||||
readonly limit: number,
|
||||
) {
|
||||
super(`Queue for agent ${agentId} is full (limit ${limit})`)
|
||||
this.name = 'MessageQueueFullError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent durable FIFO of messages waiting to run. Persists at
|
||||
* `~/.browseros/agent-harness/message-queues.json` so queues survive
|
||||
* server restarts. Atomic temp+rename writes serialised through a
|
||||
* write lock so concurrent enqueues from different request contexts
|
||||
* don't race.
|
||||
*
|
||||
* Reads and writes always touch the whole file. The file is small in
|
||||
* practice (one short JSON record per agent, capped at 50 messages
|
||||
* each), so this keeps the implementation honest and removes any need
|
||||
* for partial-update semantics.
|
||||
*/
|
||||
export class FileMessageQueue {
|
||||
private readonly filePath: string
|
||||
private writeQueue: Promise<unknown> = Promise.resolve()
|
||||
private readonly maxLength: number
|
||||
|
||||
constructor(options: { filePath?: string; maxLength?: number } = {}) {
|
||||
this.filePath =
|
||||
options.filePath ??
|
||||
join(getBrowserosDir(), 'agents', 'harness', 'message-queues.json')
|
||||
this.maxLength = options.maxLength ?? AGENT_HARNESS_LIMITS.QUEUE_MAX_LENGTH
|
||||
}
|
||||
|
||||
async list(agentId: string): Promise<QueuedMessage[]> {
|
||||
const file = await this.read()
|
||||
return file.queues[agentId] ?? []
|
||||
}
|
||||
|
||||
async snapshotAll(): Promise<Record<string, QueuedMessage[]>> {
|
||||
const file = await this.read()
|
||||
return Object.fromEntries(
|
||||
Object.entries(file.queues).map(([agentId, queue]) => [
|
||||
agentId,
|
||||
queue.slice(),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
async append(
|
||||
agentId: string,
|
||||
input: {
|
||||
message: string
|
||||
attachments?: ReadonlyArray<QueuedMessageAttachment>
|
||||
},
|
||||
): Promise<QueuedMessage> {
|
||||
return this.withWriteLock(async () => {
|
||||
const file = await this.read()
|
||||
const queue = file.queues[agentId] ?? []
|
||||
if (queue.length >= this.maxLength) {
|
||||
throw new MessageQueueFullError(agentId, this.maxLength)
|
||||
}
|
||||
const queued: QueuedMessage = {
|
||||
id: randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
message: input.message,
|
||||
attachments: input.attachments,
|
||||
}
|
||||
const next = [...queue, queued]
|
||||
await this.write({
|
||||
...file,
|
||||
queues: { ...file.queues, [agentId]: next },
|
||||
})
|
||||
logger.info('Message queue appended', {
|
||||
agentId,
|
||||
queuedId: queued.id,
|
||||
depth: next.length,
|
||||
})
|
||||
return queued
|
||||
})
|
||||
}
|
||||
|
||||
async popOldest(agentId: string): Promise<QueuedMessage | null> {
|
||||
return this.withWriteLock(async () => {
|
||||
const file = await this.read()
|
||||
const queue = file.queues[agentId] ?? []
|
||||
if (queue.length === 0) return null
|
||||
const [head, ...rest] = queue
|
||||
const nextQueues = { ...file.queues }
|
||||
if (rest.length === 0) {
|
||||
delete nextQueues[agentId]
|
||||
} else {
|
||||
nextQueues[agentId] = rest
|
||||
}
|
||||
await this.write({ ...file, queues: nextQueues })
|
||||
logger.info('Message queue popped', {
|
||||
agentId,
|
||||
queuedId: head.id,
|
||||
remaining: rest.length,
|
||||
})
|
||||
return head
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach a message to the head of an agent's queue. Used by the
|
||||
* drain pump when starting a turn fails so the message isn't
|
||||
* silently dropped. Bypasses the length cap — `pushFront` is a
|
||||
* recovery primitive, not a regular append.
|
||||
*/
|
||||
async pushFront(agentId: string, message: QueuedMessage): Promise<void> {
|
||||
await this.withWriteLock(async () => {
|
||||
const file = await this.read()
|
||||
const queue = file.queues[agentId] ?? []
|
||||
const next = [message, ...queue]
|
||||
await this.write({
|
||||
...file,
|
||||
queues: { ...file.queues, [agentId]: next },
|
||||
})
|
||||
logger.info('Message queue requeued at head', {
|
||||
agentId,
|
||||
queuedId: message.id,
|
||||
depth: next.length,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async remove(agentId: string, messageId: string): Promise<boolean> {
|
||||
return this.withWriteLock(async () => {
|
||||
const file = await this.read()
|
||||
const queue = file.queues[agentId] ?? []
|
||||
const next = queue.filter((entry) => entry.id !== messageId)
|
||||
if (next.length === queue.length) return false
|
||||
const nextQueues = { ...file.queues }
|
||||
if (next.length === 0) {
|
||||
delete nextQueues[agentId]
|
||||
} else {
|
||||
nextQueues[agentId] = next
|
||||
}
|
||||
await this.write({ ...file, queues: nextQueues })
|
||||
logger.info('Message queue removed', { agentId, messageId })
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Agent ids that have at least one queued message. */
|
||||
async agentsWithPendingMessages(): Promise<string[]> {
|
||||
const file = await this.read()
|
||||
return Object.entries(file.queues)
|
||||
.filter(([, queue]) => queue.length > 0)
|
||||
.map(([agentId]) => agentId)
|
||||
}
|
||||
|
||||
private async read(): Promise<MessageQueueFile> {
|
||||
try {
|
||||
const raw = await readFile(this.filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as MessageQueueFile
|
||||
if (parsed.version !== 1 || typeof parsed.queues !== 'object') {
|
||||
return emptyQueueFile()
|
||||
}
|
||||
return parsed
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return emptyQueueFile()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async write(file: MessageQueueFile): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
|
||||
await writeFile(tmpPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8')
|
||||
await rename(tmpPath, this.filePath)
|
||||
}
|
||||
|
||||
private withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const result = this.writeQueue.then(fn, fn)
|
||||
this.writeQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
function emptyQueueFile(): MessageQueueFile {
|
||||
return { version: 1, queues: {} }
|
||||
}
|
||||
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
@@ -79,6 +79,28 @@ export interface AgentPromptInput {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent metadata sourced from the acpx session record. Surfaced
|
||||
* by the listing endpoint to fill in command-center row info that the
|
||||
* standard `getHistory` shape doesn't carry (cwd, token usage, last
|
||||
* user message). Returned `null` when the agent has no record yet.
|
||||
*/
|
||||
export interface AgentRowSnapshot {
|
||||
cwd: string | null
|
||||
lastUsedAt: number | null
|
||||
lastUserMessage: string | null
|
||||
tokens: {
|
||||
cumulative: { input: number; output: number }
|
||||
/**
|
||||
* 7-day rolling tokens. Zeroes today; populated in a follow-up that
|
||||
* tracks per-turn deltas in an activity ledger (the session record
|
||||
* doesn't carry per-message timestamps, so we can't bucket
|
||||
* accurately from it alone).
|
||||
*/
|
||||
last7d: { input: number; output: number; requestCount: number }
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AgentRuntime {
|
||||
status(agent: AgentDefinition): Promise<AgentStatus>
|
||||
listSessions(agent: AgentDefinition): Promise<AgentSession[]>
|
||||
@@ -92,4 +114,13 @@ export interface AgentRuntime {
|
||||
sessionId: 'main'
|
||||
reason?: string
|
||||
}): Promise<void>
|
||||
/**
|
||||
* Optional. When present, the harness includes the snapshot fields
|
||||
* in `listAgentsWithActivity` for the command-center rows. Test
|
||||
* fakes can omit it; callers must tolerate `null`.
|
||||
*/
|
||||
getRowSnapshot?(input: {
|
||||
agent: AgentDefinition
|
||||
sessionId: 'main'
|
||||
}): Promise<AgentRowSnapshot | null>
|
||||
}
|
||||
|
||||
@@ -75,10 +75,6 @@ export function getVmDisksDir(): string {
|
||||
return getVmCacheDir()
|
||||
}
|
||||
|
||||
export function getAgentCacheDir(): string {
|
||||
return join(getVmCacheDir(), 'images')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringDir(): string {
|
||||
return join(getBrowserosDir(), 'lazy-monitoring')
|
||||
}
|
||||
@@ -116,7 +112,7 @@ export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getAgentCacheDir(), { recursive: true })
|
||||
await mkdir(getVmDisksDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -4,9 +4,20 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ContainerCliError } from '../vm/errors'
|
||||
import {
|
||||
ContainerCliError,
|
||||
ContainerNameInUseError,
|
||||
ContainerNameReleaseTimeoutError,
|
||||
} from '../vm/errors'
|
||||
import { LimaCli } from '../vm/lima-cli'
|
||||
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
|
||||
import type {
|
||||
ContainerInfo,
|
||||
ContainerSpec,
|
||||
LogFn,
|
||||
MountSpec,
|
||||
PortMapping,
|
||||
WaitForContainerNameReleaseOptions,
|
||||
} from './types'
|
||||
|
||||
export function buildNerdctlCommand(args: string[]): string[] {
|
||||
return ['nerdctl', ...args]
|
||||
@@ -41,17 +52,35 @@ export class ContainerCli {
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
/** Return the image ref used to create a container, or null when absent. */
|
||||
async containerImageRef(name: string): Promise<string | null> {
|
||||
const args = ['inspect', '--format', '{{.Config.Image}}', name]
|
||||
const result = await this.runCommand(args)
|
||||
if (result.exitCode === 0) {
|
||||
const image = result.stdout.trim()
|
||||
return image || null
|
||||
}
|
||||
if (isNoSuchContainer(result.stderr)) return null
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(['pull', ref], onLog)
|
||||
}
|
||||
|
||||
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
|
||||
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
|
||||
return parseLoadedImageRefs(result.stdout)
|
||||
}
|
||||
|
||||
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(buildCreateArgs(spec), onLog)
|
||||
const args = buildCreateArgs(spec)
|
||||
const result = await this.runCommand(args, onLog)
|
||||
if (result.exitCode === 0) return
|
||||
if (isContainerNameInUse(result.stderr)) {
|
||||
throw new ContainerNameInUseError(
|
||||
spec.name,
|
||||
`nerdctl ${args.join(' ')}`,
|
||||
result.exitCode,
|
||||
result.stderr.trim(),
|
||||
)
|
||||
}
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
async startContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
@@ -77,6 +106,36 @@ export class ContainerCli {
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
/** Inspect a named container without treating absence as a command failure. */
|
||||
async inspectContainer(name: string): Promise<ContainerInfo | null> {
|
||||
const args = ['container', 'inspect', '--format', '{{json .}}', name]
|
||||
const result = await this.runCommand(args)
|
||||
if (result.exitCode === 0) {
|
||||
return parseContainerInfo(result.stdout, name)
|
||||
}
|
||||
if (isNoSuchContainer(result.stderr)) return null
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
/** Wait for containerd/nerdctl to stop resolving a container name after rm. */
|
||||
async waitForContainerNameRelease(
|
||||
name: string,
|
||||
opts: WaitForContainerNameReleaseOptions = {},
|
||||
): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? 5_000
|
||||
const intervalMs = opts.intervalMs ?? 100
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
if (!(await this.inspectContainer(name))) return
|
||||
const remainingMs = timeoutMs - (Date.now() - startedAt)
|
||||
if (remainingMs <= 0) break
|
||||
await Bun.sleep(Math.min(intervalMs, remainingMs))
|
||||
}
|
||||
|
||||
throw new ContainerNameReleaseTimeoutError(name, timeoutMs)
|
||||
}
|
||||
|
||||
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
|
||||
const result = await this.runCommand(['exec', name, ...cmd], onLog)
|
||||
return result.exitCode
|
||||
@@ -191,19 +250,65 @@ function mountArg(mount: MountSpec): string {
|
||||
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
|
||||
}
|
||||
|
||||
function parseLoadedImageRefs(stdout: string): string[] {
|
||||
return stdout
|
||||
function parseContainerInfo(
|
||||
stdout: string,
|
||||
fallbackName: string,
|
||||
): ContainerInfo {
|
||||
const line = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
|
||||
.filter((ref): ref is string => !!ref)
|
||||
.map((entry) => entry.trim())
|
||||
.find(Boolean)
|
||||
if (!line) {
|
||||
throw new Error(`nerdctl container inspect returned empty output`)
|
||||
}
|
||||
const parsed = JSON.parse(line) as unknown
|
||||
const container = Array.isArray(parsed) ? parsed[0] : parsed
|
||||
const object = isRecord(container) ? container : {}
|
||||
const config = isRecord(object.Config) ? object.Config : {}
|
||||
const state = isRecord(object.State) ? object.State : {}
|
||||
const name = stringValue(object.Name)?.replace(/^\/+/, '') ?? fallbackName
|
||||
const status = stringValue(state.Status) ?? stringValue(object.Status)
|
||||
const running =
|
||||
typeof state.Running === 'boolean'
|
||||
? state.Running
|
||||
: status
|
||||
? status.toLowerCase() === 'running'
|
||||
: null
|
||||
|
||||
return {
|
||||
id: stringValue(object.ID) ?? stringValue(object.Id),
|
||||
name,
|
||||
image: stringValue(config.Image) ?? stringValue(object.Image),
|
||||
status,
|
||||
running,
|
||||
}
|
||||
}
|
||||
|
||||
function isNoSuchContainer(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return lower.includes('no such container') || lower.includes('not found')
|
||||
return (
|
||||
lower.includes('no such container') || lower.includes('container not found')
|
||||
)
|
||||
}
|
||||
|
||||
export function isContainerNameInUse(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return (
|
||||
(lower.includes('name-store error') && lower.includes('already used')) ||
|
||||
lower.includes('name is already in use')
|
||||
)
|
||||
}
|
||||
|
||||
function linesToOutput(lines: string[]): string {
|
||||
if (lines.length === 0) return ''
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === 'string' && value ? value : null
|
||||
}
|
||||
|
||||
@@ -4,87 +4,41 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { basename, join } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_AGENT_NAME,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { ContainerCliError, ImageLoadError } from '../vm/errors'
|
||||
import type { VmAgentTarball, VmManifest } from '../vm/manifest'
|
||||
import type { Arch } from '../vm/paths'
|
||||
import { getImageCacheDir, hostPathToGuest } from '../vm/paths'
|
||||
import type { ContainerCli } from './container-cli'
|
||||
import type { LogFn } from './types'
|
||||
|
||||
export class ImageLoader {
|
||||
constructor(
|
||||
private readonly cli: ContainerCli,
|
||||
private readonly manifest: VmManifest,
|
||||
private readonly arch: Arch,
|
||||
private readonly browserosRoot?: string,
|
||||
) {}
|
||||
constructor(private readonly cli: ContainerCli) {}
|
||||
|
||||
/** Ensure an image ref exists in the VM's persistent containerd store. */
|
||||
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
|
||||
if (await this.cli.imageExists(ref)) return
|
||||
|
||||
const tarball = this.resolveTarball(ref)
|
||||
await this.loadResolvedTarball(ref, tarball, onLog)
|
||||
}
|
||||
|
||||
/** Load an agent tarball from the VM cache and return its local image ref. */
|
||||
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
|
||||
const agent = this.resolveAgent(name)
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
if (await this.cli.imageExists(ref)) return ref
|
||||
|
||||
const tarball = agent.tarballs[this.arch]
|
||||
if (!tarball) {
|
||||
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
|
||||
}
|
||||
await this.loadResolvedTarball(ref, tarball, onLog)
|
||||
return ref
|
||||
}
|
||||
|
||||
private async loadResolvedTarball(
|
||||
ref: string,
|
||||
tarball: VmAgentTarball,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
const hostPath = join(
|
||||
getImageCacheDir(this.browserosRoot),
|
||||
basename(tarball.key),
|
||||
)
|
||||
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
|
||||
|
||||
try {
|
||||
await this.cli.loadImage(guestPath, onLog)
|
||||
await this.cli.pullImage(ref, onLog)
|
||||
} catch (error) {
|
||||
if (error instanceof ContainerCliError) {
|
||||
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
|
||||
throw new ImageLoadError(ref, `pull failed: ${error.stderr}`, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!(await this.cli.imageExists(ref))) {
|
||||
throw new ImageLoadError(
|
||||
ref,
|
||||
`image not present after successful load of ${guestPath}`,
|
||||
)
|
||||
throw new ImageLoadError(ref, 'image not present after successful pull')
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTarball(ref: string): VmAgentTarball {
|
||||
for (const agent of Object.values(this.manifest.agents)) {
|
||||
if (`${agent.image}:${agent.version}` !== ref) continue
|
||||
const tarball = agent.tarballs[this.arch]
|
||||
if (!tarball) {
|
||||
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
|
||||
}
|
||||
return tarball
|
||||
/** Resolve BrowserOS agent names to image refs and ensure the image exists. */
|
||||
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
|
||||
if (name !== OPENCLAW_AGENT_NAME) {
|
||||
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
|
||||
}
|
||||
|
||||
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
|
||||
}
|
||||
|
||||
private resolveAgent(name: string): VmManifest['agents'][string] {
|
||||
const agent = this.manifest.agents[name]
|
||||
if (!agent) throw new ImageLoadError(name, `no agent in manifest: ${name}`)
|
||||
return agent
|
||||
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
|
||||
return OPENCLAW_IMAGE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,19 @@ export interface ContainerSpec {
|
||||
command?: string[]
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string | null
|
||||
name: string
|
||||
image: string | null
|
||||
status: string | null
|
||||
running: boolean | null
|
||||
}
|
||||
|
||||
export interface WaitForContainerNameReleaseOptions {
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
stream: 'stdout' | 'stderr'
|
||||
line: string
|
||||
|
||||
130
packages/browseros-agent/apps/server/src/lib/process-lock.ts
Normal file
130
packages/browseros-agent/apps/server/src/lib/process-lock.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import lockfile from 'proper-lockfile'
|
||||
|
||||
const DEFAULT_STALE_MS = 60_000
|
||||
const DEFAULT_UPDATE_MS = 15_000
|
||||
const DEFAULT_TIMEOUT_MS = 120_000
|
||||
const DEFAULT_RETRY_MIN_TIMEOUT_MS = 100
|
||||
const DEFAULT_RETRY_MAX_TIMEOUT_MS = 1_000
|
||||
|
||||
export interface ProcessLockOptions {
|
||||
lockDir: string
|
||||
staleMs?: number
|
||||
updateMs?: number
|
||||
timeoutMs?: number
|
||||
retryMinTimeoutMs?: number
|
||||
retryMaxTimeoutMs?: number
|
||||
randomize?: boolean
|
||||
}
|
||||
|
||||
export class ProcessLockTimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly lockName: string,
|
||||
public readonly lockPath: string,
|
||||
public readonly timeoutMs: number,
|
||||
public override readonly cause?: unknown,
|
||||
) {
|
||||
super(
|
||||
`Timed out acquiring process lock "${lockName}" at ${lockPath} after ${timeoutMs}ms`,
|
||||
)
|
||||
this.name = 'ProcessLockTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a critical section while holding a named lock shared across processes. */
|
||||
export async function withProcessLock<T>(
|
||||
name: string,
|
||||
options: ProcessLockOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const release = await acquireProcessLock(name, options)
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveProcessLockPath(lockDir: string, name: string): string {
|
||||
return join(lockDir, `${sanitizeLockName(name)}.lock`)
|
||||
}
|
||||
|
||||
async function acquireProcessLock(
|
||||
name: string,
|
||||
options: ProcessLockOptions,
|
||||
): Promise<() => Promise<void>> {
|
||||
await mkdir(options.lockDir, { recursive: true })
|
||||
|
||||
const lockPath = resolveProcessLockPath(options.lockDir, name)
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const retryMinTimeoutMs =
|
||||
options.retryMinTimeoutMs ?? DEFAULT_RETRY_MIN_TIMEOUT_MS
|
||||
const retryMaxTimeoutMs =
|
||||
options.retryMaxTimeoutMs ?? DEFAULT_RETRY_MAX_TIMEOUT_MS
|
||||
const startedAt = Date.now()
|
||||
let lastError: unknown
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
try {
|
||||
return await lockfile.lock(lockPath, {
|
||||
lockfilePath: lockPath,
|
||||
realpath: false,
|
||||
stale: options.staleMs ?? DEFAULT_STALE_MS,
|
||||
update: options.updateMs ?? DEFAULT_UPDATE_MS,
|
||||
// The wrapper owns retry/backoff so acquisition respects timeoutMs.
|
||||
retries: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
if (!isLockedError(err)) throw err
|
||||
lastError = err
|
||||
}
|
||||
|
||||
const remainingMs = timeoutMs - (Date.now() - startedAt)
|
||||
if (remainingMs <= 0) break
|
||||
await Bun.sleep(
|
||||
Math.min(
|
||||
remainingMs,
|
||||
nextRetryDelay(retryMinTimeoutMs, retryMaxTimeoutMs, options.randomize),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
throw new ProcessLockTimeoutError(name, lockPath, timeoutMs, lastError)
|
||||
}
|
||||
|
||||
function sanitizeLockName(name: string): string {
|
||||
const safeName = name
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/^[.-]+|[.-]+$/g, '')
|
||||
if (!safeName) throw new Error('Process lock name must not be empty')
|
||||
return safeName
|
||||
}
|
||||
|
||||
function isLockedError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'code' in err &&
|
||||
err.code === 'ELOCKED'
|
||||
)
|
||||
}
|
||||
|
||||
function nextRetryDelay(
|
||||
minTimeoutMs: number,
|
||||
maxTimeoutMs: number,
|
||||
randomize = true,
|
||||
): number {
|
||||
if (maxTimeoutMs <= minTimeoutMs) return minTimeoutMs
|
||||
if (!randomize) return minTimeoutMs
|
||||
return (
|
||||
minTimeoutMs + Math.floor(Math.random() * (maxTimeoutMs - minTimeoutMs))
|
||||
)
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { arch as hostArch } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import type { VmArtifact, VmManifest } from './manifest'
|
||||
import type { Arch } from './paths'
|
||||
import { getCachedManifestPath } from './paths'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
const ARCHES: Arch[] = ['arm64', 'x64']
|
||||
const CANONICAL_MANIFEST_SUFFIX = '/vm/manifest.json'
|
||||
|
||||
export interface VmCacheSyncOptions {
|
||||
browserosRoot?: string
|
||||
manifestUrl?: string
|
||||
allArches?: boolean
|
||||
fetchImpl?: typeof fetch
|
||||
rawHostArch?: NodeJS.Architecture
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface VmCacheSyncResult {
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, Promise<VmCacheSyncResult>>()
|
||||
|
||||
export function prefetchVmCache(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<VmCacheSyncResult> {
|
||||
return startOrReuseSync(options)
|
||||
}
|
||||
|
||||
export function ensureVmCacheSynced(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<VmCacheSyncResult> {
|
||||
return startOrReuseSync(options)
|
||||
}
|
||||
|
||||
export async function ensureVmCacheAvailable(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<void> {
|
||||
const cfg = resolveSyncConfig(options)
|
||||
const pending = inFlight.get(syncKey(cfg))
|
||||
if (pending) {
|
||||
await pending.catch(() => {})
|
||||
}
|
||||
|
||||
if (existsSync(getCachedManifestPath(cfg.browserosRoot))) return
|
||||
|
||||
await startOrReuseSyncWithConfig(cfg)
|
||||
}
|
||||
|
||||
function startOrReuseSync(
|
||||
options: VmCacheSyncOptions,
|
||||
): Promise<VmCacheSyncResult> {
|
||||
try {
|
||||
return startOrReuseSyncWithConfig(resolveSyncConfig(options))
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
function startOrReuseSyncWithConfig(
|
||||
cfg: SyncConfig,
|
||||
): Promise<VmCacheSyncResult> {
|
||||
const key = syncKey(cfg)
|
||||
const existing = inFlight.get(key)
|
||||
if (existing) return existing
|
||||
const current = syncVmCache(cfg).finally(() => {
|
||||
if (inFlight.get(key) === current) inFlight.delete(key)
|
||||
})
|
||||
inFlight.set(key, current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function syncVmCache(cfg: SyncConfig): Promise<VmCacheSyncResult> {
|
||||
const remote = await fetchManifest(cfg)
|
||||
const manifestPath = getCachedManifestPath(cfg.browserosRoot)
|
||||
const local = await readLocalManifest(manifestPath)
|
||||
const plan = await planDownloads({
|
||||
remote,
|
||||
local,
|
||||
cacheRoot: cacheRootForManifest(manifestPath),
|
||||
arches: cfg.arches,
|
||||
})
|
||||
|
||||
for (const item of plan) {
|
||||
await downloadArtifact(
|
||||
cfg.fetchImpl,
|
||||
artifactUrlForKey(cfg.manifestUrl, item.key),
|
||||
item.destPath,
|
||||
item.sha256,
|
||||
cfg.timeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
|
||||
await Bun.write(tempPath, `${JSON.stringify(remote, null, 2)}\n`)
|
||||
await rename(tempPath, manifestPath)
|
||||
|
||||
return {
|
||||
downloaded: plan.map((item) => item.key),
|
||||
manifestPath,
|
||||
skipped: plan.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
interface SyncConfig {
|
||||
browserosRoot?: string
|
||||
manifestUrl: string
|
||||
fetchImpl: typeof fetch
|
||||
arches: Arch[]
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
function resolveSyncConfig(options: VmCacheSyncOptions): SyncConfig {
|
||||
return {
|
||||
browserosRoot: options.browserosRoot,
|
||||
manifestUrl:
|
||||
trimNonEmpty(options.manifestUrl) ??
|
||||
trimNonEmpty(process.env.BROWSEROS_VM_CACHE_MANIFEST_URL) ??
|
||||
EXTERNAL_URLS.VM_CACHE_MANIFEST,
|
||||
fetchImpl: options.fetchImpl ?? fetch,
|
||||
arches: selectSyncArches(options),
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchManifest(cfg: SyncConfig): Promise<VmManifest> {
|
||||
const response = await fetchWithTimeout(
|
||||
cfg.fetchImpl,
|
||||
cfg.manifestUrl,
|
||||
cfg.timeoutMs,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`manifest fetch failed: ${cfg.manifestUrl} (${response.status})`,
|
||||
)
|
||||
}
|
||||
return (await response.json()) as VmManifest
|
||||
}
|
||||
|
||||
interface DownloadPlanItem {
|
||||
key: string
|
||||
destPath: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
async function planDownloads(opts: {
|
||||
remote: VmManifest
|
||||
local: VmManifest | null
|
||||
cacheRoot: string
|
||||
arches: Arch[]
|
||||
}): Promise<DownloadPlanItem[]> {
|
||||
const out: DownloadPlanItem[] = []
|
||||
for (const arch of opts.arches) {
|
||||
for (const [name, agent] of Object.entries(opts.remote.agents)) {
|
||||
const remote = agent.tarballs[arch]
|
||||
if (!remote) continue
|
||||
const destPath = join(opts.cacheRoot, remote.key)
|
||||
if (
|
||||
!(await needsDownload(
|
||||
remote,
|
||||
opts.local?.agents[name]?.tarballs[arch],
|
||||
destPath,
|
||||
))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
out.push({ key: remote.key, destPath, sha256: remote.sha256 })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function needsDownload(
|
||||
remote: VmArtifact,
|
||||
local: VmArtifact | undefined,
|
||||
destPath: string,
|
||||
): Promise<boolean> {
|
||||
if (!existsSync(destPath)) return true
|
||||
if (local?.sha256 === remote.sha256) return false
|
||||
try {
|
||||
return (await sha256File(destPath)) !== remote.sha256
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArtifact(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
destPath: string,
|
||||
sha256: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const partialPath = `${destPath}.partial`
|
||||
await mkdir(dirname(destPath), { recursive: true })
|
||||
await rm(partialPath, { force: true })
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`download failed: ${url} (${response.status})`)
|
||||
}
|
||||
|
||||
const sink = Bun.file(partialPath).writer()
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
sink.write(value)
|
||||
}
|
||||
} finally {
|
||||
await sink.end()
|
||||
}
|
||||
|
||||
await verifySha256(partialPath, sha256)
|
||||
await rename(partialPath, destPath)
|
||||
} catch (error) {
|
||||
await rm(partialPath, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
return await fetchImpl(url, { signal: controller.signal })
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySha256(path: string, expected: string): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function readLocalManifest(path: string): Promise<VmManifest | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function selectSyncArches(options: VmCacheSyncOptions): Arch[] {
|
||||
if (options.allArches) return [...ARCHES]
|
||||
const rawArch = options.rawHostArch ?? hostArch()
|
||||
if (rawArch === 'arm64') return ['arm64']
|
||||
if (rawArch === 'x64' || rawArch === 'ia32') return ['x64']
|
||||
throw new Error(`unsupported host arch: ${rawArch}`)
|
||||
}
|
||||
|
||||
function cacheRootForManifest(manifestPath: string): string {
|
||||
return dirname(dirname(manifestPath))
|
||||
}
|
||||
|
||||
function syncKey(cfg: SyncConfig): string {
|
||||
return [
|
||||
getCachedManifestPath(cfg.browserosRoot),
|
||||
cfg.manifestUrl,
|
||||
cfg.arches.join(','),
|
||||
String(cfg.timeoutMs),
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
function artifactUrlForKey(manifestUrl: string, key: string): string {
|
||||
const artifactKey = key.replace(/^\/+/, '')
|
||||
const url = new URL(manifestUrl)
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, '')
|
||||
const prefix = normalizedPath.endsWith(CANONICAL_MANIFEST_SUFFIX)
|
||||
? normalizedPath.slice(0, -CANONICAL_MANIFEST_SUFFIX.length)
|
||||
: normalizedPath.slice(0, Math.max(0, normalizedPath.lastIndexOf('/')))
|
||||
|
||||
url.pathname = `${prefix.replace(/\/+$/, '')}/${artifactKey}`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
@@ -30,8 +30,36 @@ export class ContainerCliError extends VmError {
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
message = `${command} failed with exit code ${exitCode}: ${stderr}`,
|
||||
) {
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainerNameInUseError extends ContainerCliError {
|
||||
constructor(
|
||||
public readonly containerName: string,
|
||||
command: string,
|
||||
exitCode: number,
|
||||
stderr: string,
|
||||
) {
|
||||
super(
|
||||
command,
|
||||
exitCode,
|
||||
stderr,
|
||||
`${command} failed because container name "${containerName}" is already in use: ${stderr}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainerNameReleaseTimeoutError extends VmError {
|
||||
constructor(
|
||||
public readonly containerName: string,
|
||||
public readonly timeoutMs: number,
|
||||
) {
|
||||
super(
|
||||
`Timed out waiting ${timeoutMs}ms for container name "${containerName}" to be released`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,17 +72,3 @@ export class ImageLoadError extends VmError {
|
||||
super(`failed to load image ${imageRef}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ManifestMissingError extends VmError {
|
||||
constructor(public readonly manifestPath: string) {
|
||||
super(manifestMissingMessage(manifestPath))
|
||||
}
|
||||
}
|
||||
|
||||
function manifestMissingMessage(manifestPath: string): string {
|
||||
const message = `VM manifest is missing at ${manifestPath}`
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${message}; run bun run dev:setup before starting the server`
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
export * from './errors'
|
||||
export * from './lima-cli'
|
||||
export * from './lima-config'
|
||||
export * from './manifest'
|
||||
export * from './paths'
|
||||
export * from './telemetry'
|
||||
export * from './vm-runtime'
|
||||
|
||||
@@ -8,7 +8,6 @@ export function renderLimaTemplate(
|
||||
template: string,
|
||||
cfg: {
|
||||
vmStateDir: string
|
||||
imageCacheDir: string
|
||||
},
|
||||
): string {
|
||||
const mounts = [
|
||||
@@ -16,9 +15,6 @@ export function renderLimaTemplate(
|
||||
`- location: "${cfg.vmStateDir}"`,
|
||||
' mountPoint: "/mnt/browseros/vm"',
|
||||
' writable: true',
|
||||
`- location: "${cfg.imageCacheDir}"`,
|
||||
' mountPoint: "/mnt/browseros/cache/images"',
|
||||
' writable: false',
|
||||
].join('\n')
|
||||
|
||||
if (!template.includes('mounts: []')) {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { ManifestMissingError } from './errors'
|
||||
import type { Arch } from './paths'
|
||||
import { getCachedManifestPath, getInstalledManifestPath } from './paths'
|
||||
|
||||
export interface VmArtifact {
|
||||
key: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface VmAgentEntry {
|
||||
image: string
|
||||
version: string
|
||||
tarballs: Record<Arch, VmArtifact>
|
||||
}
|
||||
|
||||
export interface VmManifest {
|
||||
schemaVersion: number
|
||||
updatedAt: string
|
||||
agents: Record<string, VmAgentEntry>
|
||||
}
|
||||
|
||||
export type VmAgentTarball = VmArtifact
|
||||
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
|
||||
|
||||
export async function readCachedManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest> {
|
||||
const manifestPath = getCachedManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) throw new ManifestMissingError(manifestPath)
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function readInstalledManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest | null> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) return null
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function writeInstalledManifest(
|
||||
manifest: VmManifest,
|
||||
browserosRoot?: string,
|
||||
): Promise<void> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
|
||||
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
await rename(tempPath, manifestPath)
|
||||
}
|
||||
|
||||
export function compareVersions(
|
||||
installed: VmManifest | null,
|
||||
cached: VmManifest,
|
||||
): VersionComparison {
|
||||
if (!installed) return 'fresh'
|
||||
const comparison = compareVersionStrings(
|
||||
installed.updatedAt,
|
||||
cached.updatedAt,
|
||||
)
|
||||
if (comparison === 0) return 'same'
|
||||
return comparison < 0 ? 'upgrade' : 'downgrade'
|
||||
}
|
||||
|
||||
export function agentForArch(
|
||||
manifest: VmManifest,
|
||||
name: string,
|
||||
arch: Arch,
|
||||
): {
|
||||
image: string
|
||||
version: string
|
||||
tarball: VmAgentTarball
|
||||
} {
|
||||
const agent = manifest.agents[name]
|
||||
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
|
||||
const tarball = agent.tarballs[arch]
|
||||
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
|
||||
return {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarball,
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifest(path: string): Promise<VmManifest> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
|
||||
}
|
||||
|
||||
function compareVersionStrings(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { PATHS } from '@browseros/shared/constants/paths'
|
||||
|
||||
export const VM_NAME = 'browseros-vm'
|
||||
export const GUEST_VM_STATE = '/mnt/browseros/vm'
|
||||
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
|
||||
const HOST_LIMACTL_BINARY = 'limactl'
|
||||
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
@@ -54,18 +53,6 @@ export function getVmCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, PATHS.CACHE_DIR_NAME, 'vm')
|
||||
}
|
||||
|
||||
export function getImageCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'images')
|
||||
}
|
||||
|
||||
export function getCachedManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getInstalledManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmStateDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getContainerdSocketPath(browserosRoot = rootDir()): string {
|
||||
return join(getLimaHomeDir(browserosRoot), VM_NAME, 'sock', 'containerd.sock')
|
||||
}
|
||||
@@ -110,7 +97,7 @@ export function resolveBundledLimactl(
|
||||
const candidate = join(limaRoot, 'bin', 'limactl')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
`bundled limactl not found at ${candidate}; refresh server resources from the build-tools README`,
|
||||
)
|
||||
}
|
||||
assertBundledLimaGuestAgent(limaRoot, hostArch)
|
||||
@@ -158,7 +145,7 @@ export function resolveBundledLimaTemplate(resourcesDir: string): string {
|
||||
const candidate = join(resourcesDir, 'vm', 'browseros-vm.yaml')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
`bundled Lima template not found at ${candidate}; refresh server resources from the build-tools README`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
@@ -215,16 +202,10 @@ export function hostPathToGuest(
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
const vmState = getVmStateDir(browserosRoot)
|
||||
const imageCache = getImageCacheDir(browserosRoot)
|
||||
const vmStateRelative = mountedRelativePath(vmState, hostPath)
|
||||
if (vmStateRelative !== null)
|
||||
return guestPath(GUEST_VM_STATE, vmStateRelative)
|
||||
|
||||
const imageCacheRelative = mountedRelativePath(imageCache, hostPath)
|
||||
if (imageCacheRelative !== null) {
|
||||
return guestPath(GUEST_IMAGE_CACHE, imageCacheRelative)
|
||||
}
|
||||
|
||||
throw new Error(`host path ${hostPath} is not under any known guest mount`)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,19 +11,12 @@ export const VM_TELEMETRY_EVENTS = {
|
||||
create: 'vm.create',
|
||||
start: 'vm.start',
|
||||
stop: 'vm.stop',
|
||||
upgradeDetected: 'vm.upgrade.detected',
|
||||
downgradeDetected: 'vm.downgrade.detected',
|
||||
upgradeSwap: 'vm.upgrade.swap',
|
||||
upgradeReplay: 'vm.upgrade.replay',
|
||||
resetDetected: 'vm.reset.detected',
|
||||
resetOk: 'vm.reset.ok',
|
||||
nerdctlWaitStart: 'vm.nerdctl_wait.start',
|
||||
nerdctlWaitOk: 'vm.nerdctl_wait.ok',
|
||||
nerdctlWaitPoll: 'vm.nerdctl_wait.poll',
|
||||
nerdctlWaitTimeout: 'vm.nerdctl_wait.timeout',
|
||||
manifestMissing: 'vm.manifest.missing',
|
||||
manifestCompared: 'vm.manifest.compared',
|
||||
manifestWritten: 'vm.manifest.written',
|
||||
migrationOpenClawMoved: 'vm.migration.openclaw_moved',
|
||||
limaSpawn: 'vm.lima.spawn',
|
||||
limaExit: 'vm.lima.exit',
|
||||
|
||||
@@ -7,17 +7,10 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../logger'
|
||||
import { ensureVmCacheAvailable } from './cache-sync'
|
||||
import { LimaCommandError, VmError, VmNotReadyError } from './errors'
|
||||
import { LimaCli } from './lima-cli'
|
||||
import { renderLimaTemplate } from './lima-config'
|
||||
import {
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
writeInstalledManifest,
|
||||
} from './manifest'
|
||||
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
|
||||
import { getVmStateDir, VM_NAME } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
@@ -31,7 +24,6 @@ export interface VmRuntimeDeps {
|
||||
browserosRoot?: string
|
||||
readinessTimeoutMs?: number
|
||||
readinessPollMs?: number
|
||||
ensureCacheAvailable?: () => Promise<void>
|
||||
}
|
||||
|
||||
export class VmRuntime {
|
||||
@@ -59,34 +51,17 @@ export class VmRuntime {
|
||||
limactlPath: this.deps.limactlPath,
|
||||
})
|
||||
|
||||
await this.ensureCacheAvailable()
|
||||
const cached = await readCachedManifest(this.deps.browserosRoot)
|
||||
const installed = await readInstalledManifest(this.deps.browserosRoot)
|
||||
const versionComparison = compareVersions(installed, cached)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestCompared, {
|
||||
versionComparison,
|
||||
installedUpdatedAt: installed?.updatedAt ?? null,
|
||||
cachedUpdatedAt: cached.updatedAt,
|
||||
})
|
||||
|
||||
const vms = await this.cli.list()
|
||||
const existing = vms.find((vm) => vm.name === VM_NAME)
|
||||
let shouldWriteInstalledManifest =
|
||||
!existing || versionComparison === 'fresh' || versionComparison === 'same'
|
||||
|
||||
let branch = !existing
|
||||
? 'provision-fresh'
|
||||
: existing.status !== 'Running'
|
||||
? 'start-existing'
|
||||
: versionComparison === 'upgrade'
|
||||
? 'running-upgrade-warn'
|
||||
: versionComparison === 'downgrade'
|
||||
? 'running-downgrade-warn'
|
||||
: 'running-same'
|
||||
: 'running'
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
|
||||
branch,
|
||||
existingStatus: existing?.status ?? null,
|
||||
versionComparison,
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
@@ -101,28 +76,11 @@ export class VmRuntime {
|
||||
(await this.needsContainerdReprovision())
|
||||
) {
|
||||
branch = 'recreate-legacy-runtime'
|
||||
shouldWriteInstalledManifest = true
|
||||
await this.recreateForContainerd(onLog)
|
||||
} else if (versionComparison === 'upgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.upgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
} else if (versionComparison === 'downgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.downgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitForRootlessNerdctl(this.readinessTimeoutMs)
|
||||
if (shouldWriteInstalledManifest) {
|
||||
await writeInstalledManifest(cached, this.deps.browserosRoot)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestWritten, {
|
||||
updatedAt: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyOk, {
|
||||
durationMs: Date.now() - started,
|
||||
@@ -220,14 +178,6 @@ export class VmRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
private async ensureCacheAvailable(): Promise<void> {
|
||||
if (this.deps.ensureCacheAvailable) {
|
||||
await this.deps.ensureCacheAvailable()
|
||||
return
|
||||
}
|
||||
await ensureVmCacheAvailable({ browserosRoot: this.deps.browserosRoot })
|
||||
}
|
||||
|
||||
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
|
||||
onLog?.('Recreating BrowserOS VM for containerd runtime...')
|
||||
try {
|
||||
@@ -271,7 +221,6 @@ export class VmRuntime {
|
||||
|
||||
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
|
||||
vmStateDir: getVmStateDir(this.deps.browserosRoot),
|
||||
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { metrics } from './lib/metrics'
|
||||
import { isPortInUseError } from './lib/port-binding'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { prefetchVmCache } from './lib/vm/cache-sync'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
import {
|
||||
startSkillSync,
|
||||
@@ -61,7 +60,7 @@ export class Application {
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
configureVmRuntime({ resourcesDir, vmCache: this.vmCacheConfig() })
|
||||
configureVmRuntime({ resourcesDir })
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -132,17 +131,20 @@ export class Application {
|
||||
// handles async throws inside auto-start. Wrap both in try/catch so the
|
||||
// process keeps running even when OpenClaw can't initialize at all.
|
||||
try {
|
||||
configureOpenClawService({
|
||||
const openClawService = configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir,
|
||||
vmCache: this.vmCacheConfig(),
|
||||
})
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
void openClawService.prewarm().catch((err) =>
|
||||
logger.warn('OpenClaw prewarm failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
void openClawService.tryAutoStart().catch((err) =>
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn('OpenClaw configuration failed, continuing without it', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -174,7 +176,6 @@ export class Application {
|
||||
private async initCoreServices(): Promise<void> {
|
||||
this.configureLogDirectory()
|
||||
await ensureBrowserosDir()
|
||||
this.startVmCachePrefetch()
|
||||
await cleanOldSessions()
|
||||
await seedSoulTemplate()
|
||||
await migrateBuiltinSkills()
|
||||
@@ -223,25 +224,6 @@ export class Application {
|
||||
})
|
||||
}
|
||||
|
||||
private startVmCachePrefetch(): void {
|
||||
if (!this.config.vmCachePrefetch) return
|
||||
void prefetchVmCache({
|
||||
manifestUrl: this.config.vmCacheManifestUrl,
|
||||
}).catch((error) => {
|
||||
logger.warn('BrowserOS VM cache prefetch failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private vmCacheConfig(): {
|
||||
manifestUrl: string
|
||||
} {
|
||||
return {
|
||||
manifestUrl: this.config.vmCacheManifestUrl,
|
||||
}
|
||||
}
|
||||
|
||||
private configureLogDirectory(): void {
|
||||
const logDir = this.config.executionDir
|
||||
const resolvedDir = path.isAbsolute(logDir)
|
||||
|
||||
@@ -452,6 +452,142 @@ describe('createAgentRoutes', () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PATCH /:agentId updates pinned + name and rejects empty patches', async () => {
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
const route = createMountedRoutes([agent])
|
||||
|
||||
const pinned = await route.request('/agents/agent-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pinned: true }),
|
||||
})
|
||||
expect(pinned.status).toBe(200)
|
||||
expect(await pinned.json()).toMatchObject({
|
||||
agent: { id: 'agent-1', pinned: true },
|
||||
})
|
||||
|
||||
const renamed = await route.request('/agents/agent-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Renamed' }),
|
||||
})
|
||||
expect(renamed.status).toBe(200)
|
||||
expect(await renamed.json()).toMatchObject({
|
||||
agent: { id: 'agent-1', name: 'Renamed' },
|
||||
})
|
||||
|
||||
const empty = await route.request('/agents/agent-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(empty.status).toBe(400)
|
||||
|
||||
const unknown = await route.request('/agents/missing', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pinned: false }),
|
||||
})
|
||||
expect(unknown.status).toBe(404)
|
||||
})
|
||||
|
||||
it('queues + lists + removes messages for an agent', async () => {
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
const route = createMountedRoutes([agent])
|
||||
|
||||
const enqueueA = await route.request('/agents/agent-1/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'first', attachments: [] }),
|
||||
})
|
||||
expect(enqueueA.status).toBe(200)
|
||||
const enqueuedA = await enqueueA.json()
|
||||
expect(enqueuedA.queued).toMatchObject({ message: 'first' })
|
||||
|
||||
const enqueueB = await route.request('/agents/agent-1/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'second' }),
|
||||
})
|
||||
expect(enqueueB.status).toBe(200)
|
||||
const enqueuedB = await enqueueB.json()
|
||||
|
||||
const listed = await route.request('/agents/agent-1/queue')
|
||||
expect(listed.status).toBe(200)
|
||||
const listedBody = await listed.json()
|
||||
expect(listedBody.queue.map((q: { message: string }) => q.message)).toEqual(
|
||||
['first', 'second'],
|
||||
)
|
||||
|
||||
const removed = await route.request(
|
||||
`/agents/agent-1/queue/${enqueuedA.queued.id}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toEqual({ removed: true })
|
||||
|
||||
const afterRemove = await route.request('/agents/agent-1/queue')
|
||||
expect((await afterRemove.json()).queue).toEqual([
|
||||
expect.objectContaining({ id: enqueuedB.queued.id, message: 'second' }),
|
||||
])
|
||||
|
||||
const removeMissing = await route.request(
|
||||
'/agents/agent-1/queue/does-not-exist',
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
expect(removeMissing.status).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects empty queue messages and unknown agents', async () => {
|
||||
const route = createMountedRoutes([
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
])
|
||||
|
||||
const empty = await route.request('/agents/agent-1/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: ' ' }),
|
||||
})
|
||||
expect(empty.status).toBe(400)
|
||||
|
||||
const unknown = await route.request('/agents/missing/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'hi' }),
|
||||
})
|
||||
expect(unknown.status).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects overlong agent names', async () => {
|
||||
const route = createMountedRoutes([])
|
||||
const response = await route.request('/agents', {
|
||||
@@ -499,6 +635,15 @@ function createFakeService(agents: AgentDefinition[]) {
|
||||
let lastStartTurnInput:
|
||||
| { agentId: string; message?: string; cwd?: string }
|
||||
| undefined
|
||||
const queues = new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
createdAt: number
|
||||
message: string
|
||||
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
|
||||
}>
|
||||
>()
|
||||
|
||||
return {
|
||||
get _lastStartTurnInput() {
|
||||
@@ -550,6 +695,21 @@ function createFakeService(agents: AgentDefinition[]) {
|
||||
agents.splice(index, 1)
|
||||
return true
|
||||
},
|
||||
async updateAgent(
|
||||
agentId: string,
|
||||
patch: { name?: string; pinned?: boolean },
|
||||
) {
|
||||
const index = agents.findIndex((agent) => agent.id === agentId)
|
||||
if (index < 0) return null
|
||||
const next = {
|
||||
...agents[index],
|
||||
...(patch.name !== undefined ? { name: patch.name.trim() } : {}),
|
||||
...(patch.pinned !== undefined ? { pinned: patch.pinned } : {}),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
agents[index] = next
|
||||
return next
|
||||
},
|
||||
async getHistory(agentId: string) {
|
||||
return {
|
||||
agentId,
|
||||
@@ -591,8 +751,43 @@ function createFakeService(agents: AgentDefinition[]) {
|
||||
if (!turnId) return false
|
||||
return registry.cancel(turnId, input.reason)
|
||||
},
|
||||
async enqueueMessage(input: {
|
||||
agentId: string
|
||||
message: string
|
||||
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
|
||||
}) {
|
||||
if (!agents.some((a) => a.id === input.agentId)) {
|
||||
const { UnknownAgentError } = await import(
|
||||
'../../../src/api/services/agents/agent-harness-service'
|
||||
)
|
||||
throw new UnknownAgentError(input.agentId)
|
||||
}
|
||||
const queued = {
|
||||
id: `q-${Math.random().toString(36).slice(2, 10)}`,
|
||||
createdAt: Date.now(),
|
||||
message: input.message,
|
||||
attachments: input.attachments,
|
||||
}
|
||||
const list = queues.get(input.agentId) ?? []
|
||||
list.push(queued)
|
||||
queues.set(input.agentId, list)
|
||||
return queued
|
||||
},
|
||||
async removeQueuedMessage(input: { agentId: string; messageId: string }) {
|
||||
const list = queues.get(input.agentId)
|
||||
if (!list) return false
|
||||
const next = list.filter((entry) => entry.id !== input.messageId)
|
||||
if (next.length === list.length) return false
|
||||
if (next.length === 0) queues.delete(input.agentId)
|
||||
else queues.set(input.agentId, next)
|
||||
return true
|
||||
},
|
||||
async listQueuedMessages(agentId: string) {
|
||||
return queues.get(agentId)?.slice() ?? []
|
||||
},
|
||||
/** Test-only: lets tests await turn completion deterministically. */
|
||||
_registry: registry,
|
||||
_queues: queues,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,6 +841,9 @@ function createBlockingFakeService(agents: AgentDefinition[]) {
|
||||
async deleteAgent() {
|
||||
return false
|
||||
},
|
||||
async updateAgent() {
|
||||
return null
|
||||
},
|
||||
async getHistory(agentId: string) {
|
||||
return { agentId, sessionId: 'main' as const, items: [] }
|
||||
},
|
||||
@@ -679,6 +877,15 @@ function createBlockingFakeService(agents: AgentDefinition[]) {
|
||||
if (!turnId) return false
|
||||
return registry.cancel(turnId, input.reason)
|
||||
},
|
||||
async enqueueMessage() {
|
||||
throw new Error('not used in this test')
|
||||
},
|
||||
async removeQueuedMessage() {
|
||||
return false
|
||||
},
|
||||
async listQueuedMessages() {
|
||||
return []
|
||||
},
|
||||
_unblock: () => unblock(),
|
||||
_cancelCalls: cancelCalls,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
@@ -83,6 +83,10 @@ describe('container-runtime factory', () => {
|
||||
running: false,
|
||||
})
|
||||
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
|
||||
await expect(runtime.prewarmGatewayImage()).rejects.toThrow(
|
||||
'supports macOS only',
|
||||
)
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
|
||||
await expect(runtime.stopVm()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -102,24 +106,15 @@ describe('container-runtime factory', () => {
|
||||
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
|
||||
})
|
||||
|
||||
it('syncs the VM cache before deferred image loading reads the manifest', async () => {
|
||||
const ensureSynced = mock(async () => {
|
||||
throw new Error('cache sync sentinel')
|
||||
})
|
||||
it('builds a runtime whose image loader pulls directly through nerdctl', async () => {
|
||||
const runtime = buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'darwin',
|
||||
vmCache: {
|
||||
ensureSynced,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12'),
|
||||
).rejects.toThrow('cache sync sentinel')
|
||||
expect(ensureSynced).toHaveBeenCalledTimes(1)
|
||||
expect(runtime).toBeDefined()
|
||||
})
|
||||
|
||||
it('leaves both directories in place when new OpenClaw state already exists', async () => {
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
|
||||
import { ContainerNameInUseError } from '../../../../src/lib/vm/errors'
|
||||
|
||||
const PROJECT_DIR = '/tmp/openclaw'
|
||||
const GATEWAY_IMAGE_REF = 'ghcr.io/openclaw/openclaw:2026.4.12'
|
||||
const OPENCLAW_NAME_RELEASE_WAIT = { timeoutMs: 10_000, intervalMs: 100 }
|
||||
const defaultSpec = {
|
||||
hostPort: 18789,
|
||||
hostHome: '/Users/me/.browseros/vm/openclaw',
|
||||
@@ -34,6 +38,10 @@ describe('ContainerRuntime', () => {
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_NAME_RELEASE_WAIT,
|
||||
)
|
||||
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
|
||||
'openclaw',
|
||||
undefined,
|
||||
@@ -41,7 +49,7 @@ describe('ContainerRuntime', () => {
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: GATEWAY_IMAGE_REF,
|
||||
image: OPENCLAW_IMAGE,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
@@ -66,6 +74,62 @@ describe('ContainerRuntime', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles and retries when gateway create reports name-in-use', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.createContainer = mock(async () => {
|
||||
if (deps.shell.createContainer.mock.calls.length === 1) {
|
||||
throw new ContainerNameInUseError(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'nerdctl create',
|
||||
1,
|
||||
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
|
||||
)
|
||||
}
|
||||
})
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.startGateway(defaultSpec)
|
||||
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.startContainer).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds gateway create retries when the name stays in use', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.createContainer = mock(async () => {
|
||||
throw new ContainerNameInUseError(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'nerdctl create',
|
||||
1,
|
||||
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
|
||||
)
|
||||
})
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.startGateway(defaultSpec)).rejects.toBeInstanceOf(
|
||||
ContainerNameInUseError,
|
||||
)
|
||||
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledTimes(3)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(3)
|
||||
expect(deps.shell.startContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses OPENCLAW_IMAGE as a direct image override', async () => {
|
||||
const previous = process.env.OPENCLAW_IMAGE
|
||||
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
|
||||
@@ -137,7 +201,7 @@ describe('ContainerRuntime', () => {
|
||||
'/mnt/browseros/vm/openclaw:/home/node',
|
||||
'--add-host',
|
||||
'host.containers.internal:192.168.5.2',
|
||||
GATEWAY_IMAGE_REF,
|
||||
OPENCLAW_IMAGE,
|
||||
]),
|
||||
undefined,
|
||||
)
|
||||
@@ -150,6 +214,45 @@ describe('ContainerRuntime', () => {
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
OPENCLAW_NAME_RELEASE_WAIT,
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles and retries when setup create reports name-in-use', async () => {
|
||||
const deps = createDeps()
|
||||
let setupCreateCount = 0
|
||||
deps.shell.runCommand = mock(async (args: string[]) => {
|
||||
if (args[0] === 'create') {
|
||||
setupCreateCount += 1
|
||||
if (setupCreateCount === 1) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: `name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup" is already used`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
})
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.runGatewaySetupCommand(
|
||||
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
||||
defaultSpec,
|
||||
),
|
||||
).resolves.toBe(0)
|
||||
|
||||
expect(setupCreateCount).toBe(2)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('tails and fetches gateway logs through the new transport', async () => {
|
||||
@@ -175,6 +278,70 @@ describe('ContainerRuntime', () => {
|
||||
)
|
||||
expect(logs).toEqual(['log line'])
|
||||
})
|
||||
|
||||
it('prewarms the gateway image without creating a container', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.prewarmGatewayImage()
|
||||
|
||||
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
|
||||
'openclaw',
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.createContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects when the gateway container uses the current image', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.containerImageRef.mockImplementation(async () => OPENCLAW_IMAGE)
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
|
||||
expect(deps.shell.containerImageRef).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a digest-qualified current image ref as current', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.containerImageRef.mockImplementation(
|
||||
async () => `${OPENCLAW_IMAGE}@sha256:${'a'.repeat(64)}`,
|
||||
)
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('detects when the gateway container uses an old image', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.containerImageRef.mockImplementation(
|
||||
async () => 'ghcr.io/openclaw/openclaw:old',
|
||||
)
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function createDeps() {
|
||||
@@ -190,6 +357,8 @@ function createDeps() {
|
||||
startContainer: mock(async () => {}),
|
||||
stopContainer: mock(async () => {}),
|
||||
removeContainer: mock(async () => {}),
|
||||
containerImageRef: mock(async () => OPENCLAW_IMAGE),
|
||||
waitForContainerNameRelease: mock(async () => {}),
|
||||
exec: mock(async () => 0),
|
||||
runCommand: mock(
|
||||
async (_args: string[], onLog?: (line: string) => void) => {
|
||||
@@ -201,7 +370,7 @@ function createDeps() {
|
||||
},
|
||||
loader: {
|
||||
ensureImageLoaded: mock(async () => {}),
|
||||
ensureAgentImageLoaded: mock(async () => GATEWAY_IMAGE_REF),
|
||||
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
resolveSupportedOpenClawProvider,
|
||||
UnsupportedOpenClawProviderError,
|
||||
@@ -23,11 +26,13 @@ type MutableOpenClawService = OpenClawService & {
|
||||
token: string
|
||||
restart: ReturnType<typeof mock>
|
||||
runtime: {
|
||||
ensureReady?: () => Promise<void>
|
||||
ensureReady?: (_onLog?: (_line: string) => void) => Promise<void>
|
||||
isPodmanAvailable?: () => Promise<boolean>
|
||||
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
|
||||
isHealthy?: (_hostPort?: number) => Promise<boolean>
|
||||
isReady: (_hostPort?: number) => Promise<boolean>
|
||||
prewarmGatewayImage?: (_onLog?: (_line: string) => void) => Promise<void>
|
||||
isGatewayCurrent?: () => Promise<boolean>
|
||||
pullImage?: (
|
||||
_image: string,
|
||||
_onLog?: (_line: string) => void,
|
||||
@@ -87,6 +92,60 @@ describe('OpenClawService', () => {
|
||||
return forced >= 65000 ? forced - 10 : forced + 10
|
||||
}
|
||||
|
||||
it('prewarms the VM and gateway image', async () => {
|
||||
const ensureReady = mock(async () => {})
|
||||
const prewarmGatewayImage = mock(async () => {})
|
||||
const logs: string[] = []
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => false,
|
||||
prewarmGatewayImage,
|
||||
}
|
||||
|
||||
await service.prewarm((line) => logs.push(line))
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(prewarmGatewayImage).toHaveBeenCalledTimes(1)
|
||||
expect(ensureReady.mock.calls[0]?.length).toBe(0)
|
||||
expect(prewarmGatewayImage.mock.calls[0]?.length).toBe(0)
|
||||
expect(logs).toContain('OpenClaw prewarm: ensuring BrowserOS VM is ready')
|
||||
expect(logs).toContain(
|
||||
`OpenClaw prewarm: ensuring image ${OPENCLAW_IMAGE} is available`,
|
||||
)
|
||||
expect(logs).toContain('OpenClaw prewarm: ready')
|
||||
})
|
||||
|
||||
it('logs the overridden image ref during prewarm', async () => {
|
||||
const originalImage = process.env.OPENCLAW_IMAGE
|
||||
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
|
||||
const ensureReady = mock(async () => {})
|
||||
const prewarmGatewayImage = mock(async () => {})
|
||||
const logs: string[] = []
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => false,
|
||||
prewarmGatewayImage,
|
||||
}
|
||||
|
||||
try {
|
||||
await service.prewarm((line) => logs.push(line))
|
||||
} finally {
|
||||
if (originalImage === undefined) {
|
||||
delete process.env.OPENCLAW_IMAGE
|
||||
} else {
|
||||
process.env.OPENCLAW_IMAGE = originalImage
|
||||
}
|
||||
}
|
||||
|
||||
expect(logs).toContain(
|
||||
'OpenClaw prewarm: ensuring image localhost/openclaw:test is available',
|
||||
)
|
||||
})
|
||||
|
||||
it('creates agents through the cli client without role bootstrap files', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const createAgent = mock(async () => ({
|
||||
@@ -657,6 +716,7 @@ describe('OpenClawService', () => {
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => gatewayReady,
|
||||
isGatewayCurrent: mock(async () => true),
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -677,6 +737,77 @@ describe('OpenClawService', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('serializes start across service instances sharing an OpenClaw dir', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
let gatewayReady = false
|
||||
let releaseStartGateway!: () => void
|
||||
let notifyStartGatewayEntered!: () => void
|
||||
const startGatewayEntered = new Promise<void>((resolve) => {
|
||||
notifyStartGatewayEntered = resolve
|
||||
})
|
||||
const unblockStartGateway = new Promise<void>((resolve) => {
|
||||
releaseStartGateway = resolve
|
||||
})
|
||||
const firstEnsureReady = mock(async () => {})
|
||||
const secondEnsureReady = mock(async () => {})
|
||||
const startGateway = mock(async () => {
|
||||
notifyStartGatewayEntered()
|
||||
await unblockStartGateway
|
||||
gatewayReady = true
|
||||
})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const firstService = new OpenClawService() as MutableOpenClawService
|
||||
const secondService = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
firstService.openclawDir = tempDir
|
||||
secondService.openclawDir = tempDir
|
||||
firstService.runtime = {
|
||||
ensureReady: firstEnsureReady,
|
||||
isReady: async () => gatewayReady,
|
||||
isGatewayCurrent: async () => true,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
secondService.runtime = {
|
||||
ensureReady: secondEnsureReady,
|
||||
isReady: async () => gatewayReady,
|
||||
isGatewayCurrent: async () => true,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
firstService.cliClient = { probe }
|
||||
secondService.cliClient = { probe }
|
||||
mockGatewayAuth()
|
||||
|
||||
const firstStart = firstService.start()
|
||||
await startGatewayEntered
|
||||
const secondStart = secondService.start()
|
||||
await Bun.sleep(25)
|
||||
const secondEnteredBeforeFirstFinished = secondEnsureReady.mock.calls.length
|
||||
|
||||
releaseStartGateway()
|
||||
await Promise.all([firstStart, secondStart])
|
||||
|
||||
expect(secondEnteredBeforeFirstFinished).toBe(0)
|
||||
expect(firstEnsureReady).toHaveBeenCalledTimes(1)
|
||||
expect(secondEnsureReady).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not restart a ready gateway when start is called again', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
@@ -700,6 +831,7 @@ describe('OpenClawService', () => {
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => true,
|
||||
isGatewayCurrent: mock(async () => true),
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -948,6 +1080,7 @@ describe('OpenClawService', () => {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady,
|
||||
isReady,
|
||||
isGatewayCurrent: mock(async () => true),
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -971,6 +1104,71 @@ describe('OpenClawService', () => {
|
||||
expect(isReady).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('tryAutoStart reuses a ready gateway when the image is current', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const isReady = mock(async () => true)
|
||||
const isGatewayCurrent = mock(async () => true)
|
||||
const startGateway = mock(async () => {})
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady,
|
||||
isGatewayCurrent,
|
||||
startGateway,
|
||||
}
|
||||
service.cliClient = { probe }
|
||||
mockGatewayAuth()
|
||||
|
||||
await service.tryAutoStart()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(isGatewayCurrent).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway).not.toHaveBeenCalled()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart recreates a ready gateway when the image is stale', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const isReady = mock(async () => true)
|
||||
const isGatewayCurrent = mock(async () => false)
|
||||
const startGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady,
|
||||
isGatewayCurrent,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = { probe }
|
||||
mockGatewayAuth()
|
||||
|
||||
await service.tryAutoStart()
|
||||
|
||||
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps openrouter model refs verbatim without rewriting dots', () => {
|
||||
const provider = resolveSupportedOpenClawProvider({
|
||||
providerType: 'openrouter',
|
||||
|
||||
@@ -8,7 +8,6 @@ import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import {
|
||||
getAgentCacheDir,
|
||||
getBrowserosDir,
|
||||
getCacheDir,
|
||||
getVmCacheDir,
|
||||
@@ -106,12 +105,4 @@ describe('getBrowserosDir', () => {
|
||||
join(homedir(), '.browseros-dev', 'cache', 'vm'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an agent image cache directory below vm cache', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(getAgentCacheDir()).toBe(
|
||||
join(homedir(), '.browseros-dev', 'cache', 'vm', 'images'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,8 +34,6 @@ const REQUIRED_INLINE_ENV_KEYS = [
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'SENTRY_DSN',
|
||||
'BROWSEROS_VM_CACHE_PREFETCH',
|
||||
'BROWSEROS_VM_CACHE_MANIFEST_URL',
|
||||
] as const
|
||||
|
||||
const R2_ENV_KEYS = [
|
||||
@@ -52,8 +50,6 @@ const INLINE_ENV_STUBS: Record<string, string> = {
|
||||
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
|
||||
POSTHOG_API_KEY: 'phc_test_stub',
|
||||
SENTRY_DSN: 'https://stub@sentry.test/0',
|
||||
BROWSEROS_VM_CACHE_PREFETCH: 'true',
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL: 'https://stub.test/vm/manifest.json',
|
||||
}
|
||||
|
||||
const R2_ENV_STUBS: Record<string, string> = {
|
||||
|
||||
@@ -28,8 +28,6 @@ describe('loadServerConfig', () => {
|
||||
delete process.env.BROWSEROS_INSTALL_ID
|
||||
delete process.env.BROWSEROS_CLIENT_ID
|
||||
delete process.env.BROWSEROS_AI_SDK_DEVTOOLS
|
||||
delete process.env.BROWSEROS_VM_CACHE_PREFETCH
|
||||
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -446,75 +444,6 @@ describe('loadServerConfig', () => {
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.aiSdkDevtoolsEnabled, false)
|
||||
})
|
||||
|
||||
it('defaults VM cache runtime sync settings', () => {
|
||||
const result = loadServerConfig([
|
||||
'bun',
|
||||
'src/index.ts',
|
||||
'--server-port=3000',
|
||||
])
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.vmCachePrefetch, true)
|
||||
assert.strictEqual(
|
||||
result.value.vmCacheManifestUrl,
|
||||
'https://cdn.browseros.com/vm/manifest.json',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VM cache runtime sync', () => {
|
||||
it('reads VM cache settings from env', () => {
|
||||
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
|
||||
' https://manifest.test/vm.json '
|
||||
|
||||
const result = loadServerConfig([
|
||||
'bun',
|
||||
'src/index.ts',
|
||||
'--server-port=3000',
|
||||
])
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.vmCachePrefetch, false)
|
||||
assert.strictEqual(
|
||||
result.value.vmCacheManifestUrl,
|
||||
'https://manifest.test/vm.json',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads VM cache settings from config with file precedence over env', () => {
|
||||
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
|
||||
'https://env.test/manifest.json'
|
||||
const configPath = path.join(tempDir, 'config.json')
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
ports: { server: 3000 },
|
||||
vm_cache: {
|
||||
prefetch: true,
|
||||
manifest_url: ' https://config.test/vm/manifest.json ',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = loadServerConfig([
|
||||
'bun',
|
||||
'src/index.ts',
|
||||
`--config=${configPath}`,
|
||||
])
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.vmCachePrefetch, true)
|
||||
assert.strictEqual(
|
||||
result.value.vmCacheManifestUrl,
|
||||
'https://config.test/vm/manifest.json',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI SDK DevTools', () => {
|
||||
|
||||
@@ -5,15 +5,11 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { ContainerCli } from '../../src/lib/container'
|
||||
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
VM_NAME,
|
||||
} from '../../src/lib/vm/paths'
|
||||
import { LimaCli, VmRuntime } from '../../src/lib/vm'
|
||||
import { getContainerdSocketPath, VM_NAME } from '../../src/lib/vm/paths'
|
||||
|
||||
const LIVE_VM_SMOKE_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const liveIt = process.env.LIVE_VM_SMOKE === '1' ? it : it.skip
|
||||
@@ -23,12 +19,6 @@ const templatePath = resolve(
|
||||
'../../../../packages/build-tools/template/browseros-vm.yaml',
|
||||
)
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {},
|
||||
}
|
||||
|
||||
describe('BrowserOS VM live smoke', () => {
|
||||
let root: string
|
||||
let limaHome: string
|
||||
@@ -36,9 +26,6 @@ describe('BrowserOS VM live smoke', () => {
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/bovm-')
|
||||
limaHome = join(root, 'lima')
|
||||
const manifestPath = getCachedManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
FileMessageQueue,
|
||||
MessageQueueFullError,
|
||||
} from '../../../src/lib/agents/message-queue'
|
||||
|
||||
let tmp: string
|
||||
let queue: FileMessageQueue
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), 'browseros-queue-'))
|
||||
queue = new FileMessageQueue({
|
||||
filePath: join(tmp, 'queues.json'),
|
||||
maxLength: 3,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('FileMessageQueue', () => {
|
||||
it('appends in FIFO order and pops oldest first', async () => {
|
||||
await queue.append('a', { message: 'one' })
|
||||
await queue.append('a', { message: 'two' })
|
||||
const popped = await queue.popOldest('a')
|
||||
expect(popped?.message).toBe('one')
|
||||
expect(await queue.list('a')).toEqual([
|
||||
expect.objectContaining({ message: 'two' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null when popping an empty queue', async () => {
|
||||
expect(await queue.popOldest('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes a single message by id', async () => {
|
||||
const first = await queue.append('a', { message: 'one' })
|
||||
await queue.append('a', { message: 'two' })
|
||||
const removed = await queue.remove('a', first.id)
|
||||
expect(removed).toBe(true)
|
||||
expect(await queue.list('a')).toEqual([
|
||||
expect.objectContaining({ message: 'two' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns false when removing an unknown message id', async () => {
|
||||
await queue.append('a', { message: 'one' })
|
||||
expect(await queue.remove('a', 'nope')).toBe(false)
|
||||
})
|
||||
|
||||
it('throws MessageQueueFullError when capacity is reached', async () => {
|
||||
await queue.append('a', { message: 'one' })
|
||||
await queue.append('a', { message: 'two' })
|
||||
await queue.append('a', { message: 'three' })
|
||||
await expect(queue.append('a', { message: 'four' })).rejects.toBeInstanceOf(
|
||||
MessageQueueFullError,
|
||||
)
|
||||
})
|
||||
|
||||
it('pushFront bypasses the cap (recovery primitive)', async () => {
|
||||
await queue.append('a', { message: 'one' })
|
||||
await queue.append('a', { message: 'two' })
|
||||
await queue.append('a', { message: 'three' })
|
||||
await queue.pushFront('a', {
|
||||
id: 'recovered',
|
||||
createdAt: Date.now(),
|
||||
message: 'recovered',
|
||||
})
|
||||
expect((await queue.list('a')).map((q) => q.message)).toEqual([
|
||||
'recovered',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
])
|
||||
})
|
||||
|
||||
it('persists across instances on the same file path', async () => {
|
||||
await queue.append('a', { message: 'survives' })
|
||||
const other = new FileMessageQueue({
|
||||
filePath: join(tmp, 'queues.json'),
|
||||
maxLength: 3,
|
||||
})
|
||||
expect((await other.list('a')).map((q) => q.message)).toEqual(['survives'])
|
||||
})
|
||||
|
||||
it('agentsWithPendingMessages lists agents with non-empty queues', async () => {
|
||||
await queue.append('a', { message: 'x' })
|
||||
await queue.append('b', { message: 'y' })
|
||||
const pending = await queue.agentsWithPendingMessages()
|
||||
expect(pending.sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('writes are atomic (temp file rename leaves no stray files)', async () => {
|
||||
await queue.append('a', { message: 'one' })
|
||||
const raw = await readFile(join(tmp, 'queues.json'), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
expect(parsed.version).toBe(1)
|
||||
expect(parsed.queues.a[0].message).toBe('one')
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,20 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import { ContainerCliError } from '../../../src/lib/vm/errors'
|
||||
import {
|
||||
ContainerCliError,
|
||||
ContainerNameInUseError,
|
||||
} from '../../../src/lib/vm/errors'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
describe('ContainerCli', () => {
|
||||
@@ -42,6 +52,35 @@ describe('ContainerCli', () => {
|
||||
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('reads a container configured image ref', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stdout: 'ghcr.io/openclaw/openclaw:2026.4.12\n' },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.containerImageRef('gateway')).resolves.toBe(
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
)
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'inspect' '--format' '{{.Config.Image}}' 'gateway'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when reading a missing container image ref', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{
|
||||
stderr: 'no such container',
|
||||
exit: 1,
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.containerImageRef('missing')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('pulls images with progress and throws typed command errors', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
|
||||
@@ -61,21 +100,6 @@ describe('ContainerCli', () => {
|
||||
expect(lines).toContain('denied')
|
||||
})
|
||||
|
||||
it('loads images from guest tarballs and returns loaded refs', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stdout: 'Loaded image(s): openclaw:v1\n' },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(
|
||||
cli.loadImage('/mnt/browseros/cache/images/openclaw.tar.gz'),
|
||||
).resolves.toEqual(['openclaw:v1'])
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'load' '-i' '/mnt/browseros/cache/images/openclaw.tar.gz'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('creates containers from typed specs', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
@@ -149,6 +173,92 @@ describe('ContainerCli', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('inspects a container by name', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{
|
||||
stdout: JSON.stringify({
|
||||
ID: 'abc123',
|
||||
Name: 'gateway',
|
||||
Config: { Image: 'openclaw:v1' },
|
||||
State: { Status: 'running', Running: true },
|
||||
}),
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.inspectContainer('gateway')).resolves.toEqual({
|
||||
id: 'abc123',
|
||||
name: 'gateway',
|
||||
image: 'openclaw:v1',
|
||||
status: 'running',
|
||||
running: true,
|
||||
})
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
"lima-browseros-vm 'nerdctl' 'container' 'inspect' '--format' '{{json .}}' 'gateway'",
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when inspected containers are absent', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stderr: 'no such container', exit: 1 },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.inspectContainer('gateway')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat unrelated not found errors as absent containers', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stderr: 'network interface not found', exit: 1 },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.inspectContainer('gateway')).rejects.toBeInstanceOf(
|
||||
ContainerCliError,
|
||||
)
|
||||
})
|
||||
|
||||
it('waits until a container name is no longer resolvable', async () => {
|
||||
const sshPath = await fakeSshContainerExistsThenMissing(tempDir, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(
|
||||
cli.waitForContainerNameRelease('gateway', {
|
||||
timeoutMs: 500,
|
||||
intervalMs: 5,
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
const inspectCalls = (await readFile(logPath, 'utf8'))
|
||||
.split('\n')
|
||||
.filter((line) => line.includes("'container' 'inspect'"))
|
||||
expect(inspectCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('classifies create name-store collisions as name-in-use errors', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{
|
||||
stderr:
|
||||
'name-store error\nname "gateway" is already used by ID "abc123"',
|
||||
exit: 1,
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
const error = await cli
|
||||
.createContainer({ name: 'gateway', image: 'openclaw:v1' })
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ContainerNameInUseError)
|
||||
expect(error.containerName).toBe('gateway')
|
||||
expect(error.stderr).toContain('name "gateway" is already used')
|
||||
})
|
||||
|
||||
it('tolerates removal when the container is already absent', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stderr: 'no such container', exit: 1 },
|
||||
@@ -201,3 +311,31 @@ function sshConfigPath(tempDir: string): string {
|
||||
function sshPrefix(configPath: string): string {
|
||||
return `ARGS:-F ${configPath} lima-browseros-vm`
|
||||
}
|
||||
|
||||
async function fakeSshContainerExistsThenMissing(
|
||||
tempDir: string,
|
||||
logPath: string,
|
||||
): Promise<string> {
|
||||
const path = join(tempDir, 'ssh-container-exists-then-missing')
|
||||
const counterPath = join(tempDir, 'ssh-container-exists-then-missing.count')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "ARGS:$*" >> "${logPath}"
|
||||
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
|
||||
next=$((count + 1))
|
||||
printf '%s' "$next" > "${counterPath}"
|
||||
case "$count" in
|
||||
0)
|
||||
printf '{"ID":"abc123","Name":"gateway","Config":{"Image":"openclaw:v1"},"State":{"Status":"exited","Running":false}}'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "no such container" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -3,197 +3,83 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_IMAGE } from '@browseros/shared/constants/openclaw'
|
||||
import type { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import { ImageLoader } from '../../../src/lib/container/image-loader'
|
||||
import { ContainerCliError, ImageLoadError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import * as paths from '../../../src/lib/vm/paths'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('ImageLoader', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('returns without loading when the image already exists', async () => {
|
||||
it('returns without pulling when the image already exists', async () => {
|
||||
const cli = new FakeContainerCli([true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
const loader = new ImageLoader(cli as never)
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
|
||||
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
expect(cli.pullCalls).toEqual([])
|
||||
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE])
|
||||
})
|
||||
|
||||
it('loads a missing image from the guest cache and verifies it exists', async () => {
|
||||
it('pulls a missing image and verifies it exists', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
const loader = new ImageLoader(cli as never)
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
|
||||
|
||||
expect(cli.loadCalls).toEqual([
|
||||
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
])
|
||||
expect(cli.existsCalls).toEqual([
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
])
|
||||
expect(cli.pullCalls).toEqual([OPENCLAW_IMAGE])
|
||||
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE, OPENCLAW_IMAGE])
|
||||
})
|
||||
|
||||
it('loads an agent image by manifest name and returns its image ref', async () => {
|
||||
it('loads the OpenClaw agent image by manifest name', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
const loader = new ImageLoader(cli as never)
|
||||
|
||||
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
OPENCLAW_IMAGE,
|
||||
)
|
||||
|
||||
expect(cli.loadCalls).toEqual([
|
||||
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
])
|
||||
expect(cli.existsCalls).toEqual([
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
])
|
||||
expect(cli.pullCalls).toEqual([OPENCLAW_IMAGE])
|
||||
})
|
||||
|
||||
it('returns an agent image ref without loading when already cached', async () => {
|
||||
const cli = new FakeContainerCli([true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
)
|
||||
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
expect(cli.existsCalls).toEqual(['ghcr.io/openclaw/openclaw:2026.4.12'])
|
||||
})
|
||||
|
||||
it('throws ImageLoadError when the agent name is absent from the manifest', async () => {
|
||||
it('throws ImageLoadError for unknown agent names', async () => {
|
||||
const cli = new FakeContainerCli([])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
const loader = new ImageLoader(cli as never)
|
||||
|
||||
const error = await loader
|
||||
.ensureAgentImageLoaded('missing')
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ImageLoadError)
|
||||
expect(error.message).toContain('no agent in manifest: missing')
|
||||
expect(cli.existsCalls).toEqual([])
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('throws ImageLoadError when the manifest lacks a tarball for the arch', async () => {
|
||||
const missingArchManifest = {
|
||||
...manifest,
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as VmManifest
|
||||
const cli = new FakeContainerCli([false])
|
||||
const loader = new ImageLoader(cli as never, missingArchManifest, 'x64')
|
||||
|
||||
const error = await loader
|
||||
.ensureAgentImageLoaded('openclaw')
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ImageLoadError)
|
||||
expect(error.message).toContain('no x64 tarball in manifest')
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves image tarballs against the configured BrowserOS root', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const browserosRoot = '/tmp/browseros-custom-root'
|
||||
const loader = new ImageLoader(
|
||||
cli as never,
|
||||
manifest,
|
||||
'arm64',
|
||||
browserosRoot,
|
||||
)
|
||||
const getImageCacheDir = spyOn(paths, 'getImageCacheDir')
|
||||
const hostPathToGuest = spyOn(paths, 'hostPathToGuest')
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(getImageCacheDir).toHaveBeenCalledWith(browserosRoot)
|
||||
expect(hostPathToGuest).toHaveBeenCalledWith(
|
||||
'/tmp/browseros-custom-root/cache/vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
browserosRoot,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws ImageLoadError when a loaded image is still absent', async () => {
|
||||
const cli = new FakeContainerCli([false, false])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(
|
||||
loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12'),
|
||||
).rejects.toThrow(ImageLoadError)
|
||||
})
|
||||
|
||||
it('throws ImageLoadError for unknown refs without loading', async () => {
|
||||
const cli = new FakeContainerCli([false])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(loader.ensureImageLoaded('missing:v1')).rejects.toThrow(
|
||||
await expect(loader.ensureAgentImageLoaded('missing')).rejects.toThrow(
|
||||
ImageLoadError,
|
||||
)
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
expect(cli.pullCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('wraps ContainerCliError load failures as ImageLoadError', async () => {
|
||||
it('throws ImageLoadError when pull succeeds but image is still absent', async () => {
|
||||
const cli = new FakeContainerCli([false, false])
|
||||
const loader = new ImageLoader(cli as never)
|
||||
|
||||
await expect(loader.ensureImageLoaded(OPENCLAW_IMAGE)).rejects.toThrow(
|
||||
ImageLoadError,
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps ContainerCliError pull failures as ImageLoadError', async () => {
|
||||
const cli = new FakeContainerCli([false])
|
||||
cli.loadError = new ContainerCliError('nerdctl load', 125, 'bad archive')
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
cli.pullError = new ContainerCliError('nerdctl pull', 1, 'network failed')
|
||||
const loader = new ImageLoader(cli as never)
|
||||
|
||||
const error = await loader
|
||||
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
.ensureImageLoaded(OPENCLAW_IMAGE)
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ImageLoadError)
|
||||
expect(error.cause).toBe(cli.loadError)
|
||||
expect(error.cause).toBe(cli.pullError)
|
||||
})
|
||||
})
|
||||
|
||||
class FakeContainerCli
|
||||
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
|
||||
implements Pick<ContainerCli, 'imageExists' | 'pullImage'>
|
||||
{
|
||||
existsCalls: string[] = []
|
||||
loadCalls: string[] = []
|
||||
loadError: Error | null = null
|
||||
pullCalls: string[] = []
|
||||
pullError: Error | null = null
|
||||
|
||||
constructor(private readonly existsResponses: boolean[]) {}
|
||||
|
||||
@@ -202,9 +88,8 @@ class FakeContainerCli
|
||||
return this.existsResponses.shift() ?? false
|
||||
}
|
||||
|
||||
async loadImage(path: string): Promise<string[]> {
|
||||
this.loadCalls.push(path)
|
||||
if (this.loadError) throw this.loadError
|
||||
return ['loaded']
|
||||
async pullImage(ref: string): Promise<void> {
|
||||
this.pullCalls.push(ref)
|
||||
if (this.pullError) throw this.pullError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
ProcessLockTimeoutError,
|
||||
resolveProcessLockPath,
|
||||
withProcessLock,
|
||||
} from '../../src/lib/process-lock'
|
||||
|
||||
describe('process-lock', () => {
|
||||
let tempDir: string
|
||||
let lockDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'process-lock-'))
|
||||
lockDir = join(tempDir, '.locks')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('serializes concurrent callers for the same lock name', async () => {
|
||||
const events: string[] = []
|
||||
let releaseFirst!: () => void
|
||||
const firstMayFinish = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
|
||||
const first = withProcessLock(
|
||||
'openclaw-lifecycle',
|
||||
{ lockDir },
|
||||
async () => {
|
||||
events.push('first:start')
|
||||
await firstMayFinish
|
||||
events.push('first:end')
|
||||
},
|
||||
)
|
||||
|
||||
while (!events.includes('first:start')) await Bun.sleep(1)
|
||||
|
||||
const second = withProcessLock(
|
||||
'openclaw-lifecycle',
|
||||
{
|
||||
lockDir,
|
||||
retryMinTimeoutMs: 5,
|
||||
retryMaxTimeoutMs: 5,
|
||||
},
|
||||
async () => {
|
||||
events.push('second')
|
||||
},
|
||||
)
|
||||
|
||||
await Bun.sleep(25)
|
||||
expect(events).toEqual(['first:start'])
|
||||
|
||||
releaseFirst()
|
||||
await Promise.all([first, second])
|
||||
expect(events).toEqual(['first:start', 'first:end', 'second'])
|
||||
})
|
||||
|
||||
it('releases the lock when the callback throws', async () => {
|
||||
await expect(
|
||||
withProcessLock('openclaw-lifecycle', { lockDir }, async () => {
|
||||
throw new Error('boom')
|
||||
}),
|
||||
).rejects.toThrow('boom')
|
||||
|
||||
await expect(
|
||||
withProcessLock('openclaw-lifecycle', { lockDir }, async () => 'ok'),
|
||||
).resolves.toBe('ok')
|
||||
})
|
||||
|
||||
it('fails with a structured timeout error when acquisition takes too long', async () => {
|
||||
let releaseFirst!: () => void
|
||||
const firstMayFinish = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
|
||||
const first = withProcessLock(
|
||||
'openclaw-lifecycle',
|
||||
{ lockDir },
|
||||
async () => {
|
||||
await firstMayFinish
|
||||
},
|
||||
)
|
||||
|
||||
await Bun.sleep(10)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
withProcessLock(
|
||||
'openclaw-lifecycle',
|
||||
{
|
||||
lockDir,
|
||||
timeoutMs: 25,
|
||||
retryMinTimeoutMs: 5,
|
||||
retryMaxTimeoutMs: 5,
|
||||
},
|
||||
async () => undefined,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ProcessLockTimeoutError)
|
||||
} finally {
|
||||
releaseFirst()
|
||||
await first
|
||||
}
|
||||
})
|
||||
|
||||
it('sanitizes lock names into the lock directory', async () => {
|
||||
const path = resolveProcessLockPath(lockDir, '../OpenClaw Lifecycle!')
|
||||
|
||||
expect(path).toBe(join(lockDir, 'OpenClaw-Lifecycle.lock'))
|
||||
|
||||
await withProcessLock(
|
||||
'../OpenClaw Lifecycle!',
|
||||
{ lockDir },
|
||||
async () => undefined,
|
||||
)
|
||||
|
||||
const entries = await readdir(lockDir)
|
||||
expect(entries).not.toContain('..')
|
||||
})
|
||||
})
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
ensureVmCacheAvailable,
|
||||
ensureVmCacheSynced,
|
||||
prefetchVmCache,
|
||||
} from '../../../src/lib/vm/cache-sync'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import { getCachedManifestPath } from '../../../src/lib/vm/paths'
|
||||
|
||||
const CDN_BASE = 'https://cdn.test'
|
||||
const MANIFEST_URL = `${CDN_BASE}/vm/manifest.json`
|
||||
const TARBALL_KEY = 'vm/images/openclaw-2026.4.12-arm64.tar.gz'
|
||||
const TARBALL_BYTES = new TextEncoder().encode('openclaw-tarball')
|
||||
const TARBALL_SHA = sha256(TARBALL_BYTES)
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: TARBALL_KEY,
|
||||
sha256: TARBALL_SHA,
|
||||
sizeBytes: TARBALL_BYTES.byteLength,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'unused',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('runtime VM cache sync', () => {
|
||||
let root: string
|
||||
let originalManifestUrl: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/browseros-vm-cache-sync-')
|
||||
originalManifestUrl = process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
|
||||
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv('BROWSEROS_VM_CACHE_MANIFEST_URL', originalManifestUrl)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('downloads the host-arch tarball, verifies it, and writes the manifest last', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl = fakeVmCacheFetch(calls)
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
|
||||
expect(result).toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
expect(
|
||||
JSON.parse(await readFile(getCachedManifestPath(root), 'utf8')),
|
||||
).toEqual(manifest)
|
||||
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
|
||||
'openclaw-tarball',
|
||||
)
|
||||
await expect(
|
||||
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('uses the runtime env manifest URL and resolves artifacts beside it', async () => {
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
|
||||
'https://artifacts.test/vm/manifest.json'
|
||||
const calls: string[] = []
|
||||
const fetchImpl = fakeVmCacheFetch(calls, {
|
||||
manifestUrl: 'https://artifacts.test/vm/manifest.json',
|
||||
tarballUrl: `https://artifacts.test/${TARBALL_KEY}`,
|
||||
})
|
||||
|
||||
await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
'https://artifacts.test/vm/manifest.json',
|
||||
`https://artifacts.test/${TARBALL_KEY}`,
|
||||
])
|
||||
})
|
||||
|
||||
it('skips downloads when the matching manifest and tarball already exist', async () => {
|
||||
await writeLocalManifest(root)
|
||||
await writeLocalTarball(root)
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL])
|
||||
expect(result.downloaded).toEqual([])
|
||||
expect(result.skipped).toBe(true)
|
||||
})
|
||||
|
||||
it('downloads a tarball when the manifest matches but the file is missing', async () => {
|
||||
await writeLocalManifest(root)
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
|
||||
expect(result.downloaded).toEqual([TARBALL_KEY])
|
||||
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
|
||||
'openclaw-tarball',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an existing tarball when the local manifest is missing but the hash matches', async () => {
|
||||
await writeLocalTarball(root)
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL])
|
||||
expect(result.downloaded).toEqual([])
|
||||
expect(result.skipped).toBe(true)
|
||||
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
)
|
||||
})
|
||||
|
||||
it('shares concurrent prefetch calls through one in-flight sync', async () => {
|
||||
const calls: string[] = []
|
||||
let resolveManifest: (response: Response) => void = () => {}
|
||||
const manifestResponse = new Promise<Response>((resolve) => {
|
||||
resolveManifest = resolve
|
||||
})
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (url === MANIFEST_URL) return manifestResponse
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const first = prefetchVmCache({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
const second = prefetchVmCache({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(calls).toEqual([MANIFEST_URL])
|
||||
|
||||
resolveManifest(jsonResponse(manifest))
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
await expect(second).resolves.toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
|
||||
})
|
||||
|
||||
it('syncs different roots independently while another sync is in flight', async () => {
|
||||
const otherRoot = await mkdtemp('/tmp/browseros-vm-cache-sync-other-')
|
||||
try {
|
||||
const calls: string[] = []
|
||||
let resolveManifest: (response: Response) => void = () => {}
|
||||
const manifestResponse = new Promise<Response>((resolve) => {
|
||||
resolveManifest = resolve
|
||||
})
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const first = prefetchVmCache({
|
||||
browserosRoot: otherRoot,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
const second = ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
await second
|
||||
|
||||
resolveManifest(jsonResponse(manifest))
|
||||
await first
|
||||
|
||||
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
)
|
||||
await expect(
|
||||
readFile(getCachedManifestPath(otherRoot), 'utf8'),
|
||||
).resolves.toBe(`${JSON.stringify(manifest, null, 2)}\n`)
|
||||
expect(calls).toEqual([
|
||||
MANIFEST_URL,
|
||||
MANIFEST_URL,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
])
|
||||
} finally {
|
||||
await rm(otherRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries on-demand availability after an in-flight prefetch fails', async () => {
|
||||
const calls: string[] = []
|
||||
let resolveManifest: (response: Response) => void = () => {}
|
||||
const manifestResponse = new Promise<Response>((resolve) => {
|
||||
resolveManifest = resolve
|
||||
})
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const first = prefetchVmCache({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}).catch((error) => error)
|
||||
const available = ensureVmCacheAvailable({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
resolveManifest(new Response('', { status: 503 }))
|
||||
|
||||
await expect(first).resolves.toBeInstanceOf(Error)
|
||||
await available
|
||||
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
)
|
||||
expect(calls).toEqual([
|
||||
MANIFEST_URL,
|
||||
MANIFEST_URL,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
])
|
||||
})
|
||||
|
||||
it('clears failed in-flight syncs so a later call can retry', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (calls.length === 1) return new Response('', { status: 503 })
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}),
|
||||
).rejects.toThrow('manifest fetch failed')
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
MANIFEST_URL,
|
||||
MANIFEST_URL,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
])
|
||||
})
|
||||
|
||||
it('removes the partial file when sha256 verification fails', async () => {
|
||||
const badBytes = new TextEncoder().encode('bad-tarball')
|
||||
const fetchImpl = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`) return new Response(badBytes)
|
||||
return new Response('', { status: 404 })
|
||||
}) as typeof fetch
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}),
|
||||
).rejects.toThrow('sha256 mismatch')
|
||||
|
||||
await expect(stat(join(root, 'cache', TARBALL_KEY))).rejects.toThrow()
|
||||
await expect(
|
||||
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects unsupported host architectures before fetching', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm',
|
||||
}),
|
||||
).rejects.toThrow('unsupported host arch: arm')
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function fakeVmCacheFetch(
|
||||
calls: string[],
|
||||
opts?: { manifestUrl?: string; tarballUrl?: string },
|
||||
): typeof fetch {
|
||||
const manifestUrl = opts?.manifestUrl ?? MANIFEST_URL
|
||||
const tarballUrl = opts?.tarballUrl ?? `${CDN_BASE}/${TARBALL_KEY}`
|
||||
return (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (url === manifestUrl) return jsonResponse(manifest)
|
||||
if (url === tarballUrl) return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
async function writeLocalManifest(root: string): Promise<void> {
|
||||
const path = getCachedManifestPath(root)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
}
|
||||
|
||||
async function writeLocalTarball(root: string): Promise<void> {
|
||||
const path = join(root, 'cache', TARBALL_KEY)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, TARBALL_BYTES)
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ContainerCliError,
|
||||
ImageLoadError,
|
||||
LimaCommandError,
|
||||
ManifestMissingError,
|
||||
VmError,
|
||||
VmNotReadyError,
|
||||
VmStateCorruptedError,
|
||||
@@ -24,7 +23,6 @@ describe('VM errors', () => {
|
||||
new LimaCommandError('limactl start', 7, 'bad lima'),
|
||||
new ContainerCliError('nerdctl pull', 8, 'bad nerdctl'),
|
||||
new ImageLoadError('openclaw:v1', 'bad image'),
|
||||
new ManifestMissingError('/tmp/manifest.json'),
|
||||
]
|
||||
|
||||
for (const error of errors) {
|
||||
@@ -48,8 +46,30 @@ describe('VM errors', () => {
|
||||
})
|
||||
|
||||
it('exports VM telemetry event names', () => {
|
||||
expect(Object.keys(VM_TELEMETRY_EVENTS)).toEqual([
|
||||
'ensureReadyStart',
|
||||
'ensureReadyOk',
|
||||
'ensureReadyBranch',
|
||||
'create',
|
||||
'start',
|
||||
'stop',
|
||||
'resetDetected',
|
||||
'resetOk',
|
||||
'nerdctlWaitStart',
|
||||
'nerdctlWaitOk',
|
||||
'nerdctlWaitPoll',
|
||||
'nerdctlWaitTimeout',
|
||||
'migrationOpenClawMoved',
|
||||
'limaSpawn',
|
||||
'limaExit',
|
||||
'limaStderrChunk',
|
||||
'provisionYamlWrite',
|
||||
'provisionCreateStart',
|
||||
'provisionCreateOk',
|
||||
'provisionStartBegin',
|
||||
'provisionStartOk',
|
||||
])
|
||||
expect(VM_TELEMETRY_EVENTS.ensureReadyStart).toBe('vm.ensure_ready.start')
|
||||
expect(VM_TELEMETRY_EVENTS.downgradeDetected).toBe('vm.downgrade.detected')
|
||||
expect(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout).toBe(
|
||||
'vm.nerdctl_wait.timeout',
|
||||
)
|
||||
|
||||
@@ -12,14 +12,11 @@ describe('renderLimaTemplate', () => {
|
||||
'minimumLimaVersion: 2.0.0\nmounts: []\nprobes: []\n',
|
||||
{
|
||||
vmStateDir: '/Users/me/.browseros/vm',
|
||||
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
|
||||
},
|
||||
)
|
||||
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
expect(yaml).toContain('location: "/Users/me/.browseros/vm"')
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
|
||||
expect(yaml).toContain('location: "/Users/me/.browseros/cache/vm/images"')
|
||||
expect(yaml).toContain('probes: []')
|
||||
})
|
||||
|
||||
@@ -27,7 +24,6 @@ describe('renderLimaTemplate', () => {
|
||||
expect(() =>
|
||||
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
|
||||
vmStateDir: '/state',
|
||||
imageCacheDir: '/images',
|
||||
}),
|
||||
).toThrow('mounts: [] marker')
|
||||
})
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { ManifestMissingError } from '../../../src/lib/vm/errors'
|
||||
import {
|
||||
agentForArch,
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
type VmManifest,
|
||||
writeInstalledManifest,
|
||||
} from '../../../src/lib/vm/manifest'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'd',
|
||||
sizeBytes: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VM manifest helpers', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'browseros-vm-manifest-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads the cached manifest', async () => {
|
||||
const manifestPath = join(root, 'cache', 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readCachedManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws ManifestMissingError when cached manifest is absent', async () => {
|
||||
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
|
||||
})
|
||||
|
||||
it('returns null for a missing installed manifest', async () => {
|
||||
await expect(readInstalledManifest(root)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reads the installed manifest', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readInstalledManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws on malformed installed manifest JSON', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, '{not-json')
|
||||
|
||||
await expect(readInstalledManifest(root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('writes the installed manifest atomically', async () => {
|
||||
await writeInstalledManifest(manifest, root)
|
||||
|
||||
const raw = await readFile(join(root, 'vm', 'manifest.json'), 'utf8')
|
||||
expect(JSON.parse(raw)).toEqual(manifest)
|
||||
})
|
||||
|
||||
it('compares installed and cached versions', () => {
|
||||
const older = { ...manifest, updatedAt: '2026-04-21T00:00:00.000Z' }
|
||||
const newer = { ...manifest, updatedAt: '2026-04-23T00:00:00.000Z' }
|
||||
|
||||
expect(compareVersions(null, manifest)).toBe('fresh')
|
||||
expect(compareVersions(manifest, manifest)).toBe('same')
|
||||
expect(compareVersions(older, manifest)).toBe('upgrade')
|
||||
expect(compareVersions(newer, manifest)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('compares ISO timestamp versions with time-of-day precision', () => {
|
||||
const morning = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
}
|
||||
const afternoon = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T15:00:00.000Z',
|
||||
}
|
||||
|
||||
expect(compareVersions(morning, afternoon)).toBe('upgrade')
|
||||
expect(compareVersions(afternoon, morning)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('returns the requested agent tarball for an arch', () => {
|
||||
expect(agentForArch(manifest, 'openclaw', 'arm64')).toEqual({
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarball: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when an agent or arch is absent', () => {
|
||||
expect(() => agentForArch(manifest, 'missing', 'arm64')).toThrow(
|
||||
'missing agent',
|
||||
)
|
||||
expect(() =>
|
||||
agentForArch(manifest, 'openclaw', 'x64' as never),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -14,10 +14,7 @@ import {
|
||||
} from '../../../src/lib/browseros-dir'
|
||||
import {
|
||||
detectArch,
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
getImageCacheDir,
|
||||
getInstalledManifestPath,
|
||||
getLimaHomeDir,
|
||||
getVmCacheDir,
|
||||
getVmStateDir,
|
||||
@@ -81,17 +78,10 @@ describe('VM paths', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('builds cached and installed manifest paths', () => {
|
||||
it('builds VM storage paths', () => {
|
||||
const root = '/Users/foo/.browseros'
|
||||
|
||||
expect(getVmCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm')
|
||||
expect(getImageCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm/images')
|
||||
expect(getCachedManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/cache/vm/manifest.json',
|
||||
)
|
||||
expect(getInstalledManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/vm/manifest.json',
|
||||
)
|
||||
expect(getContainerdSocketPath(root)).toBe(
|
||||
'/Users/foo/.browseros/lima/browseros-vm/sock/containerd.sock',
|
||||
)
|
||||
@@ -103,9 +93,6 @@ describe('VM paths', () => {
|
||||
expect(hostPathToGuest('/Users/foo/.browseros/vm/openclaw/x', root)).toBe(
|
||||
'/mnt/browseros/vm/openclaw/x',
|
||||
)
|
||||
expect(
|
||||
hostPathToGuest('/Users/foo/.browseros/cache/vm/images/a.tar.gz', root),
|
||||
).toBe('/mnt/browseros/cache/images/a.tar.gz')
|
||||
})
|
||||
|
||||
it('rejects unmapped host paths', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
@@ -12,43 +12,13 @@ import {
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../../../src/lib/logger'
|
||||
import { join } from 'node:path'
|
||||
import { VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getInstalledManifestPath,
|
||||
VM_NAME,
|
||||
} from '../../../src/lib/vm/paths'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
import { VM_NAME } from '../../../src/lib/vm/paths'
|
||||
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
|
||||
import { fakeLimactl } from '../../__helpers__/fake-limactl'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VmRuntime', () => {
|
||||
let root: string
|
||||
let limaHome: string
|
||||
@@ -60,7 +30,6 @@ describe('VmRuntime', () => {
|
||||
limaHome = join(root, 'lima')
|
||||
logPath = join(root, 'limactl.log')
|
||||
templatePath = join(root, 'browseros-vm.yaml')
|
||||
await writeCachedManifest(root)
|
||||
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
|
||||
})
|
||||
|
||||
@@ -68,7 +37,7 @@ describe('VmRuntime', () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
|
||||
it('provisions a fresh VM and waits for rootless nerdctl', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
@@ -88,59 +57,12 @@ describe('VmRuntime', () => {
|
||||
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
|
||||
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
|
||||
await expect(
|
||||
readFile(getInstalledManifestPath(root), 'utf8'),
|
||||
).resolves.toContain(manifest.updatedAt)
|
||||
await expect(
|
||||
readFile(join(limaHome, `${VM_NAME}.yaml`), 'utf8'),
|
||||
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
})
|
||||
|
||||
it('fills a missing VM cache before reading the cached manifest', async () => {
|
||||
await rm(getCachedManifestPath(root), { force: true })
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const ensureCacheAvailable = mock(async () => {
|
||||
await writeCachedManifest(root)
|
||||
})
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
ensureCacheAvailable,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
|
||||
await expect(
|
||||
readFile(getInstalledManifestPath(root), 'utf8'),
|
||||
).resolves.toContain(manifest.updatedAt)
|
||||
})
|
||||
|
||||
it('surfaces cache sync failures before reading a missing manifest', async () => {
|
||||
await rm(getCachedManifestPath(root), { force: true })
|
||||
const ensureCacheAvailable = mock(async () => {
|
||||
throw new Error('cache offline')
|
||||
})
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath: 'unused',
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
ensureCacheAvailable,
|
||||
})
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow('cache offline')
|
||||
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns fast when the VM is already running and manifests match', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
it('returns fast when the VM is already running', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
@@ -170,7 +92,6 @@ describe('VmRuntime', () => {
|
||||
})
|
||||
|
||||
it('starts an existing stopped VM without recreating it', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
@@ -198,7 +119,6 @@ describe('VmRuntime', () => {
|
||||
})
|
||||
|
||||
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
@@ -293,92 +213,6 @@ describe('VmRuntime', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('logs upgrade mismatch and preserves the installed manifest until upgrade happens', async () => {
|
||||
await writeInstalledManifest(root, '2026-04-21T00:00:00.000Z')
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
const originalWarn = logger.warn
|
||||
const warnings: Array<{
|
||||
message: string
|
||||
meta?: Record<string, unknown>
|
||||
}> = []
|
||||
logger.warn = (message, meta) => warnings.push({ message, meta })
|
||||
|
||||
try {
|
||||
await runtime.ensureReady()
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
expect(warnings).toContainEqual({
|
||||
message: VM_TELEMETRY_EVENTS.upgradeDetected,
|
||||
meta: {
|
||||
from: '2026-04-21T00:00:00.000Z',
|
||||
to: '2026-04-22T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-21T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('logs downgrade mismatch and preserves a newer installed manifest', async () => {
|
||||
await writeInstalledManifest(root, '2026-04-23T00:00:00.000Z')
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
const originalWarn = logger.warn
|
||||
const warnings: Array<{
|
||||
message: string
|
||||
meta?: Record<string, unknown>
|
||||
}> = []
|
||||
logger.warn = (message, meta) => warnings.push({ message, meta })
|
||||
|
||||
try {
|
||||
await runtime.ensureReady()
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
expect(warnings).toContainEqual({
|
||||
message: VM_TELEMETRY_EVENTS.downgradeDetected,
|
||||
meta: {
|
||||
from: '2026-04-23T00:00:00.000Z',
|
||||
to: '2026-04-22T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('does not auto-reset when rootless nerdctl readiness fails', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
@@ -450,29 +284,6 @@ describe('VmRuntime', () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function writeCachedManifest(root: string): Promise<void> {
|
||||
const manifestPath = getCachedManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
}
|
||||
|
||||
async function writeInstalledManifest(
|
||||
root: string,
|
||||
updatedAt = manifest.updatedAt,
|
||||
): Promise<void> {
|
||||
const manifestPath = getInstalledManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify({ ...manifest, updatedAt })}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async function readInstalledUpdatedAt(root: string): Promise<string> {
|
||||
const raw = await readFile(getInstalledManifestPath(root), 'utf8')
|
||||
return (JSON.parse(raw) as VmManifest).updatedAt
|
||||
}
|
||||
|
||||
async function prepareReadySsh(
|
||||
limaHome: string,
|
||||
logPath: string,
|
||||
|
||||
@@ -14,8 +14,6 @@ const config = {
|
||||
executionDir: '/tmp/browseros-execution',
|
||||
mcpAllowRemote: false,
|
||||
aiSdkDevtoolsEnabled: false,
|
||||
vmCachePrefetch: true,
|
||||
vmCacheManifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
|
||||
}
|
||||
|
||||
describe('Application.start', () => {
|
||||
@@ -51,70 +49,45 @@ describe('Application.start', () => {
|
||||
expect(loggerError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts VM cache prefetch without blocking HTTP startup', async () => {
|
||||
const { Application, createHttpServer, prefetchVmCache } =
|
||||
it('starts OpenClaw prewarm without blocking HTTP startup', async () => {
|
||||
const { Application, createHttpServer, openClawService } =
|
||||
await setupApplicationTest()
|
||||
let resolvePrefetch: (value: {
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}) => void = () => {}
|
||||
const pendingPrefetch = new Promise<{
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}>((resolve) => {
|
||||
resolvePrefetch = resolve
|
||||
let resolvePrewarm: () => void = () => {}
|
||||
const pendingPrewarm = new Promise<void>((resolve) => {
|
||||
resolvePrewarm = resolve
|
||||
})
|
||||
prefetchVmCache.mockImplementation(() => pendingPrefetch)
|
||||
openClawService.prewarm.mockImplementation(() => pendingPrewarm)
|
||||
|
||||
const app = new Application(config)
|
||||
const startPromise = app.start()
|
||||
const completedBeforePrefetch = await Promise.race([
|
||||
const completedBeforePrewarm = await Promise.race([
|
||||
startPromise.then(() => true),
|
||||
Bun.sleep(25).then(() => false),
|
||||
])
|
||||
resolvePrefetch({
|
||||
downloaded: [],
|
||||
manifestPath: '/tmp/manifest.json',
|
||||
skipped: true,
|
||||
})
|
||||
resolvePrewarm()
|
||||
await startPromise
|
||||
|
||||
expect(completedBeforePrefetch).toBe(true)
|
||||
expect(completedBeforePrewarm).toBe(true)
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(prefetchVmCache).toHaveBeenCalledWith({
|
||||
manifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
|
||||
})
|
||||
expect(openClawService.prewarm).toHaveBeenCalledTimes(1)
|
||||
expect(openClawService.tryAutoStart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs VM cache prefetch failures without failing startup', async () => {
|
||||
const { Application, createHttpServer, loggerWarn, prefetchVmCache } =
|
||||
it('logs and continues when OpenClaw prewarm fails', async () => {
|
||||
const { Application, createHttpServer, loggerWarn, openClawService } =
|
||||
await setupApplicationTest()
|
||||
prefetchVmCache.mockImplementation(() =>
|
||||
Promise.reject(new Error('cache offline')),
|
||||
)
|
||||
openClawService.prewarm.mockImplementation(async () => {
|
||||
throw new Error('registry offline')
|
||||
})
|
||||
const app = new Application(config)
|
||||
|
||||
await app.start()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(loggerWarn).toHaveBeenCalledWith(
|
||||
'BrowserOS VM cache prefetch failed',
|
||||
{
|
||||
error: 'cache offline',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('skips VM cache prefetch when disabled', async () => {
|
||||
const { Application, prefetchVmCache } = await setupApplicationTest()
|
||||
const app = new Application({ ...config, vmCachePrefetch: false })
|
||||
|
||||
await app.start()
|
||||
|
||||
expect(prefetchVmCache).not.toHaveBeenCalled()
|
||||
expect(loggerWarn).toHaveBeenCalledWith('OpenClaw prewarm failed', {
|
||||
error: 'registry offline',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,7 +99,6 @@ async function setupApplicationTest() {
|
||||
'../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const browserosDir = await import('../src/lib/browseros-dir')
|
||||
const cacheSync = await import('../src/lib/vm/cache-sync')
|
||||
const dbModule = await import('../src/lib/db')
|
||||
const identityModule = await import('../src/lib/identity')
|
||||
const loggerModule = await import('../src/lib/logger')
|
||||
@@ -185,26 +157,24 @@ async function setupApplicationTest() {
|
||||
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
|
||||
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
|
||||
|
||||
const prewarm = mock(async () => {})
|
||||
const tryAutoStart = mock(async () => {})
|
||||
|
||||
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
prewarm,
|
||||
tryAutoStart,
|
||||
}) as never,
|
||||
)
|
||||
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
prewarm,
|
||||
tryAutoStart,
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const prefetchVmCache = spyOn(cacheSync, 'prefetchVmCache')
|
||||
prefetchVmCache.mockImplementation(async () => ({
|
||||
downloaded: [],
|
||||
manifestPath: '/tmp/manifest.json',
|
||||
skipped: true,
|
||||
}))
|
||||
|
||||
const { Application } = await import('../src/main')
|
||||
return {
|
||||
Application,
|
||||
@@ -214,6 +184,6 @@ async function setupApplicationTest() {
|
||||
loggerError,
|
||||
loggerInfo,
|
||||
loggerWarn,
|
||||
prefetchVmCache,
|
||||
openClawService: { prewarm, tryAutoStart },
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user