Bridging artifactr into reflexr¶
This guide wires relayr's inbound adapter into an application that uses both libraries, so that what people and agents do in a workspace can fire rules. The architecture says how it works.
In an application from stackr's template¶
Answer yes to the template's bridge question, with both libraries, and its bridge.py does everything below, with an incident timeline example (stackr ADR-0021). The rest of this guide is what it does, for an application wired by hand.
Install¶
relayr is distributed as relayr-ai and imported as relayr. Its ledger needs SQL in production: install relayr-ai[postgres], or relayr-ai[sqlite].
Wire it in¶
The followers start from the libraries' on_open hook. Each Workspaces takes one, so compose followers.watch with any other, such as the feedback mirrors':
import artifactr
import reflexr.workspace
from relayr.events import BRIDGED, Projections
from relayr.inbound import Followers
from relayr.sql import SqlLedger, migrate
await migrate(engine) # relayr's tables, beside the libraries'
async def on_open(tenant_id: str, workspace_id: str) -> None:
await mirrors.watch(tenant_id, workspace_id)
await followers.watch(tenant_id, workspace_id)
collaboration = artifactr.Workspaces(artifactr_storage, authorize=..., on_open=on_open)
automation = reflexr.workspace.Workspaces(
reflexr_storage,
events=[*BRIDGED, *my_event_types],
rules=my_rules,
authorize=...,
on_open=on_open,
)
followers = Followers(collaboration, automation, SqlLedger(engine), projections=Projections())
await followers.resume() # at startup: every workspace the ledger knows
...
await followers.aclose() # at shutdown: each follower releases its lease
With row-level security (ADR-0027), as the libraries have it, the ledger works as a role that owns nothing, and starts the followers from every tenant's workspaces on the tables' owner's engine. Create the role before you migrate, then:
await migrate(owner, application_role="app") # grants the role relayr's tables
ledger = SqlLedger(engine, row_level_security=True, across_tenants=owner)
eventsmust includeBRIDGED. reflexr refuses a type itsWorkspacesdoesn't accept, and the follower stops, logging an error, until the workspace is next opened. Nothing is skipped meanwhile.- Give each process its own
holder, or leave it to its random default. One process at a time follows a workspace; the others wait for its lease. - A workspace is followed from its first open, and from startup once the ledger knows it.
Write rules on bridged events¶
Bridged events are ordinary reflexr events in the artifactr namespace. Every one says who acted in artifactr, in which thread and turn:
from reflexr import F, Rule, on, run
from relayr.events import MessagePosted
triage = Rule(
name="ops:triage-mentions",
when=on(MessagePosted).where(F.author.kind.eq("user")).where(F.content.contains("@oncall")),
then=run("triage"),
)
The envelope's actor is SourceActor("artifactr") on every bridged event; author is who acted. Filtering on F.author.kind keeps a rule from reacting to agents, including its own writes once relayr's outbound adapter exists.
Bridge your own events¶
Projections maps an app_event, by its name, an artifact kind, or an answer to a deferred tool, by the tool's name, to the application's own event type, in its own namespace:
import artifactr.core
from reflexr import Event
class Ops(Event, abstract=True, event_namespace="ops"): ...
class Deployed(Ops, name="deployed"):
service: str
def deployed(envelope: artifactr.core.Envelope) -> Deployed:
assert isinstance(envelope.event, artifactr.core.AppEvent)
return Deployed.model_validate(envelope.event.data)
projections = Projections(app_events={"deploy": deployed})
- An
app_eventwith no projection stays in artifactr. - An artifact kind's projection replaces the default set's
artifact_created,artifact_changedandartifact_archivedfor that kind. Proposals on it are still bridged asartifactr:proposal_*. Return None to bridge nothing. - A deferred tool's projection replaces the default set's
artifactr:deferred_answeredfor answers to that tool (Projections(deferred={"ask_form": ...})). An answer to another tool, or one recorded before artifactr named the tool, is bridged asartifactr:deferred_answered. - Add the projected types to
events, as withBRIDGED.
Dead letters¶
An envelope whose projection raises, an invalid event included, or returns one of reflexr's own events is dead-lettered, and its follower moves on:
from relayr.ledger import WorkspaceKey
for letter in await ledger.dead_letters(WorkspaceKey("acme", "prod")):
print(letter.seq, letter.event_type, letter.error)
Any other error, a type reflexr doesn't accept included, stops the follower and logs an error on relayr.inbound. It starts again, at its cursor, the next time the workspace is opened.
Access¶
With access closed by an authorize hook, such as grantr's, the followers need four actions in every tenant (ADR-0017):
| Library | Actor | Actions |
|---|---|---|
| artifactr | SystemActor("relayr") |
artifactr:open, artifactr:read_events |
| reflexr | SourceActor("artifactr") |
reflexr:open, reflexr:publish |
Map both to the application's own principal. Never resolve a client to SourceActor("artifactr"): rules trust it to mean relayr.
Test it¶
Run the followers over both libraries' in-memory storage and an InMemoryLedger, and poll until reflexr's log has the events you expect, as relayr's own tests do: