diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b4f5137..d41d313 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -150,11 +150,12 @@ jobs: - name: Build packages run: | - npm run build --workspace=packages/core - npm run build --workspace=packages/cli + # Primitives first: core imports their built types/dist. npm run build --workspace=packages/github-primitive npm run build --workspace=packages/slack-primitive npm run build --workspace=packages/browser-primitive + npm run build --workspace=packages/core + npm run build --workspace=packages/cli - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -336,17 +337,17 @@ jobs: ### Packages - `@relayflows/core@${{ needs.build.outputs.new_version }}` - `@relayflows/cli@${{ needs.build.outputs.new_version }}` - - `@agent-relay/github-primitive@${{ needs.build.outputs.new_version }}` - - `@agent-relay/slack-primitive@${{ needs.build.outputs.new_version }}` - - `@agent-relay/browser-primitive@${{ needs.build.outputs.new_version }}` + - `@relayflows/github-primitive@${{ needs.build.outputs.new_version }}` + - `@relayflows/slack-primitive@${{ needs.build.outputs.new_version }}` + - `@relayflows/browser-primitive@${{ needs.build.outputs.new_version }}` ### Install ```bash npm install @relayflows/core@${{ needs.build.outputs.new_version }} npm install -g @relayflows/cli@${{ needs.build.outputs.new_version }} - npm install @agent-relay/github-primitive@${{ needs.build.outputs.new_version }} - npm install @agent-relay/slack-primitive@${{ needs.build.outputs.new_version }} - npm install @agent-relay/browser-primitive@${{ needs.build.outputs.new_version }} + npm install @relayflows/github-primitive@${{ needs.build.outputs.new_version }} + npm install @relayflows/slack-primitive@${{ needs.build.outputs.new_version }} + npm install @relayflows/browser-primitive@${{ needs.build.outputs.new_version }} ``` ### Publish Details diff --git a/.gitignore b/.gitignore index 765e728..d100668 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ /.npm-cache /packages/cli/.npm-cache /packages/core/.npm-cache +/packages/*/.npm-cache +# Workflow runtime scratch (step outputs, worker logs, team state) — not source +**/.agent-relay/ diff --git a/docs/sdk-v8-migration-plan.md b/docs/sdk-v8-migration-plan.md new file mode 100644 index 0000000..36e3a4e --- /dev/null +++ b/docs/sdk-v8-migration-plan.md @@ -0,0 +1,181 @@ +# `@relayflows/core` — `@agent-relay/sdk` v7 → v8 migration plan + +> Status: **proposed, not started.** This plan was produced from a read-only +> investigation of `packages/core` and the relay monorepo HEAD (all `@agent-relay/*` +> at `8.0.4` source / `8.1.2` npm). Review before any code is written. + +## 1. Why core doesn't compile today + +`packages/core` straddles two incompatible majors of the relay SDK family: + +- Its **RelayAuth** code (`provisioner.ts`, `runner.ts`) imports symbols + (`mintAgentToken`, `resolveAgentPermissions`, `createLocalJwksKeyPair`, + `compileAgentScopes`, …) that **only exist in `@agent-relay/cloud@8.x`**. +- Its **agent-spawn + broker-event** code (`runner.ts`) is written against the + **`@agent-relay/sdk@7.x` API**, all of which was removed/redesigned in 8.x. + +cloud + sdk move in lockstep, so no published version set satisfies both. Bumping +`@agent-relay/{cloud,config,sdk}` to `^8.0.4` (done) cleared all 16 cloud errors and +surfaced the real work: **82 sdk errors, 80 in `runner.ts`.** + +## 2. The key realization — the broker surface became `HarnessDriverClient` + +> **REVISED after tracing the v8 source.** An earlier draft assumed the runner +> had to be re-modelled onto the SDK's *messaging* event system (`message.created`, +> session/status events). That is **not** the right target and would have been a +> large rewrite. The correct, far smaller target: + +v7 `AgentRelay` was one object that did **both** messaging **and** local +process/PTY spawning + broker lifecycle. v8 split those: +- **Messaging** → `@agent-relay/sdk` (`AgentRelay`). **Core doesn't use this for the + broker** — it does messaging via `@relaycast/sdk` already. Every `this.relay.*` + call in `runner.ts` is a *broker* concern (verified: `addListener`×7, `spawnPty`, + `onBrokerStderr`, `listAgents`/`listAgentsRaw`, `human`). +- **Broker / PTY / lifecycle** → `@agent-relay/harness-driver` `HarnessDriverClient`. + +`HarnessDriverClient` is essentially what v7 `AgentRelay`'s broker half became, and +it preserves core's model **almost verbatim**: + +| v7 `AgentRelay` (broker) | v8 `HarnessDriverClient` | Match | +|---|---|---| +| `addListener('workerOutput'\|'messageReceived'\|'agentSpawned'\|'agentReleased'\|'agentExited'\|'agentIdle'\|'deliveryUpdate', …)` | same `addListener(event, handler)` — **same event names** | ✅ verbatim | +| `workerOutput {name, chunk}` / `messageReceived {eventId,from,to,text,threadId}` / `agentIdle {name,idleSecs}` payloads | `WorkerOutputPayload` / `DriverMessage` / `AgentIdlePayload` — **same field names** | ✅ verbatim | +| `onBrokerStderr(cb)` | `onStderr` **construction option** | ✅ moved to ctor | +| `spawnPty(opts)` | `spawnPty(SpawnPtyInput)` — same field names | ✅ | +| `listAgents()` / `listAgentsRaw()` | `listAgents(): ListAgent[]` (one method) | ✅ | +| `shutdown()` | `shutdown()` | ✅ | +| `BrokerEvent` re-emit | `onEvent((e: BrokerEvent)=>…)` — **same `BrokerEvent` union/kinds** | ✅ | +| spawn return = rich `Agent` (`.release()`,`.waitForExit()`,`.waitForIdle()`,`.send()`,`.exitCode`) | `SpawnAgentResult` = plain `{name,runtime,sessionId?,pid?}` | ⚠️ **needs adapter** | +| `relay.human({name}).sendMessage(...)` (idle nudge) | not on driver — route through relaycast messaging or `createHuman` | ⚠️ small | + +CLI utils (`getCliDefinition`/`resolveCliSync`/`resolveSpawnPolicy`) and `stripAnsi` +are gone from the SDK → vendored into `core/src/cli-registry.ts` / `strip-ansi` pkg +(done in WS-0). + +So the migration is **swap `this.relay: AgentRelay` → a `HarnessDriverClient`**, with +two real pieces of work: (1) an agent-handle adapter, (2) the idle-nudge sender. + +## 3. Blockers / decisions + +1. **`@agent-relay/harnesses` — ✅ RESOLVED.** It is now published + (`@agent-relay/harnesses@8.1.2`, confirmed resolvable on `registry.npmjs.org`). + **Decision: core depends on `@agent-relay/harnesses` directly** (option a). It + provides the `claude`/`codex`/`gemini`/`opencode` PTY harnesses and `createHuman`. +2. **Direction confirmation.** Two coherent end-states: + - **Forward (recommended):** migrate core's sdk usage to 8.x so it matches the + cloud 8.x RelayAuth it already uses. Aligns with relay HEAD. + - **Backward:** pin sdk+cloud to 7.1.1 and **remove the RelayAuth/provisioner + feature** (scoped agent-token minting). Only viable if that feature is + droppable — it appears intentional, so this is likely not acceptable. +3. **Broker process ownership — ✅ largely addressed.** In v8 the broker is owned + by the harness layer: `harness.create({ relay })` starts/attaches a `BrokerDriver` + bound to the relay's workspace (`harnesses/src/broker-binding.ts`). So core's + manual `brokerName` / `relay.shutdown()` / broker bootstrap mostly **goes away**. + - **Remaining open question:** `onBrokerStderr` (broker diagnostic lines) has no + obvious 1:1 on the `BrokerDriver` surface. Decide whether core still needs raw + broker stderr, or whether session/status events suffice. Low priority — affects + only diagnostic logging at `runner.ts:3336`. + +## 4. Workstreams (assuming "forward" + a resolved harness source) + +Ordered to minimize churn; each is independently compilable-checkable. + +### WS-0 — Utilities (small, do first) +- `stripAnsi`: add `strip-ansi` dependency (or a 3-line local helper); update + `runner.ts:28`, `channel-messenger.ts:1`, and static wrapper `runner.ts:7712`. +- `getCliDefinition` / `resolveCliSync` / `resolveSpawnPolicy`: these were CLI- + registry helpers (`runner.ts:31,32,30`, `process-spawner.ts:4,5`, + `proxy-env.ts:1`). Confirm whether equivalents exist in `@agent-relay/config` / + `harness-driver`; if not, **vendor** them into a new `core/src/cli-registry.ts` + (they're self-contained PATH/known-dir resolution + arg/env policy). +- Move `BrokerEvent` / `AgentSpawner` type imports to `@agent-relay/harness-driver` + (`runner.ts:29,126`). + +### WS-1 — AgentRelay construction & options (`runner.ts:3145`, types in `builder.ts:4`, `run.ts:1`) +- v7 `new AgentRelay({ brokerName, channels, env, requestTimeoutMs })` → + v8 `new AgentRelay({ workspaceKey, baseUrl, retryPolicy, harness })`. +- `env` / `brokerName` / `requestTimeoutMs` are gone from options. Decide where + each goes: `env` → harness-driver `SpawnRuntimeInput`; `brokerName` → broker + transport config; `requestTimeoutMs` → `retryPolicy`. +- Update the `AgentRelayOptions` type references in `builder.ts`, `run.ts`, + `runner.ts:481`. + +### WS-2 — Agent spawning (`runner.ts:444-456`, `6739`, `6742`, `7200`) +Grounded against the now-available `@agent-relay/harnesses` API: +- `getWorkflowSdkSpawner()` switch over `relay.claude/.codex/.gemini/.opencode` → + lookup into `{ claude, codex, gemini, opencode }` imported from + `@agent-relay/harnesses` (each is a `PtyHarness`). +- `sdkSpawner.spawn(opts)` / `relay.spawnPty(opts)` → + `await harness.create(input)` where + `input: HarnessCreateInput = { name, model, args, task, cwd, env, channels, relay }` + and the returned `HarnessAgent` (extends `RelayAgentClient`: `id`, `name`, + `handle`, `sendMessage`, plus `cli`/`runtime`/`definition`) replaces the v7 + `Agent` handle. **The current `spawnOptions` map ~1:1 onto `HarnessCreateInput`.** +- `harness.create({ relay })` internally attaches/starts the broker for the + workspace — remove core's manual broker bootstrap. +- `relay.human({ name })` (idle-nudge sender, `7200`) → + `createHuman({ relay, name })` from `@agent-relay/harnesses`. +- Replace the `Agent` type (`runner.ts:126`, fields at `320/332/507`) with + `HarnessAgent` / `RelayAgentClient`. + +### WS-3 — Event stream rewrite (the bulk: `runner.ts:3158-3343`) +Re-model the 7 listeners onto the v8 surface. Mapping target per listener: + +| v7 listener (fields) | v8 source | +|---|---| +| `workerOutput` `{name, chunk}` (3158) | session event `terminal.output`/`transcript.chunk` → `event.text`/`chunk` | +| `messageReceived` `{eventId,from,to,text,threadId}` (3206) | `addListener('message.created')` → `event.message.text`, `event.envelope.from/to`, `event.message.parentId` | +| `agentSpawned` `{name,runtime}` (3254) | session `status.active` / spawn ack from driver runtime | +| `agentReleased` `{name}` (3272) | driver `release()` / session `status.offline` | +| `agentExited` `{name,exitCode,exitSignal}` (3285) | session `command.completed` `{exitCode}` / `status.offline` | +| `deliveryUpdate` (3305) | `addListener` delivery events (`deliveries` surface) | +| `agentIdle` `{name,idleSecs}` (3311) | `agent.status.becomes('idle')` predicate (note: `idleSecs` may be unavailable — see open question) | +| `onBrokerStderr(line)` (3336) | no direct equivalent — see Blocker #3 | + +- Preserve the internal `BrokerEvent` re-emission contract (the + `{type:'broker:event', runId, event}` shape consumed by the CLI) by **adapting** + v8 events into the existing `BrokerEvent` union, so downstream (`WorkflowEvent`, + CLI logging) is unchanged. This keeps the blast radius inside `runner.ts`. + +### WS-4 — Agent listing & teardown (`runner.ts:3486`, `5234`, `5250`, `6810`) +- `relay.listAgents()` / `listAgentsRaw()` → `relay.messaging.agents.list()`. + Rework the stale-retry-agent cleanup + the wait-for-cleanup poll accordingly. +- `relay.shutdown()` → broker/transport lifecycle teardown (depends on Blocker #3). + +### WS-5 — Implicit-`any` cleanup (9 × TS7006, `runner.ts:2437…7077`) +- Trivial once the surrounding types resolve; annotate the `.map`/callback params. + Deferred to last because the types they touch change in WS-2/WS-3. + +## 5. Open questions for the SDK owners +- Is per-agent **idle duration** (`idleSecs`) still observable, or only a boolean + `idle` status? (Affects the idle-nudge debounce at `runner.ts:3311-3328`.) +- Is there a supported way to get **broker stderr** / diagnostics off the + `BrokerDriver` in v8, or should core drop raw broker-stderr logging? +- ~~Will `@agent-relay/harnesses` be published?~~ ✅ Published at `8.1.2`. + +## 6. Suggested sequencing & checkpoints +1. Resolve Blockers #1–#3 (decisions). +2. WS-0 (utils) → typecheck: cloud + util errors gone. +3. WS-1 (construction) → typecheck. +4. WS-2 (spawn) → typecheck + a smoke spawn of one CLI. +5. WS-3 (events) → typecheck + observe a real run's event stream. +6. WS-4, WS-5 → full `tsc` clean. +7. Run `packages/core` test suite (`vitest`) + one end-to-end workflow via the CLI. + +## 7. Effort estimate (REVISED DOWN) +The `HarnessDriverClient` discovery collapses most of the original risk: +- **WS-0 — done & verified** (85→77 errors, SDK unified on 8.x, utils vendored). +- **WS-1/WS-3** — largely a `this.relay → HarnessDriverClient` swap; listeners and + payloads are verbatim. The only event remap is `agentExited` exit code/signal + (source it from the `agent_exited` `BrokerEvent` via `onEvent`, since the named + `agentExited` payload is a method-less `DriverAgent`). +- **WS-2** — the one real build: a `WorkflowAgentHandle` adapter wrapping + `SpawnAgentResult` + the driver client to restore `.release()` / `.waitForExit()` + / `.waitForIdle()` / `.exitCode` (driven off the driver event bus), plus routing + the idle-nudge sender. Unify the two spawn paths (`getWorkflowSdkSpawner().spawn()` + and `spawnPty()`) into one `spawnPty()` call. +- **WS-4/5** — `listAgents()` + `client.release(name)` (note: `ListAgent` is data, + has no `.release()`), `shutdown()`, implicit-`any`s. + +Revised estimate: **~1–1.5 days**, the bulk in the WS-2 handle adapter and a +real-run validation of the event stream + spawn lifecycle. diff --git a/package-lock.json b/package-lock.json index 5e4f6de..2d9ecb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,10 @@ "version": "0.1.0", "workspaces": [ "packages/core", - "packages/cli" + "packages/cli", + "packages/github-primitive", + "packages/slack-primitive", + "packages/browser-primitive" ], "devDependencies": { "@types/node": "^22.10.7", @@ -18,9 +21,9 @@ } }, "node_modules/@agent-relay/broker-darwin-arm64": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-7.1.1.tgz", - "integrity": "sha512-9+2A2pAG4FbuMN4tZoTLBGH8BFc7PxbhbHw0s4PxfiytS4IFLDmP/DMSzK1UE947OLb8IVYehJvQh6XbKDfmEA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-8.2.0.tgz", + "integrity": "sha512-7TTOdB9tHZXpQ2xGG7XbvUYbr3J/YPCClM9PAANpPG8HswsDdfFl3jF7oIaFE/w61D56Yo1/7RUFAv2dwQZCsA==", "cpu": [ "arm64" ], @@ -31,9 +34,9 @@ ] }, "node_modules/@agent-relay/broker-darwin-x64": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-x64/-/broker-darwin-x64-7.1.1.tgz", - "integrity": "sha512-PnEietNBe4WB6kHvNRM/a/Zq0t01N+LFOJ1rZpHn+TGJRWg1dnqEXTywXPjzZk5vQIf9edq/EHiDrxnOe3c2Ww==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-x64/-/broker-darwin-x64-8.2.0.tgz", + "integrity": "sha512-Dvw0NvZv2j/QdPuw/eIoKP22UCCBIWwUOMh49J/FTq4UexuR3iOzl6B8Ju9z+NVEgvtpKnWZPHeq67TR3hLoTg==", "cpu": [ "x64" ], @@ -44,9 +47,9 @@ ] }, "node_modules/@agent-relay/broker-linux-arm64": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-arm64/-/broker-linux-arm64-7.1.1.tgz", - "integrity": "sha512-vOQ1gvj47KDYRZSk3FsC5rtNiGXc3QYvo3TMAuw4WQQZ3TPs7DpefQIIpo7xIrhq4hNTpJOHSzPzxjftbQpI1Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-arm64/-/broker-linux-arm64-8.2.0.tgz", + "integrity": "sha512-AzRrgqx140ZHfN1GqJF5ErQe9tLx13AeNrOI8e4qfmU1wNT/qwJ3+J9/ytIqH3o8r8DhGZbKzxZCvkFn5pI+YA==", "cpu": [ "arm64" ], @@ -57,9 +60,9 @@ ] }, "node_modules/@agent-relay/broker-linux-x64": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-x64/-/broker-linux-x64-7.1.1.tgz", - "integrity": "sha512-D1z4r5FV0Zl7+Tau5T0kUyNkoRD8qqC4CtN1HiOh2v8ZMP4DDQ2MUS+racoe/0lil+ghiIyVTFnYwpcv6wWoGw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-x64/-/broker-linux-x64-8.2.0.tgz", + "integrity": "sha512-+Po3Cw/KhGQgIH1Kb4biJyrNOJnd+HrvA5zs9EGAqwrSFWeiaoLKhe8sASxMz2yGNB8SPiinL27yx83sITYNaw==", "cpu": [ "x64" ], @@ -70,9 +73,9 @@ ] }, "node_modules/@agent-relay/broker-win32-x64": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-win32-x64/-/broker-win32-x64-7.1.1.tgz", - "integrity": "sha512-LTDl2Z3CZIC6PUiHBlw8cWFfpCDR33kS99O4dzxkJPFKCT5ehDNN5OZD06pR1RcNp2ke30N1irjz1Tw/DVVQAA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-win32-x64/-/broker-win32-x64-8.2.0.tgz", + "integrity": "sha512-lBiy4K8HyED8er48+wY6R1Beap02HR77y0/mn2eahqaiLA+7l16ymwNl07YgtVws3zFQpHkbreHsAmiyc5/LhA==", "cpu": [ "x64" ], @@ -82,24 +85,12 @@ "win32" ] }, - "node_modules/@agent-relay/browser-primitive": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/browser-primitive/-/browser-primitive-7.1.1.tgz", - "integrity": "sha512-d9Uvhhsyd7cpK7iloiKqECmGjqBVANZwBkgjf6AGXstu87RMgarGOxcybOX2DGiWkqwLZnh0xIcZT4y1qRYNhg==", - "dependencies": { - "@agent-relay/sdk": "7.1.1", - "playwright": "^1.51.1" - }, - "bin": { - "agent-relay-browser-mcp": "dist/mcp-server.js" - } - }, "node_modules/@agent-relay/cloud": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/cloud/-/cloud-7.1.1.tgz", - "integrity": "sha512-Embz+WUM0WpnAsRt8KRCYrrVJT0sjmSxGU6fKLqk6c8kpvmNF7gHdB+Rz89H9j9NVrzsZGTnGI44ZMTOADIbcA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/cloud/-/cloud-8.2.0.tgz", + "integrity": "sha512-2wipup9cWCt8nus4sIRAfxpuGu/JFzbpRqq+PHeyo/TcS8SaN5TCsb53jpXqiXlMik4lLoLP+G1KuCrJqTN8yw==", "dependencies": { - "@agent-relay/config": "7.1.1", + "@agent-relay/config": "8.2.0", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -109,134 +100,75 @@ } }, "node_modules/@agent-relay/config": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-7.1.1.tgz", - "integrity": "sha512-WP/37B4f0nZ8z4IhQ5pGn6g60TrFyoYoMBWdeuRP5F7tb/OW9YZKHPmwmtUBmmB9+0/cVIqBQ5SKp9xIcET4zg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-8.2.0.tgz", + "integrity": "sha512-ZJzTXrhOaCsCd5ssMH4vzQyqhE8QFiNzUBc8KQLzrA7/64PKuUouXP4mP2cxdbb/cOg7h8MP6gAid80mbvsMZQ==", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" } }, - "node_modules/@agent-relay/github-primitive": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/github-primitive/-/github-primitive-7.1.1.tgz", - "integrity": "sha512-1nVP++ARqXafLzBfN1gqTQctgBjJtd4exhImbVZr3HVvEw2wbeyBgEZJByy6srIfnlTju3imlUNdVMdZld9NcQ==", + "node_modules/@agent-relay/harness-driver": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/harness-driver/-/harness-driver-8.2.0.tgz", + "integrity": "sha512-rsTHO4Yi/zXnzyLNj6T2Lxp7LDWmYEbiRKzLQYVahnAG0ddLQuS1DyCtWSAuu48tXYu8mOwnXdWt4bXKFyplHQ==", + "license": "Apache-2.0", "dependencies": { - "@agent-relay/workflow-types": "7.1.1" - } - }, - "node_modules/@agent-relay/sdk": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-7.1.1.tgz", - "integrity": "sha512-WXOr6ZtCGIXDYAkU00f7o0V8fBBjO31cuEVgVWGEmArMAf8vSpA7FqA97bYFZzuUiFHV4S6DgNFBq2zMM/0w4w==", - "dependencies": { - "@agent-relay/cloud": "7.1.1", - "@agent-relay/config": "7.1.1", - "@agent-relay/github-primitive": "7.1.1", - "@agent-relay/slack-primitive": "7.1.1", - "@agent-relay/workflow-types": "7.1.1", - "@agentworkforce/harness-kit": "^0.11.0", - "@agentworkforce/workload-router": "^0.11.0", - "@relaycast/sdk": "^1.1.0", - "@relayfile/sdk": ">=0.1.2 <1", - "@sinclair/typebox": "^0.34.48", - "agent-trajectories": "^0.5.4", - "chalk": "^4.1.2", - "ignore": "^7.0.5", - "listr2": "^10.2.1", - "tar": "^7.5.10", + "@agent-relay/sdk": "8.2.0", "ws": "^8.18.3", - "yaml": "^2.7.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.23.1" + "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "7.1.1", - "@agent-relay/broker-darwin-x64": "7.1.1", - "@agent-relay/broker-linux-arm64": "7.1.1", - "@agent-relay/broker-linux-x64": "7.1.1", - "@agent-relay/broker-win32-x64": "7.1.1" - }, - "peerDependencies": { - "@agent-relay/credential-proxy": "7.1.1", - "@anthropic-ai/claude-agent-sdk": ">=0.1.0", - "@google/adk": ">=0.5.0", - "@langchain/langgraph": ">=1.2.0", - "@mariozechner/pi-coding-agent": ">=0.50.0", - "@openai/agents": ">=0.7.0", - "ai": ">=5.0.0", - "crewai": ">=1.0.0" - }, - "peerDependenciesMeta": { - "@agent-relay/credential-proxy": { - "optional": true - }, - "@anthropic-ai/claude-agent-sdk": { - "optional": true - }, - "@google/adk": { - "optional": true - }, - "@langchain/langgraph": { - "optional": true - }, - "@mariozechner/pi-coding-agent": { - "optional": true - }, - "@openai/agents": { - "optional": true - }, - "ai": { - "optional": true - }, - "crewai": { - "optional": true - } + "@agent-relay/broker-darwin-arm64": "8.2.0", + "@agent-relay/broker-darwin-x64": "8.2.0", + "@agent-relay/broker-linux-arm64": "8.2.0", + "@agent-relay/broker-linux-x64": "8.2.0", + "@agent-relay/broker-win32-x64": "8.2.0" } }, - "node_modules/@agent-relay/sdk/node_modules/agent-trajectories": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.9.tgz", - "integrity": "sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==", - "license": "MIT", + "node_modules/@agent-relay/harnesses": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/harnesses/-/harnesses-8.2.0.tgz", + "integrity": "sha512-b37CJgdzjTxnaPxcnqMM1TU1N/eSDUUo+tf+cD0attkbbajcKpxbCfb/8JpBzomoEVEDJ4Vdo+xxK2P3CMdjLQ==", + "license": "Apache-2.0", "dependencies": { - "@clack/prompts": "^0.7.0", - "commander": "^12.0.0", - "zod": "^3.23.0" - }, - "bin": { - "trail": "dist/cli/index.js" - }, - "engines": { - "node": ">=20.0.0" + "@agent-relay/harness-driver": "8.2.0", + "@agent-relay/sdk": "8.2.0" } }, - "node_modules/@agent-relay/slack-primitive": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/slack-primitive/-/slack-primitive-7.1.1.tgz", - "integrity": "sha512-uJc2HFC+kMi74nZINGpkbLu97eiChQLcx5XqMu2ZFBhtff/DBxL4G/Drq35WNAY2BqVBByly974iXG6UGLFdKA==", + "node_modules/@agent-relay/sdk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-8.2.0.tgz", + "integrity": "sha512-7j6yI81dxQvhuHBHRY0BbNSyos+NRVRCu6rVJ0iSk5mmZ7eMWNZLu5kDF3kIOkk8Qv+DED5FVQ8EbP5K3tIc+Q==", "dependencies": { - "@agent-relay/workflow-types": "7.1.1", - "@slack/web-api": "^7.16.0" + "@relaycast/sdk": "^2.5.1" } }, - "node_modules/@agent-relay/workflow-types": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@agent-relay/workflow-types/-/workflow-types-7.1.1.tgz", - "integrity": "sha512-tR2LbLD4Z/cO9vKOQ77F/Z0LKgzD8+IefcIR3zz0WNfkAuVoA/WEqc422OBlUoucAi0UalIsnKIVZZyTjx0Bgg==" + "node_modules/@agent-relay/sdk/node_modules/@relaycast/sdk": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-2.5.1.tgz", + "integrity": "sha512-QnIFYgeKIFlisNF0EJyJNJkn4YvWVbsU/0Il9TWp5USvt79jkd9I1kFbg409+ySi9UwrKUYt+e6HfIwewIXidg==", + "dependencies": { + "@relaycast/types": "2.5.1", + "zod": "^4.3.6" + } }, - "node_modules/@agentworkforce/harness-kit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@agentworkforce/harness-kit/-/harness-kit-0.11.0.tgz", - "integrity": "sha512-CtW9P0pVm0j5R+kl7OaWMkPz7akYZqJNLmQ8k1m5Ony7NIfxJKuGiTBH9kcg+6vQ7fUtnfkoa34wt3y/pEh2QQ==", + "node_modules/@agent-relay/sdk/node_modules/@relaycast/types": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@relaycast/types/-/types-2.5.1.tgz", + "integrity": "sha512-SUZhhzND7o8Y65KwzYOzymkupoZLMoNvj2BZONrXMf3t2cfYdxGCox4IMUPY8ULzFaITSoG4XSMgoA+fAAIVag==", "dependencies": { - "@agentworkforce/workload-router": "0.11.0" + "zod": "^4.3.6" } }, - "node_modules/@agentworkforce/workload-router": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@agentworkforce/workload-router/-/workload-router-0.11.0.tgz", - "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==" + "node_modules/@agent-relay/sdk/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", @@ -1430,6 +1362,10 @@ "node": ">=18" } }, + "node_modules/@relayflows/browser-primitive": { + "resolved": "packages/browser-primitive", + "link": true + }, "node_modules/@relayflows/cli": { "resolved": "packages/cli", "link": true @@ -1438,6 +1374,14 @@ "resolved": "packages/core", "link": true }, + "node_modules/@relayflows/github-primitive": { + "resolved": "packages/github-primitive", + "link": true + }, + "node_modules/@relayflows/slack-primitive": { + "resolved": "packages/slack-primitive", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -2365,6 +2309,24 @@ "node": ">=18.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3015,6 +2977,24 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -3239,6 +3219,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/listr2": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", @@ -3557,6 +3544,19 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", @@ -3855,6 +3855,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -3909,6 +3922,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -4245,45 +4275,800 @@ "zod": "^3.25.28 || ^4" } }, - "packages/cli": { - "name": "@relayflows/cli", + "packages/browser-primitive": { + "name": "@relayflows/browser-primitive", "version": "0.1.0", "dependencies": { - "@relayflows/core": "0.1.0", - "commander": "^12.1.0" + "@agent-relay/sdk": "^8.2.0", + "playwright": "^1.51.1" }, "bin": { - "relayflows": "dist/cli.js" + "agent-relay-browser-mcp": "dist/mcp-server.js" }, "devDependencies": { - "@types/node": "^22.10.7", - "typescript": "^5.7.3" + "@types/node": "^22.19.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" } }, - "packages/core": { - "name": "@relayflows/core", - "version": "0.1.0", + "packages/browser-primitive/node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@agent-relay/browser-primitive": "^7.1.1", - "@agent-relay/cloud": "^7.1.1", - "@agent-relay/config": "^7.1.1", - "@agent-relay/github-primitive": "^7.1.1", - "@agent-relay/sdk": "^7.1.1", - "@agent-relay/slack-primitive": "^7.1.1", - "@relaycast/sdk": "^1.1.0", - "@relayfile/sdk": "^0.8.0", - "@sinclair/typebox": "^0.34.48", - "agent-trajectories": "^0.6.0", - "chalk": "^4.1.2", - "ignore": "^7.0.5", - "listr2": "^10.2.1", - "yaml": "^2.7.0", - "zod": "^3.23.8" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, - "devDependencies": { - "@types/node": "^22.10.7", - "typescript": "^5.7.3", - "vitest": "^2.1.9" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/browser-primitive/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/browser-primitive/node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/browser-primitive/node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/browser-primitive/node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/browser-primitive/node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/browser-primitive/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "packages/browser-primitive/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/browser-primitive/node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/browser-primitive/node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/browser-primitive/node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/browser-primitive/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/cli": { + "name": "@relayflows/cli", + "version": "0.1.0", + "dependencies": { + "@relayflows/core": "0.1.0", + "commander": "^12.1.0" + }, + "bin": { + "relayflows": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } + }, + "packages/core": { + "name": "@relayflows/core", + "version": "0.1.0", + "dependencies": { + "@agent-relay/cloud": "^8.2.0", + "@agent-relay/config": "^8.2.0", + "@agent-relay/harness-driver": "^8.2.0", + "@agent-relay/harnesses": "^8.2.0", + "@agent-relay/sdk": "^8.2.0", + "@relaycast/sdk": "^1.1.0", + "@relayfile/sdk": "^0.8.0", + "@relayflows/browser-primitive": "0.1.0", + "@relayflows/github-primitive": "0.1.0", + "@relayflows/slack-primitive": "0.1.0", + "@sinclair/typebox": "^0.34.48", + "agent-trajectories": "^0.6.0", + "chalk": "^4.1.2", + "ignore": "^7.0.5", + "listr2": "^10.2.1", + "strip-ansi": "^7.2.0", + "yaml": "^2.7.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "typescript": "^5.7.3", + "vitest": "^2.1.9" + } + }, + "packages/github-primitive": { + "name": "@relayflows/github-primitive", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.19.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } + }, + "packages/github-primitive/node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/github-primitive/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/github-primitive/node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/github-primitive/node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/github-primitive/node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/github-primitive/node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/github-primitive/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "packages/github-primitive/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/github-primitive/node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/github-primitive/node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/github-primitive/node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/github-primitive/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/slack-primitive": { + "name": "@relayflows/slack-primitive", + "version": "0.1.0", + "dependencies": { + "@slack/web-api": "^7.16.0" + }, + "devDependencies": { + "@relayflows/github-primitive": "0.1.0", + "@types/node": "^22.19.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } + }, + "packages/slack-primitive/node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/slack-primitive/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/slack-primitive/node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/slack-primitive/node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/slack-primitive/node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/slack-primitive/node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/slack-primitive/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "packages/slack-primitive/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/slack-primitive/node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/slack-primitive/node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/slack-primitive/node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/slack-primitive/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } } } diff --git a/packages/browser-primitive/package.json b/packages/browser-primitive/package.json index 787c97e..46ab1b5 100644 --- a/packages/browser-primitive/package.json +++ b/packages/browser-primitive/package.json @@ -1,6 +1,6 @@ { - "name": "@agent-relay/browser-primitive", - "version": "7.1.1", + "name": "@relayflows/browser-primitive", + "version": "0.1.0", "description": "Browser automation workflow primitive for Agent Relay", "type": "module", "main": "dist/index.js", @@ -33,7 +33,7 @@ "test:watch": "vitest" }, "dependencies": { - "@agent-relay/sdk": "7.1.1", + "@agent-relay/sdk": "^8.2.0", "playwright": "^1.51.1" }, "devDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index cb7d499..2a64be5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,19 +52,22 @@ "directory": "packages/core" }, "dependencies": { - "@agent-relay/browser-primitive": "^7.1.1", - "@agent-relay/cloud": "^7.1.1", - "@agent-relay/config": "^7.1.1", - "@agent-relay/github-primitive": "^7.1.1", - "@agent-relay/sdk": "^7.1.1", - "@agent-relay/slack-primitive": "^7.1.1", + "@agent-relay/cloud": "^8.2.0", + "@agent-relay/config": "^8.2.0", + "@agent-relay/harness-driver": "^8.2.0", + "@agent-relay/harnesses": "^8.2.0", + "@agent-relay/sdk": "^8.2.0", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": "^0.8.0", + "@relayflows/browser-primitive": "0.1.0", + "@relayflows/github-primitive": "0.1.0", + "@relayflows/slack-primitive": "0.1.0", "@sinclair/typebox": "^0.34.48", "agent-trajectories": "^0.6.0", "chalk": "^4.1.2", "ignore": "^7.0.5", "listr2": "^10.2.1", + "strip-ansi": "^7.2.0", "yaml": "^2.7.0", "zod": "^3.23.8" }, diff --git a/packages/core/src/__tests__/budget-enforcement.test.ts b/packages/core/src/__tests__/budget-enforcement.test.ts index 7b7fd9a..3469106 100644 --- a/packages/core/src/__tests__/budget-enforcement.test.ts +++ b/packages/core/src/__tests__/budget-enforcement.test.ts @@ -99,12 +99,12 @@ vi.mock('node:child_process', async () => { const mockRelayInstance = { spawnPty: vi.fn(), - human: vi.fn().mockReturnValue({ sendMessage: vi.fn().mockResolvedValue(undefined) }), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - listAgentsRaw: vi.fn().mockResolvedValue([]), + onEvent: vi.fn(() => () => {}), + connectEvents: vi.fn(), listAgents: vi.fn().mockResolvedValue([]), - addListener: vi.fn(() => () => {}), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), }; vi.mock('@relaycast/sdk', () => ({ @@ -112,9 +112,13 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: class RelayError extends Error {}, })); -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); const { WorkflowRunner } = await import('../runner.js'); @@ -274,7 +278,7 @@ beforeEach(() => { collectorResultsByCwd = new Map(); activeRunner = undefined; mockRelayInstance.shutdown.mockResolvedValue(undefined); - mockRelayInstance.onBrokerStderr.mockReturnValue(() => {}); + mockRelayInstance.onEvent.mockReturnValue(() => {}); mockRelayInstance.listAgents.mockResolvedValue([]); }); diff --git a/packages/core/src/__tests__/completion-pipeline.test.ts b/packages/core/src/__tests__/completion-pipeline.test.ts index bff76a8..86df7fa 100644 --- a/packages/core/src/__tests__/completion-pipeline.test.ts +++ b/packages/core/src/__tests__/completion-pipeline.test.ts @@ -65,7 +65,7 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: MockRelayError, })); -// ── Mock AgentRelay ────────────────────────────────────────────────────────── +// ── Mock HarnessDriverClient ───────────────────────────────────────────────── let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; @@ -95,26 +95,25 @@ vi.mock('node:child_process', async () => { }; }); -const mockAgent = { - name: 'test-agent-abc', - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return waitForIdleFn; - }, - release: vi.fn().mockResolvedValue(undefined), -}; - -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; - function never(): Promise { return new Promise(() => {}); } +// Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, but +// driven by the test's waitForExitFn/waitForIdleFn. waitForExit/waitForIdle +// return `{ reason }` objects (the runner reads `.reason`), not raw strings. +function makeMockHandle(name: string) { + return { + name, + runtime: 'pty' as const, + exitCode: undefined as number | undefined, + exitSignal: undefined as string | undefined, + waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })), + waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), + release: vi.fn().mockResolvedValue({ name }), + }; +} + const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; task?: string }) => { const queued = mockSpawnOutputs.shift(); const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim(); @@ -128,45 +127,71 @@ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; tas : 'STEP_COMPLETE:unknown\n'); queueMicrotask(() => { - emitRelayEvent('workerOutput', { name, chunk: output }); + emitMockEvent('workerOutput', { name, chunk: output }); }); - return { ...mockAgent, name }; + return makeMockHandle(name); }; -const relayListeners = new Map void>>(); - -function emitRelayEvent(event: string, payload: any) { - const set = relayListeners.get(event); - if (!set) return; - for (const fn of set) fn(payload); +// The runner consumes broker events via `client.onEvent(BrokerEvent)`. Tests +// still call `emitMockEvent('workerOutput'|'messageReceived'|...)`; translate +// those legacy named events into the BrokerEvent shapes the runner switches on. +const eventListeners = new Set<(event: any) => void>(); +function emitMockEvent(event: string, payload: any = {}): void { + let broker: any; + switch (event) { + case 'workerOutput': + broker = { kind: 'worker_stream', name: payload.name, stream: 'stdout', chunk: payload.chunk }; + break; + case 'messageReceived': + broker = { + kind: 'relay_inbound', + event_id: payload.eventId, + from: payload.from, + target: payload.to, + body: payload.text, + thread_id: payload.threadId, + }; + break; + case 'agentSpawned': + broker = { kind: 'agent_spawned', name: payload.name, runtime: payload.runtime ?? 'pty' }; + break; + case 'agentReleased': + broker = { kind: 'agent_released', name: payload.name }; + break; + case 'agentExited': + broker = { kind: 'agent_exited', name: payload.name, code: payload.exitCode, signal: payload.exitSignal }; + break; + case 'agentIdle': + broker = { kind: 'agent_idle', name: payload.name, idle_secs: payload.idleSecs }; + break; + default: + broker = { kind: event, ...payload }; + } + for (const cb of [...eventListeners]) cb(broker); } +// Back-compat alias for tests that still call emitRelayEvent. +const emitRelayEvent = emitMockEvent; + const mockRelayInstance = { spawnPty: vi.fn().mockImplementation(defaultSpawnPtyImplementation), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - addListener: vi.fn((event: string, fn: (...args: any[]) => void) => { - let set = relayListeners.get(event); - if (!set) { - set = new Set(); - relayListeners.set(event, set); - } - set.add(fn); - return () => { - set!.delete(fn); - }; + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); }), + connectEvents: vi.fn(), listAgents: vi.fn().mockResolvedValue([]), - listAgentsRaw: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), }; let relayEventCounter = 0; function emitRelayChannelMessage(message: { from: string; to: string; text: string }) { setTimeout(() => { - emitRelayEvent('messageReceived', { + emitMockEvent('messageReceived', { eventId: `evt-${++relayEventCounter}`, from: message.from, to: message.to, @@ -176,12 +201,16 @@ function emitRelayChannelMessage(message: { from: string; to: string; text: stri }, 0); } -vi.mock('../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); // Import after mocking -const { WorkflowRunner } = await import('../workflows/runner.js'); +const { WorkflowRunner } = await import('../runner.js'); // ── Test fixtures ──────────────────────────────────────────────────────────── @@ -241,6 +270,11 @@ type WorkflowStepOverride = Partial[nu function makeSupervisedConfig(stepOverrides: WorkflowStepOverride = {}): RelayYamlConfig { return makeConfig({ + // The runner now defaults to strategy:'retry' with repairRetries; these + // supervised tests assert first-pass review/owner outcomes, so opt into + // fail-fast to exercise the failure path deterministically (no retry loop). + // Step-level `retries` overrides still drive the explicit retry tests. + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'hub-spoke' }, agents: [ { name: 'specialist', cli: 'claude', role: 'engineer' }, @@ -314,9 +348,8 @@ describe('Completion Pipeline', () => { waitForExitFn = vi.fn().mockResolvedValue('exited'); waitForIdleFn = vi.fn().mockImplementation(() => never()); mockSpawnOutputs = []; - mockAgent.release.mockResolvedValue(undefined); mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation); - relayListeners.clear(); + eventListeners.clear(); db = makeDb(); runner = new WorkflowRunner({ db, workspaceId: 'ws-test' }); }); @@ -478,6 +511,7 @@ describe('Completion Pipeline', () => { const run = await runner.execute( makeConfig({ + errorHandling: { strategy: 'fail-fast' }, agents: [{ name: 'reviewer', cli: 'claude', preset: 'reviewer' }], workflows: [ { @@ -754,7 +788,9 @@ describe('Completion Pipeline', () => { // These tests validate the tolerant parser once it's implemented. // The tolerant parser should accept semantic equivalents. - it('should still fail on review output with no usable approval or rejection signal', async () => { + it('should fail closed (reject) when the reviewer hedges instead of deciding', async () => { + // Declared indecision ("I need more context before deciding") is treated as + // a fail-closed REJECT so the step retries instead of crashing as malformed. mockSpawnOutputs = [ 'worker finished\n', 'STEP_COMPLETE:step-1\n', @@ -763,7 +799,7 @@ describe('Completion Pipeline', () => { const run = await runner.execute(makeSupervisedConfig(), 'default'); expect(run.status).toBe('failed'); - expect(run.error).toContain('review response malformed'); + expect(run.error).toContain('review rejected'); }, 15000); }); @@ -1014,7 +1050,7 @@ describe('Completion Pipeline', () => { emitRelayEvent('workerOutput', { name, chunk: output }); }); - const agent = { ...mockAgent, name }; + const agent = makeMockHandle(name); if (name.includes('map-1-worker')) { emitRelayChannelMessage({ from: agent.name, @@ -1259,7 +1295,10 @@ describe('Completion Pipeline', () => { it('should still fail when marker, owner decision, and evidence are all missing', async () => { mockSpawnOutputs = ['Did the work but no marker\n']; - const run = await runner.execute(makeConfig(), 'default'); + const run = await runner.execute( + makeConfig({ errorHandling: { strategy: 'fail-fast' } }), + 'default' + ); expect(run.status).toBe('failed'); expect(run.error).toContain('owner completion decision missing'); }, 15000); @@ -1706,6 +1745,7 @@ describe('Completion Pipeline', () => { it('should fail when process exits with non-zero code and no signal', async () => { // Agent exits with non-zero and no coordination signal — should fail const config = makeConfig({ + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'dag', completionGracePeriodMs: 5000 }, agents: [{ name: 'agent-a', cli: 'claude' }], workflows: [ @@ -1735,6 +1775,7 @@ describe('Completion Pipeline', () => { it('should respect completionGracePeriodMs: 0 to disable fallback', async () => { // With grace period disabled, missing signals should always fail const config = makeConfig({ + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'dag', completionGracePeriodMs: 0 }, agents: [{ name: 'agent-a', cli: 'claude' }], workflows: [ @@ -1862,6 +1903,7 @@ describe('Completion Pipeline', () => { it('should not complete via process-exit fallback when output contains INCOMPLETE_RETRY', async () => { const config = makeConfig({ + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'dag', completionGracePeriodMs: 5000 }, agents: [{ name: 'agent-a', cli: 'claude' }], workflows: [ diff --git a/packages/core/src/__tests__/e2big-and-verify.test.ts b/packages/core/src/__tests__/e2big-and-verify.test.ts index f623807..72ce8e1 100644 --- a/packages/core/src/__tests__/e2big-and-verify.test.ts +++ b/packages/core/src/__tests__/e2big-and-verify.test.ts @@ -5,9 +5,23 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: class RelayError extends Error {}, })); -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn(), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { + spawn: vi.fn(async () => ({ + spawnPty: vi.fn(), + onEvent: vi.fn(() => () => {}), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), + })), + }, + }; +}); const { WorkflowRunner } = await import('../runner.js'); diff --git a/packages/core/src/__tests__/e2e-owner-review.test.ts b/packages/core/src/__tests__/e2e-owner-review.test.ts index 20556b1..4ca6409 100644 --- a/packages/core/src/__tests__/e2e-owner-review.test.ts +++ b/packages/core/src/__tests__/e2e-owner-review.test.ts @@ -60,28 +60,26 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: MockRelayError, })); -// ── Mock AgentRelay ───────────────────────────────────────────────────────── +// ── Mock HarnessDriverClient ───────────────────────────────────────────────── let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; let mockSpawnOutputs: string[] = []; +// Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, but +// driven by the test's waitForExitFn/waitForIdleFn. The runner wraps this in a +// WorkflowAgentHandle, which expects waitForExit/waitForIdle to resolve to +// `{ reason }` (not a bare string). const mockAgent = { name: 'test-agent-abc', - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return waitForIdleFn; - }, + runtime: 'pty' as const, + exitCode: undefined as number | undefined, + exitSignal: undefined as string | undefined, + waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })), + waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), release: vi.fn().mockResolvedValue(undefined), }; -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; - const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; task?: string }) => { const queued = mockSpawnOutputs.shift(); const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim(); @@ -99,39 +97,54 @@ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; tas return { ...mockAgent, name }; }; -// Listener registry for the AgentRelay mock — the production AgentRelay -// uses addListener('eventName', handler), so the mock captures handlers -// here keyed by event name. Tests fire events via `emitRelayEvent`. -const relayListeners = new Map void>>(); -function emitRelayEvent(event: string, payload: unknown): void { - for (const handler of relayListeners.get(event) ?? []) { - handler(payload); +// The runner consumes broker events via `client.onEvent(BrokerEvent)`. Tests +// still fire events through `emitRelayEvent('workerOutput'|...)`; translate +// those legacy named events into the BrokerEvent shapes the runner switches on. +const eventListeners = new Set<(event: any) => void>(); +function emitRelayEvent(event: string, payload: any = {}): void { + let broker: any; + switch (event) { + case 'workerOutput': + broker = { kind: 'worker_stream', name: payload.name, stream: 'stdout', chunk: payload.chunk }; + break; + case 'agentReleased': + broker = { kind: 'agent_released', name: payload.name }; + break; + case 'agentExited': + broker = { kind: 'agent_exited', name: payload.name, code: payload.exitCode, signal: payload.exitSignal }; + break; + case 'agentIdle': + broker = { kind: 'agent_idle', name: payload.name, idle_secs: payload.idleSecs }; + break; + default: + broker = { kind: event, ...payload }; } + for (const cb of [...eventListeners]) cb(broker); } const mockRelayInstance = { spawnPty: vi.fn().mockImplementation(defaultSpawnPtyImplementation), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - addListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - let set = relayListeners.get(event); - if (!set) { - set = new Set(); - relayListeners.set(event, set); - } - set.add(handler); - return () => set!.delete(handler); + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); }), - listAgentsRaw: vi.fn().mockResolvedValue([]), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), }; -vi.mock('../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); // Import after mocking -const { WorkflowRunner } = await import('../workflows/runner.js'); +const { WorkflowRunner } = await import('../runner.js'); // ── Helpers ───────────────────────────────────────────────────────────────── @@ -194,6 +207,11 @@ type WorkflowStepOverride = Partial[nu function makeSupervisedConfig(stepOverrides: WorkflowStepOverride = {}): RelayYamlConfig { return makeConfig({ + // The runner auto-enables strategy:'retry' with repairRetries when agents are + // present (applyReliabilityDefaults). These supervised scenarios assert + // first-pass owner/review outcomes, so opt into fail-fast to exercise the + // failure path deterministically instead of entering the repair/retry loop. + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'hub-spoke' }, agents: [ { name: 'specialist', cli: 'claude', role: 'engineer' }, @@ -224,7 +242,7 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => { mockSpawnOutputs = []; mockAgent.release.mockResolvedValue(undefined); mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation); - relayListeners.clear(); + eventListeners.clear(); db = makeDb(); runner = new WorkflowRunner({ db, workspaceId: 'ws-test' }); }); @@ -549,7 +567,13 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => { return { name, - waitForExit: vi.fn().mockResolvedValue(isReview ? 'timeout' : 'exited'), + runtime: 'pty' as const, + exitCode: undefined, + exitSignal: undefined, + // SpawnedAgentHandle resolves to `{ reason }`; the runner's + // WorkflowAgentHandle destructures it, so raw strings would map to + // undefined and the timeout would never be detected. + waitForExit: vi.fn().mockResolvedValue({ reason: isReview ? 'timeout' : 'exited' }), waitForIdle: vi.fn().mockImplementation(() => never()), release: vi.fn().mockResolvedValue(undefined), }; @@ -586,7 +610,13 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => { return { name, - waitForExit: vi.fn().mockResolvedValue(isReview ? 'timeout' : 'exited'), + runtime: 'pty' as const, + exitCode: undefined, + exitSignal: undefined, + // SpawnedAgentHandle resolves to `{ reason }`; the runner's + // WorkflowAgentHandle destructures it, so raw strings would map to + // undefined and the timeout would never be detected. + waitForExit: vi.fn().mockResolvedValue({ reason: isReview ? 'timeout' : 'exited' }), waitForIdle: vi.fn().mockImplementation(() => never()), release: vi.fn().mockResolvedValue(undefined), }; @@ -617,7 +647,9 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => { waitForExitFn = vi.fn().mockResolvedValue('timeout'); waitForIdleFn = vi.fn().mockResolvedValue('timeout'); - const run = await runner.execute(makeConfig(), 'default'); + // fail-fast so the single owner timeout surfaces immediately rather than + // re-entering the auto-enabled repair/retry loop with timing-out mocks. + const run = await runner.execute(makeConfig({ errorHandling: { strategy: 'fail-fast' } }), 'default'); expect(run.status).toBe('failed'); expect(run.error).toContain('timed out'); expect(events.length).toBeGreaterThanOrEqual(1); @@ -745,7 +777,9 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => { it('should fail when owner does not provide a marker, decision, or evidence', async () => { mockSpawnOutputs = ['The work is done but I forgot the sentinel.\n']; - const run = await runner.execute(makeConfig(), 'default'); + // fail-fast so the missing-decision failure surfaces on the first pass + // instead of entering the auto-enabled repair/retry loop. + const run = await runner.execute(makeConfig({ errorHandling: { strategy: 'fail-fast' } }), 'default'); expect(run.status).toBe('failed'); expect(run.error).toContain('owner completion decision missing'); }, 15000); diff --git a/packages/core/src/__tests__/e2e-permissions.test.ts b/packages/core/src/__tests__/e2e-permissions.test.ts index 257a802..37ef793 100644 --- a/packages/core/src/__tests__/e2e-permissions.test.ts +++ b/packages/core/src/__tests__/e2e-permissions.test.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import type { WorkflowDb } from '../runner.js'; import type { RelayYamlConfig, WorkflowRunRow, WorkflowStepRow } from '../types.js'; -import type { ProvisionResult, WorkflowProvisionConfig } from '../../provisioner/types.js'; +import type { ProvisionResult, WorkflowProvisionConfig } from '../provisioner.js'; const fixturePath = fileURLToPath(new URL('./fixtures/permission-test.yaml', import.meta.url)); @@ -124,8 +124,8 @@ const mockResolveAgentPermissions = vi.fn( ) ); -vi.mock('../../provisioner/index.js', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../provisioner.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, provisionWorkflowAgents: mockProvisionWorkflowAgents, @@ -177,26 +177,57 @@ let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; let mockSpawnOutputs: string[] = []; -const mockAgent = { - name: 'test-agent-abc', - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return waitForIdleFn; - }, - release: vi.fn().mockResolvedValue(undefined), -}; - -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; +// Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, driven +// by the test's waitForExitFn/waitForIdleFn. The runner wraps these in a +// WorkflowAgentHandle that reads `.reason`, so they must resolve to { reason }. +function makeMockHandle(name: string) { + return { + name, + runtime: 'pty' as const, + exitCode: undefined as number | undefined, + exitSignal: undefined as string | undefined, + waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })), + waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), + release: vi.fn().mockResolvedValue({ name }), + }; +} -const mockListeners = new Map void>>(); -function emitMockEvent(event: string, ...args: any[]): void { - const set = mockListeners.get(event); - if (set) for (const cb of set) cb(...args); +// The runner consumes broker events via `client.onEvent(BrokerEvent)`. Tests +// still call `emitMockEvent('workerOutput'|...)`; translate those legacy named +// events into the BrokerEvent shapes the runner switches on. +const eventListeners = new Set<(event: any) => void>(); +function emitMockEvent(event: string, payload: any = {}): void { + let broker: any; + switch (event) { + case 'workerOutput': + broker = { kind: 'worker_stream', name: payload.name, stream: 'stdout', chunk: payload.chunk }; + break; + case 'messageReceived': + broker = { + kind: 'relay_inbound', + event_id: payload.eventId, + from: payload.from, + target: payload.to, + body: payload.text, + thread_id: payload.threadId, + }; + break; + case 'agentSpawned': + broker = { kind: 'agent_spawned', name: payload.name, runtime: payload.runtime ?? 'pty' }; + break; + case 'agentReleased': + broker = { kind: 'agent_released', name: payload.name }; + break; + case 'agentExited': + broker = { kind: 'agent_exited', name: payload.name, code: payload.exitCode, signal: payload.exitSignal }; + break; + case 'agentIdle': + broker = { kind: 'agent_idle', name: payload.name, idle_secs: payload.idleSecs }; + break; + default: + broker = { kind: event, ...payload }; + } + for (const cb of [...eventListeners]) cb(broker); } const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; task?: string }) => { @@ -208,29 +239,29 @@ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; tas emitMockEvent('workerOutput', { name, chunk: output }); }); - return { ...mockAgent, name }; + return makeMockHandle(name); }; const mockRelayInstance = { spawnPty: vi.fn().mockImplementation(defaultSpawnPtyImplementation), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - addListener: vi.fn((event: string, cb: (...args: any[]) => void) => { - let set = mockListeners.get(event); - if (!set) { - set = new Set(); - mockListeners.set(event, set); - } - set.add(cb); - return () => set!.delete(cb); + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); }), - listAgentsRaw: vi.fn().mockResolvedValue([]), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), }; -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); const { WorkflowRunner } = await import('../runner.js'); const { formatDryRunReport } = await import('../dry-run-format.js'); @@ -311,9 +342,8 @@ describe('WorkflowRunner permissions integration', () => { waitForExitFn = vi.fn().mockResolvedValue('exited'); waitForIdleFn = vi.fn().mockImplementation(() => never()); mockSpawnOutputs = []; - mockAgent.release.mockResolvedValue(undefined); mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation); - mockListeners.clear(); + eventListeners.clear(); lastProvisionCall = null; lastProvisionResult = null; workspaceDir = createWorkspace(); @@ -393,7 +423,10 @@ describe('WorkflowRunner permissions integration', () => { expect.objectContaining({ agent: 'writer', access: 'readwrite', - writePaths: 1, + // cloud v8 grants write on every non-denied file under readwrite + // access (5 workspace files − 2 denied: .env, secrets/** = 3), where + // the old compiler counted only the explicit files.write pattern (1). + writePaths: 3, }), expect.objectContaining({ agent: 'admin-lead', diff --git a/packages/core/src/__tests__/error-scenarios.test.ts b/packages/core/src/__tests__/error-scenarios.test.ts index e76c144..84aab9b 100644 --- a/packages/core/src/__tests__/error-scenarios.test.ts +++ b/packages/core/src/__tests__/error-scenarios.test.ts @@ -537,27 +537,15 @@ describe('SwarmCoordinator error scenarios', () => { // ── WorkflowRunner error scenarios ─────────────────────────────────────────── describe('WorkflowRunner error scenarios', () => { - // Mock AgentRelay for runner tests - const mockAgent = { - name: 'test-agent', - waitForExit: vi.fn().mockResolvedValue(0), - release: vi.fn(), - }; - - vi.mock('@agent-relay/sdk/relay', () => ({ - AgentRelay: vi.fn().mockImplementation(() => ({ - spawnPty: vi.fn().mockResolvedValue(mockAgent), - human: vi.fn().mockReturnValue({ sendMessage: vi.fn() }), - shutdown: vi.fn(), - })), - })); - + // These tests only exercise validation / variable-resolution paths and + // early-rejecting execute()/resume() calls, none of which reach agent + // spawning — so no harness-driver mock is required here. let WorkflowRunner: any; let db: any; let runner: any; beforeEach(async () => { - const mod = await import('../workflows/runner.js'); + const mod = await import('../runner.js'); WorkflowRunner = mod.WorkflowRunner; const runs = new Map(); diff --git a/packages/core/src/__tests__/idle-nudge.test.ts b/packages/core/src/__tests__/idle-nudge.test.ts index b8a5364..aaaf35b 100644 --- a/packages/core/src/__tests__/idle-nudge.test.ts +++ b/packages/core/src/__tests__/idle-nudge.test.ts @@ -53,46 +53,61 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: MockRelayError, })); -// ── Mock AgentRelay ─────────────────────────────────────────────────────────── +// ── Mock HarnessDriverClient ───────────────────────────────────────────────── let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; -const mockSendMessage = vi.fn().mockResolvedValue(undefined); -const mockRelease = vi.fn().mockResolvedValue(undefined); +const mockRelease = vi.fn().mockResolvedValue({ name: 'test-agent-abc' }); +// Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, driven +// by the test's waitForExitFn/waitForIdleFn. waitForExit/waitForIdle return +// `{ reason }` objects (the runner reads `.reason`), not raw strings. const mockAgent = { name: 'test-agent-abc', + runtime: 'pty' as const, exitCode: 0, exitSignal: undefined, - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return waitForIdleFn; - }, + waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })), + waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), release: mockRelease, - sendMessage: mockSendMessage, }; -const mockHumanSendMessage = vi.fn().mockResolvedValue(undefined); -const mockHuman = { - name: 'workflow-runner', - sendMessage: mockHumanSendMessage, +// Idle nudges now go through `client.sendMessage({ from, to, text })` (the v8 +// HarnessDriverClient API) instead of the legacy `human().sendMessage(...)`. +const mockSendMessage = vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }); + +const eventListeners = new Set<(event: any) => void>(); + +const mockRelayInstance = { + spawnPty: vi.fn().mockResolvedValue(mockAgent), + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); + }), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: mockSendMessage, + shutdown: vi.fn().mockResolvedValue(undefined), }; -vi.mock('../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => ({ - spawnPty: vi.fn().mockResolvedValue(mockAgent), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - addListener: vi.fn(() => () => {}), - listAgentsRaw: vi.fn().mockResolvedValue([]), - })), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); + +const { WorkflowRunner } = await import('../runner.js'); +const { WorkflowAgentHandle } = await import('../agent-handle.js'); -const { WorkflowRunner } = await import('../workflows/runner.js'); +// The runner internally wraps spawnPty handles in a WorkflowAgentHandle (which +// maps the driver's `{ reason }` results to the legacy string contract). Tests +// that invoke `waitForExitWithIdleNudging` directly must pass an equivalently +// wrapped handle so `agent.waitForExit()` yields a string, not `{ reason }`. +const wrappedMockAgent = () => new WorkflowAgentHandle(mockAgent as any); // ── Test fixtures ───────────────────────────────────────────────────────────── @@ -178,8 +193,8 @@ describe('Idle Nudge Detection', () => { ); expect(run.status).toBe('completed'); - expect(mockHumanSendMessage).toHaveBeenCalledTimes(1); - expect(mockHumanSendMessage).toHaveBeenCalledWith( + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledWith( expect.objectContaining({ to: 'test-agent-abc', text: expect.stringContaining('/exit'), @@ -210,11 +225,16 @@ describe('Idle Nudge Detection', () => { const agentDef = { name: 'worker', cli: 'claude' }; (runner as any).currentConfig = config; - (runner as any).relay = { human: vi.fn().mockReturnValue(mockHuman) }; - const result = await (runner as any).waitForExitWithIdleNudging(mockAgent, agentDef, step, 500); + (runner as any).relay = { sendMessage: mockSendMessage }; + const result = await (runner as any).waitForExitWithIdleNudging( + wrappedMockAgent(), + agentDef, + step, + 500 + ); expect(result).toBe('exited'); - expect(mockHumanSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); }); it('force-releases after maxNudges is exceeded', async () => { @@ -222,6 +242,9 @@ describe('Idle Nudge Detection', () => { const run = await runner.execute( makeConfig({ + // fail-fast so the force-release failure surfaces immediately instead + // of triggering the runner's default retry/repair loop. + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'dag', idleNudge: { nudgeAfterMs: 50, escalateAfterMs: 50, maxNudges: 1 }, @@ -232,7 +255,7 @@ describe('Idle Nudge Detection', () => { expect(run.status).toBe('failed'); expect(run.error).toContain('force-released'); - expect(mockHumanSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); expect(mockRelease).toHaveBeenCalledTimes(1); expect(waitForIdleFn).not.toHaveBeenCalled(); }); @@ -242,6 +265,7 @@ describe('Idle Nudge Detection', () => { const run = await runner.execute( makeConfig({ + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'dag', idleNudge: { nudgeAfterMs: 50, escalateAfterMs: 50, maxNudges: 3 }, @@ -252,7 +276,7 @@ describe('Idle Nudge Detection', () => { expect(run.status).toBe('failed'); expect(run.error).toContain('force-released'); - expect(mockHumanSendMessage).toHaveBeenCalledTimes(3); + expect(mockSendMessage).toHaveBeenCalledTimes(3); expect(mockRelease).toHaveBeenCalledTimes(1); }); @@ -287,6 +311,7 @@ describe('Idle Nudge Detection', () => { await runner.execute( makeConfig({ + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'dag', idleNudge: { nudgeAfterMs: 50, escalateAfterMs: 50, maxNudges: 1 }, @@ -303,6 +328,7 @@ describe('Idle Nudge Detection', () => { const run = await runner.execute( makeConfig({ + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'dag', idleNudge: {}, @@ -314,7 +340,7 @@ describe('Idle Nudge Detection', () => { expect(run.status).toBe('failed'); expect(run.error).toContain('force-released'); // default maxNudges is 1 - expect(mockHumanSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); expect(mockRelease).toHaveBeenCalledTimes(1); }); @@ -368,7 +394,7 @@ describe('Idle Nudge Detection', () => { expect((runner as any).shouldPreserveIdleSupervisor(agentDef, step)).toBe(true); const result = await (runner as any).waitForExitWithIdleNudging( - mockAgent, + wrappedMockAgent(), agentDef, step, 500, @@ -427,7 +453,7 @@ describe('Idle Nudge Detection', () => { expect((runner as any).shouldPreserveIdleSupervisor(agentDef, step)).toBe(true); const result = await (runner as any).waitForExitWithIdleNudging( - mockAgent, + wrappedMockAgent(), agentDef, step, 500, @@ -445,7 +471,10 @@ describe('Idle Nudge Detection', () => { waitForExitFn = vi.fn().mockResolvedValue('timeout'); waitForIdleFn = vi.fn().mockResolvedValue('timeout'); - const run = await runner.execute(makeConfig(), 'default'); + const run = await runner.execute( + makeConfig({ errorHandling: { strategy: 'fail-fast' } }), + 'default' + ); const steps = await db.getStepsByRunId(run.id); expect(run.status).toBe('failed'); diff --git a/packages/core/src/__tests__/permission-types.test.ts b/packages/core/src/__tests__/permission-types.test.ts index 779ea2f..50f0267 100644 --- a/packages/core/src/__tests__/permission-types.test.ts +++ b/packages/core/src/__tests__/permission-types.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { compileAgentScopes, resolveAgentPermissions } from '../../provisioner/compiler.js'; +import { compileAgentScopes, resolveAgentPermissions } from '@agent-relay/cloud'; import type { AccessPreset, AgentDefinition, diff --git a/packages/core/src/__tests__/permissions-integration.test.ts b/packages/core/src/__tests__/permissions-integration.test.ts index 3fe7690..7d340d8 100644 --- a/packages/core/src/__tests__/permissions-integration.test.ts +++ b/packages/core/src/__tests__/permissions-integration.test.ts @@ -25,9 +25,9 @@ let lastProvisionResult: const mockProvisionWorkflowAgents = vi.fn(); -vi.mock('../../provisioner/index.js', async () => { - const actual = await vi.importActual( - '../../provisioner/index.js' +vi.mock('../provisioner.js', async () => { + const actual = await vi.importActual( + '../provisioner.js' ); mockProvisionWorkflowAgents.mockImplementation(async (config) => { @@ -102,45 +102,70 @@ let queuedPtyOutputs: string[] = []; let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; -const mockAgent = { - name: 'workflow-agent', - exitCode: 0, - exitSignal: undefined as string | undefined, - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return waitForIdleFn; - }, - release: vi.fn().mockResolvedValue(undefined), -}; - -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; +// Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, driven +// by the test's waitForExitFn/waitForIdleFn. The runner wraps these in a +// WorkflowAgentHandle that reads `.reason`, so they must resolve to { reason }. +function makeMockHandle(name: string) { + return { + name, + runtime: 'pty' as const, + exitCode: undefined as number | undefined, + exitSignal: undefined as string | undefined, + waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })), + waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), + release: vi.fn().mockResolvedValue({ name }), + }; +} -const mockListeners = new Map void>>(); -function emitMockEvent(event: string, ...args: any[]): void { - const set = mockListeners.get(event); - if (set) for (const cb of set) cb(...args); +// The runner consumes broker events via `client.onEvent(BrokerEvent)`. Tests +// still call `emitMockEvent('workerOutput'|...)`; translate those legacy named +// events into the BrokerEvent shapes the runner switches on. +const eventListeners = new Set<(event: any) => void>(); +function emitMockEvent(event: string, payload: any = {}): void { + let broker: any; + switch (event) { + case 'workerOutput': + broker = { kind: 'worker_stream', name: payload.name, stream: 'stdout', chunk: payload.chunk }; + break; + case 'messageReceived': + broker = { + kind: 'relay_inbound', + event_id: payload.eventId, + from: payload.from, + target: payload.to, + body: payload.text, + thread_id: payload.threadId, + }; + break; + case 'agentSpawned': + broker = { kind: 'agent_spawned', name: payload.name, runtime: payload.runtime ?? 'pty' }; + break; + case 'agentReleased': + broker = { kind: 'agent_released', name: payload.name }; + break; + case 'agentExited': + broker = { kind: 'agent_exited', name: payload.name, code: payload.exitCode, signal: payload.exitSignal }; + break; + case 'agentIdle': + broker = { kind: 'agent_idle', name: payload.name, idle_secs: payload.idleSecs }; + break; + default: + broker = { kind: event, ...payload }; + } + for (const cb of [...eventListeners]) cb(broker); } const mockRelayInstance = { spawnPty: vi.fn(), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - listAgentsRaw: vi.fn().mockResolvedValue([]), - addListener: vi.fn((event: string, cb: (...args: any[]) => void) => { - let set = mockListeners.get(event); - if (!set) { - set = new Set(); - mockListeners.set(event, set); - } - set.add(cb); - return () => set!.delete(cb); + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); }), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), }; const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; task?: string }) => { @@ -152,12 +177,16 @@ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; tas emitMockEvent('workerOutput', { name, chunk: output }); }); - return { ...mockAgent, name }; + return makeMockHandle(name); }; -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); type QueuedSubprocessResult = { stdout?: string; @@ -314,9 +343,8 @@ beforeEach(() => { queuedSubprocessResults = []; waitForExitFn = vi.fn().mockResolvedValue('exited'); waitForIdleFn = vi.fn().mockImplementation(() => never()); - mockAgent.release.mockResolvedValue(undefined); mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation); - mockListeners.clear(); + eventListeners.clear(); }); afterEach(() => { @@ -586,6 +614,11 @@ describe('WorkflowRunner permission lifecycle integration', () => { }, ]); + // The runner now defaults to strategy:'retry'; a single rejected spawn + // would otherwise be retried and succeed. This test asserts the failure + // cleanup path, so opt into fail-fast to fail immediately on the rejection. + config.errorHandling = { strategy: 'fail-fast' }; + mockRelayInstance.spawnPty.mockRejectedValueOnce(new Error('spawn failed')); const run = await runner.execute(config, 'default'); diff --git a/packages/core/src/__tests__/provisioner-audit.test.ts b/packages/core/src/__tests__/provisioner-audit.test.ts index dff9e59..0491aad 100644 --- a/packages/core/src/__tests__/provisioner-audit.test.ts +++ b/packages/core/src/__tests__/provisioner-audit.test.ts @@ -1,10 +1,10 @@ -import assert from 'node:assert/strict'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import test from 'node:test'; -import { createLocalJwksKeyPair } from '@agent-relay/cloud'; +import { expect, test } from 'vitest'; + +import { createLocalJwksKeyPair, getDefaultPermissionAuditPath } from '@agent-relay/cloud'; import { provisionWorkflowAgents } from '../provisioner.js'; async function createWorkspace(): Promise<{ dir: string; cleanup: () => Promise }> { @@ -36,7 +36,7 @@ test('provisionWorkflowAgents writes a permission audit without token values', a skipMount: true, }); - const auditPath = path.join(workspace.dir, '.agent-relay', 'permission-audit.json'); + const auditPath = getDefaultPermissionAuditPath(workspace.dir); const auditRaw = await readFile(auditPath, 'utf8'); const auditJson = JSON.parse(auditRaw) as { entries: Array<{ @@ -46,17 +46,17 @@ test('provisionWorkflowAgents writes a permission audit without token values', a }>; }; - assert.ok(auditJson.entries.length >= 3); - assert.deepEqual( - auditJson.entries.map((entry) => `${entry.agentName}:${entry.action}`), - ['worker:resolve', 'worker:mint', 'relay-admin:mint'] - ); - assert.equal( - auditJson.entries[1]?.details.jwtPath, + expect(auditJson.entries.length).toBeGreaterThanOrEqual(3); + expect(auditJson.entries.map((entry) => `${entry.agentName}:${entry.action}`)).toEqual([ + 'worker:resolve', + 'worker:mint', + 'relay-admin:mint', + ]); + expect(auditJson.entries[1]?.details.jwtPath).toBe( path.join(workspace.dir, '.relay', 'tokens', 'worker.jwt') ); - assert.ok(!auditRaw.includes(result.agents.worker.token)); - assert.ok(!auditRaw.includes(result.adminToken)); + expect(auditRaw.includes(result.agents.worker.token)).toBe(false); + expect(auditRaw.includes(result.adminToken)).toBe(false); } finally { await workspace.cleanup(); } diff --git a/packages/core/src/__tests__/provisioner-mount.test.ts b/packages/core/src/__tests__/provisioner-mount.test.ts index 6dca9f6..afd246b 100644 --- a/packages/core/src/__tests__/provisioner-mount.test.ts +++ b/packages/core/src/__tests__/provisioner-mount.test.ts @@ -76,15 +76,18 @@ afterEach(async () => { describe('ensureRelayfileMount', () => { it('runs initial sync, starts the watcher, and removes the mount on stop', async () => { const binaryPath = await createFakeMountBinary(); - const mountPoint = path.join(await makeTempDir('relayfile-mount-target-'), 'workspace'); + // @relayfile/sdk v0.8 only removes mount points it created itself; a + // caller-provided `mountPoint` is treated as owned by the caller and is + // preserved on stop. Omit it so the SDK creates (and thus cleans up) the + // mount, and read the resolved path back from the returned handle. const mount = await ensureRelayfileMount({ binaryPath, relayfileUrl: 'http://127.0.0.1:8080', workspace: 'rw_test', token: 'test-token', - mountPoint, }); + const mountPoint = mount.mountPoint; expect(mount.pid).toBeGreaterThan(0); expect(existsSync(path.join(mountPoint, 'seeded.txt'))).toBe(true); diff --git a/packages/core/src/__tests__/resume-fallback.test.ts b/packages/core/src/__tests__/resume-fallback.test.ts index a941876..657e1e6 100644 --- a/packages/core/src/__tests__/resume-fallback.test.ts +++ b/packages/core/src/__tests__/resume-fallback.test.ts @@ -7,11 +7,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { WorkflowRunner } from '../runner.js'; +import { JsonFileWorkflowDb } from '../file-db.js'; import type { WorkflowDb } from '../runner.js'; -import type { RelayYamlConfig, WorkflowRunRow, WorkflowStepRow } from '../types.js'; - -// ── Mock fetch ─────────────────────────────────────────────────────────────── - +import type { + AgentDefinition, + RelayYamlConfig, + RunnerStepExecutor, + WorkflowRunRow, + WorkflowStep, + WorkflowStepRow, +} from '../types.js'; + +// ── Stub fetch ─────────────────────────────────────────────────────────────── +// The injected executor (below) means the runner never provisions a relay +// broker, but keep a harmless fetch stub so no real network call can occur. const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: { api_key: 'rk_live_test', workspace_id: 'ws-test' } }), @@ -19,104 +29,24 @@ const mockFetch = vi.fn().mockResolvedValue({ }); vi.stubGlobal('fetch', mockFetch); -// ── Mock RelayCast SDK ─────────────────────────────────────────────────────── - -const mockRelaycastAgent = { - send: vi.fn().mockResolvedValue(undefined), - heartbeat: vi.fn().mockResolvedValue(undefined), - channels: { - create: vi.fn().mockResolvedValue(undefined), - join: vi.fn().mockResolvedValue(undefined), - invite: vi.fn().mockResolvedValue(undefined), - }, -}; - -const mockRelaycast = { - agents: { - register: vi.fn().mockResolvedValue({ token: 'token-1' }), - }, - as: vi.fn().mockReturnValue(mockRelaycastAgent), -}; - -class MockRelayError extends Error { - code: string; - constructor(code: string, message: string, status = 400) { - super(message); - this.code = code; - this.name = 'RelayError'; - (this as any).status = status; - } -} - -vi.mock('@relaycast/sdk', () => ({ - RelayCast: vi.fn().mockImplementation(() => mockRelaycast), - RelayError: MockRelayError, -})); - -// ── Mock AgentRelay ────────────────────────────────────────────────────────── - -let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; - -const mockAgent = { - name: 'test-agent-abc', - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return vi.fn().mockImplementation(() => new Promise(() => {})); - }, - release: vi.fn().mockResolvedValue(undefined), -}; - -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; - -const mockListeners = new Map void>>(); -function emitMockEvent(event: string, ...args: any[]): void { - const set = mockListeners.get(event); - if (set) for (const cb of set) cb(...args); -} - -const mockRelayInstance = { - spawnPty: vi.fn().mockImplementation(async ({ name, task }: { name: string; task?: string }) => { - const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim(); - const isReview = task?.includes('REVIEW_DECISION: APPROVE or REJECT'); - const output = isReview - ? 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: looks good\n' - : stepComplete - ? `STEP_COMPLETE:${stepComplete}\n` - : 'STEP_COMPLETE:unknown\n'; - - queueMicrotask(() => { - emitMockEvent('workerOutput', { name, chunk: output }); - }); - - return { ...mockAgent, name }; - }), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - addListener: vi.fn((event: string, cb: (...args: any[]) => void) => { - let set = mockListeners.get(event); - if (!set) { - set = new Set(); - mockListeners.set(event, set); +// ── Injected step executor ──────────────────────────────────────────────────── +// Replaces real agent/PTY spawning with an in-process stub. Supplying an +// `executor` makes the runner skip broker/relay init entirely (see runner.ts), +// so resume/cache machinery is exercised without launching any processes. The +// captured `resolvedTask` lets tests assert on what each step was handed. +function makeExecutor(): RunnerStepExecutor & { + executeAgentStep: ReturnType; +} { + const executeAgentStep = vi.fn( + async (step: WorkflowStep, _agent: AgentDefinition, resolvedTask: string) => { + const isReview = resolvedTask.includes('REVIEW_DECISION: APPROVE or REJECT'); + return isReview + ? 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: looks good\n' + : `STEP_COMPLETE:${step.name}\n`; } - set.add(cb); - return () => set!.delete(cb); - }), - listAgentsRaw: vi.fn().mockResolvedValue([]), -}; - -vi.mock('../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); - -// Import after mocking -const { WorkflowRunner } = await import('../workflows/runner.js'); -const { JsonFileWorkflowDb } = await import('../workflows/file-db.js'); + ); + return { executeAgentStep }; +} // ── Helpers ────────────────────────────────────────────────────────────────── @@ -250,15 +180,15 @@ function writeCachedOutput(tmpDir: string, runId: string, stepName: string, outp describe('resume fallback to step-output cache', () => { let db: WorkflowDb; let runner: InstanceType; + let executor: ReturnType; let tmpDir: string; beforeEach(() => { vi.clearAllMocks(); - waitForExitFn = vi.fn().mockResolvedValue('exited'); - mockListeners.clear(); tmpDir = mkdtempSync(path.join(os.tmpdir(), 'resume-fallback-')); db = makeDb(); - runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: tmpDir }); + executor = makeExecutor(); + runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: tmpDir, executor }); }); afterEach(() => { @@ -302,7 +232,12 @@ describe('resume fallback to step-output cache', () => { const config = makeResumeConfig(); const dbPath = path.join(tmpDir, '.agent-relay', 'workflow-runs.jsonl'); const fileDb = new JsonFileWorkflowDb(dbPath); - const dbRunner = new WorkflowRunner({ db: fileDb, workspaceId: 'ws-test', cwd: tmpDir }); + const dbRunner = new WorkflowRunner({ + db: fileDb, + workspaceId: 'ws-test', + cwd: tmpDir, + executor: makeExecutor(), + }); await fileDb.insertRun(makeRunRow(runId, config)); await fileDb.insertStep(makeStepRow(runId, 'step-a', 'Do step A', [], 'failed')); @@ -356,8 +291,8 @@ describe('resume fallback to step-output cache', () => { const run = await (runner as any).resume(runId, undefined, config); expect(run.status, run.error).toBe('completed'); - const spawnedTasks = mockRelayInstance.spawnPty.mock.calls.map( - ([args]) => (args as { task?: string }).task ?? '' + const spawnedTasks = executor.executeAgentStep.mock.calls.map( + (call) => (call[2] as string | undefined) ?? '' ); expect(spawnedTasks.some((task) => task.includes('Use cached value: hello world'))).toBe(true); }); diff --git a/packages/core/src/__tests__/run-summary-table.test.ts b/packages/core/src/__tests__/run-summary-table.test.ts index 313781c..8b3fe7f 100644 --- a/packages/core/src/__tests__/run-summary-table.test.ts +++ b/packages/core/src/__tests__/run-summary-table.test.ts @@ -7,9 +7,23 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: class RelayError extends Error {}, })); -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn(), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { + spawn: vi.fn(async () => ({ + spawnPty: vi.fn(), + onEvent: vi.fn(() => () => {}), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), + })), + }, + }; +}); const { WorkflowRunner } = await import('../runner.js'); diff --git a/packages/core/src/__tests__/start-from.test.ts b/packages/core/src/__tests__/start-from.test.ts index 9c76dd7..39c2f1b 100644 --- a/packages/core/src/__tests__/start-from.test.ts +++ b/packages/core/src/__tests__/start-from.test.ts @@ -6,14 +6,24 @@ */ import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { WorkflowRunner } from '../runner.js'; +import { workflow } from '../builder.js'; import type { WorkflowDb } from '../runner.js'; -import type { RelayYamlConfig, WorkflowRunRow, WorkflowStepRow } from '../types.js'; - -// ── Mock fetch ─────────────────────────────────────────────────────────────── - +import type { + AgentDefinition, + RelayYamlConfig, + RunnerStepExecutor, + WorkflowRunRow, + WorkflowStep, + WorkflowStepRow, +} from '../types.js'; + +// ── Stub fetch ─────────────────────────────────────────────────────────────── +// The injected executor (below) means the runner never provisions a relay +// broker, but keep a harmless fetch stub so no real network call can occur. const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: { api_key: 'rk_live_test', workspace_id: 'ws-test' } }), @@ -21,105 +31,23 @@ const mockFetch = vi.fn().mockResolvedValue({ }); vi.stubGlobal('fetch', mockFetch); -// ── Mock RelayCast SDK ─────────────────────────────────────────────────────── - -const mockRelaycastAgent = { - send: vi.fn().mockResolvedValue(undefined), - heartbeat: vi.fn().mockResolvedValue(undefined), - channels: { - create: vi.fn().mockResolvedValue(undefined), - join: vi.fn().mockResolvedValue(undefined), - invite: vi.fn().mockResolvedValue(undefined), - }, -}; - -const mockRelaycast = { - agents: { - register: vi.fn().mockResolvedValue({ token: 'token-1' }), - }, - as: vi.fn().mockReturnValue(mockRelaycastAgent), -}; - -class MockRelayError extends Error { - code: string; - constructor(code: string, message: string, status = 400) { - super(message); - this.code = code; - this.name = 'RelayError'; - (this as any).status = status; - } -} - -vi.mock('@relaycast/sdk', () => ({ - RelayCast: vi.fn().mockImplementation(() => mockRelaycast), - RelayError: MockRelayError, -})); - -// ── Mock AgentRelay ────────────────────────────────────────────────────────── - -let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; - -const mockAgent = { - name: 'test-agent-abc', - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return vi.fn().mockImplementation(() => new Promise(() => {})); - }, - release: vi.fn().mockResolvedValue(undefined), -}; - -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; - -// Listener registry for the AgentRelay mock — production AgentRelay uses -// addListener('eventName', handler). Tests fire events via emitRelayEvent. -const relayListeners = new Map void>>(); -function emitRelayEvent(event: string, payload: unknown): void { - for (const handler of relayListeners.get(event) ?? []) { - handler(payload); - } -} - -const mockRelayInstance = { - spawnPty: vi.fn().mockImplementation(async ({ name, task }: { name: string; task?: string }) => { - const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim(); - const isReview = task?.includes('REVIEW_DECISION: APPROVE or REJECT'); - const output = isReview - ? 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: looks good\n' - : stepComplete - ? `STEP_COMPLETE:${stepComplete}\n` - : 'STEP_COMPLETE:unknown\n'; - - queueMicrotask(() => emitRelayEvent('workerOutput', { name, chunk: output })); - - return { ...mockAgent, name }; - }), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - addListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - let set = relayListeners.get(event); - if (!set) { - set = new Set(); - relayListeners.set(event, set); +// ── Injected step executor ──────────────────────────────────────────────────── +// Replaces real agent/PTY spawning with an in-process stub. Supplying an +// `executor` makes the runner skip broker/relay init entirely (see runner.ts), +// so the DAG/skip/cache machinery is exercised without launching any processes. +function makeExecutor(): RunnerStepExecutor & { + executeAgentStep: ReturnType; +} { + const executeAgentStep = vi.fn( + async (step: WorkflowStep, _agent: AgentDefinition, resolvedTask: string) => { + const isReview = resolvedTask.includes('REVIEW_DECISION: APPROVE or REJECT'); + return isReview + ? 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: looks good\n' + : `STEP_COMPLETE:${step.name}\n`; } - set.add(handler); - return () => set!.delete(handler); - }), - listAgentsRaw: vi.fn().mockResolvedValue([]), -}; - -vi.mock('../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); - -// Import after mocking -const { WorkflowRunner } = await import('../workflows/runner.js'); -const { workflow } = await import('../workflows/builder.js'); + ); + return { executeAgentStep }; +} // ── Helpers ────────────────────────────────────────────────────────────────── @@ -202,11 +130,9 @@ describe('startFrom', () => { beforeEach(() => { vi.clearAllMocks(); - waitForExitFn = vi.fn().mockResolvedValue('exited'); - relayListeners.clear(); tmpDir = mkdtempSync(path.join(os.tmpdir(), 'start-from-')); db = makeDb(); - runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: tmpDir }); + runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: tmpDir, executor: makeExecutor() }); }); it('should throw when startFrom step does not exist', async () => { diff --git a/packages/core/src/__tests__/step-cwd.test.ts b/packages/core/src/__tests__/step-cwd.test.ts index 8dd470c..21f9410 100644 --- a/packages/core/src/__tests__/step-cwd.test.ts +++ b/packages/core/src/__tests__/step-cwd.test.ts @@ -6,9 +6,23 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: class RelayError extends Error {}, })); -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn(), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { + spawn: vi.fn(async () => ({ + spawnPty: vi.fn(), + onEvent: vi.fn(() => () => {}), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), + })), + }, + }; +}); const { WorkflowRunner } = await import('../runner.js'); diff --git a/packages/core/src/__tests__/verification-custom.test.ts b/packages/core/src/__tests__/verification-custom.test.ts index 0c737cc..5bbdabe 100644 --- a/packages/core/src/__tests__/verification-custom.test.ts +++ b/packages/core/src/__tests__/verification-custom.test.ts @@ -29,23 +29,28 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: class RelayError extends Error {}, })); -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; +const eventListeners = new Set<(event: any) => void>(); const mockRelayInstance = { spawnPty: vi.fn(), - human: vi.fn().mockReturnValue(mockHuman), + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); + }), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - listAgentsRaw: vi.fn().mockResolvedValue([]), - addListener: vi.fn(() => () => {}), }; -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); type QueuedSubprocessResult = { stdout?: string; @@ -159,6 +164,11 @@ function makeConfig(projectDir: string, verificationValue: string): RelayYamlCon errorHandling: { strategy: 'retry', retryDelayMs: 0, + // The runner now defaults to spawning a repair agent between retries when + // strategy is 'retry'. This test asserts a plain first-attempt + retry + // (exactly 2 spawns) and checks the verification-failure retry prompt, so + // opt out of the repair agent to exercise the standard retry path. + repairRetries: 0, }, agents: [{ name: 'worker', cli: 'claude', interactive: false }], workflows: [ diff --git a/packages/core/src/__tests__/verification-traceback.test.ts b/packages/core/src/__tests__/verification-traceback.test.ts index 4661ad9..d96e874 100644 --- a/packages/core/src/__tests__/verification-traceback.test.ts +++ b/packages/core/src/__tests__/verification-traceback.test.ts @@ -118,18 +118,19 @@ vi.mock('node:child_process', async () => { }; }); -const mockHuman = { - sendMessage: vi.fn().mockResolvedValue(undefined), -}; +const eventListeners = new Set<(event: any) => void>(); const mockRelayInstance = { spawnPty: vi.fn(), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - listAgentsRaw: vi.fn().mockResolvedValue([]), + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); + }), + connectEvents: vi.fn(), listAgents: vi.fn().mockResolvedValue([]), - addListener: vi.fn(() => () => {}), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), }; vi.mock('@relaycast/sdk', () => ({ @@ -137,9 +138,13 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: class RelayError extends Error {}, })); -vi.mock('../../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); const { workflow } = await import('../builder.js'); const { WorkflowRunner } = await import('../runner.js'); @@ -244,6 +249,11 @@ function makeConfig(input: { errorHandling: { strategy: 'retry', retryDelayMs: 0, + // The runner now spawns a repair agent between retries by default under + // strategy 'retry'. These tests count execNonInteractive calls to assert + // the diagnostic-agent vs standard-retry flow, so opt out of the repair + // agent to keep the expected attempt/diagnostic call sequence. + repairRetries: 0, }, agents: [ { @@ -303,9 +313,8 @@ describe('verification traceback retry handling', () => { queuedSubprocessResults = []; queuedCollectorResults = []; mockRelayInstance.shutdown.mockResolvedValue(undefined); - mockRelayInstance.onBrokerStderr.mockReturnValue(() => {}); mockRelayInstance.listAgents.mockResolvedValue([]); - mockRelayInstance.listAgentsRaw.mockResolvedValue([]); + eventListeners.clear(); }); afterAll(async () => { diff --git a/packages/core/src/__tests__/workflow-runner.test.ts b/packages/core/src/__tests__/workflow-runner.test.ts index a9ab26d..d5199f6 100644 --- a/packages/core/src/__tests__/workflow-runner.test.ts +++ b/packages/core/src/__tests__/workflow-runner.test.ts @@ -63,32 +63,62 @@ vi.mock('@relaycast/sdk', () => ({ RelayError: MockRelayError, })); -// ── Mock AgentRelay ────────────────────────────────────────────────────────── +// ── Mock HarnessDriverClient ───────────────────────────────────────────────── let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; let mockSpawnOutputs: string[] = []; -const mockAgent = { - name: 'test-agent-abc', - get waitForExit() { - return waitForExitFn; - }, - get waitForIdle() { - return waitForIdleFn; - }, - release: vi.fn().mockResolvedValue(undefined), -}; - -const mockHuman = { - name: 'WorkflowRunner', - sendMessage: vi.fn().mockResolvedValue(undefined), -}; +// Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, but +// driven by the test's waitForExitFn/waitForIdleFn. +function makeMockHandle(name: string) { + return { + name, + runtime: 'pty' as const, + exitCode: undefined as number | undefined, + exitSignal: undefined as string | undefined, + waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })), + waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), + release: vi.fn().mockResolvedValue({ name }), + }; +} -const mockListeners = new Map void>>(); -function emitMockEvent(event: string, ...args: any[]): void { - const set = mockListeners.get(event); - if (set) for (const cb of set) cb(...args); +// The runner consumes broker events via `client.onEvent(BrokerEvent)`. Tests +// still call `emitMockEvent('workerOutput'|'messageReceived'|...)`; translate +// those legacy named events into the BrokerEvent shapes the runner switches on. +const eventListeners = new Set<(event: any) => void>(); +function emitMockEvent(event: string, payload: any = {}): void { + let broker: any; + switch (event) { + case 'workerOutput': + broker = { kind: 'worker_stream', name: payload.name, stream: 'stdout', chunk: payload.chunk }; + break; + case 'messageReceived': + broker = { + kind: 'relay_inbound', + event_id: payload.eventId, + from: payload.from, + target: payload.to, + body: payload.text, + thread_id: payload.threadId, + }; + break; + case 'agentSpawned': + broker = { kind: 'agent_spawned', name: payload.name, runtime: payload.runtime ?? 'pty' }; + break; + case 'agentReleased': + broker = { kind: 'agent_released', name: payload.name }; + break; + case 'agentExited': + broker = { kind: 'agent_exited', name: payload.name, code: payload.exitCode, signal: payload.exitSignal }; + break; + case 'agentIdle': + broker = { kind: 'agent_idle', name: payload.name, idle_secs: payload.idleSecs }; + break; + default: + broker = { kind: event, ...payload }; + } + for (const cb of [...eventListeners]) cb(broker); } const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; task?: string }) => { @@ -107,32 +137,32 @@ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; tas emitMockEvent('workerOutput', { name, chunk: output }); }); - return { ...mockAgent, name }; + return makeMockHandle(name); }; const mockRelayInstance = { spawnPty: vi.fn().mockImplementation(defaultSpawnPtyImplementation), - human: vi.fn().mockReturnValue(mockHuman), - shutdown: vi.fn().mockResolvedValue(undefined), - onBrokerStderr: vi.fn().mockReturnValue(() => {}), - addListener: vi.fn((event: string, cb: (...args: any[]) => void) => { - let set = mockListeners.get(event); - if (!set) { - set = new Set(); - mockListeners.set(event, set); - } - set.add(cb); - return () => set!.delete(cb); + onEvent: vi.fn((cb: (event: any) => void) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); }), - listAgentsRaw: vi.fn().mockResolvedValue([]), + connectEvents: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + release: vi.fn().mockResolvedValue({ name: '' }), + sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }), + shutdown: vi.fn().mockResolvedValue(undefined), }; -vi.mock('../relay.js', () => ({ - AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance), -})); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) }, + }; +}); // Import after mocking -const { WorkflowRunner } = await import('../workflows/runner.js'); +const { WorkflowRunner } = await import('../runner.js'); // ── Test fixtures ──────────────────────────────────────────────────────────── @@ -196,6 +226,10 @@ type WorkflowStepOverride = Partial[nu function makeSupervisedConfig(stepOverrides: WorkflowStepOverride = {}): RelayYamlConfig { return makeConfig({ + // The runner now defaults to strategy:'retry' with repairRetries; these + // supervised tests assert first-pass review/owner outcomes, so opt into + // fail-fast to exercise the failure path deterministically (no retry loop). + errorHandling: { strategy: 'fail-fast' }, swarm: { pattern: 'hub-spoke' }, agents: [ { name: 'specialist', cli: 'claude', role: 'engineer' }, @@ -218,14 +252,28 @@ function makeSupervisedConfig(stepOverrides: WorkflowStepOverride = {}): RelayYa }); } -function readCompletedTrajectoryFile(dir: string): any { - const completedDir = path.join(dir, '.trajectories', 'completed'); - if (!existsSync(completedDir)) return null; +function findFirstJsonFile(dir: string): string | null { + if (!existsSync(dir)) return null; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = findFirstJsonFile(entryPath); + if (nested) return nested; + } + if (entry.isFile() && entry.name.endsWith('.json')) return entryPath; + } + return null; +} - const jsonFile = readdirSync(completedDir).find((file) => file.endsWith('.json')); +function readCompletedTrajectoryFile(dir: string): any { + // agent-trajectories v0.6 relocated the default data dir from `.trajectories` + // to `.agentworkforce/trajectories` and stores each trajectory in a per-id + // subdirectory (completed//trajectory.json), so scan recursively. + const completedDir = path.join(dir, '.agentworkforce', 'trajectories', 'completed'); + const jsonFile = findFirstJsonFile(completedDir); if (!jsonFile) return null; - return JSON.parse(readFileSync(path.join(completedDir, jsonFile), 'utf-8')); + return JSON.parse(readFileSync(jsonFile, 'utf-8')); } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -239,9 +287,8 @@ describe('WorkflowRunner', () => { waitForExitFn = vi.fn().mockResolvedValue('exited'); waitForIdleFn = vi.fn().mockImplementation(() => never()); mockSpawnOutputs = []; - mockAgent.release.mockResolvedValue(undefined); mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation); - mockListeners.clear(); + eventListeners.clear(); db = makeDb(); runner = new WorkflowRunner({ db, workspaceId: 'ws-test' }); }); @@ -608,7 +655,12 @@ agents: } }); - it('does not spawn deterministic repair agents unless repair retries are explicitly enabled', async () => { + it('does not spawn deterministic repair agents when retries are disabled (fail-fast)', async () => { + // The runner's applyReliabilityDefaults auto-enables strategy:'retry' with + // repairRetries when agents are present, so a failing deterministic gate is + // repaired by default. Opting into fail-fast is the contract for disabling + // that — a deterministic failure should then surface immediately with no + // repair agent spawned. const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'relay-deterministic-no-implicit-repair-')); const repairAgent = vi.fn(async () => 'unexpected repair'); runner = new WorkflowRunner({ @@ -623,6 +675,7 @@ agents: try { const run = await runner.execute( makeConfig({ + errorHandling: { strategy: 'fail-fast' }, agents: [{ name: 'fixer', cli: 'claude', role: 'implementation engineer' }], workflows: [ { @@ -649,7 +702,10 @@ agents: it('should fail when owner response provides no decision, marker, or evidence', async () => { mockSpawnOutputs = ['Owner completed work but forgot sentinel\n']; - const run = await runner.execute(makeConfig(), 'default'); + // The runner now defaults to strategy:'retry' with repairRetries. This test + // asserts the first-pass failure path, so opt into fail-fast to surface the + // missing-decision error immediately instead of entering the repair/retry loop. + const run = await runner.execute(makeConfig({ errorHandling: { strategy: 'fail-fast' } }), 'default'); expect(run.status).toBe('failed'); expect(run.error).toContain('owner completion decision missing'); }); @@ -774,7 +830,7 @@ agents: emitMockEvent('workerOutput', { name, chunk: output }); }); - return { ...mockAgent, name }; + return makeMockHandle(name); } ); @@ -891,7 +947,10 @@ agents: expect(stepRows[0].output).not.toContain('Worker already exited; artifacts look correct'); }); - it('should fail when review response lacks any usable decision signal', async () => { + it('should fail closed (reject) when the reviewer hedges instead of deciding', async () => { + // A reviewer that explicitly defers ("I need more context before deciding") + // never emitted a decision. The runner treats declared indecision as a + // fail-closed REJECT so the step retries rather than crashing as malformed. mockSpawnOutputs = [ 'worker finished\n', 'STEP_COMPLETE:step-1\n', @@ -899,7 +958,7 @@ agents: ]; const run = await runner.execute(makeSupervisedConfig(), 'default'); expect(run.status).toBe('failed'); - expect(run.error).toContain('review response malformed'); + expect(run.error).toContain('review rejected'); }); it('should fail when review explicitly rejects step output', async () => { @@ -994,20 +1053,29 @@ agents: if (isOwner) { return { name, + runtime: 'pty' as const, + exitCode: undefined, + exitSignal: undefined, + // SpawnedAgentHandle resolves to `{ reason }` objects; the runner's + // WorkflowAgentHandle destructures `reason`, so raw strings would + // map to `undefined` and the timeout would go undetected. waitForExit: vi.fn().mockImplementation(async () => { await Promise.resolve(); - return 'timeout'; + return { reason: 'timeout' }; }), - waitForIdle: vi.fn().mockResolvedValue('timeout'), + waitForIdle: vi.fn().mockResolvedValue({ reason: 'timeout' }), release: ownerRelease, }; } return { name, + runtime: 'pty' as const, + exitCode: undefined, + exitSignal: undefined, waitForExit: vi.fn().mockImplementation(async () => { await workerRelease(); - return 'released'; + return { reason: 'released' }; }), waitForIdle: vi.fn().mockImplementation(() => never()), release: workerRelease, @@ -1037,7 +1105,9 @@ agents: waitForExitFn = vi.fn().mockResolvedValue('timeout'); waitForIdleFn = vi.fn().mockResolvedValue('timeout'); - const run = await runner.execute(makeConfig(), 'default'); + // fail-fast so the single timeout surfaces immediately instead of entering + // the auto-enabled repair/retry loop (which would re-spawn timing-out mocks). + const run = await runner.execute(makeConfig({ errorHandling: { strategy: 'fail-fast' } }), 'default'); expect(run.status).toBe('failed'); expect(run.error).toContain('timed out'); expect(events).toContainEqual({ type: 'step:owner-timeout', stepName: 'step-1' }); diff --git a/packages/core/src/__tests__/workflow-trajectory.test.ts b/packages/core/src/__tests__/workflow-trajectory.test.ts index 34dbdf7..5cf1e51 100644 --- a/packages/core/src/__tests__/workflow-trajectory.test.ts +++ b/packages/core/src/__tests__/workflow-trajectory.test.ts @@ -36,13 +36,17 @@ function findFirstJsonFile(dir: string): string | null { return null; } +// agent-trajectories' default data dir relative to the base/cwd. v0.6 relocated +// this from the legacy `.trajectories` to `.agentworkforce/trajectories`. +const DEFAULT_TRAJECTORY_DIR = path.join('.agentworkforce', 'trajectories'); + function readTrajectoryFile(dir: string): any { - const file = findFirstJsonFile(path.join(dir, '.trajectories', 'active')); + const file = findFirstJsonFile(path.join(dir, DEFAULT_TRAJECTORY_DIR, 'active')); return file ? JSON.parse(readFileSync(file, 'utf-8')) : null; } function readCompletedTrajectoryFile(dir: string): any { - const file = findFirstJsonFile(path.join(dir, '.trajectories', 'completed')); + const file = findFirstJsonFile(path.join(dir, DEFAULT_TRAJECTORY_DIR, 'completed')); return file ? JSON.parse(readFileSync(file, 'utf-8')) : null; } @@ -89,7 +93,7 @@ describe('WorkflowTrajectory', () => { expect(traj.isEnabled()).toBe(false); expect(traj.getTrajectoryId()).toBeNull(); - expect(existsSync(path.join(tmpDir, '.trajectories'))).toBe(false); + expect(existsSync(path.join(tmpDir, DEFAULT_TRAJECTORY_DIR))).toBe(false); }); it('should not create files when enabled is false', async () => { diff --git a/packages/core/src/__tests__/yaml-validation.test.ts b/packages/core/src/__tests__/yaml-validation.test.ts index 545f289..ddf1a9f 100644 --- a/packages/core/src/__tests__/yaml-validation.test.ts +++ b/packages/core/src/__tests__/yaml-validation.test.ts @@ -29,7 +29,7 @@ import { } from '../custom-steps.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const TEMPLATES_DIR = path.resolve(__dirname, '../workflows/builtin-templates'); +const TEMPLATES_DIR = path.resolve(__dirname, '../builtin-templates'); // Mock DB for coordinator tests const mockDb = { diff --git a/packages/core/src/agent-handle.ts b/packages/core/src/agent-handle.ts new file mode 100644 index 0000000..38a0458 --- /dev/null +++ b/packages/core/src/agent-handle.ts @@ -0,0 +1,47 @@ +/** + * Thin adapter over the harness-driver's `SpawnedAgentHandle`. + * + * The driver (`@agent-relay/harness-driver` ≥ 8.2) owns the real lifecycle + * logic — event subscription, replay-correct exit/idle resolution, exit + * code/signal capture. This wrapper only maps its structured results + * (`{ reason, code, signal }` / `{ reason, idleSecs }`) back to the simple + * string contract the workflow runner has always used (`'exited'` / `'timeout'` + * / `'idle'`), so `runner.ts` keeps consuming handles unchanged. + */ +import type { SpawnedAgentHandle } from '@agent-relay/harness-driver'; + +export class WorkflowAgentHandle { + constructor(private readonly inner: SpawnedAgentHandle) {} + + get name(): string { + return this.inner.name; + } + + get runtime(): SpawnedAgentHandle['runtime'] { + return this.inner.runtime; + } + + get exitCode(): number | undefined { + return this.inner.exitCode; + } + + get exitSignal(): string | undefined { + return this.inner.exitSignal; + } + + /** Resolves `'exited'` when the agent exits, or `'timeout'` after `timeoutMs`. */ + async waitForExit(timeoutMs?: number): Promise<'exited' | 'timeout'> { + const { reason } = await this.inner.waitForExit(timeoutMs); + return reason; + } + + /** Resolves `'idle'` on the next idle signal, `'exited'` if it exits first, or `'timeout'`. */ + async waitForIdle(timeoutMs?: number): Promise<'idle' | 'exited' | 'timeout'> { + const { reason } = await this.inner.waitForIdle(timeoutMs); + return reason; + } + + release(reason?: string): Promise<{ name: string }> { + return this.inner.release(reason); + } +} diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index 170836d..5e604dd 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { stringify as stringifyYaml } from 'yaml'; -import type { AgentRelayOptions } from '@agent-relay/sdk'; +import type { RuntimeSpawnOptions } from '@agent-relay/harness-driver'; import type { AgentCli, AgentDefinition, @@ -126,7 +126,7 @@ export interface WorkflowRunOptions { /** Working directory (default: process.cwd()). */ cwd?: string; /** AgentRelay options (all optional). */ - relay?: AgentRelayOptions; + relay?: RuntimeSpawnOptions; /** Progress callback. */ onEvent?: WorkflowEventListener; /** Validate and print execution plan without spawning agents. */ diff --git a/packages/core/src/channel-messenger.ts b/packages/core/src/channel-messenger.ts index bb3ac45..565afa7 100644 --- a/packages/core/src/channel-messenger.ts +++ b/packages/core/src/channel-messenger.ts @@ -1,4 +1,4 @@ -import { stripAnsi as stripAnsiFn } from '@agent-relay/sdk'; +import stripAnsiFn from 'strip-ansi'; import type { StepOutcome } from './trajectory.js'; import type { AgentDefinition, WorkflowStepRow } from './types.js'; diff --git a/packages/core/src/cli-registry.ts b/packages/core/src/cli-registry.ts new file mode 100644 index 0000000..eed12cc --- /dev/null +++ b/packages/core/src/cli-registry.ts @@ -0,0 +1,321 @@ +/** + * CLI registry, binary resolver, and spawn-policy helpers. + * + * Vendored from `@agent-relay/sdk` v7 (cli-registry / cli-resolver / + * spawn-from-env), which dropped these exports in v8 when agent spawning moved + * to `@agent-relay/harnesses` + `@agent-relay/harness-driver`. Core still needs + * the CLI metadata, sync binary resolution (e.g. resolving `cursor` to its + * concrete binary), and the bypass-flag spawn policy, so they live here. + * + * Single source of truth for supported agent CLI metadata: binary names, + * non-interactive args, bypass flags, and well-known install paths. + */ +import { execFileSync } from 'node:child_process'; +import { access, constants } from 'node:fs/promises'; +import { accessSync, constants as constantsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// ── Well-known install paths ─────────────────────────────────────────────── +/** + * Common install directories checked when PATH is empty or incomplete. + * Paths containing `~` are expanded at resolution time. + */ +export const COMMON_SEARCH_PATHS = [ + '~/.local/bin', + '~/.opencode/bin', + '~/.claude/local', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/opt/homebrew/bin', +]; + +export interface CliDefinition { + /** Binary name(s) to try, in order of preference */ + binaries: string[]; + /** Build non-interactive mode args for a one-shot task */ + nonInteractiveArgs: (task: string, extraArgs?: string[]) => string[]; + /** Bypass flag for auto-approve / unattended mode */ + bypassFlag?: string; + /** Bypass flag aliases (alternative forms accepted by the CLI) */ + bypassAliases?: string[]; + /** Extra install paths to check beyond PATH (resolved relative to $HOME) */ + searchPaths?: string[]; + /** When true, non-zero exit codes are not treated as failures */ + ignoreExitCode?: boolean; +} + +// ── Registry ─────────────────────────────────────────────────────────────── +const CLI_REGISTRY: Record = { + claude: { + binaries: ['claude'], + nonInteractiveArgs: (task, extra = []) => ['-p', '--dangerously-skip-permissions', task, ...extra], + bypassFlag: '--dangerously-skip-permissions', + searchPaths: ['~/.claude/local'], + }, + codex: { + binaries: ['codex'], + nonInteractiveArgs: (task, extra = []) => [ + 'exec', + '--dangerously-bypass-approvals-and-sandbox', + task, + ...extra, + ], + bypassFlag: '--dangerously-bypass-approvals-and-sandbox', + bypassAliases: ['--full-auto'], + searchPaths: ['~/.local/bin'], + }, + gemini: { + binaries: ['gemini'], + nonInteractiveArgs: (task, extra = []) => ['-p', task, ...extra], + bypassFlag: '--yolo', + bypassAliases: ['-y'], + }, + opencode: { + binaries: ['opencode'], + nonInteractiveArgs: (task, extra = []) => ['run', task, ...extra], + searchPaths: ['~/.opencode/bin'], + ignoreExitCode: true, + }, + droid: { + binaries: ['droid'], + nonInteractiveArgs: (task, extra = []) => ['exec', task, ...extra], + }, + aider: { + binaries: ['aider'], + nonInteractiveArgs: (task, extra = []) => ['--message', task, '--yes-always', '--no-git', ...extra], + }, + goose: { + binaries: ['goose'], + nonInteractiveArgs: (task, extra = []) => ['run', '--text', task, '--no-session', ...extra], + }, + 'cursor-agent': { + binaries: ['cursor-agent'], + nonInteractiveArgs: (task, extra = []) => ['--force', '-p', task, ...extra], + }, + agent: { + binaries: ['agent'], + nonInteractiveArgs: (task, extra = []) => ['--force', '-p', task, ...extra], + }, + cursor: { + binaries: ['cursor-agent', 'agent'], + nonInteractiveArgs: (task, extra = []) => ['--force', '-p', task, ...extra], + }, + api: { + binaries: [], + nonInteractiveArgs: (task) => [task], + }, +}; + +/** + * Get the CLI definition for a given CLI identifier. + * Handles `cli:model` variants (e.g. `claude:opus`) by extracting the base CLI. + */ +export function getCliDefinition(cli: string): CliDefinition | undefined { + const baseCli = cli.includes(':') ? cli.split(':')[0] : cli; + return CLI_REGISTRY[baseCli]; +} + +/** Get the full registry (read-only). */ +export function getCliRegistry(): Readonly> { + return CLI_REGISTRY; +} + +// ── Binary resolution ──────────────────────────────────────────────────────── +export interface ResolvedCli { + /** The binary name that was found */ + binary: string; + /** The full path to the binary */ + path: string; +} + +// null sentinel means "looked up, not found" — avoids repeating expensive searches +const resolveCache = new Map(); + +/** Clear the resolution cache. Useful for testing or after PATH changes. */ +export function clearResolveCache(): void { + resolveCache.clear(); +} + +function expandHome(p: string): string { + if (p.startsWith('~/')) { + return join(homedir(), p.slice(2)); + } + return p; +} + +/** + * Resolve a CLI to its binary path. Checks PATH via `which`, then falls back + * to well-known install directories from the CLI registry. Memoized. + */ +export async function resolveCli(cli: string): Promise { + if (resolveCache.has(cli)) { + return resolveCache.get(cli) ?? undefined; + } + const def = getCliDefinition(cli); + if (!def) return undefined; + for (const binary of def.binaries) { + try { + const { stdout } = await execFileAsync('which', [binary]); + const path = stdout.trim(); + if (path) { + const result = { binary, path }; + resolveCache.set(cli, result); + return result; + } + } catch { + // not in PATH + } + const searchDirs = [...(def.searchPaths ?? []), ...COMMON_SEARCH_PATHS]; + const seen = new Set(); + for (const dir of searchDirs) { + const expanded = expandHome(dir); + if (seen.has(expanded)) continue; + seen.add(expanded); + const candidate = join(expanded, binary); + try { + await access(candidate, constants.X_OK); + const result = { binary, path: candidate }; + resolveCache.set(cli, result); + return result; + } catch { + // not found here + } + } + } + resolveCache.set(cli, null); + return undefined; +} + +/** + * Synchronous version of `resolveCli`. Uses `which` via execFileSync and + * synchronous fs.accessSync. Prefer the async version when possible. + */ +export function resolveCliSync(cli: string): ResolvedCli | undefined { + if (resolveCache.has(cli)) { + return resolveCache.get(cli) ?? undefined; + } + const def = getCliDefinition(cli); + if (!def) return undefined; + for (const binary of def.binaries) { + try { + const stdout = execFileSync('which', [binary], { stdio: ['pipe', 'pipe', 'ignore'] }); + const path = stdout.toString().trim(); + if (path) { + const result = { binary, path }; + resolveCache.set(cli, result); + return result; + } + } catch { + // not in PATH + } + const searchDirs = [...(def.searchPaths ?? []), ...COMMON_SEARCH_PATHS]; + const seen = new Set(); + for (const dir of searchDirs) { + const expanded = expandHome(dir); + if (seen.has(expanded)) continue; + seen.add(expanded); + const candidate = join(expanded, binary); + try { + accessSync(candidate, constantsSync.X_OK); + const result = { binary, path: candidate }; + resolveCache.set(cli, result); + return result; + } catch { + // not found here + } + } + } + resolveCache.set(cli, null); + return undefined; +} + +// ── Spawn policy ────────────────────────────────────────────────────────────── +export interface SpawnEnvInput { + AGENT_NAME: string; + AGENT_CLI: string; + RELAY_API_KEY: string; + AGENT_TASK?: string; + /** JSON array preferred, space-delimited fallback */ + AGENT_ARGS?: string; + AGENT_CWD?: string; + /** Comma-separated channel list, defaults to "general" */ + AGENT_CHANNELS?: string; + RELAY_BASE_URL?: string; + BROKER_BINARY_PATH?: string; + /** Model override (e.g. "opus", "sonnet") */ + AGENT_MODEL?: string; + /** "1" disables SDK default bypass flags */ + AGENT_DISABLE_DEFAULT_BYPASS?: string; +} + +export interface SpawnPolicyResult { + name: string; + cli: string; + args: string[]; + channels: string[]; + task?: string; + cwd?: string; + model?: string; + bypassApplied: boolean; +} + +/** Resolve bypass flag config for a CLI from the consolidated registry. */ +function getBypassFlagConfig(cli: string): { flag: string; aliases?: string[] } | undefined { + const def = getCliDefinition(cli); + if (!def?.bypassFlag) return undefined; + return { flag: def.bypassFlag, aliases: def.bypassAliases }; +} + +/** Parse extra args from env. Supports JSON array or space-delimited string. */ +function parseArgs(raw?: string): string[] { + if (!raw) return []; + const trimmed = raw.trim(); + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { + // Fall through to space-delimited + } + } + return trimmed.split(/\s+/).filter(Boolean); +} + +/** + * Resolve the full spawn policy from parsed env input. + * Applies bypass flags unless disabled or already present. + */ +export function resolveSpawnPolicy(input: SpawnEnvInput): SpawnPolicyResult { + const extraArgs = parseArgs(input.AGENT_ARGS); + const channels = input.AGENT_CHANNELS + ? input.AGENT_CHANNELS.split(',') + .map((c) => c.trim()) + .filter(Boolean) + : ['general']; + const disableBypass = input.AGENT_DISABLE_DEFAULT_BYPASS === '1'; + const bypassConfig = getBypassFlagConfig(input.AGENT_CLI); + let bypassApplied = false; + const args = [...extraArgs]; + const bypassValues = bypassConfig ? [bypassConfig.flag, ...(bypassConfig.aliases ?? [])] : []; + const hasBypassAlready = bypassValues.some((value) => args.includes(value)); + if (bypassConfig && !disableBypass && !hasBypassAlready) { + args.push(bypassConfig.flag); + bypassApplied = true; + } + return { + name: input.AGENT_NAME, + cli: input.AGENT_CLI, + args, + channels, + task: input.AGENT_TASK, + cwd: input.AGENT_CWD, + model: input.AGENT_MODEL, + bypassApplied, + }; +} diff --git a/packages/core/src/integrations/browser.ts b/packages/core/src/integrations/browser.ts index e47ed19..7a192d0 100644 --- a/packages/core/src/integrations/browser.ts +++ b/packages/core/src/integrations/browser.ts @@ -1,6 +1,6 @@ import type { RunnerStepExecutor, WorkflowStep } from '../types.js'; -import { BrowserClient, type BrowserClientOptions } from '@agent-relay/browser-primitive'; +import { BrowserClient, type BrowserClientOptions } from '@relayflows/browser-primitive'; import type { ActionResult, BrowserActionName, @@ -8,7 +8,7 @@ import type { BrowserActionRequest, BrowserConfig, BrowserSession, -} from '@agent-relay/browser-primitive'; +} from '@relayflows/browser-primitive'; export type BrowserStepOutputMode = 'last' | 'all' | 'captures' | 'summary' | 'none'; export type BrowserStepOutputFormat = 'json' | 'text'; diff --git a/packages/core/src/integrations/github.ts b/packages/core/src/integrations/github.ts index 35f2e82..3cbfc2c 100644 --- a/packages/core/src/integrations/github.ts +++ b/packages/core/src/integrations/github.ts @@ -1,6 +1,6 @@ import type { RunnerStepExecutor, WorkflowStep } from '../types.js'; -import { GitHubClient } from '@agent-relay/github-primitive'; +import { GitHubClient } from '@relayflows/github-primitive'; import type { GitHubActionName, GitHubActionParamsMap, @@ -8,8 +8,8 @@ import type { GitHubRuntime, GitHubRuntimeConfig, RepositoryRef, -} from '@agent-relay/github-primitive'; -import { GITHUB_ACTIONS } from '@agent-relay/github-primitive'; +} from '@relayflows/github-primitive'; +import { GITHUB_ACTIONS } from '@relayflows/github-primitive'; export type GitHubStepOutputMode = 'data' | 'result' | 'summary' | 'raw' | 'none'; export type GitHubStepOutputFormat = 'json' | 'text'; diff --git a/packages/core/src/integrations/slack.ts b/packages/core/src/integrations/slack.ts index 981a7aa..f145543 100644 --- a/packages/core/src/integrations/slack.ts +++ b/packages/core/src/integrations/slack.ts @@ -1,13 +1,13 @@ import type { RunnerStepExecutor, WorkflowStep } from '../types.js'; -import { SlackClient } from '@agent-relay/slack-primitive'; +import { SlackClient } from '@relayflows/slack-primitive'; import { SlackAction, SLACK_ACTIONS, type PostMessageParams, type SlackActionResult, type SlackRuntimeConfig, -} from '@agent-relay/slack-primitive'; +} from '@relayflows/slack-primitive'; export type SlackStepOutputMode = 'data' | 'result' | 'summary' | 'raw' | 'none'; export type SlackStepOutputFormat = 'json' | 'text'; diff --git a/packages/core/src/process-spawner.ts b/packages/core/src/process-spawner.ts index 9c63610..ec71e97 100644 --- a/packages/core/src/process-spawner.ts +++ b/packages/core/src/process-spawner.ts @@ -1,8 +1,8 @@ import { spawn as cpSpawn } from 'node:child_process'; import type { ChildProcess, SpawnOptions } from 'node:child_process'; -import { getCliDefinition } from '@agent-relay/sdk'; -import { resolveCliSync } from '@agent-relay/sdk'; +import { getCliDefinition } from './cli-registry.js'; +import { resolveCliSync } from './cli-registry.js'; import { runVerification } from './verification.js'; import type { AgentCli, AgentDefinition, VerificationCheck } from './types.js'; @@ -68,18 +68,29 @@ export function spawnProcess(command: string[], options: SpawnOptions): ChildPro return cpSpawn(bin, args, options); } -export function collectOutput(process: ChildProcess): Promise { +export function collectOutput( + process: ChildProcess, + onChunk?: (accumulated: string) => void, +): Promise { return new Promise((resolve, reject) => { let settled = false; const stdout: string[] = []; const stderr: string[] = []; + const notify = () => { + if (onChunk && !settled) { + onChunk(`${stdout.join('')}${stderr.join('')}`); + } + }; + process.stdout?.on('data', (chunk: Buffer | string) => { stdout.push(chunk.toString()); + notify(); }); process.stderr?.on('data', (chunk: Buffer | string) => { stderr.push(chunk.toString()); + notify(); }); process.once('error', (err) => { @@ -124,7 +135,23 @@ async function runCommand(command: SpawnCommand, opts: ShellOpts): Promise | undefined; + + const outputPromise = collectOutput(child, (accumulated) => { + if (!completedEarly && detectCompletion(accumulated)) { + completedEarly = true; + child.kill('SIGTERM'); + earlyKillTimer = setTimeout(() => child.kill('SIGKILL'), 5000); + } + }); + const exitPromise = new Promise<{ exitCode?: number; exitSignal?: string }>((resolve, reject) => { let timedOut = false; let timer: ReturnType | undefined; @@ -141,6 +168,7 @@ async function runCommand(command: SpawnCommand, opts: ShellOpts): Promise { if (timer) clearTimeout(timer); if (killTimer) clearTimeout(killTimer); + if (earlyKillTimer) clearTimeout(earlyKillTimer); }; child.once('error', (error) => { @@ -151,6 +179,16 @@ async function runCommand(command: SpawnCommand, opts: ShellOpts): Promise { clearTimer(); + // Early completion wins over the timeout: even though we SIGTERM'd the + // child, the marker was already present, so this is a clean success. + // Normalise the outcome to exit code 0 with no signal — otherwise the + // SIGTERM would surface downstream as `exitCode === undefined && + // exitSignal !== undefined`, which step execution treats as a failure. + if (completedEarly) { + resolve({ exitCode: 0, exitSignal: undefined }); + return; + } + if (timedOut) { reject(new Error(`Process timed out after ${opts.timeoutMs ?? 'unknown'}ms`)); return; diff --git a/packages/core/src/proxy-env.ts b/packages/core/src/proxy-env.ts index f16e165..312ecae 100644 --- a/packages/core/src/proxy-env.ts +++ b/packages/core/src/proxy-env.ts @@ -1,4 +1,4 @@ -import { getCliDefinition } from '@agent-relay/sdk'; +import { getCliDefinition } from './cli-registry.js'; import type { AgentDefinition, SwarmConfig } from './types.js'; export interface ProxyEnvBinding { diff --git a/packages/core/src/run-script.ts b/packages/core/src/run-script.ts index 097066d..6a157fb 100644 --- a/packages/core/src/run-script.ts +++ b/packages/core/src/run-script.ts @@ -475,10 +475,16 @@ export async function runScriptWorkflow( ): Promise { diag(`runScriptWorkflow: resolving ${filePath}`); const resolved = path.resolve(filePath); + // Validate the extension before existence so an unsupported file type is + // reported as such even when the path does not exist — the file type is a + // caller mistake regardless of whether the file is present. + const ext = path.extname(resolved).toLowerCase(); + if (ext !== '.ts' && ext !== '.tsx' && ext !== '.py') { + throw new Error(`Unsupported file type: ${ext}. Use .yaml, .yml, .ts, or .py`); + } if (!fs.existsSync(resolved)) { throw new Error(`File not found: ${resolved}`); } - const ext = path.extname(resolved).toLowerCase(); const runIdFile = path.join( process.cwd(), '.agent-relay', diff --git a/packages/core/src/run.ts b/packages/core/src/run.ts index 05b5f49..3d32ceb 100644 --- a/packages/core/src/run.ts +++ b/packages/core/src/run.ts @@ -1,4 +1,4 @@ -import type { AgentRelayOptions } from '@agent-relay/sdk'; +import type { RuntimeSpawnOptions } from '@agent-relay/harness-driver'; import type { DryRunReport, TrajectoryConfig, WorkflowRunRow } from './types.js'; import { WorkflowRunner, type WorkflowEventListener } from './runner.js'; import { createDefaultEventLogger } from './default-logger.js'; @@ -16,7 +16,7 @@ export interface RunWorkflowOptions { /** Working directory. Defaults to process.cwd(). */ cwd?: string; /** AgentRelay options (all optional — broker starts automatically). */ - relay?: AgentRelayOptions; + relay?: RuntimeSpawnOptions; /** Progress callback for workflow events. */ onEvent?: WorkflowEventListener; /** Override trajectory config. Set to false to disable trajectory recording. */ diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index a9f1b2e..656d73e 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -25,11 +25,11 @@ import chalk from 'chalk'; import ignore from 'ignore'; import { parse as parseYaml } from 'yaml'; -import { stripAnsi as stripAnsiFn } from '@agent-relay/sdk'; -import type { BrokerEvent } from '@agent-relay/sdk'; -import { resolveSpawnPolicy } from '@agent-relay/sdk'; -import { getCliDefinition } from '@agent-relay/sdk'; -import { resolveCliSync } from '@agent-relay/sdk'; +import stripAnsiFn from 'strip-ansi'; +import type { BrokerEvent } from '@agent-relay/harness-driver'; +import { resolveSpawnPolicy } from './cli-registry.js'; +import { getCliDefinition } from './cli-registry.js'; +import { resolveCliSync } from './cli-registry.js'; import { buildNormalizedProxyEnv, getStrippedApiKeyVars, @@ -119,11 +119,13 @@ import { WorkflowCompletionError, } from './verification.js'; -// ── AgentRelay SDK imports ────────────────────────────────────────────────── +// ── Broker client / messaging imports ─────────────────────────────────────── -// Import from sub-paths to avoid pulling in the full @relaycast/sdk dependency. -import { AgentRelay } from '@agent-relay/sdk'; -import type { Agent, AgentRelayOptions, AgentSpawner } from '@agent-relay/sdk'; +// Broker / PTY / lifecycle is driven by the harness-driver client; messaging +// uses @relaycast/sdk (below). +import { HarnessDriverClient } from '@agent-relay/harness-driver'; +import type { RuntimeSpawnOptions, SpawnPtyInput } from '@agent-relay/harness-driver'; +import { WorkflowAgentHandle } from './agent-handle.js'; import { RelayCast, RelayError, type AgentClient } from '@relaycast/sdk'; // ── Environment filtering ────────────────────────────────────────────────── @@ -295,7 +297,7 @@ export type WorkflowEventListener = (event: WorkflowEvent) => void; export interface WorkflowRunnerOptions { db?: WorkflowDb; workspaceId?: string; - relay?: AgentRelayOptions; + relay?: RuntimeSpawnOptions; cwd?: string; summaryDir?: string; executor?: RunnerStepExecutor; @@ -317,7 +319,7 @@ export interface WorkflowRunnerOptions { interface StepState { row: WorkflowStepRow; - agent?: Agent; + agent?: WorkflowAgentHandle; } interface SupervisedStep { @@ -329,7 +331,7 @@ interface SupervisedStep { interface SpawnedAgentInfo { requestedName: string; actualName: string; - agent: Agent; + agent: WorkflowAgentHandle; } interface SpawnAndWaitOptions { @@ -441,21 +443,6 @@ function resolveCursorCli(): 'cursor-agent' | 'agent' { return (resolved?.binary as 'cursor-agent' | 'agent') ?? 'agent'; } -function getWorkflowSdkSpawner(relay: AgentRelay, cli: AgentCli): AgentSpawner | null { - switch (cli) { - case 'claude': - return relay.claude; - case 'codex': - return relay.codex; - case 'gemini': - return relay.gemini; - case 'opencode': - return relay.opencode; - default: - return null; - } -} - function resolveWorkflowTokenSigningKey(env: NodeJS.ProcessEnv): LocalJwksSigningKey { const privateKeyPem = env[RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV]; const kid = env[RELAYAUTH_JWT_KID_ENV]; @@ -478,7 +465,7 @@ function resolveWorkflowTokenSigningKey(env: NodeJS.ProcessEnv): LocalJwksSignin export class WorkflowRunner { private readonly db: WorkflowDb; private readonly workspaceId: string; - private readonly relayOptions: AgentRelayOptions; + private readonly relayOptions: RuntimeSpawnOptions; private readonly cwd: string; private readonly summaryDir: string; private executor?: RunnerStepExecutor; @@ -487,7 +474,7 @@ export class WorkflowRunner { private readonly channelMessenger: ChannelMessenger; /** @internal exposed for CLI signal-handler shutdown only */ - relay?: AgentRelay; + relay?: HarnessDriverClient; private relaycast?: RelayCast; private relaycastAgent?: AgentClient; private relayApiKey?: string; @@ -504,7 +491,7 @@ export class WorkflowRunner { /** Current run ID for event emission from spawnAndWait context. */ private currentRunId?: string; /** Live Agent handles keyed by name, for hub-mediated nudging. */ - private readonly activeAgentHandles = new Map(); + private readonly activeAgentHandles = new Map(); /** Per-agent workflow tokens for relay/relayfile auth across spawn modes. */ private readonly agentTokens = new Map(); /** Per-agent credential proxy tokens keyed by logical agent definition name. */ @@ -3142,7 +3129,7 @@ export class WorkflowRunner { // workspace hits a 409 conflict because the previous run's agent is still registered. const brokerBaseName = path.basename(this.cwd) || 'workflow'; const brokerName = `${brokerBaseName}-${runId.slice(0, 8)}`; - this.relay = new AgentRelay({ + this.relay = await HarnessDriverClient.spawn({ ...this.relayOptions, brokerName, channels: relaycastDisabled ? [] : [channel], @@ -3151,197 +3138,153 @@ export class WorkflowRunner { // Relaycast registration. 60s is too tight when the broker is saturated with // long-running PTY processes from earlier steps. 120s gives room to breathe. requestTimeoutMs: this.relayOptions.requestTimeoutMs ?? 120_000, + // Wire broker stderr to console for observability — skip empty and + // JSON event lines (already surfaced via the broker:event emitter). + onStderr: (line: string) => { + const trimmed = line.trim(); + if (!trimmed) return; + if (trimmed.startsWith('{') && trimmed.endsWith('}')) return; + console.log(`${chalk.dim.yellow('[broker]')} ${line}`); + }, }); - // Wire PTY output dispatcher — routes chunks to per-agent listeners + activity logging + // Single dispatcher over the broker event stream. The named event bus + // (`addListener('workerOutput'|...)`) is not fed for direct clients, so + // broker events must be consumed via `onEvent` (the BrokerEvent stream). this.unsubRelayListeners.push( - this.relay.addListener('workerOutput', ({ name, chunk }) => { - const listener = this.ptyListeners.get(name); - if (listener) listener(chunk); - - // Parse PTY output for high-signal activity - const stripped = WorkflowRunner.stripAnsi(chunk); - const shortName = name.replace(/-[a-f0-9]{6,}$/, ''); - let activity: string | undefined; - if (/Read\(/.test(stripped)) { - // Extract filename — path may be truncated at chunk boundary so require - // at least a dir separator or 8+ chars to trust the basename. - const m = stripped.match(/Read\(\s*~?([^\s)"']{8,})/); - if (m) { - const base = path.basename(m[1]); - activity = base.length >= 3 ? `Reading ${base}` : 'Reading file...'; - } else { - activity = 'Reading file...'; - } - } else if (/Edit\(/.test(stripped)) { - const m = stripped.match(/Edit\(\s*~?([^\s)"']{8,})/); - if (m) { - const base = path.basename(m[1]); - activity = base.length >= 3 ? `Editing ${base}` : 'Editing file...'; - } else { - activity = 'Editing file...'; - } - } else if (/Bash\(/.test(stripped)) { - // Extract a short preview of the command - const m = stripped.match(/Bash\(\s*(.{1,40})/); - activity = m ? `Running: ${m[1].trim()}...` : 'Running command...'; - } else if (/Explore\(/.test(stripped)) { - const m = stripped.match(/Explore\(\s*(.{1,50})/); - activity = m ? `Exploring: ${m[1].replace(/\).*/, '').trim()}` : 'Exploring codebase...'; - } else if (/Task\(/.test(stripped)) { - activity = 'Running sub-agent...'; - } else if (/Sublimating|Thinking|Coalescing|Cultivating/.test(stripped)) { - const m = stripped.match(/(\d+)s/); - activity = m ? `Thinking... (${m[1]}s)` : 'Thinking...'; + this.relay.onEvent((event: BrokerEvent) => { + // Re-emit every broker event except the high-volume PTY stream. + if (event.kind !== 'worker_stream') { + this.emit({ type: 'broker:event', runId, event }); } - if (activity && this.lastActivity.get(name) !== activity) { - this.lastActivity.set(name, activity); - this.log(`[${shortName}] ${activity}`); - } - }) - ); - // Wire relay event hooks for rich console logging - this.unsubRelayListeners.push( - this.relay.addListener('messageReceived', (msg) => { - this.emit({ - type: 'broker:event', - runId, - event: { - kind: 'relay_inbound', - event_id: msg.eventId, - from: msg.from, - target: msg.to, - body: msg.text, - thread_id: msg.threadId, - } as BrokerEvent, - }); - const body = msg.text.length > 120 ? msg.text.slice(0, 117) + '...' : msg.text; - const fromShort = msg.from.replace(/-[a-f0-9]{6,}$/, ''); - const toShort = msg.to.replace(/-[a-f0-9]{6,}$/, ''); - this.log(`[msg] ${fromShort} → ${toShort}: ${body}`); - - if (this.channel && (msg.to === this.channel || msg.to === `#${this.channel}`)) { - const runtimeAgent = this.runtimeStepAgents.get(msg.from); - this.recordChannelEvidence(msg.text, { - sender: runtimeAgent?.logicalName ?? msg.from, - actor: msg.from, - role: runtimeAgent?.role, - target: msg.to, - origin: 'relay_message', - stepName: runtimeAgent?.stepName, - }); - } + switch (event.kind) { + case 'worker_stream': { + const { name, chunk } = event; + const listener = this.ptyListeners.get(name); + if (listener) listener(chunk); + + // Parse PTY output for high-signal activity + const stripped = WorkflowRunner.stripAnsi(chunk); + const shortName = name.replace(/-[a-f0-9]{6,}$/, ''); + let activity: string | undefined; + if (/Read\(/.test(stripped)) { + // Extract filename — path may be truncated at chunk boundary so require + // at least a dir separator or 8+ chars to trust the basename. + const m = stripped.match(/Read\(\s*~?([^\s)"']{8,})/); + if (m) { + const base = path.basename(m[1]); + activity = base.length >= 3 ? `Reading ${base}` : 'Reading file...'; + } else { + activity = 'Reading file...'; + } + } else if (/Edit\(/.test(stripped)) { + const m = stripped.match(/Edit\(\s*~?([^\s)"']{8,})/); + if (m) { + const base = path.basename(m[1]); + activity = base.length >= 3 ? `Editing ${base}` : 'Editing file...'; + } else { + activity = 'Editing file...'; + } + } else if (/Bash\(/.test(stripped)) { + // Extract a short preview of the command + const m = stripped.match(/Bash\(\s*(.{1,40})/); + activity = m ? `Running: ${m[1].trim()}...` : 'Running command...'; + } else if (/Explore\(/.test(stripped)) { + const m = stripped.match(/Explore\(\s*(.{1,50})/); + activity = m ? `Exploring: ${m[1].replace(/\).*/, '').trim()}` : 'Exploring codebase...'; + } else if (/Task\(/.test(stripped)) { + activity = 'Running sub-agent...'; + } else if (/Sublimating|Thinking|Coalescing|Cultivating/.test(stripped)) { + const m = stripped.match(/(\d+)s/); + activity = m ? `Thinking... (${m[1]}s)` : 'Thinking...'; + } + if (activity && this.lastActivity.get(name) !== activity) { + this.lastActivity.set(name, activity); + this.log(`[${shortName}] ${activity}`); + } + break; + } - const supervision = this.supervisedRuntimeAgents.get(msg.from); - if (supervision?.role === 'owner') { - this.recordStepToolSideEffect(supervision.stepName, { - type: 'owner_monitoring', - detail: `Owner messaged ${msg.to}: ${msg.text.slice(0, 120)}`, - raw: { to: msg.to, text: msg.text }, - }); - void this.trajectory?.ownerMonitoringEvent( - supervision.stepName, - supervision.logicalName, - `Messaged ${msg.to}: ${msg.text.slice(0, 120)}`, - { to: msg.to, text: msg.text } - ); - } - }) - ); + case 'relay_inbound': { + const from = event.from; + const to = event.target; + const text = event.body; + const body = text.length > 120 ? text.slice(0, 117) + '...' : text; + const fromShort = from.replace(/-[a-f0-9]{6,}$/, ''); + const toShort = to.replace(/-[a-f0-9]{6,}$/, ''); + this.log(`[msg] ${fromShort} → ${toShort}: ${body}`); + + if (this.channel && (to === this.channel || to === `#${this.channel}`)) { + const runtimeAgent = this.runtimeStepAgents.get(from); + this.recordChannelEvidence(text, { + sender: runtimeAgent?.logicalName ?? from, + actor: from, + role: runtimeAgent?.role, + target: to, + origin: 'relay_message', + stepName: runtimeAgent?.stepName, + }); + } - this.unsubRelayListeners.push( - this.relay.addListener('agentSpawned', (agent) => { - this.emit({ - type: 'broker:event', - runId, - event: { - kind: 'agent_spawned', - name: agent.name, - runtime: agent.runtime, - } as BrokerEvent, - }); - // Skip agents already managed by step execution - if (!this.activeAgentHandles.has(agent.name)) { - this.log(`[spawned] ${agent.name} (${agent.runtime})`); - } - }) - ); + const supervision = this.supervisedRuntimeAgents.get(from); + if (supervision?.role === 'owner') { + this.recordStepToolSideEffect(supervision.stepName, { + type: 'owner_monitoring', + detail: `Owner messaged ${to}: ${text.slice(0, 120)}`, + raw: { to, text }, + }); + void this.trajectory?.ownerMonitoringEvent( + supervision.stepName, + supervision.logicalName, + `Messaged ${to}: ${text.slice(0, 120)}`, + { to, text } + ); + } + break; + } - this.unsubRelayListeners.push( - this.relay.addListener('agentReleased', (agent) => { - this.emit({ - type: 'broker:event', - runId, - event: { - kind: 'agent_released', - name: agent.name, - } as BrokerEvent, - }); - }) - ); + case 'agent_spawned': { + // Skip agents already managed by step execution + if (!this.activeAgentHandles.has(event.name)) { + this.log(`[spawned] ${event.name} (${event.runtime})`); + } + break; + } - this.unsubRelayListeners.push( - this.relay.addListener('agentExited', (agent) => { - this.emit({ - type: 'broker:event', - runId, - event: { - kind: 'agent_exited', - name: agent.name, - code: agent.exitCode, - signal: agent.exitSignal, - } as BrokerEvent, - }); - this.lastActivity.delete(agent.name); - this.lastIdleLog.delete(agent.name); - if (!this.activeAgentHandles.has(agent.name)) { - this.log(`[exited] ${agent.name} (code: ${agent.exitCode ?? '?'})`); - } - }) - ); + case 'agent_exited': { + this.lastActivity.delete(event.name); + this.lastIdleLog.delete(event.name); + if (!this.activeAgentHandles.has(event.name)) { + this.log(`[exited] ${event.name} (code: ${event.code ?? '?'})`); + } + break; + } - this.unsubRelayListeners.push( - this.relay.addListener('deliveryUpdate', (event) => { - this.emit({ type: 'broker:event', runId, event }); - }) - ); + case 'agent_idle': { + const { name, idle_secs } = event; + // Only log at 30s multiples to avoid watchdog spam + const bucket = Math.floor(idle_secs / 30) * 30; + if (bucket >= 30 && this.lastIdleLog.get(name) !== bucket) { + this.lastIdleLog.set(name, bucket); + const shortName = name.replace(/-[a-f0-9]{6,}$/, ''); + this.log(`[idle] ${shortName} silent for ${bucket}s`); + } + break; + } - this.unsubRelayListeners.push( - this.relay.addListener('agentIdle', ({ name, idleSecs }) => { - this.emit({ - type: 'broker:event', - runId, - event: { - kind: 'agent_idle', - name, - idle_secs: idleSecs, - } as BrokerEvent, - }); - // Only log at 30s multiples to avoid watchdog spam - const bucket = Math.floor(idleSecs / 30) * 30; - if (bucket >= 30 && this.lastIdleLog.get(name) !== bucket) { - this.lastIdleLog.set(name, bucket); - const shortName = name.replace(/-[a-f0-9]{6,}$/, ''); - this.log(`[idle] ${shortName} silent for ${bucket}s`); + default: + break; } }) ); + // Open the broker event stream so the dispatcher above receives events. + this.relay.connectEvents(); + this.relaycast = undefined; this.relaycastAgent = undefined; - // Wire broker stderr to console for observability — skip empty and - // JSON event lines (already surfaced via the broker:event emitter). - this.unsubBrokerStderr = this.relay.onBrokerStderr((line: string) => { - const trimmed = line.trim(); - if (!trimmed) return; - // JSON event lines from the Rust EventEmitter are already parsed - // and emitted as broker:event — no need to double-log them. - if (trimmed.startsWith('{') && trimmed.endsWith('}')) return; - console.log(`${chalk.dim.yellow('[broker]')} ${line}`); - }); - if (!relaycastDisabled) { this.log(`Creating channel: ${channel}...`); if (isResume) { @@ -3422,6 +3365,7 @@ export class WorkflowRunner { }); } } catch (err) { + if (process.env.RF_DEBUG_STACK) console.error('RF_DEBUG_STACK_EXEC', (err as Error)?.stack); const errorMsg = err instanceof Error ? err.message : String(err); const status: WorkflowRunStatus = !isResume && this.abortController?.signal.aborted ? 'cancelled' : 'failed'; @@ -3962,6 +3906,7 @@ export class WorkflowRunner { exitSignal: lastExitSignal, }), onAttemptFailed: async (error) => { + if (process.env.RF_DEBUG_STACK) console.error('RF_DEBUG_STACK', (error as Error)?.stack); lastError = error instanceof Error ? error.message : String(error); lastCompletionReason = error instanceof WorkflowCompletionError ? error.completionReason : undefined; }, @@ -4397,7 +4342,7 @@ export class WorkflowRunner { getFailureResult: (error) => ({ status: 'failed', output: '', - error: error instanceof Error ? error.message : String(error), + error: (() => { if (error instanceof Error && /reading 'get'/.test(error.message)) console.error('DEBUG_STACK>>>', error.stack); return error instanceof Error ? error.message : String(error); })(), retries: state.row.retryCount, exitCode: lastExitCode, exitSignal: lastExitSignal, @@ -4463,7 +4408,7 @@ export class WorkflowRunner { getFailureResult: (error) => ({ status: 'failed', output: '', - error: error instanceof Error ? error.message : String(error), + error: (() => { if (error instanceof Error && /reading 'get'/.test(error.message)) console.error('DEBUG_STACK>>>', error.stack); return error instanceof Error ? error.message : String(error); })(), retries: state.row.retryCount, }), }); @@ -4720,7 +4665,7 @@ export class WorkflowRunner { effectiveSpecialist ); const explicitInteractiveWorker = this.isExplicitInteractiveWorker(effectiveOwner); - let explicitWorkerHandle: Agent | undefined; + let explicitWorkerHandle: WorkflowAgentHandle | undefined; let explicitWorkerCompleted = false; let explicitWorkerOutput = ''; @@ -4929,6 +4874,7 @@ export class WorkflowRunner { await this.trajectory?.stepCompleted(step, combinedOutput, attempt + 1); return; } catch (err) { + if (process.env.RF_DEBUG_STACK) console.error('RF_DEBUG_STACK', (err as Error)?.stack); lastError = err instanceof Error ? err.message : String(err); lastCompletionReason = err instanceof WorkflowCompletionError ? err.completionReason : undefined; if (stepOutputForDiagnostic) { @@ -5241,13 +5187,13 @@ export class WorkflowRunner { const staleNames = [...new Set(staleAgents.map((agent) => agent.name))].sort(); this.log(`[${stepName}] Releasing stale retry agent(s): ${staleNames.join(', ')}`); - for (const agent of staleAgents) { - await agent.release(`workflow retry cleanup for step "${stepName}"`); + for (const name of staleNames) { + await this.relay.release(name, `workflow retry cleanup for step "${stepName}"`); } const deadline = Date.now() + 5_000; while (Date.now() < deadline) { - const remaining = (await this.relay.listAgentsRaw()) + const remaining = (await this.relay.listAgents()) .map((agent) => agent.name) .filter((name) => staleNames.includes(name)); if (remaining.length === 0) { @@ -5344,7 +5290,7 @@ export class WorkflowRunner { } } - let workerHandle: Agent | undefined; + let workerHandle: WorkflowAgentHandle | undefined; let workerRuntimeName = supervised.specialist.name; let workerSpawned = false; let workerReleased = false; @@ -5492,7 +5438,21 @@ export class WorkflowRunner { } await workerSettled; if (/\btimed out\b/i.test(message)) { - throw new Error(`Step "${step.name}" owner timed out after ${timeoutMs ?? 'unknown'}ms`); + // Resolve the effective owner timeout so the failure is actionable. A bare + // `${timeoutMs ?? 'unknown'}ms` renders "unknownms" whenever the step has no + // timeout configured, which leaves every downstream repair attempt with an + // undiagnosable context. Fall back through the same precedence the runner uses + // elsewhere (step -> owner agent constraints -> swarm), and name the gap when + // nothing is configured at all. + const effectiveTimeoutMs = + timeoutMs ?? + supervised.owner.constraints?.timeoutMs ?? + this.currentConfig?.swarm?.timeoutMs; + const timeoutLabel = + effectiveTimeoutMs != null + ? `${effectiveTimeoutMs}ms` + : 'the default timeout (no step, owner-agent, or swarm timeout configured)'; + throw new Error(`Step "${step.name}" owner timed out after ${timeoutLabel}`); } throw error; } @@ -6018,6 +5978,10 @@ export class WorkflowRunner { `Original objective:\n${resolvedTask}\n\n` + `Specialist output:\n${specialistSnippet}\n\n` + `Owner verification notes:\n${ownerSnippet}\n\n` + + `You MUST end with a decision line. Do not ask for more context or defer — if the evidence above is insufficient to confirm completion, return REVIEW_DECISION: REJECT and state what is missing in REVIEW_REASON. A response without a REVIEW_DECISION line fails the step.\n` + + `You MUST end with a decision line. If you cannot verify completion, lack\n` + + `context, or are otherwise unsure, fail closed and respond REJECT — do not\n` + + `ask for more information or defer the decision.\n` + `Return exactly:\n` + `REVIEW_DECISION: APPROVE or REJECT\n` + `REVIEW_REASON: \n` + @@ -6075,7 +6039,7 @@ export class WorkflowRunner { return reviewOutput; } - let reviewerHandle: Agent | undefined; + let reviewerHandle: WorkflowAgentHandle | undefined; let reviewerReleased = false; let reviewOutput = ''; let completedReview: { decision: 'approved' | 'rejected'; reason?: string } | undefined; @@ -6154,7 +6118,48 @@ export class WorkflowRunner { return tolerant; } - return this.judgeReviewDecisionFromEvidence(reviewOutput); + const judged = this.judgeReviewDecisionFromEvidence(reviewOutput); + if (judged) { + return judged; + } + + // Fail closed: a reviewer that explicitly hedges (e.g. "I need more context + // before deciding") never emitted a decision. Treat declared indecision as a + // REJECT so the step retries rather than crashing with a "malformed" error. + return this.parseIndecisionAsRejection(reviewOutput); + } + + private parseIndecisionAsRejection( + reviewOutput: string + ): { decision: 'approved' | 'rejected'; reason?: string } | null { + const sanitized = this.stripEchoedPromptLines(reviewOutput, [ + /^Return exactly:?$/i, + /^REVIEW_DECISION:\s*APPROVE\s+or\s+REJECT$/i, + /^REVIEW_REASON:\s*$/i, + ]); + if (!sanitized) { + return null; + } + + const indecision = + /\bneed(?:s|ing)?\s+(?:more|additional|further)\s+(?:context|information|info|detail|details|time|clarification)\b/i.test( + sanitized + ) || + /\bnot\s+enough\s+(?:context|information|info|detail|details)\b/i.test(sanitized) || + /\b(?:can(?:'|no)?t|cannot|unable to|can not)\s+(?:decide|determine|tell|verify|assess|confirm)\b/i.test( + sanitized + ) || + /\b(?:before|prior to)\s+deciding\b/i.test(sanitized) || + /\b(?:unclear|uncertain|ambiguous|unsure)\b/i.test(sanitized); + + if (!indecision) { + return null; + } + + return { + decision: 'rejected', + reason: this.firstMeaningfulLine(sanitized) ?? 'reviewer could not reach a decision', + }; } private parseStrictReviewDecision( @@ -6701,7 +6706,7 @@ export class WorkflowRunner { const agentChannels = this.channel ? [this.channel] : agentDef.channels; - let agent: Agent | undefined; + let agent: WorkflowAgentHandle | undefined; let exitResult: string = 'unknown'; let stopHeartbeat: (() => void) | undefined; let ptyChunks: string[] = []; @@ -6731,19 +6736,13 @@ export class WorkflowRunner { agentToken: this.agentTokens.get(agentDef.name), env: proxyEnvOverrides ? { ...baseEnv, ...proxyEnvOverrides } : baseEnv, }; - const sdkSpawner = getWorkflowSdkSpawner(this.relay, agentDef.cli); - if (sdkSpawner) { - this.log( - `[${step.name}] Using SDK spawner for ${agentDef.cli} (requested runtime: ${agentDef.cli === 'opencode' ? 'headless' : 'pty'})` - ); - agent = await sdkSpawner.spawn(spawnOptions as Parameters[0]); - } else { - this.log(`[${step.name}] Using PTY fallback for ${agentDef.cli}`); - agent = await this.relay.spawnPty({ + this.log(`[${step.name}] Spawning ${agentDef.cli} (pty)`); + agent = new WorkflowAgentHandle( + await this.relay.spawnPty({ ...(spawnOptions as Record), cli: agentDef.cli, - } as Parameters[0]); - } + } as SpawnPtyInput) + ); // Re-key PTY maps if broker assigned a different name than requested if (agent.name !== agentName) { @@ -6807,7 +6806,7 @@ export class WorkflowRunner { // Register in workers.json so `agents:kill` can find this agent let workerPid: number | undefined; try { - const rawAgents = await this.relay!.listAgentsRaw(); + const rawAgents = await this.relay!.listAgents(); workerPid = rawAgents.find((a) => a.name === agentName)?.pid ?? undefined; } catch { // Best-effort PID lookup @@ -7028,7 +7027,7 @@ export class WorkflowRunner { * If no idle nudge config is set, falls through to simple waitForExit. */ private async waitForExitWithIdleNudging( - agent: Agent, + agent: WorkflowAgentHandle, agentDef: AgentDefinition, step: WorkflowStep, timeoutMs?: number, @@ -7179,13 +7178,19 @@ export class WorkflowRunner { * Send a nudge to an idle agent. Uses hub-mediated nudge for hub patterns, * or direct system injection otherwise. */ - private async nudgeIdleAgent(agent: Agent, agentDef: AgentDefinition, step: WorkflowStep): Promise { + private async nudgeIdleAgent( + agent: WorkflowAgentHandle, + agentDef: AgentDefinition, + step: WorkflowStep + ): Promise { + if (!this.relay) return; const hubAgent = this.resolveHubForNudge(agentDef); if (hubAgent) { - // Hub-mediated: tell the hub to check on the idle agent + // Hub-mediated: tell the hub to check on the idle agent (sent as the hub). try { - await hubAgent.sendMessage({ + await this.relay.sendMessage({ + from: hubAgent.name, to: agent.name, text: `Agent ${agent.name} appears idle on step "${step.name}". Check on them and remind them to /exit when done.`, }); @@ -7195,25 +7200,23 @@ export class WorkflowRunner { } } - // Direct system injection via human handle - if (this.relay) { - const human = this.relay.human({ name: 'workflow-runner' }); - await human - .sendMessage({ - to: agent.name, - text: "You appear idle. If you've completed your task, output /exit. If still working, continue.", - }) - .catch(() => { - // Non-critical — don't break workflow - }); - } + // Direct system injection from the workflow runner. + await this.relay + .sendMessage({ + from: 'workflow-runner', + to: agent.name, + text: "You appear idle. If you've completed your task, output /exit. If still working, continue.", + }) + .catch(() => { + // Non-critical — don't break workflow + }); } /** * Find the hub agent for hub-mediated nudging. * Returns the hub's live Agent handle if this is a hub pattern and the idle agent is not the hub. */ - private resolveHubForNudge(idleAgentDef: AgentDefinition): Agent | undefined { + private resolveHubForNudge(idleAgentDef: AgentDefinition): WorkflowAgentHandle | undefined { const pattern = this.currentConfig?.swarm.pattern; if (!pattern || !WorkflowRunner.HUB_PATTERNS.has(pattern)) { return undefined; diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..d9f44c3 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,47 @@ +import { createRequire } from 'node:module'; +import type { Plugin } from 'vitest/config'; +import { defineConfig } from 'vitest/config'; + +/** + * `node:sqlite` is a very new builtin (Node 22.5+) that Vite's resolver does + * not yet know about. Left alone, Vite strips the `node:` prefix and tries to + * resolve an npm package named `sqlite`, failing with + * "Failed to load url sqlite". This plugin intercepts the import, marks it + * external, and lets Node's own `require` load the real builtin at runtime. + */ +function nodeSqliteExternal(): Plugin { + const require = createRequire(import.meta.url); + return { + name: 'node-sqlite-external', + enforce: 'pre', + resolveId(id) { + if (id === 'node:sqlite' || id === 'sqlite') { + return { id: 'node:sqlite', external: true }; + } + return null; + }, + load(id) { + if (id === 'node:sqlite') { + const mod = require('node:sqlite'); + const exports = Object.keys(mod) + .map((key) => `export const ${key} = mod[${JSON.stringify(key)}];`) + .join('\n'); + return `const mod = require('node:sqlite');\n${exports}\nexport default mod;`; + } + return null; + }, + }; +} + +export default defineConfig({ + plugins: [nodeSqliteExternal()], + test: { + include: ['src/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + testTimeout: 30000, + // Cap concurrent worker processes. Workflow tests can spawn child + // processes (brokers/PTYs); an uncapped fork pool can overwhelm the host. + pool: 'forks', + poolOptions: { forks: { maxForks: 4, minForks: 1 } }, + }, +}); diff --git a/packages/github-primitive/README.md b/packages/github-primitive/README.md index 0efc7a9..96f2241 100644 --- a/packages/github-primitive/README.md +++ b/packages/github-primitive/README.md @@ -1,4 +1,4 @@ -# @agent-relay/github-primitive +# @relayflows/github-primitive GitHub workflow primitive for Agent Relay. It exposes a typed client and a workflow integration step that can run through the local `gh` CLI or a cloud @@ -14,7 +14,7 @@ The primitive supports three runtime modes: - `cloud`: use Nango first, then relay-cloud proxy when configured. ```ts -import { GitHubClient } from '@agent-relay/github-primitive'; +import { GitHubClient } from '@relayflows/github-primitive'; const github = await GitHubClient.create({ runtime: 'auto', @@ -85,7 +85,7 @@ helper: ```ts // cloud/packages/web/lib/github/connection-resolver.ts -import type { GitHubRuntimeConfig } from '@agent-relay/github-primitive'; +import type { GitHubRuntimeConfig } from '@relayflows/github-primitive'; export async function githubConfigForRepo(opts: { repo: string; // "owner/repo" diff --git a/packages/github-primitive/package.json b/packages/github-primitive/package.json index 4740f52..4676990 100644 --- a/packages/github-primitive/package.json +++ b/packages/github-primitive/package.json @@ -1,6 +1,6 @@ { - "name": "@agent-relay/github-primitive", - "version": "7.1.1", + "name": "@relayflows/github-primitive", + "version": "0.1.0", "description": "GitHub workflow primitive for Agent Relay", "type": "module", "main": "dist/index.js", diff --git a/packages/slack-primitive/package.json b/packages/slack-primitive/package.json index b0170ee..6262ffe 100644 --- a/packages/slack-primitive/package.json +++ b/packages/slack-primitive/package.json @@ -1,6 +1,6 @@ { - "name": "@agent-relay/slack-primitive", - "version": "7.1.1", + "name": "@relayflows/slack-primitive", + "version": "0.1.0", "description": "Slack workflow primitive for Agent Relay", "type": "module", "main": "dist/index.js", @@ -28,7 +28,7 @@ "@slack/web-api": "^7.16.0" }, "devDependencies": { - "@agent-relay/github-primitive": "7.1.1", + "@relayflows/github-primitive": "0.1.0", "@types/node": "^22.19.3", "typescript": "^5.9.3", "vitest": "^3.2.4" diff --git a/vitest.config.ts b/vitest.config.ts index 26621d2..6e04010 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,47 @@ +import { createRequire } from 'node:module'; +import type { Plugin } from 'vitest/config'; import { defineConfig } from 'vitest/config'; +/** + * `node:sqlite` is a very new builtin (Node 22.5+) that Vite's resolver does + * not yet know about. Left alone, Vite strips the `node:` prefix and tries to + * resolve an npm package named `sqlite`, failing with + * "Failed to load url sqlite". This plugin intercepts the import, marks it + * external, and lets Node's own `require` load the real builtin at runtime. + */ +function nodeSqliteExternal(): Plugin { + const require = createRequire(import.meta.url); + return { + name: 'node-sqlite-external', + enforce: 'pre', + resolveId(id) { + if (id === 'node:sqlite' || id === 'sqlite') { + return { id: 'node:sqlite', external: true }; + } + return null; + }, + load(id) { + if (id === 'node:sqlite') { + const mod = require('node:sqlite'); + const exports = Object.keys(mod) + .map((key) => `export const ${key} = mod[${JSON.stringify(key)}];`) + .join('\n'); + return `const mod = require('node:sqlite');\n${exports}\nexport default mod;`; + } + return null; + }, + }; +} + export default defineConfig({ + plugins: [nodeSqliteExternal()], test: { include: ['packages/*/src/**/*.test.ts'], exclude: ['**/node_modules/**', '**/dist/**'], testTimeout: 30000, + // Cap concurrent worker processes. Workflow tests can spawn child + // processes (brokers/PTYs); an uncapped fork pool can overwhelm the host. + pool: 'forks', + poolOptions: { forks: { maxForks: 4, minForks: 1 } }, }, });