imbi-workflows 2.38.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ __pycache__
2
+ *.egg-info
3
+ *.pyc
4
+ *.env
5
+ .env
6
+ .env.test
7
+ build/
8
+ dist/
9
+ docs/site/
10
+ helm/imbi/charts/
11
+ runtime/state/
12
+ .venv/
13
+ .coverage
14
+ .mypy_cache/
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ coverage.xml
18
+ site/
19
+
20
+ # moon
21
+ .moon/cache
22
+ .moon/docker
23
+
24
+ # design-sync
25
+ .design-sync/sb-reference/
26
+ .design-sync/learnings/
27
+ .design-sync/.cache/
28
+ .design-sync/node_modules
29
+ .ds-sync/
30
+ ds-bundle/
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: imbi-workflows
3
+ Version: 2.38.0
4
+ Summary: Imbi workflow engine
5
+ Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
+ License-Expression: BSD-3-Clause
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: fastapi<0.137,>=0.135.1
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: imbi-common[databases,server]==2.38.0
11
+ Requires-Dist: psycopg[binary,pool]~=3.3.2
12
+ Requires-Dist: pydantic~=2.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Imbi Workflows
16
+
17
+ The workflow engine (ADR 0021). It claims queued and running workflow runs
18
+ from the `workflow_runtime` Postgres schema, runs one step per claim, records
19
+ the result, and releases the run. imbi-api owns the schema, starts runs, and
20
+ cancels them; the engine has no public API, only `/status`.
21
+
22
+ Deploy, rollback, and email steps call imbi-api as the `imbi-workflows`
23
+ service account. The bounds of ADR 0017 apply to deployments: the account
24
+ needs `integration:act-as-service`, membership in the workflow's
25
+ organization, and a target environment with `allow_autonomous`. imbi-api
26
+ sends email, to users and teams of the organization only.
27
+
28
+ The engine also reconciles trigger bindings. When a workflow activates,
29
+ pauses, or is deleted, imbi-api writes the desired bindings to
30
+ `workflows.trigger_bindings` and notifies. The engine then makes the
31
+ scheduler task of each schedule trigger (as the `imbi-workflows` account)
32
+ and the `gateway-actions#start_workflow` rule of each webhook trigger, and
33
+ records each binding as `synced` or `failed`. It also does this on a
34
+ schedule, so a failed call is tried again.
35
+
36
+ ## Settings
37
+
38
+ | Variable | Purpose | Default |
39
+ |---|---|---|
40
+ | `IMBI_INTERNAL_API_URL` | In-cluster origin of imbi-api | `http://localhost:8000` |
41
+ | `IMBI_API_URL` | imbi-api's public URL, whose path is its route prefix | |
42
+ | `IMBI_WORKFLOWS_SA_CLIENT_ID` | Client id of the `imbi-workflows` service account | |
43
+ | `IMBI_WORKFLOWS_SA_CLIENT_SECRET` | Client secret of that account | |
44
+ | `IMBI_WORKFLOWS_WORKERS` | Runs one process works on at the same time | `10` |
45
+ | `IMBI_WORKFLOWS_POLL_INTERVAL` | Upper bound in seconds on the sleep between polls | `10` |
46
+ | `IMBI_WORKFLOWS_SCHEDULER_URL` | imbi-scheduler's task API, with its route prefix | `http://localhost:8005/api` |
47
+ | `IMBI_WORKFLOWS_RECONCILE_INTERVAL` | Seconds between passes of the trigger reconciler | `300` |
48
+
49
+ Postgres and the graph use the shared settings of `imbi-common`.
50
+
51
+ ## Developer Quickstart
52
+
53
+ ```shell
54
+ moon run root:setup
55
+ moon run root:services
56
+ uv run --env-file .env.test pytest apps/workflows/tests
57
+ ```
@@ -0,0 +1,43 @@
1
+ # Imbi Workflows
2
+
3
+ The workflow engine (ADR 0021). It claims queued and running workflow runs
4
+ from the `workflow_runtime` Postgres schema, runs one step per claim, records
5
+ the result, and releases the run. imbi-api owns the schema, starts runs, and
6
+ cancels them; the engine has no public API, only `/status`.
7
+
8
+ Deploy, rollback, and email steps call imbi-api as the `imbi-workflows`
9
+ service account. The bounds of ADR 0017 apply to deployments: the account
10
+ needs `integration:act-as-service`, membership in the workflow's
11
+ organization, and a target environment with `allow_autonomous`. imbi-api
12
+ sends email, to users and teams of the organization only.
13
+
14
+ The engine also reconciles trigger bindings. When a workflow activates,
15
+ pauses, or is deleted, imbi-api writes the desired bindings to
16
+ `workflows.trigger_bindings` and notifies. The engine then makes the
17
+ scheduler task of each schedule trigger (as the `imbi-workflows` account)
18
+ and the `gateway-actions#start_workflow` rule of each webhook trigger, and
19
+ records each binding as `synced` or `failed`. It also does this on a
20
+ schedule, so a failed call is tried again.
21
+
22
+ ## Settings
23
+
24
+ | Variable | Purpose | Default |
25
+ |---|---|---|
26
+ | `IMBI_INTERNAL_API_URL` | In-cluster origin of imbi-api | `http://localhost:8000` |
27
+ | `IMBI_API_URL` | imbi-api's public URL, whose path is its route prefix | |
28
+ | `IMBI_WORKFLOWS_SA_CLIENT_ID` | Client id of the `imbi-workflows` service account | |
29
+ | `IMBI_WORKFLOWS_SA_CLIENT_SECRET` | Client secret of that account | |
30
+ | `IMBI_WORKFLOWS_WORKERS` | Runs one process works on at the same time | `10` |
31
+ | `IMBI_WORKFLOWS_POLL_INTERVAL` | Upper bound in seconds on the sleep between polls | `10` |
32
+ | `IMBI_WORKFLOWS_SCHEDULER_URL` | imbi-scheduler's task API, with its route prefix | `http://localhost:8005/api` |
33
+ | `IMBI_WORKFLOWS_RECONCILE_INTERVAL` | Seconds between passes of the trigger reconciler | `300` |
34
+
35
+ Postgres and the graph use the shared settings of `imbi-common`.
36
+
37
+ ## Developer Quickstart
38
+
39
+ ```shell
40
+ moon run root:setup
41
+ moon run root:services
42
+ uv run --env-file .env.test pytest apps/workflows/tests
43
+ ```
@@ -0,0 +1,22 @@
1
+ # yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json
2
+ layer: 'application'
3
+ stack: 'backend'
4
+ language: 'python'
5
+ tags:
6
+ - 'python'
7
+
8
+ project:
9
+ name: 'imbi-workflows'
10
+ description: 'Imbi workflow engine (:8006)'
11
+
12
+ dependsOn:
13
+ - 'common'
14
+
15
+ # Inherits lint / format / typecheck / test from .moon/tasks/python.yml. The
16
+ # shared test task points --cov-config at this member's pyproject, where
17
+ # [tool.coverage] scopes source + threshold to the member.
18
+ tasks:
19
+ test:
20
+ args:
21
+ - '--cov'
22
+ - '--cov-report=term-missing'
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "imbi-workflows"
3
+ version = "2.38.0"
4
+ description = "Imbi workflow engine"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "BSD-3-Clause"
8
+ authors = [
9
+ { name = "Gavin M. Roy", email = "gavinr@aweber.com" },
10
+ ]
11
+ classifiers = []
12
+ dependencies = [
13
+ "fastapi>=0.135.1,<0.137",
14
+ "httpx>=0.28.1",
15
+ "imbi-common[databases,server]==2.38.0",
16
+ "psycopg[binary,pool]~=3.3.2",
17
+ "pydantic~=2.12",
18
+ ]
19
+
20
+ [project.scripts]
21
+ imbi-workflows = "imbi.workflows.app:cli"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ # Coverage for the per-member `moon run workflows:test` run. Scoped to this
28
+ # member so the report/threshold reflect only imbi.workflows; the aggregate
29
+ # `root:coverage` run uses the root pyproject config instead.
30
+ [tool.coverage.run]
31
+ branch = true
32
+ source_pkgs = ["imbi.workflows"]
33
+
34
+ [tool.coverage.report]
35
+ exclude_also = ["typing.TYPE_CHECKING"]
36
+ fail_under = 85
37
+ show_missing = true
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/imbi"]
41
+ include = ["src/imbi/workflows/py.typed"]
@@ -0,0 +1,4 @@
1
+ from importlib import metadata
2
+
3
+ version = metadata.version('imbi-workflows')
4
+ del metadata
@@ -0,0 +1,134 @@
1
+ """imbi-workflows: the workflow engine with a ``/status`` endpoint."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import datetime
6
+ import logging
7
+ import os
8
+ import socket
9
+ import typing
10
+ import uuid
11
+ from collections import abc
12
+
13
+ import fastapi
14
+ import httpx
15
+ import psycopg_pool
16
+ import pydantic
17
+ import typer
18
+
19
+ import imbi.workflows
20
+ from imbi.common import graph, iggy, lifespan, server
21
+ from imbi.common import settings as common_settings
22
+ from imbi.common.prompts import pool as prompt_pool
23
+ from imbi.workflows import bindings, engine, executors, settings, store
24
+
25
+ LOGGER = logging.getLogger(__name__)
26
+
27
+ #: How long shutdown waits for the calls in flight before it cancels.
28
+ SHUTDOWN_DRAIN_TIMEOUT = 25.0
29
+
30
+
31
+ @contextlib.asynccontextmanager
32
+ async def engine_lifespan() -> abc.AsyncGenerator[engine.Engine]:
33
+ """Run the engine for the life of the process.
34
+
35
+ The engine borrows nothing from other hooks: it opens its own graph
36
+ and Postgres pool. imbi-api applies the schemas; this process only
37
+ reads and writes them.
38
+ """
39
+ config = settings.Workflows()
40
+ db = graph.Graph()
41
+ await db.open()
42
+ postgres = common_settings.Postgres()
43
+ pool: store.Pool = psycopg_pool.AsyncConnectionPool(
44
+ conninfo=str(postgres.url),
45
+ min_size=postgres.min_pool_size,
46
+ max_size=postgres.max_pool_size,
47
+ open=False,
48
+ )
49
+ await pool.open()
50
+ stop = asyncio.Event()
51
+ worker_id = f'{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}'
52
+ try:
53
+ async with httpx.AsyncClient(timeout=60) as client:
54
+ steps = executors.Executors(
55
+ executors.Api(client, config), db, config
56
+ )
57
+ runner = engine.Engine(store.Store(pool, worker_id), steps, config)
58
+ reconciler = bindings.Reconciler(pool, client, steps, config)
59
+ listener = asyncio.create_task(runner.listen(reconciler.notify))
60
+ loop = asyncio.create_task(runner.run_forever(stop))
61
+ sync = asyncio.create_task(reconciler.run_forever(stop))
62
+ try:
63
+ yield runner
64
+ finally:
65
+ stop.set()
66
+ runner.notify()
67
+ await asyncio.wait({loop}, timeout=SHUTDOWN_DRAIN_TIMEOUT)
68
+ for task in (loop, listener, sync):
69
+ task.cancel()
70
+ await asyncio.gather(
71
+ loop, listener, sync, return_exceptions=True
72
+ )
73
+ finally:
74
+ await pool.close()
75
+ await db.close()
76
+
77
+
78
+ @contextlib.asynccontextmanager
79
+ async def iggy_lifespan() -> abc.AsyncGenerator[None]:
80
+ """Connect to Iggy, which takes run history to ClickHouse.
81
+
82
+ Runs go on without it: the archive sweep of imbi-api publishes what
83
+ the engine could not (ADR 0021, Run history).
84
+ """
85
+ try:
86
+ connected = await iggy.initialize()
87
+ except Exception: # noqa: BLE001 -- the sweep publishes later
88
+ LOGGER.warning('Iggy initialization failed', exc_info=True)
89
+ connected = False
90
+ if not connected:
91
+ LOGGER.warning('Iggy is not available; the sweep publishes history')
92
+ try:
93
+ yield
94
+ finally:
95
+ await iggy.aclose()
96
+
97
+
98
+ class Status(pydantic.BaseModel):
99
+ service: str = 'imbi-workflows'
100
+ status: typing.Literal['ok']
101
+ version: str
102
+ started_at: datetime.datetime
103
+
104
+
105
+ def create_app() -> fastapi.FastAPI:
106
+ app = fastapi.FastAPI(
107
+ version=imbi.workflows.version,
108
+ started_at=datetime.datetime.now(datetime.UTC),
109
+ lifespan=lifespan.Lifespan(
110
+ prompt_pool.pool_lifespan, iggy_lifespan, engine_lifespan
111
+ ),
112
+ )
113
+
114
+ @app.get('/status', operation_id='getStatus')
115
+ def status(request: fastapi.Request) -> Status: # pyright: ignore[reportUnusedFunction]
116
+ """Report that this process is up. It checks no dependency."""
117
+ return Status(
118
+ status='ok',
119
+ version=request.app.version,
120
+ started_at=request.app.extra['started_at'],
121
+ )
122
+
123
+ return app
124
+
125
+
126
+ cli = typer.Typer(no_args_is_help=True)
127
+ cli.command('serve')(
128
+ server.bind_entrypoint('imbi.workflows.app:create_app', default_port=8006)
129
+ )
130
+
131
+
132
+ @cli.callback()
133
+ def _callback() -> None: # pyright: ignore[reportUnusedFunction]
134
+ """Imbi Workflows CLI"""
@@ -0,0 +1,329 @@
1
+ """The trigger reconciler (ADR 0021, Triggers).
2
+
3
+ imbi-api writes the desired trigger bindings when a workflow activates,
4
+ pauses, or is deleted, in Postgres only. This writes the scheduler and
5
+ the gateway to match, and records each binding as ``synced`` or
6
+ ``failed`` with the error. It runs when imbi-api notifies and on a
7
+ schedule, so a lost notification or a failed call delays a binding and
8
+ leaves nothing half made: the next pass tries again.
9
+
10
+ - A ``schedule`` binding is the scheduler task ``workflow-<binding id>``.
11
+ Its ``api`` target posts to the run endpoint as the scheduler's service
12
+ account. A pause disables it. The scheduler checks the cron
13
+ expression: a bad one fails the binding with the scheduler's error.
14
+ - A ``webhook`` binding is a ``gateway-actions#start_workflow`` rule on
15
+ the Webhook that the trigger names. A pause keeps it, because the run
16
+ endpoint refuses a paused workflow with ``409`` and the gateway records
17
+ that delivery as no effect.
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import typing
24
+
25
+ import httpx
26
+ from psycopg import rows
27
+
28
+ from imbi.common import graph
29
+ from imbi.common.graph import cypher
30
+ from imbi.workflows import executors, settings, store
31
+
32
+ LOGGER = logging.getLogger(__name__)
33
+
34
+ HANDLER = 'gateway-actions#start_workflow'
35
+
36
+ #: The scheduler runs an ``api`` task only as its own service account
37
+ #: (scheduler ADR 0002).
38
+ SCHEDULER_ACCOUNT = 'imbi-scheduler'
39
+
40
+ HTTP_NOT_FOUND = 404
41
+
42
+ _RULES: typing.LiteralString = """
43
+ MATCH (r:WebhookRule)-[:ACTIONS]->(w:Webhook)
44
+ WHERE r.handler = {handler} AND r.handler_config CONTAINS {binding}
45
+ RETURN w.id AS webhook, r.filter_expression AS filter,
46
+ r.handler_config AS config
47
+ """
48
+
49
+ _DELETE_RULES: typing.LiteralString = """
50
+ MATCH (r:WebhookRule)
51
+ WHERE r.handler = {handler} AND r.handler_config CONTAINS {binding}
52
+ DETACH DELETE r
53
+ RETURN 1 AS ok
54
+ """
55
+
56
+ _CREATE_RULE: typing.LiteralString = """
57
+ MATCH (w:Webhook {{id: {webhook}}})
58
+ -[:BELONGS_TO]->(:Organization {{id: {org}}})
59
+ OPTIONAL MATCH (r:WebhookRule)-[:ACTIONS]->(w)
60
+ WITH w, max(r.ordinal) AS top
61
+ CREATE (n:WebhookRule {{filter_expression: {filter}, handler: {handler},
62
+ handler_config: {config},
63
+ ordinal: coalesce(top, -1) + 1}})-[:ACTIONS]->(w)
64
+ RETURN w.id AS id
65
+ """
66
+
67
+
68
+ class SyncError(Exception):
69
+ """The scheduler or the gateway did not take the binding."""
70
+
71
+
72
+ class Reconciler:
73
+ def __init__(
74
+ self,
75
+ pool: store.Pool,
76
+ http: httpx.AsyncClient,
77
+ steps: executors.Executors,
78
+ config: settings.Workflows,
79
+ organization_id: str | None = None,
80
+ ) -> None:
81
+ self.pool = pool
82
+ self.http = http
83
+ self.steps = steps
84
+ self.config = config
85
+ #: Sync only the bindings of this organization. ``None``, as in
86
+ #: production, syncs every binding. Tests that share one
87
+ #: database with other engines set it, as for ``store.Store``.
88
+ self.organization_id = organization_id
89
+ self._wake = asyncio.Event()
90
+
91
+ def notify(self) -> None:
92
+ """Start the next pass now."""
93
+ self._wake.set()
94
+
95
+ async def run_forever(self, stop: asyncio.Event) -> None:
96
+ while not stop.is_set():
97
+ self._wake.clear()
98
+ try:
99
+ await self.reconcile()
100
+ except Exception:
101
+ LOGGER.exception('Trigger reconcile failed; continuing')
102
+ waiters = [
103
+ asyncio.create_task(stop.wait()),
104
+ asyncio.create_task(self._wake.wait()),
105
+ ]
106
+ try:
107
+ await asyncio.wait(
108
+ waiters,
109
+ timeout=self.config.reconcile_interval,
110
+ return_when=asyncio.FIRST_COMPLETED,
111
+ )
112
+ finally:
113
+ for waiter in waiters:
114
+ waiter.cancel()
115
+ await asyncio.gather(*waiters, return_exceptions=True)
116
+
117
+ async def reconcile(self) -> None:
118
+ """Sync every binding once."""
119
+ async with (
120
+ self.pool.connection() as conn,
121
+ conn.cursor(row_factory=rows.dict_row) as cur,
122
+ ):
123
+ await cur.execute(
124
+ 'SELECT b.*, w.slug AS workflow_slug'
125
+ ' FROM workflows.trigger_bindings AS b'
126
+ ' LEFT JOIN workflows.workflows AS w ON w.id = b.workflow_id'
127
+ ' WHERE %(org)s::text IS NULL OR b.organization_id = %(org)s'
128
+ ' ORDER BY b.updated_at',
129
+ {'org': self.organization_id},
130
+ )
131
+ bindings = await cur.fetchall()
132
+ for binding in bindings:
133
+ try:
134
+ if binding['type'] == 'schedule':
135
+ target_id = await self._schedule(binding)
136
+ else:
137
+ target_id = await self._webhook(binding)
138
+ except Exception as e: # noqa: BLE001 -- recorded on the binding
139
+ LOGGER.warning(
140
+ 'Trigger binding %s failed: %s', binding['id'], e
141
+ )
142
+ await self._record(binding, None, str(e) or type(e).__name__)
143
+ else:
144
+ await self._record(binding, target_id, None)
145
+
146
+ async def _record(
147
+ self, binding: store.Row, target_id: str | None, error: str | None
148
+ ) -> None:
149
+ """Write the outcome, unless imbi-api changed the binding since.
150
+
151
+ A changed binding has a new ``updated_at`` and is ``pending``,
152
+ and imbi-api notified, so the next pass syncs it.
153
+ """
154
+ where = (binding['id'], binding['updated_at'])
155
+ async with self.pool.connection() as conn:
156
+ if error is not None:
157
+ await conn.execute(
158
+ 'UPDATE workflows.trigger_bindings SET sync_state ='
159
+ " 'failed', error = %s WHERE id = %s AND updated_at = %s",
160
+ (error[:1000], *where),
161
+ )
162
+ elif binding['removed']:
163
+ await conn.execute(
164
+ 'DELETE FROM workflows.trigger_bindings'
165
+ ' WHERE id = %s AND updated_at = %s',
166
+ where,
167
+ )
168
+ else:
169
+ await conn.execute(
170
+ 'UPDATE workflows.trigger_bindings SET sync_state ='
171
+ " 'synced', error = NULL, target_id = %s,"
172
+ ' synced_at = NOW() WHERE id = %s AND updated_at = %s',
173
+ (target_id, *where),
174
+ )
175
+
176
+ # --- Scheduler -----------------------------------------------------
177
+
178
+ async def _scheduler(
179
+ self, method: str, path: str, body: typing.Any = None
180
+ ) -> httpx.Response:
181
+ return await self.http.request(
182
+ method,
183
+ self.config.scheduler_url.rstrip('/') + path,
184
+ json=body,
185
+ headers={
186
+ 'Authorization': f'Bearer {await self.steps.api.bearer()}'
187
+ },
188
+ )
189
+
190
+ async def _schedule(self, binding: store.Row) -> str | None:
191
+ """Make the scheduler task match the binding. Return its id."""
192
+ path = f'/tasks/workflow-{binding["id"]}'
193
+ if binding['removed']:
194
+ response = await self._scheduler('DELETE', path)
195
+ if response.status_code != HTTP_NOT_FOUND:
196
+ _check(response)
197
+ return None
198
+ desired = await self._task(binding)
199
+ response = await self._scheduler('GET', path)
200
+ if response.status_code == HTTP_NOT_FOUND:
201
+ response = await self._scheduler(
202
+ 'POST',
203
+ '/tasks',
204
+ {'slug': path.rsplit('/', 1)[1], 'kind': 'user', **desired},
205
+ )
206
+ else:
207
+ _check(response)
208
+ task = response.json()
209
+ patch = [
210
+ {'op': 'replace', 'path': f'/{key}', 'value': value}
211
+ for key, value in desired.items()
212
+ if task.get(key) != value
213
+ ]
214
+ if patch:
215
+ response = await self._scheduler('PATCH', path, patch)
216
+ _check(response)
217
+ return str(response.json()['id'])
218
+
219
+ async def _task(self, binding: store.Row) -> dict[str, typing.Any]:
220
+ """Return the fields of the scheduler task, as the scheduler
221
+ returns them, so that a task that matches is not patched."""
222
+ slug = binding['workflow_slug']
223
+ params = binding['params']
224
+ return {
225
+ 'name': f'Workflow {slug}: {binding["node_id"]}',
226
+ 'description': 'Schedule trigger of a workflow. imbi-workflows '
227
+ 'manages this task: a change to it is undone.',
228
+ 'organization': await self.steps.org_slug(
229
+ binding['organization_id']
230
+ ),
231
+ 'enabled': binding['enabled'],
232
+ 'trigger': {
233
+ 'kind': 'cron',
234
+ 'expression': params['cron'],
235
+ 'jitter': None,
236
+ },
237
+ 'timezone': params.get('timezone', 'UTC'),
238
+ 'identity': {
239
+ 'kind': 'service_account',
240
+ 'subject': SCHEDULER_ACCOUNT,
241
+ 'consent_id': None,
242
+ 'scope': 'imbi-api:*',
243
+ },
244
+ 'target': {
245
+ 'kind': 'api',
246
+ 'method': 'POST',
247
+ 'path': f'/workflows/{slug}/runs',
248
+ 'query': {},
249
+ # The scheduler renders the body; its run id is the
250
+ # Idempotency-Key, so a retry starts no second run.
251
+ 'body': {
252
+ 'trigger': binding['node_id'],
253
+ 'payload': {'fired_at': '{{ run.fired_at }}'},
254
+ },
255
+ 'organization': None,
256
+ },
257
+ }
258
+
259
+ # --- Gateway -------------------------------------------------------
260
+
261
+ async def _webhook(self, binding: store.Row) -> str | None:
262
+ """Make the gateway rule match the binding. Return the webhook id.
263
+
264
+ The rule is found by the binding id in its config, so a rule on a
265
+ Webhook that the trigger no longer names is removed too.
266
+ """
267
+ # The exact field, as json.dumps writes it here and in the
268
+ # webhooks API: an id elsewhere in the config (a trigger node id
269
+ # can be any string) must not match.
270
+ marker = {
271
+ 'handler': HANDLER,
272
+ 'binding': f'"binding": "{binding["id"]}"',
273
+ }
274
+ if binding['removed']:
275
+ await self.steps.db.execute(_DELETE_RULES, marker, ['ok'])
276
+ return None
277
+ params = binding['params']
278
+ rule_filter = params.get('filter') or 'true'
279
+ config = {
280
+ 'workflow_slug': binding['workflow_slug'],
281
+ 'trigger': binding['node_id'],
282
+ 'binding': str(binding['id']),
283
+ }
284
+ found = await self.steps.db.execute(
285
+ _RULES, marker, ['webhook', 'filter', 'config']
286
+ )
287
+ current = [
288
+ (
289
+ graph.parse_agtype(r['webhook']),
290
+ graph.parse_agtype(r['filter']),
291
+ json.loads(str(graph.parse_agtype(r['config']))),
292
+ )
293
+ for r in found
294
+ ]
295
+ if current == [(params['webhook_id'], rule_filter, config)]:
296
+ return str(params['webhook_id'])
297
+ # One transaction, delete first: the gateway never sees the
298
+ # webhook without the rule, and a webhook outside the org rolls
299
+ # the delete back.
300
+ try:
301
+ await self.steps.db._execute_batch( # pyright: ignore[reportPrivateUsage]
302
+ [
303
+ cypher.Statement(_DELETE_RULES, marker),
304
+ cypher.Statement(
305
+ _CREATE_RULE,
306
+ {
307
+ **marker,
308
+ 'webhook': params['webhook_id'],
309
+ 'org': binding['organization_id'],
310
+ 'filter': rule_filter,
311
+ 'config': json.dumps(config),
312
+ },
313
+ expect_rows=True,
314
+ ),
315
+ ]
316
+ )
317
+ except graph.StatementMatchedNothing as e:
318
+ raise SyncError(
319
+ f'Webhook {params["webhook_id"]!r} is not in the organization'
320
+ ) from e
321
+ return str(params['webhook_id'])
322
+
323
+
324
+ def _check(response: httpx.Response) -> None:
325
+ if response.is_error:
326
+ raise SyncError(
327
+ f'imbi-scheduler returned {response.status_code}: '
328
+ f'{response.text[:500]}'
329
+ )