imbi-workflows 2.38.0__py3-none-any.whl
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.
- imbi/workflows/__init__.py +4 -0
- imbi/workflows/app.py +134 -0
- imbi/workflows/bindings.py +329 -0
- imbi/workflows/engine.py +708 -0
- imbi/workflows/executors.py +590 -0
- imbi/workflows/py.typed +0 -0
- imbi/workflows/settings.py +67 -0
- imbi/workflows/store.py +207 -0
- imbi_workflows-2.38.0.dist-info/METADATA +57 -0
- imbi_workflows-2.38.0.dist-info/RECORD +12 -0
- imbi_workflows-2.38.0.dist-info/WHEEL +4 -0
- imbi_workflows-2.38.0.dist-info/entry_points.txt +2 -0
imbi/workflows/app.py
ADDED
|
@@ -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
|
+
)
|