Skip to content

flows: event triggers via webhook + inbox watcher — SURFACE §1 harness #301

Description

@kjgbot

Summary

Land the event-trigger half of SURFACE.md §1 (the harness example) as a single
webhook-backed slice, end-to-end. Today flow() returns a plain handle; the
.on(...) surface described in §1 does not exist, no trigger executor exists,
and there is no path from an external event to a spawned journal run. This
issue closes that gap for one canonical trigger source (HTTP webhook), setting
the pattern for provider-specific triggers like slack.mention() (which land
with slice B, helper namespaces).

The spec text

From docs/SURFACE.md §1 (lines 33-53):

export default flow("chief", {
  identity: "chief",                          // gate 8 — a principal
  memory: { script: true, agent: true },      // gate 5 — relayhistory-backed
  budget: "$20/day",
})
.on(slack.mention("#exec"), async (f, event) => {          // gate 2 — trigger = entry condition
  ...
});

No process runs between events: the handler wakes, executes to its next await,
parks. That is gate 4's system-of-ephemeral-agents, and the author never meets
a lease, journal, or offset.

Scope

Three deliverables in one slice:

  1. Surface primitive. flow(...).on(trigger, body) and one built-in trigger
    source constructor webhook(name: string, filter?: WebhookFilter):

    import { flow, webhook } from '@relayflows/surface';
    export default flow('release-note').on(webhook('release-note'), async (f, event) => {
      // event: parsed JSON payload
      await f.run(`echo ${JSON.stringify(event.tag)}`);
      f.done('success');
    });

    .on() is chainable on the existing FlowHandle and returns a
    TriggeredFlowHandle that keeps the same runtime contract; multiple .on()
    calls on one flow are allowed. Each call registers a (triggerSource, flowName, body) triple with the surface's authored-flow definition so
    getFlowDefinition can expose it.

  2. Trigger executor CLI. flows serve-webhook --data-dir <dir> --port <p>
    binds an HTTP receiver. Each POST /webhooks/<trigger-name> writes the JSON
    body to <data-dir>/inbox/<trigger-name>/<uuid>.json (atomic rename) and
    returns 202 Accepted. Failure modes: non-JSON body → 400 invalid_json;
    trigger name that is not registered in the loaded flow set → 404 unknown_trigger; write failure → 500 inbox_write_failed. The receiver
    itself is stateless — one process, one port, no queue in RAM.

  3. Watcher + spawn. Inside relayflowd, a new
    kernel/relayflowd/src/trigger_watcher.rs polls <data-dir>/inbox/*/ at 1
    Hz (bounded, cheap; a file-system watcher upgrade is a follow-up). Each new
    <trigger>/<uuid>.json triggers a run of every flow that registered on that
    trigger: journal run.spawned with event: <parsed-json> as the input, then
    move the file to <data-dir>/inbox-processed/<trigger>/<uuid>.json before
    the journal write completes. On restart, files still in inbox/ are
    re-picked up (idempotency: filename is the run's idempotency key, matching
    gate 6's writeback election).

Preflight

webhook('X') on a flow is legal only if X is present in the nearest
flows.json's executors array (SURFACE.md §1 line 195 wording preserved
verbatim: "a trigger executor is considered registered only when its name is
present in this author-written executors array"
). Refusal:
no_executor: webhook trigger "X" is not registered in flows.json. This is
checked in packages/sdk/src/preflight.ts before any journal write.

Direct-input runs (flows run flow.ts --input …) remain unchanged. Preflight
only enforces the executor allowlist when the flow declares a trigger.

Acceptance evidence

  1. curl -X POST http://localhost:8087/webhooks/release-note -d '{"tag":"v1.0"}'
    returns 202 and writes <data-dir>/inbox/release-note/<uuid>.json with
    exactly that payload.
  2. The running relayflowd picks the file up within ≤2s (poll interval + one
    spawn) and journals run.spawned for the registered flow with input: {"tag":"v1.0"}; the body's second arg is {"tag":"v1.0"}; run.completed
    follows.
  3. flows check refuses a flow declaring webhook('unregistered') before any
    journal contact, exit code 2, message: REFUSED [no_executor] webhook trigger "unregistered" is not registered in flows.json.
  4. Killing flows serve-webhook mid-run does not lose the event: on restart of
    the daemon, files remaining in inbox/ are re-picked up (idempotency: the
    filename is the run's idempotency key, so the second attempt is a no-op if
    the journal already recorded the spawn).

Files

  • packages/surface/src/triggers.ts — new. Exports webhook(name, filter?)
    and the TriggerSource type. No side effects; a trigger source is a
    plain-data object {kind: 'webhook', name, filter}.
  • packages/surface/src/flow.ts — extend to record .on() triples on the
    authored flow definition; export TriggeredFlowHandle.
  • packages/surface/src/index.ts — re-export webhook, TriggerSource.
  • packages/sdk/src/cli/serve-webhook.ts — new. Node http server + inbox
    writer. No dependencies beyond stdlib.
  • packages/sdk/src/cli.ts — wire flows serve-webhook subcommand.
  • packages/sdk/src/preflight.ts — trigger-in-executors check.
  • kernel/relayflowd/src/trigger_watcher.rs — new. tokio::spawn inside main
    poll loop; reuses the existing journal client.
  • kernel/relayflowd/src/lib.rs — register trigger_watcher.
  • testdata/webhook-hello.flow.ts — one authored trigger flow.
  • packages/sdk/tests/serve-webhook.test.ts — HTTP end-to-end.
  • kernel/relayflowd/tests/trigger_watcher_test.rs — file-in-inbox → journal
    spawn.
  • docs/SURFACE.md — add the "Triggers" subsection under §1 documenting
    webhook() and the serve-webhook command.

Not-in-scope (follow-ups)

  • Webhook signing/auth. Add a NOTE(security): in serve-webhook.ts
    linking a follow-up issue. This slice accepts unsigned POSTs — usable in a
    local dev demo only.
  • Rate limiting. No limit today; follow-up.
  • Provider-specific triggers like slack.mention(), github.pr_opened().
    These are helper-namespace primitives and land with slice B.
  • Replay of missed webhooks under daemon downtime beyond the inbox retry
    guarantee.
    If the receiver was down when the event was emitted, this slice
    never sees it. Documented as a limitation; follow-up: reconciliation from
    provider read state.
  • File-system-notify upgrade of the 1 Hz poller. Follow-up if latency
    becomes an issue.
  • Multi-tenant port sharing. One receiver process, one data-dir. Follow-up.
  • Trigger removal / stop-serving. SIGTERM only; no admin API.

PR feedback loop rule

Before task-exit: run the full CI pipeline locally where possible; push; watch
gh pr checks; fix any bot findings of severity ≥ Medium before declaring
done. False-positive bot findings get a comment reply explaining why.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions