Compare commits

..

12 Commits

Author SHA1 Message Date
Nikhil Sonti
8cd7c985e3 fix: address review feedback for PR #891 2026-04-30 12:54:44 -07:00
Nikhil Sonti
548c9dd996 feat(dev): bootstrap setup from dev watch 2026-04-30 12:49:25 -07:00
Nikhil
d38b01a8c7 feat(dev): add guided cleanup and reset commands (#890)
* feat(dev): add guided cleanup and reset commands

* fix: address cleanup reset review feedback
2026-04-30 12:27:15 -07:00
Nikhil
ff36c8412b fix(dev): use run lock for watch cleanup (#889)
* fix(dev): use run lock for watch cleanup

* fix(dev): address watch lock review comments
2026-04-30 11:46:17 -07:00
Nikhil
fd5aba249b fix: stabilize OpenClaw gateway startup (#888)
* feat(server): add shared process lock helper

* feat(container): add container name reconciliation helpers

* feat(openclaw): serialize lifecycle across processes

* fix(openclaw): reconcile fixed gateway container startup

* test(openclaw): cover lifecycle race recovery

* fix(server): satisfy process lock error override

* fix(openclaw): address review feedback

* test(openclaw): align serialization mock with image check
2026-04-30 11:31:40 -07:00
Nikhil
492f3fcdf2 feat(openclaw): prewarm ghcr image in vm (#887)
* feat(openclaw): add gateway image inspection

* feat(openclaw): pull gateway image from registry

* refactor(vm): decouple readiness from image cache

* refactor(openclaw): remove vm cache from runtime factory

* feat(openclaw): detect current gateway image

* feat(openclaw): prewarm vm runtime and reuse current gateway

* feat(openclaw): prewarm runtime on server startup

* refactor(vm): remove browseros image cache runtime

* refactor(build-tools): remove openclaw tarball pipeline

* chore: self-review fixes

* fix(openclaw): suppress prewarm pull progress logs

* fix(openclaw): address review feedback

* fix(openclaw): resolve review findings

* fix(dev): stop stale watch supervisors
2026-04-30 11:18:11 -07:00
Nikhil
cb0c0dd0c1 chore: simplify root test scripts (#886)
* chore: simplify root test scripts

* fix: avoid chained root test scripts

* fix: update test workflow commands

* fix: move app test commands into packages
2026-04-30 10:58:08 -07:00
Dani Akash
8712f89f18 feat(agents): durable per-agent chat message queue + composer Stop (#880)
* feat(agents): durable per-agent chat message queue + composer Stop button

* fix(agents): tighten queue UI — smaller Stop, drop empty indicator, live drain attach

User feedback round 1 on the message-queue UX:

1) The Stop button matched the send/voice mics at h-10 w-10 with a
   solid destructive fill, which read as alarming. Shrunk to h-8 w-8,
   ghost variant with a soft destructive/10 background, smaller
   filled square glyph. Reads as a calm 'stop' affordance instead of
   a panic button.

2) The QueueItem's leading <QueueItemIndicator> dot was decorative
   only — no state, no interaction. Dropped it from QueuePanel along
   with the import; queue items now render as a clean preview line
   with the trailing X remove action.

3) When the server drained the queue and started the next turn, the
   chat panel didn't pick up the live stream until the user
   navigated away and back. The hook's resume effect previously
   only fired on agent change, not on listing-observed activeTurnId
   change. Surface activeTurnId from useHarnessAgents into
   useAgentConversation; effect now re-runs when the id changes,
   calls /chat/active, and attaches to the new turn — so a queued
   message starts streaming the moment the server drain pops it.

* fix(agents): don't reset streaming state from the resume effect's no-op paths

The Stop button was disappearing while the agent was actively
streaming, even though events were still flowing into the chat. Root
cause: the resume effect's `finally` block reset `streaming`,
`turnIdRef`, and `lastSeqRef` unconditionally — including on the
early-return paths (no active turn, or another mechanism already
owns the stream).

Sequence that triggered it:
  1) User sends a message → send() sets streamAbortRef + streaming=true
     and starts consuming the SSE.
  2) User enqueues another message → enqueue mutation invalidates the
     listing query.
  3) Listing refetches with the live activeTurnId → the resume
     effect re-fires (deps include activeTurnIdDep).
  4) attemptResume hits `if (streamAbortRef.current) return` because
     send() owns it.
  5) The finally clause fires anyway and calls setStreaming(false),
     clobbering the live state set by send(). The SSE consumer keeps
     running (refs are intact) so text keeps streaming, but the React
     flag is wrong, so the Stop button gates off.

Fix: track whether *this* run actually started a stream
(`weStartedStream`). The finally only resets state when it does.
Early-return / no-active-turn paths now leave streaming/turnIdRef/
lastSeqRef alone for whoever does own them.

Also widens the Stop button's visibility (`canStop` prop on
ConversationInput) so it stays steady across the brief gap between
turns when a queue drain is mid-flight; the parent computes
`streaming || activeTurnId !== null || queue.length > 0`. The
visibility widening is independent of the streaming-state fix above
— both are now in place.

* revert: drop canStop widening — Stop only shows while streaming

Reverts the canStop prop on ConversationInput and the OR-with-queue
visibility from AgentCommandConversation. Stop is gated solely on
`streaming` again. Between turns (queue draining) the button stays
hidden — only the actively-streaming turn is interruptible from the
composer, which matches what the user actually expects.

* fix(agents): persist the kicking-off prompt on active turns so the resume placeholder isn't empty

When a queued message drained and started a new turn, the chat
panel's resume effect staged a placeholder turn with userText: ''
because the hook had no way to know what message kicked off the
turn — only the agent-side stream was visible, and the user bubble
above it was blank until the user navigated away and back (at which
point the session record's history loaded normally).

Fix: ActiveTurnRegistry.register now accepts an optional `prompt`
that's stashed on the turn and surfaced via describe() / the
ActiveTurnInfo response. AgentHarnessService.startTurn passes the
incoming message into register. /chat/active returns it. The chat
hook's resume effect uses active.prompt as the placeholder
turn's userText, so the user bubble shows the queued message text
the moment streaming begins. Falls back to '' for older clients
that haven't been refetched yet.

* fix(agents): always release streamAbortRef on resume cleanup, even when cancelled

Greptile P1 follow-up. The previous `weStartedStream` guard correctly
stopped the resume effect's no-op early-returns from clobbering an
in-flight `send()` stream — but it also stopped a *cancelled*
mid-stream resume from clearing its own `streamAbortRef`. When the
cleanup fires (e.g. the 5s listing poll captures a new queue-drain
turn id while the SSE for the prior turn is still finishing), the
next effect run hits the `if (streamAbortRef.current) return` guard
against the now-aborted controller and never reattaches, leaving
`streaming === true` with no live stream until the user navigates
away.

Split the finally block: always release `streamAbortRef` when we
owned the controller (so the next run can take over), but only
reset the streaming flag / turn id / lastSeq on a clean exit (the
new run will set those itself, so resetting on cancel would just
flicker).
2026-04-30 18:26:56 +05:30
Dani Akash
ba60bf466f feat(agents): rich command-center rows + home grid + dead-code sweep (#879)
* feat(agents): rich-info command center rows + pin/PATCH/adapter-health backbone

Splits AgentRowCard from a 271-line monolith into a shallow tree of
single-responsibility sub-components under `agent-row/`:

  AgentTile, AdapterHealthDot, PinToggle, AgentTitleRow,
  AgentSparkline, AgentSummaryChips, AgentLastMessage, CwdChip,
  AgentTokenSummary, AgentMetaRow, AgentErrorPanel, AgentActions

Adds the data each row consumes:

- pinned: boolean field on AgentDefinition + FileAgentStore.update
  + new PATCH /agents/:id route. useUpdateHarnessAgent mutation
  optimistically updates the listing cache so the star flips
  instantly; rolls back on error.
- Listing payload extended with lastUserMessage, cwd, tokens
  (cumulative + last7d shape — last7d zero-filled until the
  activity ledger lands), turnsByDay/failedByDay (zero-filled),
  lastError/lastErrorAt, activeTurnId. AcpxRuntime grows a
  getRowSnapshot() that reads cwd + cumulative tokens + last user
  message from the session record in one pass.
- Adapter health: in-memory AdapterHealthChecker probes
  `claude --version` / `codex --version` with a 2s timeout and
  caches results for 5 min. /adapters response carries
  { healthy, reason?, checkedAt }. Tile-corner dot exposes the
  state via HoverCard; openclaw inherits health from the gateway
  snapshot already on the page.

Sub-components are pure: card itself owns no state. Sort order
becomes pinned-first, then recency. HoverCard is the workhorse for
keeping rows compact while exposing depth (full message, token
breakdown, daily turn list, error stack, adapter reason).

* refactor(agents): tighten command-center row design + cut redundant affordances

User feedback round 1:
1) Two green dots on the tile (health + liveness) was confusing. Health
   moves out of the tile entirely and surfaces as an inline 'Unavailable'
   chip in the model line — silent when the adapter is healthy, with a
   warning amber chip + HoverCard reason when not. The tile now shows
   one signal: liveness.
2) The last-user-message HoverCard wasn't telegraphing intent. Drop the
   HoverCard. The line is informational, italic, with a leading quote
   glyph so the row reads like a conversation snippet. To see the full
   message the user opens the chat (which is the action they want next
   anyway).
3) Resume + Chat were duplicate CTAs. Single primary action per row:
   Resume (filled, accent-orange, with a pulsing dot) replaces Chat
   when there's an active turn. Both navigate to /agents/:id but the
   row tells the user which action they're taking.
4) Tokens weren't visible because the row gated on last7d.requestCount,
   which is zero until the activity ledger ships. Switch to lifetime
   tokens (which we have today). Drop the '7d stats:' framing — talking
   about a window we can't compute would be misleading. The HoverCard
   surfaces input/output split + a footnote that per-window stats land
   in a follow-up.
5) CWD was rendering the server's own running directory, which is
   meaningless to users. Hide it from the row entirely. The cwd field
   still rides in the listing payload for future surfaces (chat panel,
   debug view) — only the row stops rendering it.

Aesthetic refinements while we're here:
- Whole card carries state, not just the tile: working rows get an
  accent-orange tinted border with a soft glow, error rows tint
  destructive, idle rows lift on hover.
- Pin star fades in on hover (group-hover) when unpinned and stays
  solid amber when pinned — keeps the rail calm by default.
- Tabular-nums on token figures so columns visually align across rows.
- Drop CwdChip and AdapterHealthDot files: no callers left.

* fix(agents): align row title flush-left whether pinned or not

Pin star moved from leading the title to trailing the badges, and
hidden from layout entirely (`hidden group-hover:inline-flex`) when
unpinned. The previous `opacity-0` rule kept the star reserving its
`size-6` slot, which left every unpinned title indented relative to
the model / preview / meta lines underneath it. Title now flushes
left in both states; pinned star stays solid amber so the signal
isn't hidden, and unpinned reveals an outline star on row hover for
the toggle affordance.

* fix(agents): keep pin-toggle slot reserved so row height is constant

Switching the unpinned star from `hidden group-hover:inline-flex`
to `opacity-0 group-hover:opacity-100`. The hidden/show variant was
collapsing the title row's height when the star wasn't rendered,
which made every card below visibly shift on hover. Always rendering
the button (with opacity-only visibility) keeps the row's vertical
metrics constant; the title still flushes left because the slot is
trailing, not leading.

Card hover effect (-translate-y + shadow-md) restored — the layout
shift wasn't coming from the card hover; it was the pin slot
appearing and disappearing.

* fix(agents): quieten row hover — border-tint only, no lift, no shadow

Drop the `-translate-y-px` and `hover:shadow-md` from the row card
plus the working-state inner ring. The translate + shadow grow
combination was visibly noisy as the cursor moved through the rail —
each row 'lifted' as you passed over it. Hover now just tints the
border in accent-orange/30; working and error states keep their
distinct border colours but no inner ring. Card height and shadow
stay constant in every state, so the rail reads as a calm vertical
list of cards.

* feat(home): rich Recent Agents grid + dead-code sweep

