Skip to content

feat(event-bridge): wire on-relay agents to inbound webhook events (Slack)#1013

Open
khaliqgant wants to merge 2 commits into
mainfrom
feature/slack-relay-bridge
Open

feat(event-bridge): wire on-relay agents to inbound webhook events (Slack)#1013
khaliqgant wants to merge 2 commits into
mainfrom
feature/slack-relay-bridge

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

Summary

Adds @agent-relay/event-bridge — connects a long-lived on-relay agent to inbound integration webhook events (Slack first) and relays its replies back out, with no MCP, mount, or credentials on the agent. This is the single-agent Slack happy path toward controlling a team of relay agents from Slack.

Slack ─▶ relayfile (/slack/** change) ─▶ relay gateway ─▶ event-bridge ─▶ broker /api/send ─▶ agent (just a turn)
agent writes ./outbox/<id>.md ─▶ event-bridge ─▶ gateway writeFile ─▶ relayfile Slack writeback ─▶ Slack

How it works

  • Inbound: subscribes to the deployed relay gateway (@agent-relay/events), registers provider watch globs (/slack/channels/**), and for each actionable relayfile.changed injects a nudge into the target broker agent via sendMessage.
  • Outbound (interim): the agent writes its reply as plain text to ./outbox/<replyId>.md with its native file-write tool; the bridge relays that file through the gateway to the provider's writeback path (/slack/channels/<id>/messages/<ts>/replies/<draft>.json), which cloud's Slack writeback posts in-thread.
  • Provider-agnostic: all provider knowledge (watch globs, writeback paths, content format, scopes, loop-prevention) lives in a ProviderAdapter. Slack is implemented; Linear/Notion follow the same contract.
  • Reuse-cloud bootstrap: the CLI auto-discovers the deployed gateway URL (GET /api/v1/workspaces/<ws>/agent-events) and provisions a /slack/**-scoped token (POST /api/v1/agents/provision) from your cloud login — nothing runs locally. --gateway-url/--api-key skip it.

Run

agent-relay login
agent-relay up && agent-relay spawn claude --name lead
npx agent-relay-event-bridge --workspace <id> --agent lead --providers slack

Local loop test (no cloud/Slack): node packages/event-bridge/dist/simulate.js --agent lead --text "deploy staging".

Follow-up: replace the outbox with an MCP writeback primitive

The ./outbox file relay is an interim outbound mechanism. The plan is to add a primitive MCP that exposes tools letting the agent run a script which calls the relayfile SDK directly for the writeback logic. Once that lands, the agent posts replies via the MCP tool (script → relayfile SDK) and the bridge no longer needs the outbox (or its gateway writeFile relay) — outbound becomes a first-class tool call instead of a watched file. The inbound half (gateway watch → inject) is unaffected.

Notes / validation

  • Bootstrap verified against prod cloud (auth + endpoint path correct; a fake workspace returns structured 403).
  • Slack VFS paths confirmed against relayfile (VFSRoot: /slack) and cloud (identical reply-path derivation).
  • The one hop not yet exercised live: the final Slack chat.postMessage from the relayfile writeback in a real Slack-connected workspace.
  • 11/11 unit tests pass (adapter filtering/paths, full inbound→inject→outbox→writeback loop with fakes, two-call bootstrap); build + Prettier clean; CHANGELOG.md updated.

🤖 Generated with Claude Code

Proactive Runtime Bot and others added 2 commits May 28, 2026 22:12
Add @agent-relay/event-bridge: connects a long-lived on-relay agent to
inbound integration webhook events (Slack first) and relays its replies
back out, with no MCP, mount, or credentials on the agent.

- Inbound: subscribe to the relay gateway, watch provider VFS paths, and
  inject each actionable change into a target broker agent.
- Outbound: watch the agent's outbox dir and relay reply files through the
  gateway as relayfile writes, which the provider writeback posts to source.
- Provider-agnostic ProviderAdapter contract (Slack implemented; Linear/
  Notion follow the same shape) owns paths, writeback format, loop-prevention.
- CLI auto-bootstraps gateway URL + scoped token from cloud login (reuses
  deployed cloud), with a simulate bin for a no-cloud loop test.

11/11 tests pass; build + Prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent-events config endpoint hardcodes a /integrations/** token scope,
which does not authorize the /slack/** paths where Slack files actually live
(confirmed against relayfile VFSRoot and cloud's reply-path derivation).

Bootstrap now resolves the gateway URL from that endpoint but provisions a
correctly-scoped token via POST /api/v1/agents/provision, with scopes derived
from each ProviderAdapter (Slack: relayfile:fs:{read,write}:/slack/**).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@khaliqgant khaliqgant requested a review from willwashburn as a code owner May 28, 2026 21:39
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces @agent-relay/event-bridge, a new package that bridges long-lived on-relay agents with inbound webhook events (Slack first). It provisions gateway access via cloud auth, injects incoming messages into a broker agent, watches the agent's outbox for replies, and relays them back to the originating provider. Includes daemon CLI and local simulation harness.

Changes

Event Bridge Feature

Layer / File(s) Summary
Package setup and documentation
CHANGELOG.md, packages/event-bridge/README.md, packages/event-bridge/tsconfig.json, packages/event-bridge/package.json
New package metadata, TypeScript configuration, comprehensive README with how-it-works, run instructions, and provider-adapter guidance, plus changelog entry documenting the feature.
Core type system and contracts
packages/event-bridge/src/types.ts
Foundational types for workspace files, inbound message contexts, actionable inbound items with reply serialization, and the provider-adapter contract pattern.
Gateway provisioning and bootstrap
packages/event-bridge/src/bootstrap.ts, packages/event-bridge/src/bootstrap.test.ts
Cloud authentication, gateway URL discovery, and scoped agent token provisioning with token refresh logic, error handling, and test coverage for success/auth-missing/endpoint-failure paths.
Configuration resolution from environment
packages/event-bridge/src/config.ts
Parses environment variables and CLI flags into unified EventBridgeConfig with required/optional fields, defaults (providers, outbox directory, inject mode), and validation.
Provider adapter pattern with Slack implementation
packages/event-bridge/src/providers/slack.ts, packages/event-bridge/src/providers/slack.test.ts, packages/event-bridge/src/providers/index.ts
Slack provider adapter matching message changes to actionable items with threaded reply paths, filtering bot/agent/blocked-user messages, serializing replies to Slack JSON; provider factory and known-provider registry.
Outbox file watching for agent replies
packages/event-bridge/src/outbox.ts
Monitors outbox directory for reply files keyed by reply ID, debounces processing, reads reply text, archives processed files to .sent/ subdirectory with timestamps to prevent re-delivery; combines filesystem watch with polling fallback.
Event bridge core orchestration
packages/event-bridge/src/bridge.ts, packages/event-bridge/src/bridge.test.ts
Main wiring connecting gateway events to broker nudges and outbound writeback: deduplicates, routes to providers, injects messages with outbox paths, handles replies with provider serialization, includes broker resolution and error handling.
CLI daemon and simulation harness
packages/event-bridge/src/cli.ts, packages/event-bridge/src/simulate.ts
Main daemon CLI parsing args/env, conditionally bootstrapping gateway, creating bridge, and running with signal handlers; plus simulation harness for local no-cloud testing with synthetic Slack events and writeback logging.
Public API surface exports
packages/event-bridge/src/index.ts
Barrel export module re-exporting the complete public API: bridge factory, configuration utilities, bootstrap helpers, outbox watcher, provider factories, and shared types.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • willwashburn

Poem

🐰 A bridge hops between agents and webhooks with care,
Slack messages leap through the relay air,
Bootstrap the tokens, watch the outbox drawer,
Replies archived so clean, we won't skip one more!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: wiring on-relay agents to inbound webhook events with Slack as the first provider.
Description check ✅ Passed The description follows the template structure with a comprehensive Summary section detailing the feature, design, and implementation; Test Plan checklist is present; however, the description is primarily narrative documentation rather than a typical PR description.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/slack-relay-bridge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the @agent-relay/event-bridge package, which connects long-lived on-relay agents to inbound integration webhook events (starting with Slack) and relays replies back via outbox files. The implementation includes the core bridge logic, a Slack provider adapter, bootstrap utilities, a CLI daemon, and a local simulation harness. Feedback has been provided to optimize network roundtrips by deferring file reads in the bridge, prevent potential infinite loops in the Slack adapter when file reads fail, simplify redundant types and checks in the bootstrap logic, and shorten the changelog entry to comply with the repository's style guide.

return;
}

const file = await readFileSafe(stream, change.path);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Eagerly calling readFileSafe before verifying if the event is actionable results in unnecessary network roundtrips to the gateway. For example, when the agent writes a reply, a relayfile.changed event is triggered for the draft reply path. The bridge will eagerly read this file, only for slackProvider.resolveInbound to reject it because it doesn't match MESSAGE_PATH_RE. Similarly, deleted events (where the file no longer exists) or self-authored events (with event.agentId) will also trigger useless read attempts. Checking change.action === 'deleted' and change.agentId beforehand avoids these redundant network calls.

    if (change.action === 'deleted' || change.agentId) {
      return;
    }

    const file = await readFileSafe(stream, change.path);

const channelSeg = match[1];
const messageSeg = match[2];

const msg = asRecord(file?.body);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

If file is null (e.g., due to a transient read failure), the adapter currently proceeds using fallback titles from the event. However, because msg will be undefined, all loop-prevention checks (such as ignoreBotMessages and ignoreUserIds) are bypassed. This could lead to infinite reply loops if a bot message or our own echoed message fails to be read but is still processed. Safely aborting and returning null if file is null prevents this loop-prevention bypass.

      if (!file) {
        return null;
      }
      const msg = asRecord(file.body);

Comment thread CHANGELOG.md

### Added

- `@agent-relay/event-bridge` connects a long-lived on-relay agent to inbound integration webhook events: it subscribes to the relay gateway for provider file changes (Slack first), injects each new message into a target broker agent, and relays the agent's outbox reply files back through relayfile writeback to the source — no MCP, mount, or credentials required on the agent. The `agent-relay-event-bridge` daemon auto-bootstraps the gateway URL + scoped token from your cloud login (reusing deployed cloud, nothing to run locally), ships an `agent-relay-event-bridge-simulate` harness for a no-cloud loop test, and exposes a provider-adapter contract for adding Linear, Notion, etc.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The changelog entry is very long and detailed. According to the repository style guide, changelog entries should be concise and impact-first, preferring one short bullet per user-visible change. Name the package touched and its practical effect.

Suggested change
- `@agent-relay/event-bridge` connects a long-lived on-relay agent to inbound integration webhook events: it subscribes to the relay gateway for provider file changes (Slack first), injects each new message into a target broker agent, and relays the agent's outbox reply files back through relayfile writeback to the source — no MCP, mount, or credentials required on the agent. The `agent-relay-event-bridge` daemon auto-bootstraps the gateway URL + scoped token from your cloud login (reusing deployed cloud, nothing to run locally), ships an `agent-relay-event-bridge-simulate` harness for a no-cloud loop test, and exposes a provider-adapter contract for adding Linear, Notion, etc.
- `@agent-relay/event-bridge`: Adds a daemon to connect long-lived on-relay agents to inbound integration webhook events (starting with Slack) and relay replies back via outbox files, with automatic cloud bootstrap and a local simulation harness.
References
  1. Changelog entries should be concise and impact-first. Prefer one short bullet per user-visible change. (link)

);
}

const auth = (await maybeRefresh(stored, refreshAuth)) ?? stored;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since maybeRefresh always returns a StoredAuth object (and never null), the nullish coalescing fallback ?? stored is redundant and can be removed.

Suggested change
const auth = (await maybeRefresh(stored, refreshAuth)) ?? stored;
const auth = await maybeRefresh(stored, refreshAuth);

async function maybeRefresh(
auth: StoredAuth,
refreshAuth: typeof refreshStoredAuth
): Promise<StoredAuth | null> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The maybeRefresh function is typed to return Promise<StoredAuth | null>, but it actually always returns a StoredAuth object. Simplifying the return type to Promise<StoredAuth> improves type safety.

Suggested change
): Promise<StoredAuth | null> {
): Promise<StoredAuth> {

}

function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, '')}${path}`;
apiUrl,
`/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events`
);
const configResponse = await fetchImpl(configUrl, { headers: authHeader });
apiUrl,
`/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events`
);
const configResponse = await fetchImpl(configUrl, { headers: authHeader });

// 2. Provision a token scoped to the providers' roots (config token is /integrations/** only).
const provisionUrl = joinUrl(apiUrl, '/api/v1/agents/provision');
const provisionResponse = await fetchImpl(provisionUrl, {
@agent-relay-bot
Copy link
Copy Markdown

I’m blocked by the environment before I can make the PR ready.

What I found:

  • packages/event-bridge/src/providers/slack.ts: unreadable Slack message files currently bypass loop-prevention checks; this can create reply loops.
  • packages/event-bridge/src/bridge.ts: deleted/self-authored events should be filtered before readFileSafe to avoid unnecessary gateway reads.
  • packages/event-bridge/src/bootstrap.ts: CodeQL is flagging the trailing-slash regex and file-derived API URL fetch targets; this needs regex removal plus URL validation/normalization.
  • CHANGELOG.md: the event-bridge entry should be shortened to match repo style.

What failed:

  • Local shell and apply_patch both fail at sandbox setup with bwrap permission errors.
  • GitHub write/review tools are being cancelled before execution, so I could not persist fixes or post the requested review.

I did not mark this READY because the PR still has unresolved findings and I could not apply or post the review from this session.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/event-bridge/src/bridge.ts (1)

81-81: ⚡ Quick win

pending can grow unbounded in a long-lived daemon. Entries are only removed on successful writeback, empty reply, or inject failure. An inbound item the agent never replies to stays forever, so over a long-running process pending leaks memory (unlike handled, which is capped via remember). Consider a TTL/size cap or periodic eviction of stale pending replies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/event-bridge/src/bridge.ts` at line 81, The pending Map named
"pending" (Map<string, PendingReply>) can grow unbounded; add bounded retention
and eviction: attach a timestamp/expiry to each PendingReply when inserted,
enforce a max size (e.g., MAX_PENDING) and drop oldest entries when exceeded,
and add a periodic sweeper (setInterval) that removes entries older than TTL and
triggers their failure/cleanup path (same cleanup used for inject failure/empty
reply/writeback) so promises/handlers don't leak; update the places that insert
into "pending" to set the timestamp/expiry and reuse the existing removal logic
for evicted entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/event-bridge/src/bootstrap.ts`:
- Around line 73-93: In bootstrapGatewayAccess, add request timeouts to both
fetchImpl calls by passing a signal created with AbortSignal.timeout(...);
specifically attach signal: AbortSignal.timeout(timeoutMs) (choose a sensible
timeout or use an existing option if available) to the config fetch (where
configResponse is created from configUrl) and to the provision fetch (where
provisionResponse is created from provisionUrl) so the function won't hang
indefinitely; update the fetch options objects for those calls (the ones that
include headers/authHeader and the POST body) to include the signal and ensure
any existing error handling still handles AbortError.

In `@packages/event-bridge/src/bridge.ts`:
- Around line 159-167: The onReply handler currently catches writeFile errors,
logs them, and returns which lets outbox.archive the file and leaks pending;
change the catch to log the error but then rethrow it so outbox.processFile can
retain/retry the message; ensure pending.delete(replyId) remains only on
successful await stream.writeFile (and do not delete pending in the catch),
referencing onReply, stream.writeFile, pending.delete, log, errMessage,
target.serializeReply, and target.replyPath.

In `@packages/event-bridge/src/simulate.ts`:
- Around line 97-99: The shutdown handler currently calls bridge.stop().then(()
=> process.exit(0)) so rejections are unhandled; update the shutdown logic (the
shutdown function used in process.on('SIGINT'/'SIGTERM')) to handle
bridge.stop() rejections by awaiting or attaching a .catch that logs the error
(or processLogger/error output) and still calls process.exit(0) (or a non-zero
code if you prefer) in both success and failure paths; ensure the handler
returns void and that both SIGINT and SIGTERM listeners use the updated shutdown
function.

---

Nitpick comments:
In `@packages/event-bridge/src/bridge.ts`:
- Line 81: The pending Map named "pending" (Map<string, PendingReply>) can grow
unbounded; add bounded retention and eviction: attach a timestamp/expiry to each
PendingReply when inserted, enforce a max size (e.g., MAX_PENDING) and drop
oldest entries when exceeded, and add a periodic sweeper (setInterval) that
removes entries older than TTL and triggers their failure/cleanup path (same
cleanup used for inject failure/empty reply/writeback) so promises/handlers
don't leak; update the places that insert into "pending" to set the
timestamp/expiry and reuse the existing removal logic for evicted entries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ceb61c6a-810d-41ff-9e24-53264339bfc6

📥 Commits

Reviewing files that changed from the base of the PR and between 9eb948b and 5e1b24b.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • CHANGELOG.md
  • packages/event-bridge/README.md
  • packages/event-bridge/package.json
  • packages/event-bridge/src/bootstrap.test.ts
  • packages/event-bridge/src/bootstrap.ts
  • packages/event-bridge/src/bridge.test.ts
  • packages/event-bridge/src/bridge.ts
  • packages/event-bridge/src/cli.ts
  • packages/event-bridge/src/config.ts
  • packages/event-bridge/src/index.ts
  • packages/event-bridge/src/outbox.ts
  • packages/event-bridge/src/providers/index.ts
  • packages/event-bridge/src/providers/slack.test.ts
  • packages/event-bridge/src/providers/slack.ts
  • packages/event-bridge/src/simulate.ts
  • packages/event-bridge/src/types.ts
  • packages/event-bridge/tsconfig.json

Comment on lines +73 to +93
const configResponse = await fetchImpl(configUrl, { headers: authHeader });
await assertOk(configResponse, 'config', configUrl);
const config = (await configResponse.json().catch(() => null)) as {
workspaceId?: string;
gatewayUrl?: string;
} | null;
if (!config?.gatewayUrl) {
throw new Error('Gateway bootstrap response missing gatewayUrl');
}
const workspaceId = config.workspaceId ?? options.workspace;

// 2. Provision a token scoped to the providers' roots (config token is /integrations/** only).
const provisionUrl = joinUrl(apiUrl, '/api/v1/agents/provision');
const provisionResponse = await fetchImpl(provisionUrl, {
method: 'POST',
headers: { ...authHeader, 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId,
agents: [{ name: options.agentName ?? DEFAULT_BRIDGE_AGENT, scopes: options.scopes }],
}),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What Node.js version added AbortSignal.timeout support?

💡 Result:

The static method AbortSignal.timeout was added to Node.js in versions v17.3.0 and v16.14.0 [1]. It provides a way to create an AbortSignal that automatically aborts after a specified number of milliseconds [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find relevant package.json files
fd -t f "package.json" packages/event-bridge -a > /tmp/pjson_event_bridge.txt || true
cat /tmp/pjson_event_bridge.txt || true

# Also check workspace root engines/node support
if [ -f package.json ]; then
  echo "----- root package.json -----"
  cat -n package.json | sed -n '1,120p'
fi

# Show packages/event-bridge package.json (first 200 lines)
if [ -s /tmp/pjson_event_bridge.txt ]; then
  while read -r f; do
    echo "----- $f -----"
    cat -n "$f" | sed -n '1,200p'
  done < /tmp/pjson_event_bridge.txt
fi

# Search for engines/node in the repo for event-bridge context
rg -n --hidden --no-ignore-vcs "AbortSignal\.timeout|\"engines\"|node\s*[:=]|nodeVersion|engines\.node" packages/event-bridge package.json -S || true

Repository: AgentWorkforce/relay

Length of output: 8363


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show bootstrap.ts around the snippet
BOOTSTRAP="packages/event-bridge/src/bootstrap.ts"
if [ -f "$BOOTSTRAP" ]; then
  echo "---- $BOOTSTRAP (lines 1-200) ----"
  cat -n "$BOOTSTRAP" | sed -n '1,220p'
fi

# Find fetchImpl definition/usages within event-bridge
rg -n "fetchImpl" packages/event-bridge/src -S

# Find where fetchImpl is imported from (in bootstrap.ts)
rg -n "import .*fetchImpl|fetchImpl\s*=" packages/event-bridge/src/bootstrap.ts -S || true

# Search for fetchImpl in whole repo (to inspect signature)
rg -n "fetchImpl" packages -S

Repository: AgentWorkforce/relay

Length of output: 10415


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show bootstrap.test.ts (relevant sections)
BOOT_TEST="packages/event-bridge/src/bootstrap.test.ts"
if [ -f "$BOOT_TEST" ]; then
  echo "---- $BOOT_TEST (lines 1-160) ----"
  cat -n "$BOOT_TEST" | sed -n '1,200p'
fi

# Search for AbortSignal.timeout usage across repo
rg -n "AbortSignal\.timeout" -S packages || true
rg -n "new AbortController|AbortController" -S packages || true

# Check event-bridge tsconfig for libs/dom types
if [ -f "packages/event-bridge/tsconfig.json" ]; then
  echo "---- packages/event-bridge/tsconfig.json (lines 1-220) ----"
  cat -n packages/event-bridge/tsconfig.json | sed -n '1,260p'
fi

# Check root tsconfig if needed
if [ -f "tsconfig.json" ]; then
  echo "---- tsconfig.json (lines 1-220) ----"
  cat -n tsconfig.json | sed -n '1,260p'
fi

Repository: AgentWorkforce/relay

Length of output: 8503


Add timeouts to the bootstrap config/provision fetch calls.

bootstrapGatewayAccess awaits both fetchImpl calls without a signal, so if the cloud endpoint stalls the bootstrap can hang indefinitely. Add signal: AbortSignal.timeout(...) (Node runtime here is >=18.0.0, and the repo already uses AbortSignal.timeout elsewhere) to both the config and provision requests.

♻️ Example for the config call
-  const configResponse = await fetchImpl(configUrl, { headers: authHeader });
+  const configResponse = await fetchImpl(configUrl, {
+    headers: authHeader,
+    signal: AbortSignal.timeout(15_000),
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/event-bridge/src/bootstrap.ts` around lines 73 - 93, In
bootstrapGatewayAccess, add request timeouts to both fetchImpl calls by passing
a signal created with AbortSignal.timeout(...); specifically attach signal:
AbortSignal.timeout(timeoutMs) (choose a sensible timeout or use an existing
option if available) to the config fetch (where configResponse is created from
configUrl) and to the provision fetch (where provisionResponse is created from
provisionUrl) so the function won't hang indefinitely; update the fetch options
objects for those calls (the ones that include headers/authHeader and the POST
body) to include the signal and ensure any existing error handling still handles
AbortError.

Comment on lines +159 to +167
const { content, contentType } = target.serializeReply(trimmed);
try {
await stream.writeFile(target.replyPath, content, { contentType });
pending.delete(replyId);
log('reply posted', { replyId, source: target.source, path: target.replyPath });
} catch (err) {
log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Writeback failure silently loses the reply. On a failed stream.writeFile, onReply logs and returns normally without rethrowing. Because outbox.processFile archives the file into .sent/ once onReply resolves (it only retains/retries the file when onReply rejects), a transient gateway failure permanently drops the agent's reply and leaks the pending entry. Rethrow so the outbox watcher keeps the file and retries on the next rescan.

🛠️ Proposed fix
     const { content, contentType } = target.serializeReply(trimmed);
     try {
       await stream.writeFile(target.replyPath, content, { contentType });
       pending.delete(replyId);
       log('reply posted', { replyId, source: target.source, path: target.replyPath });
     } catch (err) {
       log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
+      // Rethrow so the outbox watcher retains the file and retries on the next rescan
+      // instead of archiving it (which would drop the reply permanently).
+      throw err instanceof Error ? err : new Error(errMessage(err));
     }

Note: this enables retry of transient failures but will retry persistently on permanent failures (e.g. an invalid path); consider a bounded attempt count if that becomes noisy.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { content, contentType } = target.serializeReply(trimmed);
try {
await stream.writeFile(target.replyPath, content, { contentType });
pending.delete(replyId);
log('reply posted', { replyId, source: target.source, path: target.replyPath });
} catch (err) {
log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
}
};
const { content, contentType } = target.serializeReply(trimmed);
try {
await stream.writeFile(target.replyPath, content, { contentType });
pending.delete(replyId);
log('reply posted', { replyId, source: target.source, path: target.replyPath });
} catch (err) {
log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
// Rethrow so the outbox watcher retains the file and retries on the next rescan
// instead of archiving it (which would drop the reply permanently).
throw err instanceof Error ? err : new Error(errMessage(err));
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/event-bridge/src/bridge.ts` around lines 159 - 167, The onReply
handler currently catches writeFile errors, logs them, and returns which lets
outbox.archive the file and leaks pending; change the catch to log the error but
then rethrow it so outbox.processFile can retain/retry the message; ensure
pending.delete(replyId) remains only on successful await stream.writeFile (and
do not delete pending in the catch), referencing onReply, stream.writeFile,
pending.delete, log, errMessage, target.serializeReply, and target.replyPath.

Comment on lines +97 to +99
const shutdown = (): void => void bridge.stop().then(() => process.exit(0));
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle bridge.stop() rejection in the shutdown handler.

Unlike the daemon in cli.ts, this only attaches a fulfillment handler. If bridge.stop() rejects, process.exit(0) is never reached and the rejection surfaces as an unhandled promise rejection.

🛡️ Proposed fix
-  const shutdown = (): void => void bridge.stop().then(() => process.exit(0));
+  const shutdown = (): void =>
+    void bridge.stop().then(
+      () => process.exit(0),
+      () => process.exit(1)
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const shutdown = (): void => void bridge.stop().then(() => process.exit(0));
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
const shutdown = (): void =>
void bridge.stop().then(
() => process.exit(0),
() => process.exit(1)
);
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/event-bridge/src/simulate.ts` around lines 97 - 99, The shutdown
handler currently calls bridge.stop().then(() => process.exit(0)) so rejections
are unhandled; update the shutdown logic (the shutdown function used in
process.on('SIGINT'/'SIGTERM')) to handle bridge.stop() rejections by awaiting
or attaching a .catch that logs the error (or processLogger/error output) and
still calls process.exit(0) (or a non-zero code if you prefer) in both success
and failure paths; ensure the handler returns void and that both SIGINT and
SIGTERM listeners use the updated shutdown function.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 issues found across 18 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/event-bridge/src/simulate.ts">

<violation number="1" location="packages/event-bridge/src/simulate.ts:97">
P2: Handle `bridge.stop()` rejection in shutdown so termination is deterministic and does not surface an unhandled promise rejection.</violation>

<violation number="2" location="packages/event-bridge/src/simulate.ts:121">
P2: Missing option values are coerced to `'true'`, which lets `--agent` pass validation and causes the simulator to run against an unintended agent name.</violation>
</file>

<file name="packages/event-bridge/src/cli.ts">

<violation number="1" location="packages/event-bridge/src/cli.ts:52">
P2: Reject partial manual credentials. Mixing a user-supplied gateway URL with a bootstrapped token (or vice versa) can create an invalid credential pair.</violation>
</file>

<file name="packages/event-bridge/src/outbox.ts">

<violation number="1" location="packages/event-bridge/src/outbox.ts:65">
P1: Skip `onReply` for empty/whitespace outbox files so empty replies remain silent per the outbox contract.</violation>

<violation number="2" location="packages/event-bridge/src/outbox.ts:67">
P2: Do not swallow archive failures; silent `rename` errors can cause duplicate redelivery and make failures hard to diagnose.</violation>
</file>

<file name="packages/event-bridge/src/config.ts">

<violation number="1" location="packages/event-bridge/src/config.ts:49">
P2: Normalize provider names from env before storing config to avoid case-sensitive runtime failures for valid provider values.</violation>

<violation number="2" location="packages/event-bridge/src/config.ts:62">
P2: Validate `EVENT_BRIDGE_REPLAY` format (`none`, `last:<n>`, `since:<iso>`) before adding it to config; invalid values currently flow to gateway registration and can fail at runtime.</violation>
</file>

<file name="packages/event-bridge/src/providers/slack.ts">

<violation number="1" location="packages/event-bridge/src/providers/slack.ts:43">
P2: `resolveInbound` also accepts `updated` events, so Slack message edits can retrigger agent nudges. Restrict inbound handling to newly created messages.</violation>
</file>

<file name="packages/event-bridge/src/bridge.ts">

<violation number="1" location="packages/event-bridge/src/bridge.ts:130">
P1: Injection failures are swallowed after the event is marked handled, so transient broker errors can permanently drop inbound messages.</violation>

<violation number="2" location="packages/event-bridge/src/bridge.ts:165">
P1: Writeback failures are swallowed, causing failed reply files to be archived as if they succeeded.</violation>

<violation number="3" location="packages/event-bridge/src/bridge.ts:270">
P2: Reply IDs are truncated to 32-bit entropy, which risks collisions and pending-reply overwrites over time.</violation>
</file>

<file name="packages/event-bridge/package.json">

<violation number="1" location="packages/event-bridge/package.json:21">
P2: Missing `vitest` in `devDependencies` — the package uses vitest in its test scripts but does not explicitly declare it. Every other package in the monorepo that runs vitest lists it as a devDependency. This will cause `npm test` failures if the package is ever installed independently of the workspace.</violation>
</file>

<file name="packages/event-bridge/src/bootstrap.ts">

<violation number="1" location="packages/event-bridge/src/bootstrap.ts:73">
P2: Add a timeout signal to this bootstrap request so the process cannot hang indefinitely on a stalled cloud endpoint.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

try {
const full = path.join(dir, file);
const text = await readFile(full, 'utf-8');
await options.onReply(replyId, text);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Skip onReply for empty/whitespace outbox files so empty replies remain silent per the outbox contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/outbox.ts, line 65:

<comment>Skip `onReply` for empty/whitespace outbox files so empty replies remain silent per the outbox contract.</comment>

<file context>
@@ -0,0 +1,130 @@
+    try {
+      const full = path.join(dir, file);
+      const text = await readFile(full, 'utf-8');
+      await options.onReply(replyId, text);
+      // Archive so a later rescan does not re-deliver the same reply.
+      await rename(full, path.join(sentDir, `${Date.now()}-${file}`)).catch(() => {});
</file context>

pending.delete(replyId);
log('reply posted', { replyId, source: target.source, path: target.replyPath });
} catch (err) {
log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Writeback failures are swallowed, causing failed reply files to be archived as if they succeeded.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bridge.ts, line 165:

<comment>Writeback failures are swallowed, causing failed reply files to be archived as if they succeeded.</comment>

<file context>
@@ -0,0 +1,286 @@
+      pending.delete(replyId);
+      log('reply posted', { replyId, source: target.source, path: target.replyPath });
+    } catch (err) {
+      log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
+    }
+  };
</file context>

agent: config.agentName,
});
} catch (err) {
pending.delete(replyId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Injection failures are swallowed after the event is marked handled, so transient broker errors can permanently drop inbound messages.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bridge.ts, line 130:

<comment>Injection failures are swallowed after the event is marked handled, so transient broker errors can permanently drop inbound messages.</comment>

<file context>
@@ -0,0 +1,286 @@
+        agent: config.agentName,
+      });
+    } catch (err) {
+      pending.delete(replyId);
+      log('inject failed', { provider: provider.name, replyId, error: errMessage(err) });
+    }
</file context>

` which will be printed here as the would-be Slack post.\n`
);

const shutdown = (): void => void bridge.stop().then(() => process.exit(0));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Handle bridge.stop() rejection in shutdown so termination is deterministic and does not surface an unhandled promise rejection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/simulate.ts, line 97:

<comment>Handle `bridge.stop()` rejection in shutdown so termination is deterministic and does not surface an unhandled promise rejection.</comment>

<file context>
@@ -0,0 +1,131 @@
+      `      which will be printed here as the would-be Slack post.\n`
+  );
+
+  const shutdown = (): void => void bridge.stop().then(() => process.exit(0));
+  process.on('SIGINT', shutdown);
+  process.on('SIGTERM', shutdown);
</file context>

let gatewayUrl = flags['gateway-url'] ?? env.RELAY_GATEWAY_URL;
let apiKey = flags['api-key'] ?? env.RELAY_API_KEY;

if (!gatewayUrl || !apiKey) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Reject partial manual credentials. Mixing a user-supplied gateway URL with a bootstrapped token (or vice versa) can create an invalid credential pair.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/cli.ts, line 52:

<comment>Reject partial manual credentials. Mixing a user-supplied gateway URL with a bootstrapped token (or vice versa) can create an invalid credential pair.</comment>

<file context>
@@ -0,0 +1,138 @@
+  let gatewayUrl = flags['gateway-url'] ?? env.RELAY_GATEWAY_URL;
+  let apiKey = flags['api-key'] ?? env.RELAY_API_KEY;
+
+  if (!gatewayUrl || !apiKey) {
+    if (flags.bootstrap === 'false') {
+      console.error('Missing --gateway-url/--api-key and --no-bootstrap was set.');
</file context>

throw new Error('EVENT_BRIDGE_AGENT (target on-relay agent name) is required');
}

const providers = csv(env.EVENT_BRIDGE_PROVIDERS) ?? ['slack'];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Normalize provider names from env before storing config to avoid case-sensitive runtime failures for valid provider values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/config.ts, line 49:

<comment>Normalize provider names from env before storing config to avoid case-sensitive runtime failures for valid provider values.</comment>

<file context>
@@ -0,0 +1,77 @@
+    throw new Error('EVENT_BRIDGE_AGENT (target on-relay agent name) is required');
+  }
+
+  const providers = csv(env.EVENT_BRIDGE_PROVIDERS) ?? ['slack'];
+  const injectMode = trim(env.EVENT_BRIDGE_INJECT_MODE) === 'steer' ? 'steer' : 'wait';
+
</file context>

file: WorkspaceFileLike | null,
ctx: InboundContext
): InboundItem | null {
if (event.action === 'deleted') {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: resolveInbound also accepts updated events, so Slack message edits can retrigger agent nudges. Restrict inbound handling to newly created messages.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/providers/slack.ts, line 43:

<comment>`resolveInbound` also accepts `updated` events, so Slack message edits can retrigger agent nudges. Restrict inbound handling to newly created messages.</comment>

<file context>
@@ -0,0 +1,115 @@
+      file: WorkspaceFileLike | null,
+      ctx: InboundContext
+    ): InboundItem | null {
+      if (event.action === 'deleted') {
+        return null;
+      }
</file context>

}

function mintReplyId(): string {
return `r-${globalThis.crypto.randomUUID().slice(0, 8)}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Reply IDs are truncated to 32-bit entropy, which risks collisions and pending-reply overwrites over time.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bridge.ts, line 270:

<comment>Reply IDs are truncated to 32-bit entropy, which risks collisions and pending-reply overwrites over time.</comment>

<file context>
@@ -0,0 +1,286 @@
+}
+
+function mintReplyId(): string {
+  return `r-${globalThis.crypto.randomUUID().slice(0, 8)}`;
+}
+
</file context>

"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"test": "vitest run",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing vitest in devDependencies — the package uses vitest in its test scripts but does not explicitly declare it. Every other package in the monorepo that runs vitest lists it as a devDependency. This will cause npm test failures if the package is ever installed independently of the workspace.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/package.json, line 21:

<comment>Missing `vitest` in `devDependencies` — the package uses vitest in its test scripts but does not explicitly declare it. Every other package in the monorepo that runs vitest lists it as a devDependency. This will cause `npm test` failures if the package is ever installed independently of the workspace.</comment>

<file context>
@@ -0,0 +1,46 @@
+  "scripts": {
+    "build": "tsc",
+    "clean": "rm -rf dist",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "typecheck": "tsc -p tsconfig.json --noEmit"
</file context>

apiUrl,
`/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events`
);
const configResponse = await fetchImpl(configUrl, { headers: authHeader });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Add a timeout signal to this bootstrap request so the process cannot hang indefinitely on a stalled cloud endpoint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bootstrap.ts, line 73:

<comment>Add a timeout signal to this bootstrap request so the process cannot hang indefinitely on a stalled cloud endpoint.</comment>

<file context>
@@ -0,0 +1,136 @@
+    apiUrl,
+    `/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events`
+  );
+  const configResponse = await fetchImpl(configUrl, { headers: authHeader });
+  await assertOk(configResponse, 'config', configUrl);
+  const config = (await configResponse.json().catch(() => null)) as {
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants