Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions web/app/flows/onboarding/SourcePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,17 @@ export function SourcePicker({ draft, onChange, onTrack }: { draft: FactoryDraft
<div className={s.filterFields}>
{source.fields.map(field => <div key={`${source.id}-${field.key}`}>
<label htmlFor={`source-${source.id}-${field.key}`}>{field.label}</label>
<input id={`source-${source.id}-${field.key}`} type="text" maxLength={200} placeholder={field.placeholder} value={settings[field.key] ?? ''}
onChange={event => update({ [field.key]: event.target.value })}
aria-describedby={field.key === 'labels' ? 'source-labels-help' : undefined} />
{field.key === 'labels' && <small id="source-labels-help">Separate with commas. Every label must match.</small>}
{'options' in field
? <select id={`source-${source.id}-${field.key}`} value={settings[field.key] ?? field.options[0].value}
onChange={event => update({ [field.key]: event.target.value })}>
Comment on lines +67 to +68

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 Badge Include the default wake mode in source summaries

When a user selects Linear and leaves Wake on untouched, this fallback displays “New issues” without writing issues to sourceSettings. The later workflow preview and final review pass the empty settings to sourceSummary, which consequently says “All incoming items from this connection”; that now incorrectly suggests assigned issues can also trigger the flow even though the effective default only handles new issues. Persist the displayed default or make the summary resolve the choice field's default.

Useful? React with 👍 / 👎.

{field.options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
: <>
<input id={`source-${source.id}-${field.key}`} type="text" maxLength={200} placeholder={field.placeholder} value={settings[field.key] ?? ''}
onChange={event => update({ [field.key]: event.target.value })}
aria-describedby={field.key === 'labels' ? 'source-labels-help' : undefined} />
{field.key === 'labels' && <small id="source-labels-help">Separate with commas. Every label must match.</small>}
</>}
</div>)}
</div>
{source.id === 'slack' && <label className={s.mentionFilter}>
Expand Down
4 changes: 2 additions & 2 deletions web/app/flows/onboarding/onboarding.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@
.filterFields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 20px; }
.filterFields > div:last-child:nth-child(odd) { grid-column: 1 / -1; }
.filterFields label { display: block; font-size: 12px; color: #c2cedb; margin-bottom: 8px; }
.filterFields input { width: 100%; min-height: 44px; border: 1px solid var(--setup-control-border); border-radius: var(--r-xs); background: var(--setup-control); padding: 10px 12px; color: #edf4fb; font-size: 13px; }
.filterFields input, .filterFields select { width: 100%; min-height: 44px; border: 1px solid var(--setup-control-border); border-radius: var(--r-xs); background: var(--setup-control); padding: 10px 12px; color: #edf4fb; font-size: 13px; }
.filterFields input::placeholder { color: #8b9aaa; }
.filterFields small { display: block; font-size: 11px; color: #a8b8c8; margin-top: 7px; }
.filterFields input:focus-visible, .sourceTabs button:focus-visible { outline: 2px solid #8bc4ef; outline-offset: 3px; }
.filterFields input:focus-visible, .filterFields select:focus-visible, .sourceTabs button:focus-visible { outline: 2px solid #8bc4ef; outline-offset: 3px; }
.mentionFilter { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #c2cedb; margin-top: 16px; min-height: 36px; cursor: pointer; }
.mentionFilter input { width: 17px; height: 17px; accent-color: #8bc4ef; }
.sourceSettings .sourceNote { font-size: 11px; margin-top: 20px; }
Expand Down
15 changes: 15 additions & 0 deletions web/content/docs/cli-broker-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Flags:
| `--workspace-key <key>` | Join a pre-existing Relay workspace. |
| `--state-dir <path>` | Write runtime state outside `.agentworkforce/relay/`. |
| `--broker-name <name>` | Override the broker identity. Defaults to the project directory basename. |
| `--force` | Take the enrolled node over from a live broker on this machine; evicts that broker's delivery socket. |
| `--verbose` | Enable verbose startup logging (raises the node log level to `debug`). |
| `--log-file <path>` | Write structured node logs — each capability registered and every action invoked/completed — to a file. |
| `--log-level <level>` | Node log verbosity: `debug` \| `info` \| `warn` \| `error` (default `info`). |
Expand All @@ -53,6 +54,20 @@ agent-relay node up --background --workspace-key "$RELAY_WORKSPACE_KEY"

The broker listens on a local API port starting from `3888` (override with `AGENT_RELAY_BROKER_PORT`). If this machine was enrolled as a Cloud-managed node with `agent-relay cloud enroll`, `node up` picks up the persisted enrollment automatically and serves under the enrolled node name.

### One broker per enrolled node

An enrolled node has one Cloud delivery socket. If two brokers served the same node id, the second registration would evict the first broker's socket and it would silently stop receiving messages. `node up` prevents this with a machine-local claim in `~/.agentworkforce/relay/node-claims/`: the first broker claims its enrolled node id, and a later `node up` for that node — including one pinned with `RELAY_NODE_ID` — refuses and names the holding broker's pid and state directory.

If the refusal is wrong, or you want the takeover:

```bash
agent-relay node down --state-dir <holder's state dir> # stop the running broker
agent-relay node up --workspace-key <different key> # serve a different enrolled node
agent-relay node up --force # take the node over anyway
```

`--force` evicts the incumbent's delivery socket: the old broker keeps running but stops receiving realtime delivery. A crashed broker's stale claim never blocks a restart, `--local-only` claims nothing, and `node down` releases the claim on clean exit.

## Check Status

```bash
Expand Down
2 changes: 2 additions & 0 deletions web/content/docs/nodes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ A node enrolls with `POST /v1/nodes` using the workspace key. The request carrie

A node id supplied or pinned by an operator (`node_id` in the enroll request, used with its node token) is taken as-is. Otherwise the id derives from the machine identity, the working directory, and the workspace, so several nodes on one host — for example one per project directory — do not collide.

The agent runtime also claims the enrolled id locally: a running broker records a claim, and a second broker for the same node on that host is refused instead of evicting the incumbent's delivery socket. See [Broker lifecycle → One broker per enrolled node](/docs/cli-broker-lifecycle#one-broker-per-enrolled-node).

## Presence And Context

Workspace observers see node presence events as `node.online`, `node.heartbeat`, and `node.offline`. Each event carries a node payload matching the roster entry.
Expand Down
63 changes: 63 additions & 0 deletions web/content/docs/provider-subscriptions.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: 'Provider Subscriptions'
description: 'Bind a provider resource — a repo, a team, a channel — to an agent so provider events wake it.'
---

Provider subscriptions bind an external resource to a Relay recipient. When an event fires — an issue opened, a comment posted — Relayfile writes it to the mounted tree and Relay delivers it as a message that wakes the recipient.

```bash
agent-relay integration subscribe github \
--to @watcher \
--resource AgentWorkforce/software-garden \
--events issues,issue_comment
```

The recipient is an agent (`@watcher`) or a channel (`#triage`). The agent does not need to exist yet; `--spawn` creates it.

## What the agent sees

Each event is a message from the provider identity (`github`) with the event type and the Relayfile path holding the payload:

```
Relay message from github: Github issue_comment.created
Relayfile path: /github/repos/AgentWorkforce/software-garden/issues/531/comments/5744794683/meta.json
```

The mounted file holds the full payload — the same tree as [Relayfile integrations](/docs/file/integrations). An in-channel reply posts back to the issue or thread when the provider supports writeback.

Events that arrive while the node is disconnected queue durably and replay on reconnect, so a subscription survives broker restarts. See [Delivery](/docs/delivery).

## Spawning a recipient

`--spawn <cli>` launches a new agent and confirms it is live before subscribing:

```bash
agent-relay integration subscribe github \
--resource AgentWorkforce/software-garden \
--events issues,issue_comment \
--spawn claude --task "Triage new issue comments and reply with a summary."
```

## Managing subscriptions

```bash
agent-relay integration subscribe --list # active bindings
agent-relay integration unsubscribe github --resource AgentWorkforce/software-garden
```

Unsubscribing removes the binding and its inbound webhook; agents and channels are untouched.

<CardGroup cols={2}>
<Card title="Events" href="/docs/events">
The event vocabulary subscriptions and listeners share.
</Card>
<Card title="Relayfile integrations" href="/docs/file/integrations">
How provider resources become files an agent can read and watch.
</Card>
<Card title="Delivery" href="/docs/delivery">
Sequencing, acks, and reconnect replay for node-delivered events.
</Card>
<Card title="CLI reference" href="/docs/reference-cli">
The full `integration` command surface.
</Card>
</CardGroup>
3 changes: 2 additions & 1 deletion web/content/docs/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ directions, both under the `relay.webhooks` namespace:
- **Outbound:** subscribe your service to Relay events. Relay POSTs HMAC-signed event payloads to your URL.

Provider connections (Slack, GitHub App installs, and similar) live under the separate `relay.integrations`
namespace — don't conflate it with webhooks.
namespace — don't conflate it with webhooks. To wake an agent on provider events like GitHub issues and
comments, use [Provider subscriptions](/docs/provider-subscriptions).

## Inbound: external services into Relay

Expand Down
1 change: 1 addition & 0 deletions web/lib/docs-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const docsNav: NavGroup[] = [
{ title: 'Events', slug: 'events' },
{ title: 'Event handlers', slug: 'event-handlers' },
{ title: 'Webhooks', slug: 'webhooks' },
{ title: 'Provider subscriptions', slug: 'provider-subscriptions' },
],
},
{
Expand Down
4 changes: 3 additions & 1 deletion web/lib/flow-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export function localInput(draft: FactoryDraft) {
const source = draft.sources.find(id => id !== 'markdown');
if (!source) return { approver: 'local' };
const settings = draft.sourceSettings[source] ?? {};
const { labels, contains, ...fields } = settings;
// labels/contains are filters, not ticket fields; `events` is the Cloud wake
// choice — none of them belong on the prefilled ticket.
const { labels, contains, events: _events, ...fields } = settings;
return { approver: 'local', issue: {
source, title: contains?.trim() || PLACEHOLDER_TITLE,
body: PLACEHOLDER_BODY,
Expand Down
33 changes: 29 additions & 4 deletions web/lib/flow-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export const ISSUE_SOURCES = [
{ key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent, bug' },
] },
{ id: 'linear', label: 'Linear', fields: [
// Assignment is how a ticket is delegated to the agent: Cloud hears it as
// AppUserNotification.issueAssignedToYou, not an issue.create record, so
// it is a wake choice rather than another field to match on.
{ key: 'events', label: 'Wake on', options: [
{ value: 'issues', label: 'New issues' },
{ value: 'assigned', label: 'Issues assigned to the agent' },
{ value: 'all', label: 'New or assigned issues' },
] },
{ key: 'team', label: 'Team', placeholder: 'Engineering' },
{ key: 'project', label: 'Project', placeholder: 'Website' },
{ key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent' },
Expand Down Expand Up @@ -59,17 +67,31 @@ export function validSourcePreferences(value: unknown): value is SourcePreferenc
return Object.entries(value).every(([id, settings]) => {
const source = ISSUE_SOURCES.find(source => source.id === id);
if (!source || !settings || typeof settings !== 'object' || Array.isArray(settings)) return false;
return Object.entries(settings).every(([key, val]) => key === 'mentioned'
? id === 'slack' && typeof val === 'boolean'
: source.fields.some(field => field.key === key) && typeof val === 'string' && val.length <= 200);
return Object.entries(settings).every(([key, val]) => {
if (key === 'mentioned') return id === 'slack' && typeof val === 'boolean';
const field = source.fields.find(field => field.key === key);
if (!field || typeof val !== 'string' || val.length > 200) return false;
// A choice field accepts its options — or blank, which means the default
// and is dropped before it ever reaches a deploy request.
return !('options' in field) || !val || field.options.some(option => option.value === val);
});
});
}

export function sourceSummary(id: IssueSourceId, settings: SourceSettings): string {
if (id === 'markdown') return `${settings.path?.trim() || 'tasks.md'} · Local runs only, nothing to connect`;
const parts = ISSUE_SOURCES.find(source => source.id === id)!.fields.flatMap(field => {
const value = settings[field.key]?.trim();
return value ? [`${field.label}: ${value}`] : [];
// A choice field unset still has a visible default in the picker — the
// first option — so the summary must name it rather than read as if every
// event family were subscribed.
if (!value) {
return 'options' in field ? [`${field.label}: ${field.options[0].label}`] : [];
}
const shown = 'options' in field
? field.options.find(option => option.value === value)?.label ?? value
: value;
return [`${field.label}: ${shown}`];
});
if (id === 'slack' && settings.mentioned) parts.push('Only when the app is mentioned');
return parts.join(' · ') || 'All incoming items from this connection';
Expand All @@ -81,6 +103,9 @@ function sourceFilterRules(sources: IssueSourceId[], preferences: SourcePreferen
const settings = preferences[id] ?? {};
const entries: [string, string | string[] | boolean][] = [];
for (const field of ISSUE_SOURCES.find(source => source.id === id)!.fields) {
// `events` chooses what wakes the Cloud listener; a local run has no
// dispatcher and a ticket never carries it, so it is not a filter rule.
if (field.key === 'events') continue;
const value = field.key === 'path' ? settings.path?.trim() || 'tasks.md' : settings[field.key]?.trim();
if (value) entries.push([field.key, field.key === 'labels'
? [...new Set(value.split(',').map(label => label.trim()).filter(Boolean))] : value]);
Expand Down
36 changes: 35 additions & 1 deletion web/lib/test/flow-sources.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import ts from 'typescript';
import { issueSourceCode, type IssueSourceId, type SourcePreferences } from '../flow-sources';
import { ISSUE_SOURCES, issueSourceCode, sourceSummary, validSourcePreferences, type IssueSourceId, type SourcePreferences } from '../flow-sources';
import { localInput } from '../flow-local';
import { DEFAULT_FACTORY } from '../flow-onboarding';

/**
* Filtering now ships only in the local flow: a Cloud deployment is filtered by
Expand Down Expand Up @@ -113,6 +115,38 @@ describe('generated issue source filters', () => {
expect(slack).not.toContain('repository?');
});

it('offers Linear a wake-on choice and never turns it into a local filter', () => {
const linear = ISSUE_SOURCES.find(source => source.id === 'linear')!;
const events = linear.fields.find(field => field.key === 'events')!;
expect('options' in events && events.options.map(option => option.value)).toEqual(['issues', 'assigned', 'all']);

// Storage validation accepts the choices (and a blank default) but nothing else.
for (const value of ['issues', 'assigned', 'all', '']) {
expect(validSourcePreferences({ linear: { events: value } })).toBe(true);
}
expect(validSourcePreferences({ linear: { events: 'mentions' } })).toBe(false);
expect(validSourcePreferences({ github: { events: 'assigned' } })).toBe(false);

// The summary names the choice, not its storage value — and an untouched
// select still means its displayed default, not "all incoming items".
expect(sourceSummary('linear', { events: 'assigned', team: 'Engineering' }))
.toBe('Wake on: Issues assigned to the agent · Team: Engineering');
expect(sourceSummary('linear', {})).toBe('Wake on: New issues');
expect(sourceSummary('linear', { team: 'Engineering' }))
.toBe('Wake on: New issues · Team: Engineering');

// `events` is what wakes the Cloud listener, not a field a ticket carries:
// the local flow must not filter on it or declare it on Issue.
const settings: SourcePreferences = { linear: { events: 'assigned' } };
const local = issueSourceCode(['linear'], settings, 'local');
expect(local).not.toContain('events');
expect(matcher(['linear'], settings)({ ...issue, team: 'Engineering' })).toBe(true);
expect(issueSourceCode(['linear'], settings, 'cloud')).not.toContain('events?:');
// And the prefilled local ticket carries real ticket fields only.
expect(localInput({ ...DEFAULT_FACTORY, sources: ['linear'], sourceSettings: settings }).issue)
.not.toHaveProperty('events');
});

it('never reads a field the trimmed Issue type does not declare', () => {
// The kit tells people to run `npx flows check` before `flows run`, so the
// generated filter has to typecheck against the trimmed Issue. Slack is the
Expand Down
Loading