The /home Recent Agents grid was a placeholder shell. Every 'rich'
field on the card (lastMessage, lastMessageTimestamp, activitySummary,
currentTool, costUsd) was wired to undefined because AgentCommandHome
called `buildAgentCardData(agents, status?.status, undefined)` — the
dashboard arg has been hard-coded undefined since the harness
migration. Repointing the grid at `useHarnessAgents` + `useAgentAdapters`
gives every card the same enriched data the rail uses.

What the new card shows per agent:
  • Adapter glyph tile + liveness dot (working pulses; asleep is
    hollow; error is red)
  • Name + Working pill (when active)
  • Adapter · model · reasoning summary line, with an inline
    Unavailable chip + HoverCard reason when the adapter binary
    isn't on $PATH
  • Italic last-user-message preview (line-clamp-2, leading quote
    glyph) — same visual language as the rail
  • Footer: 'X ago' + state chip (Asleep / Attention) OR a Resume
    button (orange, with pulsing dot) when activeTurnId is non-null

Sort on the home grid is active-turn → recency. Pinning is NOT a
sort key here (and there's no pin indicator on the card) — pinning
belongs to the rail at /agents; the home page is action-oriented
and trusts active-turn + recency to surface the right agent.

Dead code removed:
  • useAgentDashboard.ts (96 lines, no callers; subscribed to the
    dead /claw/dashboard/stream from the OpenClaw-only era)
  • useAgentCardData.ts (the dashboard-merge shim; passed undefined
    every call so all enriched fields landed as undefined)
  • AgentCard.tsx (AgentCardExpanded replaced by HomeAgentCard;
    AgentCardCompact had no callers — the dock's compact mode was
    never used)
  • AgentCardData interface dropped from lib/agent-conversations/
    types.ts; the new card consumes HarnessAgent directly

Visual language stays continuous between rail and grid: same
<AgentTile>, same <LivenessDot>, same italic-quote message
preview, same orange Resume button with a pulsing dot.
2026-04-30 16:36:22 +05:30
Nikhil
26afb826c6 feat(eval): add viewer manifest contract (#878)
* refactor(eval): canonicalize viewer manifest contract

* refactor(eval): publish canonical viewer manifests

* feat(eval): make r2 viewer use manifest artifact paths

* fix(eval): keep weekly report compatible with viewer manifests

* docs(eval): document r2 viewer manifest contract

* chore: self-review fixes

* fix: address review feedback for PR #878
2026-04-29 20:50:35 -07:00
Nikhil
b2340c8afa refactor(eval): split orchestrated executor backends (#876)
* refactor(eval): split orchestrated executor backends

* fix(eval): address executor backend review comments
2026-04-29 18:02:32 -07:00
Felarof
790a270f47 Update README.md (#877) 2026-04-29 17:35:15 -07:00
140 changed files with 6934 additions and 4545 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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}`)}
/>

View File

@@ -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 ? (

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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'])
})
})

View File

@@ -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)
})
}

View File

@@ -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,
}
})
}

View File

@@ -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 =

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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+/)
})
})

View File

@@ -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

View File

@@ -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
}

View File

@@ -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],
})
},
})
}

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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": {

View File

@@ -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, '&amp;')
@@ -193,12 +104,6 @@ function escHtml(s: string): string {
.replace(/"/g, '&quot;')
}
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'

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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> {

View 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))
}

View File

@@ -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),
}
}),
}
}

View File

@@ -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',
},
},
],
})
})
})

View 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',
})
})
})

View 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')
})
})

View File

@@ -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)
})
})

View File

@@ -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=

View File

@@ -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=

View File

@@ -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",

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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)[]

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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'
)
}

View File

@@ -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>
}

View File

@@ -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> {

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View 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))
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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: []')) {

View File

@@ -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
}

View File

@@ -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`)
}

View File

@@ -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',

View File

@@ -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),
})
}

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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 () => {

View File

@@ -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),
},
}
}

View File

@@ -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',

View File

@@ -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'),
)
})
})

View File

@@ -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> = {

View File

@@ -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', () => {

View File

@@ -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 () => {

View File

@@ -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')
})
})

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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('..')
})
})

View File

@@ -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
}
}

View File

@@ -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',
)

View File

@@ -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')
})

View File

@@ -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()
})
})

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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