@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
|
@@ -102,6 +102,37 @@ app authenticates **tenant-scoped against exactly one tenant**,
|
|
|
102
102
|
with that tenant's publishable key; connected apps in particular
|
|
103
103
|
are single-tenant by construction (`connected_apps.md`).
|
|
104
104
|
|
|
105
|
+
### Key-only clients — machine-to-machine on the publishable key
|
|
106
|
+
|
|
107
|
+
`sessionToken` is optional on the client config. A deployed
|
|
108
|
+
connected app that authenticates purely on its tenant's publishable
|
|
109
|
+
key constructs with just the key — no session mint, no fabricated
|
|
110
|
+
empty token:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const api = createIsolatedPlatformApi({ baseUrl, apiKey })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Such a client sends `X-API-Key` and **no `Authorization` header**,
|
|
117
|
+
and reaches exactly the `public_api` (publishable-key) allowlist —
|
|
118
|
+
five calls:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
api.connectedApps.resolvePage mint/read a delivered page
|
|
122
|
+
api.connectedApps.updateMessageTracking opened_at / form_submitted_at
|
|
123
|
+
api.dataActivationClients.ingest submit rows
|
|
124
|
+
api.dataActivationClients.ingestFile submit a file
|
|
125
|
+
api.mdm.verify resolve an identity
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
(The same allowlist `connected_apps.md` documents — that guide owns
|
|
129
|
+
the runtime-call details.) Every call outside these five returns
|
|
130
|
+
401; that ceiling is the point, not an error to work around.
|
|
131
|
+
Minting a session and passing `sessionToken` unlocks the full
|
|
132
|
+
tenant-scoped API exactly as before — the two credentials are
|
|
133
|
+
complementary (the key identifies the client; the Bearer
|
|
134
|
+
authorizes), never substitutes.
|
|
135
|
+
|
|
105
136
|
### Explicit teardown — `revokeSession`
|
|
106
137
|
|
|
107
138
|
The inverse of `createSession`. Revokes the current Bearer on
|
|
@@ -40,8 +40,8 @@ const { data: created } = await api.actionStatusUpdaters.create(
|
|
|
40
40
|
updater_body: {
|
|
41
41
|
updater_body_type: 'cloud_watch_request',
|
|
42
42
|
log_group_name: 'sns/us-east-1/...',
|
|
43
|
-
start_time: '{{
|
|
44
|
-
end_time: '{{
|
|
43
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}', // Liquid window
|
|
44
|
+
end_time: '{{ now_msec }}',
|
|
45
45
|
},
|
|
46
46
|
|
|
47
47
|
// Liquid, rendered ONCE PER EVENT — the event IS the assigns, so there is no
|
|
@@ -51,6 +51,14 @@ const { data: created } = await api.actionStatusUpdaters.create(
|
|
|
51
51
|
type: 'custom',
|
|
52
52
|
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
53
53
|
},
|
|
54
|
+
|
|
55
|
+
// REQUIRED on every updater, both types, on create AND update. Same
|
|
56
|
+
// per-event rendering contract as `message_config`, but targets the
|
|
57
|
+
// action_log row instead of the message row.
|
|
58
|
+
action_log_config: {
|
|
59
|
+
type: 'custom',
|
|
60
|
+
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
61
|
+
},
|
|
54
62
|
},
|
|
55
63
|
)
|
|
56
64
|
```
|
|
@@ -74,22 +82,30 @@ updater_body_type: 'cloud_watch_request'
|
|
|
74
82
|
follow the same `<verb>_request` naming and carry the fields
|
|
75
83
|
their target API requires.)
|
|
76
84
|
|
|
85
|
+
`updater_body_type: 'restapi_request'` additionally **requires an
|
|
86
|
+
`events_template` field**. Without it the REST poll fails with
|
|
87
|
+
`missing_events_template`. `events_template` is specific to the REST
|
|
88
|
+
poll's event-list extraction — it is not read by data-activation
|
|
89
|
+
fetches.
|
|
90
|
+
|
|
77
91
|
`start_time` / `end_time` are Liquid, and they **must render to
|
|
78
|
-
unix milliseconds** — that is what the poll request takes.
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
unix milliseconds** — that is what the poll request takes. Use the
|
|
93
|
+
**`now_msec`** variable (unix ms) and pipe it through the
|
|
94
|
+
relative-time filters. **Not the bare `now`** — that is a DateTime,
|
|
95
|
+
and feeding it a millisecond filter now raises at render:
|
|
81
96
|
|
|
82
97
|
```
|
|
83
|
-
start_time: '{{
|
|
84
|
-
end_time: '{{
|
|
98
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}' // 45 minutes back
|
|
99
|
+
end_time: '{{ now_msec }}' // right now
|
|
85
100
|
```
|
|
86
101
|
|
|
87
102
|
Also available: `hours_ago: N`.
|
|
88
103
|
|
|
89
|
-
**Do not use the
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
literal is not valid on
|
|
104
|
+
**Do not use the bare `now` variable or the ISO-8601 `now` filter
|
|
105
|
+
here.** The `now` variable is a DateTime and `{{ "" | now }}`
|
|
106
|
+
renders a datetime *string* — neither is a valid unix-ms window,
|
|
107
|
+
and the platform rejects both. An ISO 8601 literal is not valid on
|
|
108
|
+
these fields either.
|
|
93
109
|
|
|
94
110
|
### `message_config.body` renders ONCE PER EVENT
|
|
95
111
|
|
|
@@ -123,6 +139,79 @@ events for messages this updater doesn't own.
|
|
|
123
139
|
Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
|
|
124
140
|
available here, as everywhere.
|
|
125
141
|
|
|
142
|
+
### MMS delivery tracking uses a system `message_config` template
|
|
143
|
+
|
|
144
|
+
MMS delivery tracking is wired as a `cloud_watch` updater whose
|
|
145
|
+
`message_config` points at a system-defined template rather than a
|
|
146
|
+
hand-authored `custom` body — the system template lives at
|
|
147
|
+
`status_poller/end_user_messaging/mms_delivery_status`. Everything
|
|
148
|
+
else about the updater is ordinary: it still needs an
|
|
149
|
+
`action_log_config` like every other updater (see below) — the
|
|
150
|
+
system template covers `message_config` only.
|
|
151
|
+
|
|
152
|
+
### Carrier information is available for SMS, not MMS
|
|
153
|
+
|
|
154
|
+
The recipient's mobile carrier is present on SMS delivery events but
|
|
155
|
+
not MMS. This is AWS behavior — verified against real delivery
|
|
156
|
+
events — not something the platform chooses to drop:
|
|
157
|
+
|
|
158
|
+
- **SMS via Amazon SNS** — the delivery-status log
|
|
159
|
+
(`sns/<region>/<account>/DirectPublishToPhoneNumber`) nests carrier
|
|
160
|
+
under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
|
|
161
|
+
numeric `mcc`/`mnc`). The SNS status template maps it to
|
|
162
|
+
`sms_carrier`.
|
|
163
|
+
- **MMS via AWS End User Messaging (`sms-voice`)** — the delivery
|
|
164
|
+
event has no carrier field. Verified on **both** a config-set
|
|
165
|
+
CloudWatch destination and the EventBridge path: the terminal
|
|
166
|
+
`MEDIA_DELIVERED` event exposes only `totalCarrierFee` (a cost),
|
|
167
|
+
never `carrierName`/`mcc`/`mnc`. So `sms_carrier` stays unset for
|
|
168
|
+
MMS.
|
|
169
|
+
|
|
170
|
+
AWS's Nov-2024 EventBridge-integration announcement states MMS
|
|
171
|
+
delivery events carry carrier information in EventBridge, but the
|
|
172
|
+
real events do not — a documented doc-vs-behavior discrepancy. For
|
|
173
|
+
per-number US carrier, use `PhoneNumberValidate` (returns `Carrier` +
|
|
174
|
+
`PhoneType`) at enrollment and join on destination/`messageId`; it is
|
|
175
|
+
not in the delivery stream.
|
|
176
|
+
|
|
177
|
+
### `action_log_config` is required — same rendering contract as `message_config`
|
|
178
|
+
|
|
179
|
+
`action_log_config` is a **required** field on every action status
|
|
180
|
+
updater — `cloud_watch` and `restapi` alike — on **both `create()` and
|
|
181
|
+
`update()`**. There is no updater for which it is optional and no way
|
|
182
|
+
to omit it on a PUT.
|
|
183
|
+
|
|
184
|
+
It follows the same per-event rendering contract as `message_config`
|
|
185
|
+
(§2 above), but targets the **action_log** row instead of the message
|
|
186
|
+
row: it must render a JSON object containing `external_id` plus at
|
|
187
|
+
least one updatable action-log field — one of `status`, `sent_at`, or
|
|
188
|
+
`metadata`. A minimal valid value:
|
|
189
|
+
|
|
190
|
+
```liquid
|
|
191
|
+
{"external_id": "{{ notification.messageId }}", "status": "delivered"}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Two distinct rejections come off this field, and they read differently:
|
|
195
|
+
|
|
196
|
+
- **Missing entirely** — omitting `action_log_config` on create or
|
|
197
|
+
update is a 422 with `Missing field: action_log_config` at
|
|
198
|
+
`source.pointer: /action_log_config`.
|
|
199
|
+
- **Present but renders nothing** — a value that passes shallow
|
|
200
|
+
validation but renders `null` or an empty result at run time (e.g.
|
|
201
|
+
a `{"type": "null"}` config) is refused with a 422 whose detail
|
|
202
|
+
begins `must render a JSON object containing external_id`.
|
|
203
|
+
|
|
204
|
+
**Response nullability is lazy, not retroactive.** Updaters created
|
|
205
|
+
before this became required still read back `action_log_config: null`
|
|
206
|
+
— the platform does not backfill a template onto old rows. That
|
|
207
|
+
`null` persists until the row's **next edit** forces a real template
|
|
208
|
+
through, and "next edit" is broader than a plain field change: it
|
|
209
|
+
also includes resuming a halted updater by setting `status` back to
|
|
210
|
+
`'active'` (§7) and issuing a `refresh()` call with a one-shot
|
|
211
|
+
`updater_body` override (§7) — both now require `action_log_config`
|
|
212
|
+
to be present too, which is what completes the migration for an old
|
|
213
|
+
row.
|
|
214
|
+
|
|
126
215
|
### `cron_expression` is a standard 5-field cron string
|
|
127
216
|
|
|
128
217
|
```
|
|
@@ -201,6 +290,7 @@ sender_tool_ids required UUID[] — tools whose messages this updater trac
|
|
|
201
290
|
datalake_id required UUID
|
|
202
291
|
updater_body required embed — { updater_body_type, ... }
|
|
203
292
|
message_config required embed — { type, body }
|
|
293
|
+
action_log_config required embed — { type, body } (required on create AND update)
|
|
204
294
|
```
|
|
205
295
|
|
|
206
296
|
**Write-only (Request-only).** None.
|
|
@@ -216,6 +306,8 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
|
|
|
216
306
|
| `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
|
|
217
307
|
| `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
|
|
218
308
|
| `/message_config/body` | empty when `type: 'custom'` |
|
|
309
|
+
| `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
|
|
310
|
+
| (detail, not pointer) | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
|
|
219
311
|
| `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
|
|
220
312
|
|
|
221
313
|
## 5. Lifecycle
|
|
@@ -318,7 +410,7 @@ check or retry for partial-delete states.
|
|
|
318
410
|
The poll follows pages while `has_next` is true and stops on
|
|
319
411
|
the first empty page.
|
|
320
412
|
|
|
321
|
-
## 7. Manual trigger via `refresh` —
|
|
413
|
+
## 7. Manual trigger via `refresh` — 202, and what `last_run_*` means
|
|
322
414
|
|
|
323
415
|
An action status updater CAN be fired on demand:
|
|
324
416
|
|
|
@@ -328,28 +420,85 @@ const { data } = await api.actionStatusUpdaters.refresh(
|
|
|
328
420
|
);
|
|
329
421
|
```
|
|
330
422
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
423
|
+
**`refresh` answers `202` with the updater row AS-IS — the poll is
|
|
424
|
+
ENQUEUED, not run.** The `last_run_*` fields on that response are the
|
|
425
|
+
PREVIOUS run's stamps; reading them as the outcome of the refresh you
|
|
426
|
+
just issued is the single most common mistake here. An optional body
|
|
427
|
+
carries a one-shot `updater_body` override (e.g. a widened
|
|
428
|
+
`start_time`/`end_time` window for a backfill) that does not mutate
|
|
429
|
+
the persisted updater. Apply jobs are asynchronous too — observe
|
|
430
|
+
delivery status on the message rows, not in this response.
|
|
431
|
+
(CLI: `alvera refresh-action-status-updater --id <uuid>`, alias
|
|
432
|
+
`refresh-updater`.)
|
|
433
|
+
|
|
434
|
+
The correct read is baseline-then-advance:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
const before = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
|
|
438
|
+
const baseline = before.data.last_run_at ?? null // BEFORE refreshing
|
|
439
|
+
|
|
440
|
+
await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
|
|
441
|
+
|
|
442
|
+
let row = before.data
|
|
443
|
+
const deadline = Date.now() + 60_000
|
|
444
|
+
while (Date.now() < deadline) {
|
|
445
|
+
await new Promise((r) => setTimeout(r, 2_000))
|
|
446
|
+
const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
|
|
447
|
+
if (data.last_run_at && data.last_run_at !== baseline) { row = data; break }
|
|
448
|
+
}
|
|
449
|
+
// row.last_run_status / last_run_error now describe THIS run
|
|
450
|
+
```
|
|
335
451
|
|
|
336
452
|
What the ASU still does NOT have is a **per-run log** (nothing like
|
|
337
453
|
a data activation client's `logs` or a workflow's `batchLogs`). The
|
|
338
|
-
|
|
339
|
-
|
|
454
|
+
run-outcome surface is four fields on the row itself, plus the halt
|
|
455
|
+
flag:
|
|
340
456
|
|
|
341
457
|
```
|
|
342
|
-
last_run_at when the poll worker last
|
|
343
|
-
last_run_status 'ok' | 'error'
|
|
344
|
-
last_run_error why the last run failed
|
|
345
|
-
last_run_events_found
|
|
458
|
+
last_run_at when the poll worker last made progress
|
|
459
|
+
last_run_status 'ok' | 'partial' | 'error'
|
|
460
|
+
last_run_error why the last run failed, or why a partial run was truncated
|
|
461
|
+
last_run_events_found events fetched so far in the current run
|
|
462
|
+
status 'active' | 'cycle_detected'
|
|
346
463
|
```
|
|
347
464
|
|
|
465
|
+
**A poll run has three outcomes, and only `'ok'` means it reconciled its
|
|
466
|
+
whole time window:**
|
|
467
|
+
|
|
468
|
+
- `'ok'` — the run completed and read its entire window. This is the only
|
|
469
|
+
value that proves the reconciliation is complete.
|
|
470
|
+
- `'partial'` — the run completed but its provider fetch was **truncated**:
|
|
471
|
+
the window was not fully read, and the events it missed are the **newest**
|
|
472
|
+
ones. `last_run_error` carries the detail (it is **not** null on a
|
|
473
|
+
`partial` run). A client that treats `last_run_status !== 'error'` as
|
|
474
|
+
healthy silently accepts this incomplete reconciliation — check
|
|
475
|
+
`last_run_status === 'ok'` instead.
|
|
476
|
+
- `'error'` — the run failed; `last_run_error` says why.
|
|
477
|
+
|
|
478
|
+
`last_run_events_found` is the event count for the current run; because
|
|
479
|
+
`refresh` is asynchronous (below), a read taken before the run finishes can
|
|
480
|
+
legitimately show a smaller, in-progress figure than the final count.
|
|
481
|
+
|
|
482
|
+
**`status` is the halt flag.** The server sets `'cycle_detected'` when
|
|
483
|
+
a run re-reads events it has already handled — the runtime backstop for
|
|
484
|
+
a poll whose pagination does not advance (a provider cursor that never
|
|
485
|
+
drains, or a one-shot `updater_body` override). Every later job then
|
|
486
|
+
fails without calling the provider, and `last_run_error` carries the
|
|
487
|
+
diagnostic. **Nothing clears it but setting `status` back to
|
|
488
|
+
`'active'`.** It is also the cheapest deterministic failure to assert
|
|
489
|
+
on: a non-advancing config trips it within seconds, no provider
|
|
490
|
+
credential rejection required. Related: a `restapi` updater that
|
|
491
|
+
declares pagination but whose `path`/`params`/`body` never read
|
|
492
|
+
`msg.pagination_context` or `msg.page` is now refused at create/update
|
|
493
|
+
— see §2's template contract.
|
|
494
|
+
|
|
348
495
|
**If a message's status is not reconciling, do this — in order:**
|
|
349
496
|
|
|
350
|
-
1. Call `refresh()
|
|
351
|
-
|
|
352
|
-
|
|
497
|
+
1. Call `refresh()`, wait for `last_run_at` to advance past the
|
|
498
|
+
baseline you captured, then read `last_run_status` /
|
|
499
|
+
`last_run_error` / `status`. They name the failing stage: window
|
|
500
|
+
render, source fetch, template render, JSON parse — or a halted
|
|
501
|
+
poll cycle.
|
|
353
502
|
2. Check the config against §2 above. A config that violates the
|
|
354
503
|
template contract is rejected at create, so a *deployed* updater's
|
|
355
504
|
templates are structurally sound — the fault is more likely the
|
package/.agent/ai_sandbox.md
CHANGED
|
@@ -103,10 +103,12 @@ consumers cannot extend the set:
|
|
|
103
103
|
|
|
104
104
|
minutes_ago Unix ms N minutes before a unix-ms input. Bounds
|
|
105
105
|
the start of an ActionStatusUpdater poll window.
|
|
106
|
-
|
|
106
|
+
`now_msec` is the injected unix-ms variable (the
|
|
107
|
+
bare `now` is a DateTime — feeding it here raises).
|
|
108
|
+
Example: {{ now_msec | minutes_ago: 45 }} → 1773585000000
|
|
107
109
|
|
|
108
110
|
hours_ago Unix ms N hours before a unix-ms input.
|
|
109
|
-
Example: {{
|
|
111
|
+
Example: {{ now_msec | hours_ago: 2 }} → 1773578400000
|
|
110
112
|
|
|
111
113
|
uuid Generate a new v4 UUID.
|
|
112
114
|
Example: {{ '' | uuid }} → a1b2c3d4-…
|
package/.agent/connected_apps.md
CHANGED
|
@@ -64,7 +64,8 @@ const { data: created } = await api.connectedApps.create(
|
|
|
64
64
|
)
|
|
65
65
|
// created.id, created.slug — server-derived
|
|
66
66
|
// created.status === 'pending' (transient) → 'synced' after route fetch
|
|
67
|
-
//
|
|
67
|
+
// (an M2M key IS auto-provisioned at create (see §2), but its id is NOT
|
|
68
|
+
// echoed on the resource — no response carries an api_key_id field)
|
|
68
69
|
// created.routes — array populated from .well-known/routes.json
|
|
69
70
|
// (empty until first sync)
|
|
70
71
|
// created.last_synced_at — set when routes successfully validated
|
|
@@ -191,9 +192,13 @@ last_synced_at string — ISO 8601; set at successful route sync
|
|
|
191
192
|
error string — populated when status === 'error'
|
|
192
193
|
routes array — discovered from .well-known/routes.json
|
|
193
194
|
(each: { name, path, description? })
|
|
194
|
-
api_key_id UUID — id of the auto-provisioned M2M key
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
+
(The auto-provisioned M2M key's id is **not** among these — neither
|
|
198
|
+
`.get` nor `get-metadata` returns an `api_key_id`. Don't assert on it
|
|
199
|
+
in a contract hook; the key itself reaches the deployment as
|
|
200
|
+
`ALVERA_API_KEY`, §2.)
|
|
201
|
+
|
|
197
202
|
**Caller-supplied (round-trip).**
|
|
198
203
|
|
|
199
204
|
```
|
|
@@ -105,11 +105,16 @@ ctx.cwToolId = cwTool.id!
|
|
|
105
105
|
|
|
106
106
|
The ASU ties it together: a `cron_expression` schedule, the `updater_tool_id`
|
|
107
107
|
(the poller), the `sender_tool_ids` it reconciles for, and an `updater_body`
|
|
108
|
-
naming the log group + the time window. On `start_time`/`end_time`,
|
|
109
|
-
variable holding unix milliseconds —
|
|
108
|
+
naming the log group + the time window. On `start_time`/`end_time`, use
|
|
109
|
+
`now_msec` — the injected variable holding unix milliseconds — piped through
|
|
110
|
+
`minutes_ago`; the bare `now` is a DateTime and raises here. The
|
|
110
111
|
`message_config` Liquid is rendered ONCE PER EVENT, with the event itself as the
|
|
111
112
|
assigns (there is no `events` list to loop), and emits a FLAT object: the
|
|
112
113
|
`external_id` of the message to update plus the fields to set at the top level.
|
|
114
|
+
`action_log_config` is REQUIRED alongside it — same per-event assigns, rendered
|
|
115
|
+
into the action-log write shape: a JSON object with `external_id` plus at least
|
|
116
|
+
one of `status` / `sent_at` / `metadata`. Omitting it is a 422 (`Missing field:
|
|
117
|
+
action_log_config`).
|
|
113
118
|
|
|
114
119
|
```typescript
|
|
115
120
|
const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
@@ -122,13 +127,17 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
|
|
|
122
127
|
updater_body: {
|
|
123
128
|
updater_body_type: 'cloud_watch_request',
|
|
124
129
|
log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
|
|
125
|
-
start_time: '{{
|
|
126
|
-
end_time: '{{
|
|
130
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}',
|
|
131
|
+
end_time: '{{ now_msec }}',
|
|
127
132
|
},
|
|
128
133
|
message_config: {
|
|
129
134
|
type: 'custom',
|
|
130
135
|
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
131
136
|
},
|
|
137
|
+
action_log_config: {
|
|
138
|
+
type: 'custom',
|
|
139
|
+
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
140
|
+
},
|
|
132
141
|
})
|
|
133
142
|
actionStatusUpdaterId = asu.id!
|
|
134
143
|
```
|
|
@@ -155,13 +164,17 @@ const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalak
|
|
|
155
164
|
updater_body: {
|
|
156
165
|
updater_body_type: 'cloud_watch_request',
|
|
157
166
|
log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
|
|
158
|
-
start_time: '{{
|
|
159
|
-
end_time: '{{
|
|
167
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}',
|
|
168
|
+
end_time: '{{ now_msec }}',
|
|
160
169
|
},
|
|
161
170
|
message_config: {
|
|
162
171
|
type: 'custom',
|
|
163
172
|
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
164
173
|
},
|
|
174
|
+
action_log_config: {
|
|
175
|
+
type: 'custom',
|
|
176
|
+
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
177
|
+
},
|
|
165
178
|
})
|
|
166
179
|
if (typeof ck.checksum !== 'string' || ck.checksum.length === 0) {
|
|
167
180
|
throw new Error('expected a checksum for the ASU body')
|
|
@@ -190,6 +203,35 @@ if (typeof detail !== 'string' || detail.length === 0) {
|
|
|
190
203
|
}
|
|
191
204
|
```
|
|
192
205
|
|
|
206
|
+
## 007 — write the integration test
|
|
207
|
+
|
|
208
|
+
End the build with a test you keep: one block that re-reads the reconciler
|
|
209
|
+
and asserts the facts the scenario depends on. This block runs live under
|
|
210
|
+
`make validate-cookbook`.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// Re-GET — the stored row echoes what was authored.
|
|
214
|
+
const { data: asuRow } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
215
|
+
if (asuRow.cron_expression !== '*/30 * * * *') {
|
|
216
|
+
throw new Error(`cron mismatch on read-back: ${asuRow.cron_expression}`)
|
|
217
|
+
}
|
|
218
|
+
// last_run_* is the ONLY run surface (there is no per-run log). On a
|
|
219
|
+
// fresh create it is legitimately null — assert the field EXISTS on the
|
|
220
|
+
// wire, never a value.
|
|
221
|
+
if (!('last_run_status' in asuRow)) {
|
|
222
|
+
throw new Error('ASU response carries no last_run_status field')
|
|
223
|
+
}
|
|
224
|
+
// Behavioural probe — the metadata surface an agent reads must render.
|
|
225
|
+
const { data: asuDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
226
|
+
if (typeof asuDetail !== 'string' || asuDetail.length === 0) {
|
|
227
|
+
throw new Error('ASU metadataDetails came back empty')
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
If the test fails in production, escalate with the failing read's
|
|
232
|
+
evidence (the response body, `last_run_error`) — don't rewrite the config
|
|
233
|
+
blind.
|
|
234
|
+
|
|
193
235
|
# Gotchas
|
|
194
236
|
|
|
195
237
|
- **The poller tool's intent is `status_poller`, not the sender's intent.** The
|
|
@@ -199,14 +241,31 @@ if (typeof detail !== 'string' || detail.length === 0) {
|
|
|
199
241
|
is the assigns — there is no `events` list to loop — and every top-level key
|
|
200
242
|
that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
|
|
201
243
|
no `set_params` wrapper. Both mistakes are rejected at create.
|
|
202
|
-
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
244
|
+
- **`action_log_config` is REQUIRED on both create and update (PUT), same as
|
|
245
|
+
`message_config`.** Omitting it is a 422 (`Missing field: action_log_config`).
|
|
246
|
+
It renders the same per-event assigns into the action-log write shape:
|
|
247
|
+
`external_id` plus at least one of `status` / `sent_at` / `metadata`.
|
|
248
|
+
- **The schedule is a cron, evaluated platform-side — and one poll cycle can
|
|
249
|
+
be fired on demand.** Creating the ASU registers the schedule. The
|
|
250
|
+
`updater_body` window (`{{ now_msec | minutes_ago: 45 }}` … `{{ now_msec }}`)
|
|
251
|
+
bounds each run's log query, and must render to unix milliseconds — `now_msec`
|
|
252
|
+
is the unix-ms variable, not the bare `now` (a DateTime) or the ISO-8601 `now`
|
|
253
|
+
filter. To trigger a cycle without
|
|
254
|
+
waiting for the cron tick, `api.actionStatusUpdaters.refresh(tenantSlug,
|
|
255
|
+
datalakeSlug, id)` answers `202` with the updater row AS-IS (the poll runs
|
|
256
|
+
async — the row still shows the PREVIOUS run's stamps; re-read later for
|
|
257
|
+
the outcome). See `action_status_updaters.md` §7.
|
|
258
|
+
- **If it doesn't reconcile, read `last_run_error` — refresh, don't guess.**
|
|
259
|
+
There is no per-run log (known limitation): the run surface is the
|
|
260
|
+
read-only `last_run_*` fields. Fire a `refresh`, re-read `last_run_status`
|
|
261
|
+
/ `last_run_events_found` / `last_run_error`, and escalate with that
|
|
262
|
+
evidence — don't rewrite the config blind between cron ticks.
|
|
263
|
+
- **`last_run_status` has three values — only `'ok'` means done.** `'ok'`
|
|
264
|
+
is the run that read its whole time window; `'partial'` completed but its
|
|
265
|
+
provider fetch was truncated, so the newest events may be missing and
|
|
266
|
+
`last_run_error` says so (it is not null on a `partial`); `'error'` failed.
|
|
267
|
+
Check `last_run_status === 'ok'` — a `!== 'error'` check silently accepts
|
|
268
|
+
a truncated `partial` as if it had reconciled.
|
|
210
269
|
- **`checksum` parity is your drift signal.** The fingerprint of the desired body
|
|
211
270
|
must match the stored row's; a mismatch means the resource drifted.
|
|
212
271
|
|
|
@@ -214,6 +214,42 @@ if (!rejected) {
|
|
|
214
214
|
}
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
+
## 006 — write the integration test
|
|
218
|
+
|
|
219
|
+
End the build with a test you keep: re-read the agent and probe the one
|
|
220
|
+
behaviour that is provable deterministically — the input gate. A real
|
|
221
|
+
extraction (§004) needs a live provider behind the tool; the schema gate
|
|
222
|
+
does not, which is exactly what makes it the durable test. This block
|
|
223
|
+
runs live under `make validate-cookbook`.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// Re-GET — the agent must be enabled, or every invoke will refuse.
|
|
227
|
+
const { data: agentRow } = await api.aiAgents.get(tenantSlug, datalakeSlug, aiAgentId)
|
|
228
|
+
if (agentRow.enabled !== true) {
|
|
229
|
+
throw new Error('expected enabled=true on agent read-back')
|
|
230
|
+
}
|
|
231
|
+
// Behavioural probe — the input_schema gate fires BEFORE the model:
|
|
232
|
+
// a malformed input must 422 even with no working provider.
|
|
233
|
+
let gateFired = false
|
|
234
|
+
try {
|
|
235
|
+
await api.aiAgents.invoke(tenantSlug, datalakeSlug, aiAgentId, {
|
|
236
|
+
input: { wrong_field: 'test — no instructions here' },
|
|
237
|
+
files: [],
|
|
238
|
+
})
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const status = (err as { _httpStatus?: number })._httpStatus
|
|
241
|
+
if (status !== 422) throw err
|
|
242
|
+
gateFired = true
|
|
243
|
+
}
|
|
244
|
+
if (!gateFired) {
|
|
245
|
+
throw new Error('agent accepted input that violates its input_schema (expected 422)')
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
If the gate stops firing in production, escalate with the
|
|
250
|
+
accepted-payload evidence — don't loosen the `input_schema` to make the
|
|
251
|
+
error go away.
|
|
252
|
+
|
|
217
253
|
# Gotchas
|
|
218
254
|
|
|
219
255
|
- **Uploading a file is not ingesting it.** The upload + `invoke` here makes the
|
|
@@ -675,6 +675,38 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
|
|
|
675
675
|
}
|
|
676
676
|
```
|
|
677
677
|
|
|
678
|
+
## 016 — write the integration test
|
|
679
|
+
|
|
680
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
681
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
682
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
683
|
+
decision) and intercepts only the final action call, so no message
|
|
684
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
685
|
+
block runs live under `make validate-cookbook`.
|
|
686
|
+
|
|
687
|
+
```typescript
|
|
688
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
689
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
690
|
+
if (wfRow.status !== 'live') {
|
|
691
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
692
|
+
}
|
|
693
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
694
|
+
// the pipeline executes end-to-end, the final action call is
|
|
695
|
+
// intercepted, and the acknowledgement carries the run-log id.
|
|
696
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
697
|
+
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
698
|
+
mode: 'dry_run',
|
|
699
|
+
manual_override: false,
|
|
700
|
+
})
|
|
701
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
702
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
If the probe fails in production, escalate with the run response as
|
|
707
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
708
|
+
chase the error.
|
|
709
|
+
|
|
678
710
|
# Branches
|
|
679
711
|
|
|
680
712
|
- **The cancelled appointment is filtered, not failed** — §012
|
|
@@ -577,6 +577,40 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
|
|
|
577
577
|
}
|
|
578
578
|
```
|
|
579
579
|
|
|
580
|
+
## 015 — write the integration test
|
|
581
|
+
|
|
582
|
+
End the build with a test you keep: re-read the workflow and prove the
|
|
583
|
+
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
584
|
+
never-matching selection runs the FULL pipeline (selection → filter →
|
|
585
|
+
decision) and intercepts only the final action call, so no message
|
|
586
|
+
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
587
|
+
block runs live under `make validate-cookbook`.
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
// Re-GET — the workflow must still be live, or nothing will run.
|
|
591
|
+
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
592
|
+
if (wfRow.status !== 'live') {
|
|
593
|
+
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
594
|
+
}
|
|
595
|
+
// Behavioural probe — a dry run against a selection no row can match:
|
|
596
|
+
// the pipeline executes end-to-end, the final action call is
|
|
597
|
+
// intercepted, and the acknowledgement carries the run-log id. The
|
|
598
|
+
// clause must speak this workflow's selection dialect — the dataset
|
|
599
|
+
// alias is `rle` here, the same alias the live run above uses.
|
|
600
|
+
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
601
|
+
sql_where_clause: "rle.batch_id = 'test-never-matching-batch'",
|
|
602
|
+
mode: 'dry_run',
|
|
603
|
+
manual_override: false,
|
|
604
|
+
})
|
|
605
|
+
if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
|
|
606
|
+
throw new Error('dry-run probe returned no workflow_run_log_id')
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
If the probe fails in production, escalate with the run response as
|
|
611
|
+
evidence — don't flip the workflow's status or rewrite its configs to
|
|
612
|
+
chase the error.
|
|
613
|
+
|
|
580
614
|
# Branches
|
|
581
615
|
|
|
582
616
|
- **The no-DoB row is filtered, not failed** — §011 asserts the no-DoB
|