@alvera-ai/platform-sdk 0.13.0 → 0.15.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 +273 -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 +35 -1
- package/.agent/cookbook/bulk-ingest.md +48 -0
- package/.agent/cookbook/contact-us-triage-with-llm.md +36 -1
- package/.agent/cookbook/dunning-sms-for-delinquent.md +33 -1
- package/.agent/cookbook/generic-tables.md +40 -0
- package/.agent/cookbook/kyc-notification-on-account-activation.md +35 -1
- package/.agent/cookbook/marketing-campaign-send.md +35 -0
- package/.agent/cookbook/paginated-restapi-poller.md +383 -0
- package/.agent/cookbook/rest-fetch.md +27 -0
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +35 -1
- package/.agent/cookbook/score-leads-with-llm-categorization.md +36 -1
- 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 +33 -1
- package/.agent/cookbook/welcome-sms-for-customers.md +33 -1
- package/.agent/datalakes.md +68 -3
- 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
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
// FLOOR the platform enforces: an array whose items are objects listing
|
|
132
|
+
// `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
|
|
133
|
+
// create — every event has to name the message it reconciles.
|
|
134
|
+
events_output_schema: {
|
|
135
|
+
type: 'array',
|
|
136
|
+
items: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
required: ['external_id'],
|
|
139
|
+
properties: { external_id: { type: 'string' } },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
pagination_context_output_schema: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
required: ['has_next'],
|
|
145
|
+
properties: { has_next: { type: 'boolean' } },
|
|
146
|
+
},
|
|
147
|
+
updater_body: {
|
|
148
|
+
updater_body_type: 'restapi_request',
|
|
149
|
+
method: 'get',
|
|
150
|
+
// Static on both — the cursor is captured below and never read back.
|
|
151
|
+
path: { type: 'custom', body: '/wiremock.domain/events' },
|
|
152
|
+
params: { type: 'custom', body: '{"event": "delivered"}' },
|
|
153
|
+
// Providers name their own id — `message-id` here, `messageId` / `sid`
|
|
154
|
+
// elsewhere. The events_template is where that becomes `external_id`:
|
|
155
|
+
// the apply path POPS that key off every rendered event to find the row
|
|
156
|
+
// it updates, so the render PROJECTS each event rather than passing the
|
|
157
|
+
// provider body through untouched.
|
|
158
|
+
events_template: {
|
|
159
|
+
type: 'custom',
|
|
160
|
+
body:
|
|
161
|
+
'[{% for item in response.items %}' +
|
|
162
|
+
'{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
|
|
163
|
+
'{% unless forloop.last %},{% endunless %}{% endfor %}]',
|
|
164
|
+
},
|
|
165
|
+
pagination_context_template: {
|
|
166
|
+
type: 'custom',
|
|
167
|
+
body:
|
|
168
|
+
'{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
|
|
169
|
+
'"next": "{{ response.paging.next }}"}',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
message_config: {
|
|
173
|
+
type: 'custom',
|
|
174
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
175
|
+
},
|
|
176
|
+
action_log_config: {
|
|
177
|
+
type: 'custom',
|
|
178
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const status = (err as { _httpStatus?: number })._httpStatus
|
|
183
|
+
if (status !== 422) throw err
|
|
184
|
+
rejected = true
|
|
185
|
+
}
|
|
186
|
+
if (!rejected) {
|
|
187
|
+
throw new Error('expected a 422 for a poller whose request never consumes the cursor')
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## 005 — the accepted shape: the path follows the cursor
|
|
192
|
+
|
|
193
|
+
Page 1 (`msg.pagination_context` is falsy) renders the plain collection path
|
|
194
|
+
with bounded query params; every later page rides the cursor link the
|
|
195
|
+
pagination template captured — and drops the params, because Mailgun's
|
|
196
|
+
`paging.next` is a complete URL that already carries them.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
200
|
+
name: `Mailgun Delivery Poller ${runSuffix} advancing`,
|
|
201
|
+
cron_expression: '*/30 * * * *',
|
|
202
|
+
updater_type: 'restapi',
|
|
203
|
+
updater_tool_id: ctx.restToolId,
|
|
204
|
+
sender_tool_ids: [ctx.senderToolId],
|
|
205
|
+
datalake_id: ctx.datalakeId,
|
|
206
|
+
events_output_schema: {
|
|
207
|
+
type: 'array',
|
|
208
|
+
items: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
required: ['external_id'],
|
|
211
|
+
properties: { external_id: { type: 'string' } },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
pagination_context_output_schema: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
required: ['has_next'],
|
|
217
|
+
properties: { has_next: { type: 'boolean' } },
|
|
218
|
+
},
|
|
219
|
+
updater_body: {
|
|
220
|
+
updater_body_type: 'restapi_request',
|
|
221
|
+
method: 'get',
|
|
222
|
+
path: {
|
|
223
|
+
type: 'custom',
|
|
224
|
+
body:
|
|
225
|
+
'{% if msg.pagination_context %}{{ msg.pagination_context.next }}' +
|
|
226
|
+
'{% else %}/wiremock.domain/events{% endif %}',
|
|
227
|
+
},
|
|
228
|
+
params: {
|
|
229
|
+
type: 'custom',
|
|
230
|
+
body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
|
|
231
|
+
},
|
|
232
|
+
// The provider's own id (`message-id`) becomes `external_id` HERE — the
|
|
233
|
+
// apply path pops that key to find the row it reconciles, so project each
|
|
234
|
+
// event instead of passing the provider body through untouched.
|
|
235
|
+
events_template: {
|
|
236
|
+
type: 'custom',
|
|
237
|
+
body:
|
|
238
|
+
'[{% for item in response.items %}' +
|
|
239
|
+
'{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
|
|
240
|
+
'{% unless forloop.last %},{% endunless %}{% endfor %}]',
|
|
241
|
+
},
|
|
242
|
+
pagination_context_template: {
|
|
243
|
+
type: 'custom',
|
|
244
|
+
body:
|
|
245
|
+
'{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
|
|
246
|
+
'"next": "{{ response.paging.next }}"}',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
message_config: {
|
|
250
|
+
type: 'custom',
|
|
251
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
252
|
+
},
|
|
253
|
+
action_log_config: {
|
|
254
|
+
type: 'custom',
|
|
255
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
actionStatusUpdaterId = asu.id!
|
|
259
|
+
if (asu.status !== 'active') {
|
|
260
|
+
throw new Error(`a newly created poller must be free to poll — got status ${asu.status}`)
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## 006 — discover it (list + metadata)
|
|
265
|
+
|
|
266
|
+
The poller shows up in the paginated ASU list, and `metadataDetails` renders
|
|
267
|
+
the markdown an agent reads to understand the reconciler.
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
|
|
271
|
+
if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
|
|
272
|
+
throw new Error('created restapi ASU not found in the list')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
276
|
+
if (typeof detail !== 'string' || detail.length === 0) {
|
|
277
|
+
throw new Error('expected non-empty ASU metadata details')
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## 007 — write the integration test
|
|
282
|
+
|
|
283
|
+
End the build with a test you keep: re-read the poller and assert the
|
|
284
|
+
facts the scenario depends on — the create was accepted (so the
|
|
285
|
+
pagination guard passed), the row is free to poll, and the run surface is
|
|
286
|
+
on the wire. This block runs live under `make validate-cookbook`.
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
// Re-GET — the stored row echoes the authored cron.
|
|
290
|
+
const { data: poller } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
291
|
+
if (poller.cron_expression !== '*/30 * * * *') {
|
|
292
|
+
throw new Error(`cron mismatch on read-back: ${poller.cron_expression}`)
|
|
293
|
+
}
|
|
294
|
+
// cycle_detected is only ever set at runtime by the poll driver —
|
|
295
|
+
// a fresh create MUST read active.
|
|
296
|
+
if (poller.status !== 'active') {
|
|
297
|
+
throw new Error(`a fresh poller must be free to poll — got status ${poller.status}`)
|
|
298
|
+
}
|
|
299
|
+
// last_run_* is the ONLY run surface (no per-run log); fresh create ⇒
|
|
300
|
+
// legitimately null. Assert the field EXISTS, never a value.
|
|
301
|
+
if (!('last_run_status' in poller)) {
|
|
302
|
+
throw new Error('poller response carries no last_run_status field')
|
|
303
|
+
}
|
|
304
|
+
// Behavioural probe — the metadata surface an agent reads must render.
|
|
305
|
+
const { data: pollerDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
306
|
+
if (typeof pollerDetail !== 'string' || pollerDetail.length === 0) {
|
|
307
|
+
throw new Error('poller metadataDetails came back empty')
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
If the poller stops reconciling in production, refresh once, re-read
|
|
312
|
+
`last_run_error`, and escalate with that evidence — don't rewrite the
|
|
313
|
+
config and wait for another tick.
|
|
314
|
+
|
|
315
|
+
# Gotchas
|
|
316
|
+
|
|
317
|
+
- **The pagination guard is create-time and non-negotiable.** If neither
|
|
318
|
+
`path` nor `params` reads `msg.pagination_context` / `msg.page`, the create
|
|
319
|
+
is a 422 — the request could never advance past page 1. The guard reads
|
|
320
|
+
your template SOURCE, so a dead `{% if false %}{{ msg.page }}{% endif %}`
|
|
321
|
+
won't fool a reviewer even where it fools a regex; write the real cursor
|
|
322
|
+
read.
|
|
323
|
+
- **Page 1 is the falsy-context branch.** `msg.pagination_context` is unset
|
|
324
|
+
on the first page — `{% if msg.pagination_context %}…{% else %}<collection
|
|
325
|
+
path>{% endif %}` is the canonical shape. Bound page 1's `params`
|
|
326
|
+
with `{% unless msg.pagination_context %}` when the cursor link already
|
|
327
|
+
carries the query (Mailgun's `paging.next` does).
|
|
328
|
+
- **`has_next` decides termination — prefer the full-page heuristic in
|
|
329
|
+
production.** The walked shape (`response.items.size > 0`) terminates on
|
|
330
|
+
the first empty page, costing one extra request. Where the provider
|
|
331
|
+
documents a page size, `has_next: {% if response.items.size == 300 %}` (a
|
|
332
|
+
full page implies more) saves that call; a `paging.next` link that is
|
|
333
|
+
absent on the last page is an even stronger signal.
|
|
334
|
+
- **`events_template` must render a JSON ARRAY**, validated against
|
|
335
|
+
`events_output_schema` on every cycle; the pagination render is validated
|
|
336
|
+
against `pagination_context_output_schema` (which must require `has_next`).
|
|
337
|
+
Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
|
|
338
|
+
- **Each schema has a FLOOR the platform enforces, above which the contract is
|
|
339
|
+
yours.** `events_output_schema` must describe an **array whose `items` are
|
|
340
|
+
objects listing `external_id` in `required`**; a bare `{ type: 'array' }` is a
|
|
341
|
+
422 at create. `pagination_context_output_schema` must be an **object listing
|
|
342
|
+
`has_next` in `required`** — that key is what ends the page loop. Demand more
|
|
343
|
+
of your provider on top if you like; only the floor is checked.
|
|
344
|
+
- **The floor exists because `external_id` is how reconciliation finds the row.**
|
|
345
|
+
`apply_status_update` pops that key off every rendered event, so mapping the
|
|
346
|
+
provider's own id (`message-id` / `messageId` / `sid`) into `external_id` is
|
|
347
|
+
the **`events_template`'s job** — project each event, never pass the provider
|
|
348
|
+
body through untouched. A schema that satisfies the floor while the template
|
|
349
|
+
emits raw provider rows creates cleanly and then reconciles nothing on every
|
|
350
|
+
cycle. `message_config` / `action_log_config` then read the **mapped**
|
|
351
|
+
`{{ external_id }}`, not the provider's original key.
|
|
352
|
+
- **`action_log_config` is required alongside `message_config`** for every
|
|
353
|
+
updater type, and both are cast against the *same* pinned reconciliation
|
|
354
|
+
schema — so one template body satisfies both.
|
|
355
|
+
- **A newly created poller is `status: 'active'`.** `cycle_detected` is only
|
|
356
|
+
ever set at runtime by the poll driver — never by a caller; you cannot
|
|
357
|
+
create your way into it.
|
|
358
|
+
- **`action_log_config` is REQUIRED alongside `message_config`** on create
|
|
359
|
+
AND update (PUT) — same per-event assigns, rendered into the action-log
|
|
360
|
+
write shape (`external_id` + at least one of `status` / `sent_at` /
|
|
361
|
+
`metadata`).
|
|
362
|
+
- **One poll cycle can be fired on demand.**
|
|
363
|
+
`api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, id)` runs the
|
|
364
|
+
cycle the cron would — `202` with the updater row AS-IS (the poll is
|
|
365
|
+
async; re-read `last_run_status` / `last_run_events_found` /
|
|
366
|
+
`last_run_error` for the outcome) — a day-two operate surface, not a
|
|
367
|
+
build step. That outcome has three values: `'ok'` (the whole window was
|
|
368
|
+
read), `'partial'` (the fetch was truncated — the newest events may be
|
|
369
|
+
missing, with `last_run_error` explaining), and `'error'` (the run
|
|
370
|
+
failed). Treat only `last_run_status === 'ok'` as a complete
|
|
371
|
+
reconciliation; a `!== 'error'` check silently accepts a truncated
|
|
372
|
+
`partial`.
|
|
373
|
+
|
|
374
|
+
# See also
|
|
375
|
+
|
|
376
|
+
- `action_status_updaters.md` §7 — restapi wire shape, the two output
|
|
377
|
+
schemas, `msg.*` assigns
|
|
378
|
+
- `tools.md` — `rest_api` poller body, `status_poller` intent
|
|
379
|
+
- `cookbook/action-status-updaters.md` — the CloudWatch-flavoured sibling
|
|
380
|
+
(log-group polling instead of HTTP pagination)
|
|
381
|
+
- `_setup/foundation.md` — the bootstrap this walk starts from
|
|
382
|
+
- `integration-tests/tests/foundation/action-status-updaters.test.ts` —
|
|
383
|
+
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
|
|
@@ -286,7 +286,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
286
286
|
tool_id: toolId,
|
|
287
287
|
position: 0,
|
|
288
288
|
trigger_template: 'now',
|
|
289
|
-
idempotency_template: `{{ compliance_screening.id }}-${verdict}
|
|
289
|
+
idempotency_template: `{{ compliance_screening.id }}-${verdict}`,
|
|
290
290
|
tool_call: {
|
|
291
291
|
tool_call_type: 'sms_request',
|
|
292
292
|
to: { type: 'custom', body: '+15550000000' },
|
|
@@ -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
|
|
@@ -337,7 +337,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
337
337
|
tool_id: toolId,
|
|
338
338
|
position: 0,
|
|
339
339
|
trigger_template: 'now',
|
|
340
|
-
idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}
|
|
340
|
+
idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}`,
|
|
341
341
|
tool_call: {
|
|
342
342
|
tool_call_type: 'sms_request',
|
|
343
343
|
to: { type: 'custom', body: '+15551234567' },
|
|
@@ -547,6 +547,41 @@ for (const wel of ourWels) {
|
|
|
547
547
|
}
|
|
548
548
|
```
|
|
549
549
|
|
|
550
|
+
## 011 — write the integration test
|
|
551
|
+
|
|
552
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
553
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
554
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
555
|
+
decision) and intercepts only the final action call, so no message
|
|
556
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
557
|
+
block runs live under `make validate-cookbook`.
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
561
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
562
|
+
if (wfRow.status !== 'live') {
|
|
563
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
564
|
+
}
|
|
565
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
566
|
+
// the pipeline executes end-to-end, the final action call is
|
|
567
|
+
// intercepted, and the acknowledgement carries the run-log id. The
|
|
568
|
+
// clause speaks this workflow's selection dialect: a GENERIC-TABLE
|
|
569
|
+
// dataset is addressed by its own columns (no `ra.` dataset alias —
|
|
570
|
+
// that alias exists only for system-dataset selections).
|
|
571
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
572
|
+
sql_where_clause: "submission_id = 'test-never-matching-submission'",
|
|
573
|
+
mode: 'dry_run',
|
|
574
|
+
manual_override: false,
|
|
575
|
+
})
|
|
576
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
577
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
If the probe fails in production, escalate with the run response as
|
|
582
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
583
|
+
chase the error.
|
|
584
|
+
|
|
550
585
|
# Branches
|
|
551
586
|
|
|
552
587
|
- **The filter is permissive** — `filter_config.body: 'true'` passes
|
|
@@ -103,6 +103,42 @@ if (typeof detail !== 'string' || detail.length === 0) {
|
|
|
103
103
|
}
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
## 004 — write the integration test
|
|
107
|
+
|
|
108
|
+
End the walk with a test you keep: anything that references a system
|
|
109
|
+
template by path (`template_config: { type: 'system', path: … }`) depends
|
|
110
|
+
on that path staying in this datalake's scope — so the durable test
|
|
111
|
+
re-asserts the anchor is listed, the domain filter holds, and the detail
|
|
112
|
+
surface renders. This block runs live under `make validate-cookbook`.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
const { data: tplList } = await api.templates.systemTemplates(tenantSlug, datalakeSlug)
|
|
116
|
+
const tplPaths = (tplList.data ?? []).map((t) => t.path)
|
|
117
|
+
const anchor = 'data_activation/interoperability/subscription/stripe/customers_subscription_customer'
|
|
118
|
+
if (!tplPaths.some((p) => p.includes(anchor))) {
|
|
119
|
+
throw new Error(`anchor template ${anchor} vanished from the domain-scoped list`)
|
|
120
|
+
}
|
|
121
|
+
const foreign = ['healthcare', 'payments', 'foundation', 'core_banking', 'service_commerce', 'trading']
|
|
122
|
+
const leakedPaths = tplPaths.filter((p) => foreign.some((d) => p.split('/').includes(d)))
|
|
123
|
+
if (leakedPaths.length > 0) {
|
|
124
|
+
throw new Error(`cross-domain templates leaked: ${leakedPaths.join(', ')}`)
|
|
125
|
+
}
|
|
126
|
+
// Behavioural probe — the detail surface must render for the anchor.
|
|
127
|
+
const { data: anchorDetail } = await api.templates.metadataDetails(
|
|
128
|
+
tenantSlug,
|
|
129
|
+
datalakeSlug,
|
|
130
|
+
'customers_subscription_customer',
|
|
131
|
+
'data_activation_interoperability',
|
|
132
|
+
)
|
|
133
|
+
if (typeof anchorDetail !== 'string' || anchorDetail.length === 0) {
|
|
134
|
+
throw new Error('anchor template metadataDetails came back empty')
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
If the anchor disappears in production, escalate with the listing
|
|
139
|
+
evidence — a `type: system` reference that stops resolving is a platform
|
|
140
|
+
regression, not something to paper over by inlining the template body.
|
|
141
|
+
|
|
106
142
|
# Gotchas
|
|
107
143
|
|
|
108
144
|
- **The list is domain-scoped, not global.** `systemTemplates` only returns
|
|
@@ -103,6 +103,45 @@ if (!/\bn\b/.test(csv) || !csv.includes('1')) {
|
|
|
103
103
|
}
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
## 004 — write the integration test
|
|
107
|
+
|
|
108
|
+
End the walk with a test you keep: both surfaces are read-only, so the
|
|
109
|
+
durable test is the pair of deterministic boundary probes — the
|
|
110
|
+
round-trip works, and the read-only wall holds. Neither depends on an
|
|
111
|
+
LLM provider or seeded data, which is what makes the test durable. This
|
|
112
|
+
block runs live under `make validate-cookbook`.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// Round-trip probe — deterministic, data-independent.
|
|
116
|
+
const probe = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
|
|
117
|
+
sql: 'SELECT 1 AS ok',
|
|
118
|
+
mode: 'unregulated',
|
|
119
|
+
})
|
|
120
|
+
if (typeof probe.data === 'string' || probe.data.data[0]?.[0] !== 1) {
|
|
121
|
+
throw new Error('executeSql round-trip probe failed')
|
|
122
|
+
}
|
|
123
|
+
// Boundary probe — write SQL must be rejected with a 422; the
|
|
124
|
+
// read-only wall is the security property this capability rests on.
|
|
125
|
+
let walled = false
|
|
126
|
+
try {
|
|
127
|
+
await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
|
|
128
|
+
sql: "INSERT INTO legal_entities (legal_name) VALUES ('test-should-never-land')",
|
|
129
|
+
mode: 'unregulated',
|
|
130
|
+
})
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const status = (err as { _httpStatus?: number })._httpStatus
|
|
133
|
+
if (status !== 422) throw err
|
|
134
|
+
walled = true
|
|
135
|
+
}
|
|
136
|
+
if (!walled) {
|
|
137
|
+
throw new Error('executeSql accepted a write statement — the read-only wall is down')
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
If the write probe ever lands in production, stop and escalate
|
|
142
|
+
immediately with the accepted statement as evidence — a read-only
|
|
143
|
+
boundary failure is a security regression, never a config problem.
|
|
144
|
+
|
|
106
145
|
# Branches
|
|
107
146
|
|
|
108
147
|
- **Generation failure.** If every configured LLM provider fails, `textToSql`
|
|
@@ -309,7 +309,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
309
309
|
tool_id: toolId,
|
|
310
310
|
position: 0,
|
|
311
311
|
trigger_template: 'now',
|
|
312
|
-
idempotency_template: `{{ customer_id }}-${band}
|
|
312
|
+
idempotency_template: `{{ customer_id }}-${band}`,
|
|
313
313
|
tool_call: {
|
|
314
314
|
tool_call_type: 'sms_request',
|
|
315
315
|
to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
|
|
@@ -480,6 +480,38 @@ for (const wel of ourWels) {
|
|
|
480
480
|
}
|
|
481
481
|
```
|
|
482
482
|
|
|
483
|
+
## 013 — write the integration test
|
|
484
|
+
|
|
485
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
486
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
487
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
488
|
+
decision) and intercepts only the final action call, so no message
|
|
489
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
490
|
+
block runs live under `make validate-cookbook`.
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
494
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
495
|
+
if (wfRow.status !== 'live') {
|
|
496
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
497
|
+
}
|
|
498
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
499
|
+
// the pipeline executes end-to-end, the final action call is
|
|
500
|
+
// intercepted, and the acknowledgement carries the run-log id.
|
|
501
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
502
|
+
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
503
|
+
mode: 'dry_run',
|
|
504
|
+
manual_override: false,
|
|
505
|
+
})
|
|
506
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
507
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
If the probe fails in production, escalate with the run response as
|
|
512
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
513
|
+
chase the error.
|
|
514
|
+
|
|
483
515
|
# Branches
|
|
484
516
|
|
|
485
517
|
- **Agent emits an out-of-enum band** — the response schema's
|