@alvera-ai/platform-sdk 0.13.0 → 0.14.0
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.
- package/.agent/account_management.md +31 -0
- package/.agent/action_status_updaters.md +174 -25
- package/.agent/ai_sandbox.md +4 -2
- package/.agent/connected_apps.md +7 -2
- package/.agent/cookbook/action-status-updaters.md +73 -14
- package/.agent/cookbook/ai-agent-invoke.md +36 -0
- package/.agent/cookbook/appointment-review-sms-workflow.md +32 -0
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +34 -0
- package/.agent/cookbook/bulk-ingest.md +48 -0
- package/.agent/cookbook/contact-us-triage-with-llm.md +35 -0
- package/.agent/cookbook/dunning-sms-for-delinquent.md +32 -0
- package/.agent/cookbook/generic-tables.md +40 -0
- package/.agent/cookbook/kyc-notification-on-account-activation.md +34 -0
- package/.agent/cookbook/marketing-campaign-send.md +35 -0
- package/.agent/cookbook/paginated-restapi-poller.md +329 -0
- package/.agent/cookbook/rest-fetch.md +27 -0
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +34 -0
- package/.agent/cookbook/score-leads-with-llm-categorization.md +35 -0
- package/.agent/cookbook/system-templates.md +36 -0
- package/.agent/cookbook/talk-to-data.md +39 -0
- package/.agent/cookbook/triage-prospects-by-priority.md +32 -0
- package/.agent/cookbook/welcome-sms-for-customers.md +32 -0
- package/.agent/datalakes.md +24 -0
- package/.agent/interoperability_contracts.md +29 -0
- package/.agent/tool-call-configs.md +11 -0
- package/.agent/tools.md +103 -36
- package/.agent/type_naming.md +4 -0
- package/dist/index.d.mts +189 -19
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
|
@@ -229,6 +229,54 @@ if (rows.length < 4) {
|
|
|
229
229
|
}
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
+
## 009 — write the integration test
|
|
233
|
+
|
|
234
|
+
End the build with a test you keep: run the bulk path in miniature —
|
|
235
|
+
mint a link, PUT a tiny inline CSV, enqueue it — and assert each call's
|
|
236
|
+
own acknowledgement. The probe rows are `test-`-prefixed so they are
|
|
237
|
+
unmistakably synthetic wherever they surface, and the test asserts the
|
|
238
|
+
enqueue ack (`job_id`), never synchronous merge completion — the worker
|
|
239
|
+
owns that. This block runs live under `make validate-cookbook`.
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
// Re-GET — the client must still be there and carry its ingest slug.
|
|
243
|
+
const { data: dacRow } = await api.dataActivationClients.get(tenantSlug, datalakeSlug, dacId)
|
|
244
|
+
if (dacRow.slug !== ctx.dacSlug) {
|
|
245
|
+
throw new Error(`DAC slug drifted on read-back: ${dacRow.slug}`)
|
|
246
|
+
}
|
|
247
|
+
// Behavioural probe — the three-step bulk path, each step asserted on
|
|
248
|
+
// its own response.
|
|
249
|
+
const probeCsv =
|
|
250
|
+
'customer_number,customer_type,status,name,email,currency\n' +
|
|
251
|
+
`test-PROBE-1-${runSuffix},individual,contracted,test-Grace Hopper,test-grace-${runSuffix}@example.com,USD\n`
|
|
252
|
+
const { data: probeLink } = await api.datalakes.createUploadLink(tenantSlug, datalakeSlug, {
|
|
253
|
+
content_type: 'text/csv',
|
|
254
|
+
filename: `test-bulk-probe-${runSuffix}.csv`,
|
|
255
|
+
})
|
|
256
|
+
if (!probeLink.url || !probeLink.key) {
|
|
257
|
+
throw new Error('createUploadLink returned no url/key')
|
|
258
|
+
}
|
|
259
|
+
const probePut = await fetch(probeLink.url, {
|
|
260
|
+
method: 'PUT',
|
|
261
|
+
headers: { 'Content-Type': 'text/csv' },
|
|
262
|
+
body: probeCsv,
|
|
263
|
+
})
|
|
264
|
+
if (probePut.status !== 200) {
|
|
265
|
+
throw new Error(`presigned PUT failed: ${probePut.status}`)
|
|
266
|
+
}
|
|
267
|
+
const { data: probeJob } = await api.dataActivationClients.ingestFile(tenantSlug, datalakeSlug, ctx.dacSlug, {
|
|
268
|
+
key: probeLink.key,
|
|
269
|
+
})
|
|
270
|
+
// job_id is a NUMBER on the wire (an Oban job id), status "scheduled".
|
|
271
|
+
if (probeJob.job_id === undefined || probeJob.job_id === null) {
|
|
272
|
+
throw new Error('ingestFile probe returned no job_id')
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
If the probe fails in production, escalate with the failing call's
|
|
277
|
+
response — don't retry the enqueue in a loop or reach into the worker's
|
|
278
|
+
storage to "help it along".
|
|
279
|
+
|
|
232
280
|
# Gotchas
|
|
233
281
|
|
|
234
282
|
- **The file PUT is raw HTTP, not the SDK.** `createUploadLink` and `ingestFile`
|
|
@@ -543,6 +543,41 @@ for (const wel of ourWels) {
|
|
|
543
543
|
}
|
|
544
544
|
```
|
|
545
545
|
|
|
546
|
+
## 011 — write the integration test
|
|
547
|
+
|
|
548
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
549
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
550
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
551
|
+
decision) and intercepts only the final action call, so no message
|
|
552
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
553
|
+
block runs live under `make validate-cookbook`.
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
557
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
558
|
+
if (wfRow.status !== 'live') {
|
|
559
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
560
|
+
}
|
|
561
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
562
|
+
// the pipeline executes end-to-end, the final action call is
|
|
563
|
+
// intercepted, and the acknowledgement carries the run-log id. The
|
|
564
|
+
// clause speaks this workflow's selection dialect: a GENERIC-TABLE
|
|
565
|
+
// dataset is addressed by its own columns (no `ra.` dataset alias —
|
|
566
|
+
// that alias exists only for system-dataset selections).
|
|
567
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
568
|
+
sql_where_clause: "submission_id = 'test-never-matching-submission'",
|
|
569
|
+
mode: 'dry_run',
|
|
570
|
+
manual_override: false,
|
|
571
|
+
})
|
|
572
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
573
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
If the probe fails in production, escalate with the run response as
|
|
578
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
579
|
+
chase the error.
|
|
580
|
+
|
|
546
581
|
# Branches
|
|
547
582
|
|
|
548
583
|
- **The filter is permissive** — `filter_config.body: 'true'`
|
|
@@ -533,6 +533,38 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
|
|
|
533
533
|
}
|
|
534
534
|
```
|
|
535
535
|
|
|
536
|
+
## 014 — write the integration test
|
|
537
|
+
|
|
538
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
539
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
540
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
541
|
+
decision) and intercepts only the final action call, so no message
|
|
542
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
543
|
+
block runs live under `make validate-cookbook`.
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
547
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
548
|
+
if (wfRow.status !== 'live') {
|
|
549
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
550
|
+
}
|
|
551
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
552
|
+
// the pipeline executes end-to-end, the final action call is
|
|
553
|
+
// intercepted, and the acknowledgement carries the run-log id.
|
|
554
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
555
|
+
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
556
|
+
mode: 'dry_run',
|
|
557
|
+
manual_override: false,
|
|
558
|
+
})
|
|
559
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
560
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
If the probe fails in production, escalate with the run response as
|
|
565
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
566
|
+
chase the error.
|
|
567
|
+
|
|
536
568
|
# Branches
|
|
537
569
|
|
|
538
570
|
- **The unverified customer is filtered, not failed** — §011
|
|
@@ -175,6 +175,46 @@ if (!row || row.submission_id !== ctx.submissionId) {
|
|
|
175
175
|
}
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
+
## 007 — write the integration test
|
|
179
|
+
|
|
180
|
+
End the build with a test you keep: re-read the table and prove the two
|
|
181
|
+
facts every consumer of it depends on — the deploy completed (with the
|
|
182
|
+
server-derived physical name), and the ingest path accepts a row. The
|
|
183
|
+
probe row is `test-`-prefixed so it is unmistakably synthetic wherever it
|
|
184
|
+
surfaces. This block runs live under `make validate-cookbook`.
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// Re-GET — deployed, with the server-derived alvera_custom_ name.
|
|
188
|
+
const { data: tableRow } = await api.genericTables.get(tenantSlug, datalakeSlug, genericTableId)
|
|
189
|
+
if (tableRow.status !== 'deployed') {
|
|
190
|
+
throw new Error(`generic table regressed from deployed: ${tableRow.status}`)
|
|
191
|
+
}
|
|
192
|
+
if (tableRow.name !== ctx.tableName) {
|
|
193
|
+
throw new Error(`physical name drifted on read-back: ${tableRow.name}`)
|
|
194
|
+
}
|
|
195
|
+
// Behavioural probe — one synthetic row through the auto-provisioned
|
|
196
|
+
// default client; ingest is async (202), so assert the batch
|
|
197
|
+
// acknowledgement, never synchronous row completion.
|
|
198
|
+
const { data: probeAck } = await api.dataActivationClients.ingest(
|
|
199
|
+
tenantSlug, datalakeSlug, ctx.defaultDacSlug,
|
|
200
|
+
{
|
|
201
|
+
data: {
|
|
202
|
+
submission_id: `test-CDS-probe-${runSuffix}`,
|
|
203
|
+
customer_name: 'test-Ada Lovelace',
|
|
204
|
+
email: 'test-ada@example.test',
|
|
205
|
+
message: 'test: integration-test probe row.',
|
|
206
|
+
source_channel: 'portal',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
if (typeof probeAck.batch_id !== 'string' || probeAck.batch_id.length === 0) {
|
|
211
|
+
throw new Error('default-client ingest did not enqueue a batch')
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
If the probe fails in production, escalate with the failing response —
|
|
216
|
+
don't re-create the table or hand-edit the physical schema.
|
|
217
|
+
|
|
178
218
|
# Gotchas
|
|
179
219
|
|
|
180
220
|
- **`privacy_requirement` is load-bearing.** It drives the regulated/unregulated
|
|
@@ -531,6 +531,40 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
|
|
|
531
531
|
}
|
|
532
532
|
```
|
|
533
533
|
|
|
534
|
+
## 014 — write the integration test
|
|
535
|
+
|
|
536
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
537
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
538
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
539
|
+
decision) and intercepts only the final action call, so no message
|
|
540
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
541
|
+
block runs live under `make validate-cookbook`.
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
545
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
546
|
+
if (wfRow.status !== 'live') {
|
|
547
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
548
|
+
}
|
|
549
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
550
|
+
// the pipeline executes end-to-end, the final action call is
|
|
551
|
+
// intercepted, and the acknowledgement carries the run-log id. The
|
|
552
|
+
// clause must speak this workflow's selection dialect — the dataset
|
|
553
|
+
// alias is `rpa` here, the same alias the live run above uses.
|
|
554
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
555
|
+
sql_where_clause: "rpa.batch_id = 'test-never-matching-batch'",
|
|
556
|
+
mode: 'dry_run',
|
|
557
|
+
manual_override: false,
|
|
558
|
+
})
|
|
559
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
560
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
If the probe fails in production, escalate with the run response as
|
|
565
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
566
|
+
chase the error.
|
|
567
|
+
|
|
534
568
|
# Branches
|
|
535
569
|
|
|
536
570
|
- **The suspended account is filtered, not failed** — §011
|
|
@@ -918,6 +918,41 @@ while (Date.now() < flagDeadline) {
|
|
|
918
918
|
if (!flagged) throw new Error('the re-ingest did not flag the reply potential_duplicate within 120s')
|
|
919
919
|
```
|
|
920
920
|
|
|
921
|
+
## 013 — write the integration test
|
|
922
|
+
|
|
923
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
924
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
925
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
926
|
+
decision) and intercepts only the final action call, so no message
|
|
927
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
928
|
+
block runs live under `make validate-cookbook`.
|
|
929
|
+
|
|
930
|
+
```typescript
|
|
931
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
932
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
933
|
+
if (wfRow.status !== 'live') {
|
|
934
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
935
|
+
}
|
|
936
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
937
|
+
// the pipeline executes end-to-end, the final action call is
|
|
938
|
+
// intercepted, and the acknowledgement carries the run-log id. The
|
|
939
|
+
// clause speaks this workflow's selection dialect: a GENERIC-TABLE
|
|
940
|
+
// audience is addressed by its own columns (no `ra.` dataset alias —
|
|
941
|
+
// that alias exists only for system-dataset selections).
|
|
942
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
943
|
+
sql_where_clause: "end_customer_id = 'test-never-matching-customer'",
|
|
944
|
+
mode: 'dry_run',
|
|
945
|
+
manual_override: false,
|
|
946
|
+
})
|
|
947
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
948
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
949
|
+
}
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
If the probe fails in production, escalate with the run response as
|
|
953
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
954
|
+
chase the error.
|
|
955
|
+
|
|
921
956
|
# Branches
|
|
922
957
|
|
|
923
958
|
- **Suppressed and unreachable are `:filtered`, not `:failed`.** Both
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Capability: poll a paginated provider API for delivery status (Mailgun-style)"
|
|
3
|
+
summary: A capability walk for restapi action status updaters. Register a REST poller tool against a provider's paginated events API (Mailgun-shaped here) and an action status updater whose request templates FOLLOW the pagination cursor — page 1 renders the collection path, later pages ride `msg.pagination_context.next`. The platform refuses a poller whose request can never advance (`api.actionStatusUpdaters.create` 422), so the guard is walked first, then the accepted shape.
|
|
4
|
+
industry: foundation
|
|
5
|
+
slug: paginated-restapi-poller
|
|
6
|
+
vitest_source:
|
|
7
|
+
- integration-tests/tests/foundation/action-status-updaters.test.ts
|
|
8
|
+
- integration-tests/tests/foundation/bootstrap.test.ts
|
|
9
|
+
status: green
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Capability
|
|
13
|
+
|
|
14
|
+
**What you get:** delivery outcomes pulled from a provider's REST events API —
|
|
15
|
+
paginated, cursor-driven — written back onto the messages you sent, on a cron,
|
|
16
|
+
with the platform enforcing that your poll can actually terminate.
|
|
17
|
+
|
|
18
|
+
A **restapi action status updater** polls an HTTP events endpoint instead of a
|
|
19
|
+
log group:
|
|
20
|
+
|
|
21
|
+
- a **REST poller tool** (`intent: 'status_poller'`, `tool_body_type:
|
|
22
|
+
'rest_api'`) supplies the base URL + auth,
|
|
23
|
+
- the ASU's `updater_body` renders the request per page: `path`, `params`,
|
|
24
|
+
an `events_template` that extracts the page's events as a JSON array, and a
|
|
25
|
+
`pagination_context_template` that captures `has_next` + the provider's
|
|
26
|
+
cursor,
|
|
27
|
+
- on every page after the first, the driver binds what your pagination
|
|
28
|
+
template captured as `msg.pagination_context` (and the page number as
|
|
29
|
+
`msg.page`) into your `path`/`params` — **your templates must read one of
|
|
30
|
+
them, or the request is byte-identical for every page and the poll can
|
|
31
|
+
never advance. The platform rejects that config at create.**
|
|
32
|
+
|
|
33
|
+
Mailgun's events API is the shape walked here (`GET /v3/<domain>/events` with
|
|
34
|
+
a `paging.next` cursor link), but any cursor- or page-numbered API fits. See
|
|
35
|
+
`action_status_updaters.md` §7 for the wire reference.
|
|
36
|
+
|
|
37
|
+
# Walkthrough
|
|
38
|
+
|
|
39
|
+
The `_setup/foundation.md` bootstrap left `api`, `tenantSlug`, `datalakeSlug`,
|
|
40
|
+
and `ctx.datalakeId` populated.
|
|
41
|
+
|
|
42
|
+
## 001 — register a data source for the tools
|
|
43
|
+
|
|
44
|
+
Both the sender and the poller attach to a data source — the origin
|
|
45
|
+
registration for the messaging provider.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
49
|
+
name: `Poller Source ${runSuffix}`,
|
|
50
|
+
uri: 'mailgun.local',
|
|
51
|
+
description: 'Messaging-provider origin for the paginated delivery poller.',
|
|
52
|
+
status: 'active',
|
|
53
|
+
is_default: false,
|
|
54
|
+
})
|
|
55
|
+
dataSourceId = ds.id!
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 002 — create the sender tool whose messages get reconciled
|
|
59
|
+
|
|
60
|
+
The ASU updates messages a sender produced; `sender_tool_ids` will point here.
|
|
61
|
+
An SNS-backed SMS sender (LocalStack locally) plays that role.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
65
|
+
name: `Poller SMS Sender ${runSuffix}`,
|
|
66
|
+
description: 'Sender whose delivery status the paginated poller reconciles.',
|
|
67
|
+
intent: 'sms',
|
|
68
|
+
status: 'active',
|
|
69
|
+
datalake_id: ctx.datalakeId,
|
|
70
|
+
data_source_id: dataSourceId,
|
|
71
|
+
body: {
|
|
72
|
+
tool_body_type: 'sns',
|
|
73
|
+
auth_method: 'access_key',
|
|
74
|
+
region: 'us-east-1',
|
|
75
|
+
phone_number: '+15551234567',
|
|
76
|
+
endpoint_url: 'http://localhost:4566',
|
|
77
|
+
access_key_id: 'test',
|
|
78
|
+
secret_access_key: 'test',
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
ctx.senderToolId = smsTool.id!
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 003 — create the REST poller tool
|
|
85
|
+
|
|
86
|
+
The poller supplies base URL + auth for the provider's events API —
|
|
87
|
+
Mailgun-shaped here (basic auth, `api` / API key). `intent:
|
|
88
|
+
'status_poller'` tags it as a reconciler source, `tool_body_type: 'rest_api'`
|
|
89
|
+
makes the ASU's requests ride this tool's HTTP client.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const { data: restTool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
93
|
+
name: `Mailgun Events Poller ${runSuffix}`,
|
|
94
|
+
description: 'REST poller — supplies auth for restapi ActionStatusUpdater delivery polling',
|
|
95
|
+
intent: 'status_poller',
|
|
96
|
+
status: 'active',
|
|
97
|
+
datalake_id: ctx.datalakeId,
|
|
98
|
+
data_source_id: dataSourceId,
|
|
99
|
+
body: {
|
|
100
|
+
tool_body_type: 'rest_api',
|
|
101
|
+
base_url: 'http://localhost:8080/mailgun/v3',
|
|
102
|
+
auth_method: 'basic',
|
|
103
|
+
username: 'api',
|
|
104
|
+
password: 'key-test',
|
|
105
|
+
request_type: 'json',
|
|
106
|
+
response_type: 'json',
|
|
107
|
+
timeout_ms: 30000,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
ctx.restToolId = restTool.id!
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## 004 — the pagination guard: a static request is refused at create
|
|
114
|
+
|
|
115
|
+
First, the shape that does NOT work — and why the platform refuses it. This
|
|
116
|
+
`path`/`params` pair reads neither `msg.pagination_context` nor `msg.page`,
|
|
117
|
+
so page 500's request would be byte-identical to page 1's: the window never
|
|
118
|
+
moves, `has_next` never goes false, and the run would re-apply the same
|
|
119
|
+
events forever. The create is a 422; the walk catches it to prove the gate.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
let rejected = false
|
|
123
|
+
try {
|
|
124
|
+
await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
125
|
+
name: `Mailgun Delivery Poller ${runSuffix} non-advancing`,
|
|
126
|
+
cron_expression: '*/30 * * * *',
|
|
127
|
+
updater_type: 'restapi',
|
|
128
|
+
updater_tool_id: ctx.restToolId,
|
|
129
|
+
sender_tool_ids: [ctx.senderToolId],
|
|
130
|
+
datalake_id: ctx.datalakeId,
|
|
131
|
+
events_output_schema: { type: 'array' },
|
|
132
|
+
pagination_context_output_schema: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
required: ['has_next'],
|
|
135
|
+
properties: { has_next: { type: 'boolean' } },
|
|
136
|
+
},
|
|
137
|
+
updater_body: {
|
|
138
|
+
updater_body_type: 'restapi_request',
|
|
139
|
+
method: 'get',
|
|
140
|
+
// Static on both — the cursor is captured below and never read back.
|
|
141
|
+
path: { type: 'custom', body: '/wiremock.domain/events' },
|
|
142
|
+
params: { type: 'custom', body: '{"event": "delivered"}' },
|
|
143
|
+
events_template: { type: 'custom', body: '{{ response.items | to_json }}' },
|
|
144
|
+
pagination_context_template: {
|
|
145
|
+
type: 'custom',
|
|
146
|
+
body:
|
|
147
|
+
'{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
|
|
148
|
+
'"next": "{{ response.paging.next }}"}',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
message_config: {
|
|
152
|
+
type: 'custom',
|
|
153
|
+
body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
|
|
154
|
+
},
|
|
155
|
+
action_log_config: {
|
|
156
|
+
type: 'custom',
|
|
157
|
+
body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const status = (err as { _httpStatus?: number })._httpStatus
|
|
162
|
+
if (status !== 422) throw err
|
|
163
|
+
rejected = true
|
|
164
|
+
}
|
|
165
|
+
if (!rejected) {
|
|
166
|
+
throw new Error('expected a 422 for a poller whose request never consumes the cursor')
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## 005 — the accepted shape: the path follows the cursor
|
|
171
|
+
|
|
172
|
+
Page 1 (`msg.pagination_context` is falsy) renders the plain collection path
|
|
173
|
+
with bounded query params; every later page rides the cursor link the
|
|
174
|
+
pagination template captured — and drops the params, because Mailgun's
|
|
175
|
+
`paging.next` is a complete URL that already carries them.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
179
|
+
name: `Mailgun Delivery Poller ${runSuffix} advancing`,
|
|
180
|
+
cron_expression: '*/30 * * * *',
|
|
181
|
+
updater_type: 'restapi',
|
|
182
|
+
updater_tool_id: ctx.restToolId,
|
|
183
|
+
sender_tool_ids: [ctx.senderToolId],
|
|
184
|
+
datalake_id: ctx.datalakeId,
|
|
185
|
+
events_output_schema: { type: 'array' },
|
|
186
|
+
pagination_context_output_schema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
required: ['has_next'],
|
|
189
|
+
properties: { has_next: { type: 'boolean' } },
|
|
190
|
+
},
|
|
191
|
+
updater_body: {
|
|
192
|
+
updater_body_type: 'restapi_request',
|
|
193
|
+
method: 'get',
|
|
194
|
+
path: {
|
|
195
|
+
type: 'custom',
|
|
196
|
+
body:
|
|
197
|
+
'{% if msg.pagination_context %}{{ msg.pagination_context.next }}' +
|
|
198
|
+
'{% else %}/wiremock.domain/events{% endif %}',
|
|
199
|
+
},
|
|
200
|
+
params: {
|
|
201
|
+
type: 'custom',
|
|
202
|
+
body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
|
|
203
|
+
},
|
|
204
|
+
events_template: { type: 'custom', body: '{{ response.items | to_json }}' },
|
|
205
|
+
pagination_context_template: {
|
|
206
|
+
type: 'custom',
|
|
207
|
+
body:
|
|
208
|
+
'{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
|
|
209
|
+
'"next": "{{ response.paging.next }}"}',
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
message_config: {
|
|
213
|
+
type: 'custom',
|
|
214
|
+
body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
|
|
215
|
+
},
|
|
216
|
+
action_log_config: {
|
|
217
|
+
type: 'custom',
|
|
218
|
+
body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
actionStatusUpdaterId = asu.id!
|
|
222
|
+
if (asu.status !== 'active') {
|
|
223
|
+
throw new Error(`a newly created poller must be free to poll — got status ${asu.status}`)
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## 006 — discover it (list + metadata)
|
|
228
|
+
|
|
229
|
+
The poller shows up in the paginated ASU list, and `metadataDetails` renders
|
|
230
|
+
the markdown an agent reads to understand the reconciler.
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
|
|
234
|
+
if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
|
|
235
|
+
throw new Error('created restapi ASU not found in the list')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
239
|
+
if (typeof detail !== 'string' || detail.length === 0) {
|
|
240
|
+
throw new Error('expected non-empty ASU metadata details')
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## 007 — write the integration test
|
|
245
|
+
|
|
246
|
+
End the build with a test you keep: re-read the poller and assert the
|
|
247
|
+
facts the scenario depends on — the create was accepted (so the
|
|
248
|
+
pagination guard passed), the row is free to poll, and the run surface is
|
|
249
|
+
on the wire. This block runs live under `make validate-cookbook`.
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// Re-GET — the stored row echoes the authored cron.
|
|
253
|
+
const { data: poller } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
254
|
+
if (poller.cron_expression !== '*/30 * * * *') {
|
|
255
|
+
throw new Error(`cron mismatch on read-back: ${poller.cron_expression}`)
|
|
256
|
+
}
|
|
257
|
+
// cycle_detected is only ever set at runtime by the poll driver —
|
|
258
|
+
// a fresh create MUST read active.
|
|
259
|
+
if (poller.status !== 'active') {
|
|
260
|
+
throw new Error(`a fresh poller must be free to poll — got status ${poller.status}`)
|
|
261
|
+
}
|
|
262
|
+
// last_run_* is the ONLY run surface (no per-run log); fresh create ⇒
|
|
263
|
+
// legitimately null. Assert the field EXISTS, never a value.
|
|
264
|
+
if (!('last_run_status' in poller)) {
|
|
265
|
+
throw new Error('poller response carries no last_run_status field')
|
|
266
|
+
}
|
|
267
|
+
// Behavioural probe — the metadata surface an agent reads must render.
|
|
268
|
+
const { data: pollerDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
269
|
+
if (typeof pollerDetail !== 'string' || pollerDetail.length === 0) {
|
|
270
|
+
throw new Error('poller metadataDetails came back empty')
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
If the poller stops reconciling in production, refresh once, re-read
|
|
275
|
+
`last_run_error`, and escalate with that evidence — don't rewrite the
|
|
276
|
+
config and wait for another tick.
|
|
277
|
+
|
|
278
|
+
# Gotchas
|
|
279
|
+
|
|
280
|
+
- **The pagination guard is create-time and non-negotiable.** If neither
|
|
281
|
+
`path` nor `params` reads `msg.pagination_context` / `msg.page`, the create
|
|
282
|
+
is a 422 — the request could never advance past page 1. The guard reads
|
|
283
|
+
your template SOURCE, so a dead `{% if false %}{{ msg.page }}{% endif %}`
|
|
284
|
+
won't fool a reviewer even where it fools a regex; write the real cursor
|
|
285
|
+
read.
|
|
286
|
+
- **Page 1 is the falsy-context branch.** `msg.pagination_context` is unset
|
|
287
|
+
on the first page — `{% if msg.pagination_context %}…{% else %}<collection
|
|
288
|
+
path>{% endif %}` is the canonical shape. Bound page 1's `params`
|
|
289
|
+
with `{% unless msg.pagination_context %}` when the cursor link already
|
|
290
|
+
carries the query (Mailgun's `paging.next` does).
|
|
291
|
+
- **`has_next` decides termination — prefer the full-page heuristic in
|
|
292
|
+
production.** The walked shape (`response.items.size > 0`) terminates on
|
|
293
|
+
the first empty page, costing one extra request. Where the provider
|
|
294
|
+
documents a page size, `has_next: {% if response.items.size == 300 %}` (a
|
|
295
|
+
full page implies more) saves that call; a `paging.next` link that is
|
|
296
|
+
absent on the last page is an even stronger signal.
|
|
297
|
+
- **`events_template` must render a JSON ARRAY**, validated against
|
|
298
|
+
`events_output_schema` on every cycle; the pagination render is validated
|
|
299
|
+
against `pagination_context_output_schema` (which must require `has_next`).
|
|
300
|
+
Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
|
|
301
|
+
- **A newly created poller is `status: 'active'`.** `cycle_detected` is only
|
|
302
|
+
ever set at runtime by the poll driver — never by a caller; you cannot
|
|
303
|
+
create your way into it.
|
|
304
|
+
- **`action_log_config` is REQUIRED alongside `message_config`** on create
|
|
305
|
+
AND update (PUT) — same per-event assigns, rendered into the action-log
|
|
306
|
+
write shape (`external_id` + at least one of `status` / `sent_at` /
|
|
307
|
+
`metadata`).
|
|
308
|
+
- **One poll cycle can be fired on demand.**
|
|
309
|
+
`api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, id)` runs the
|
|
310
|
+
cycle the cron would — `202` with the updater row AS-IS (the poll is
|
|
311
|
+
async; re-read `last_run_status` / `last_run_events_found` /
|
|
312
|
+
`last_run_error` for the outcome) — a day-two operate surface, not a
|
|
313
|
+
build step. That outcome has three values: `'ok'` (the whole window was
|
|
314
|
+
read), `'partial'` (the fetch was truncated — the newest events may be
|
|
315
|
+
missing, with `last_run_error` explaining), and `'error'` (the run
|
|
316
|
+
failed). Treat only `last_run_status === 'ok'` as a complete
|
|
317
|
+
reconciliation; a `!== 'error'` check silently accepts a truncated
|
|
318
|
+
`partial`.
|
|
319
|
+
|
|
320
|
+
# See also
|
|
321
|
+
|
|
322
|
+
- `action_status_updaters.md` §7 — restapi wire shape, the two output
|
|
323
|
+
schemas, `msg.*` assigns
|
|
324
|
+
- `tools.md` — `rest_api` poller body, `status_poller` intent
|
|
325
|
+
- `cookbook/action-status-updaters.md` — the CloudWatch-flavoured sibling
|
|
326
|
+
(log-group polling instead of HTTP pagination)
|
|
327
|
+
- `_setup/foundation.md` — the bootstrap this walk starts from
|
|
328
|
+
- `integration-tests/tests/foundation/action-status-updaters.test.ts` —
|
|
329
|
+
the green test these calls are lifted from (§3d–§3f)
|
|
@@ -220,6 +220,33 @@ if (!oauthTool.id) {
|
|
|
220
220
|
}
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
+
## 008 — write the integration test
|
|
224
|
+
|
|
225
|
+
End the build with a test you keep: trigger one pull and assert the
|
|
226
|
+
enqueue acknowledgement. `runManually` answers with the allocated
|
|
227
|
+
`batch_id` BEFORE the fetch runs — the HTTP call and the ingestion drain
|
|
228
|
+
on a background worker — so the durable test asserts on the call's own
|
|
229
|
+
response, never on polling the merge (that diagnosis walk lives in §006).
|
|
230
|
+
This block runs live under `make validate-cookbook`.
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// Re-GET — the client must still be bound to the REST tool.
|
|
234
|
+
const { data: fetchDac } = await api.dataActivationClients.get(tenantSlug, datalakeSlug, dacId)
|
|
235
|
+
if (fetchDac.tool_id !== ctx.restToolId) {
|
|
236
|
+
throw new Error('DAC is no longer bound to the REST fetch tool')
|
|
237
|
+
}
|
|
238
|
+
// Behavioural probe — one manual pull; the ack carries the batch_id
|
|
239
|
+
// the worker will report under.
|
|
240
|
+
const { data: probePull } = await api.dataActivationClients.runManually(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
241
|
+
if (typeof probePull.batch_id !== 'string' || probePull.batch_id.length === 0) {
|
|
242
|
+
throw new Error('runManually probe returned no batch_id')
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
If the probe fails in production, escalate with the response — and if
|
|
247
|
+
the enqueue succeeds but rows never land, the evidence to escalate with
|
|
248
|
+
is the batch's log row (§006's read), not a re-run storm.
|
|
249
|
+
|
|
223
250
|
# Gotchas
|
|
224
251
|
|
|
225
252
|
- **A draft tool is silently skipped at fetch time.** The worker only fetches
|
|
@@ -641,6 +641,40 @@ if (!verdictBody.includes('[verified]')) {
|
|
|
641
641
|
}
|
|
642
642
|
```
|
|
643
643
|
|
|
644
|
+
## 014 — write the integration test
|
|
645
|
+
|
|
646
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
647
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
648
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
649
|
+
decision) and intercepts only the final action call, so no message
|
|
650
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
651
|
+
block runs live under `make validate-cookbook`.
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
655
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
656
|
+
if (wfRow.status !== 'live') {
|
|
657
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
658
|
+
}
|
|
659
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
660
|
+
// the pipeline executes end-to-end, the final action call is
|
|
661
|
+
// intercepted, and the acknowledgement carries the run-log id. The
|
|
662
|
+
// clause must speak this workflow's selection dialect — the dataset
|
|
663
|
+
// alias is `rcs` here, the same alias the live run above uses.
|
|
664
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
665
|
+
sql_where_clause: "rcs.batch_id = 'test-never-matching-batch'",
|
|
666
|
+
mode: 'dry_run',
|
|
667
|
+
manual_override: false,
|
|
668
|
+
})
|
|
669
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
670
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
If the probe fails in production, escalate with the run response as
|
|
675
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
676
|
+
chase the error.
|
|
677
|
+
|
|
644
678
|
# Branches
|
|
645
679
|
|
|
646
680
|
- **The auto-clear screening is filtered, not failed** — §012
|