feat(local-surface): fleet-node bridge for running proactive personas on a laptop

Merged
KH
3mo ago
+1820-5

Summary

Implements the ../workforce half of "Local Surface" — running any proactive
persona on a user's own laptop, triggered like a real Cloudflare deployment —
per AgentWorkforce/cloud#2623 (companion PR, cloud-side provisioning +
webhook consumer) and AgentWorkforce/relaycast#269 (fleet-trigger wiring for
the inbound-webhook path).

  • packages/local-surface (new package): defineWorkforcePersonaNode({personaPath, channel, connection})
    wraps @agent-relay/fleet's defineNode with a run-event action and an
    onMessage({channel}) trigger. On first invocation it launches the persona
    via the existing deploy({mode: 'dev', detach: true}) orchestrator
    (packages/deploy), keeping the child alive across messages. Each
    relaycast message is unpacked from message.metadata (the NormalizedWebhook
    cloud posts, flattened by relaycast's triggerWebhook()) into a
    RawGatewayEnvelope, mirroring cloud's real gateway construction
    (buildPayload/buildEnvelope in
    deployment-trigger-delivery.ts/integration-watch-dispatcher.ts:
    type: ${provider}.${eventType}``, resource: payload) rather than an ad
    hoc shape — confirmed against cloud+relaycast source together with
    cloud-builder/lead in the coordination channel.
  • packages/deploy: ModeLaunchHandle gains an optional write(line)
    (dev mode only). The bridge calls deploy() from a long-lived fleet-node
    process with no real piped parent stdin, so it needs direct access to the
    child's stdin that dev.ts already holds in closure — this is the smallest
    change that avoids reimplementing the spawn/bundling dev.ts owns.
  • packages/cli: agentworkforce local-surface <persona-path> —
    resolves/redeems a fleet-node enrollment (@agent-relay/cloud's
    enrollFleetNode/resolveActiveFleetNodeEnrollment, the same persisted
    store relay cloud enroll/relay node up already read/write — bumped
    @agent-relay/cloud to ^10.1.0 for this package only, since these exports
    don't exist in the previously-pinned ^8.7.0), resolves the persona's
    bound relaycast channel via --channel (cached in
    ~/.agentworkforce/local-surface/state.json), writes a generated
    node-config file, and shells out to relay node up --config <file>.

Why --channel instead of calling cloud's opt-in API directly

POST /api/v1/fleet/local-surface (cloud PR #2623) is session-cookie-gated
(requireSessionAuth, org-owner-only) — same constraint as
POST /api/v1/fleet/enrollment-tokens. A CLI's workspace bearer token
resolves server-side to source: "token", never "session", so it can never
satisfy that gate. This mirrors the existing enrollment-token flow exactly:
the CLI can redeem something minted via a real browser session but can't
mint/opt-in itself. --channel carries the value from wherever Cloud's
dashboard surfaces that opt-in action, cached locally after the first run.

Known gap (explicitly out of scope per the plan)

Local secrets/credentials are not mirrored — a --mode dev persona has no
Resource.<Name>.value equivalent. local-surface warns when a persona
declares integrations; the safe target today is cron/timer-only or
webhook-shape-only personas.

Test plan

  • packages/local-surface: 7/7 unit tests (envelope mapping incl.
    provider/eventType-missing drop-and-ack, lazy single-launch + per-message
    stdin writes, missing-token rejection)
  • packages/cli: 10/10 new local-surface-command tests (arg parsing,
    enrollment reuse/redeem, channel cache round-trip, missing-enrollment
    and missing-channel error messages, --json mode, integration-gap
    warning)
  • packages/deploy: 220/220 (no regressions from the ModeLaunchHandle.write
    addition)
  • Full repo pnpm -r build and pnpm -r test --no-bail: all 17 relevant
    packages green; the only failures are 34 pre-existing local-personas.test.js
    failures in packages/cli, verified present on a clean origin/main
    checkout before this branch existed (environment-dependent, unrelated to
    this change)
  • Root pnpm run typecheck (aggregate tsconfig): clean
  • Real end-to-end (relay node up against a live relaycast + a real
    provider webhook) — deferred to the companion cloud/relaycast PRs
    landing; not exercisable from this repo alone

🤖 Generated with Claude Code