@alvera-ai/platform-sdk 0.17.0 → 0.18.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/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Subscription industry bootstrap setup
|
|
3
|
-
summary: Auth as root, sign up + confirm a fresh industry-admin user, create an subscription tenant + datalake. Shared by every subscription scenario cookbook.
|
|
4
|
-
industry: subscription
|
|
5
|
-
slug: subscription
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/subscription/bootstrap.test.ts
|
|
8
|
-
status: green
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
# Problem
|
|
12
|
-
|
|
13
|
-
Every subscription scenario cookbook needs the same
|
|
14
|
-
starting point: a fresh tenant on the platform with an
|
|
15
|
-
subscription-domain datalake attached, accessed by an
|
|
16
|
-
industry-admin user. Authoring this prelude inline inside every
|
|
17
|
-
scenario cookbook would duplicate the auth + tenant + datalake
|
|
18
|
-
chain across two cookbooks (and six more across the other
|
|
19
|
-
industries). The validator auto-discovers this file from each
|
|
20
|
-
cookbook's `industry: subscription` front-matter and
|
|
21
|
-
inlines its numbered steps ahead of the scenario's own, so the
|
|
22
|
-
generated bun:test spec is hermetic without the cookbook author
|
|
23
|
-
having to copy-paste this prelude.
|
|
24
|
-
|
|
25
|
-
# Composition
|
|
26
|
-
|
|
27
|
-
| Resource provisioned | Owner |
|
|
28
|
-
|-------------------------------|-------------|
|
|
29
|
-
| Root session | setup |
|
|
30
|
-
| Industry-admin user (`sarah`) | setup |
|
|
31
|
-
| Subscription tenant | setup |
|
|
32
|
-
| Subscription datalake | setup |
|
|
33
|
-
|
|
34
|
-
# Walkthrough
|
|
35
|
-
|
|
36
|
-
## 001 — root admin signs in and provisions a fresh industry-admin user
|
|
37
|
-
|
|
38
|
-
Authenticate as the platform's root admin (`admin@dev.local` /
|
|
39
|
-
`devpassword` in local dev) via the tenantless bootstrap login —
|
|
40
|
-
keyless by structural necessity (no tenant exists yet to scope a
|
|
41
|
-
key to), and a dev/test-only surface. Then sign up a per-run industry-admin
|
|
42
|
-
user (`sarah`) under a unique email derived from `runSuffix`, and
|
|
43
|
-
confirm them so they can sign in. Both the signup and the confirm
|
|
44
|
-
endpoints require root scope.
|
|
45
|
-
|
|
46
|
-
```typescript
|
|
47
|
-
ctx.rootSession = await createBootstrapSession({
|
|
48
|
-
baseUrl: process.env.ALVERA_BASE_URL!,
|
|
49
|
-
email: process.env.ALVERA_ROOT_EMAIL!,
|
|
50
|
-
password: process.env.ALVERA_ROOT_PASSWORD!,
|
|
51
|
-
})
|
|
52
|
-
ctx.rootApi = createIsolatedPlatformApi({
|
|
53
|
-
baseUrl: process.env.ALVERA_BASE_URL!,
|
|
54
|
-
sessionToken: ctx.rootSession.sessionToken,
|
|
55
|
-
apiKey: '',
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
ctx.sarahEmail = `cookbook-sarah-${runSuffix}@dev.local`
|
|
59
|
-
ctx.sarahPassword = 'CookbookPass1!'
|
|
60
|
-
|
|
61
|
-
const signUpResp = await ctx.rootApi.admin.signUp({
|
|
62
|
-
email: ctx.sarahEmail,
|
|
63
|
-
password: ctx.sarahPassword,
|
|
64
|
-
first_name: 'Cookbook',
|
|
65
|
-
last_name: 'Sarah',
|
|
66
|
-
})
|
|
67
|
-
ctx.sarahUserId = signUpResp.data.id
|
|
68
|
-
await ctx.rootApi.admin.confirmUser(ctx.sarahUserId)
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
## 002 — sarah signs in (tenantless) and creates the subscription tenant
|
|
72
|
-
|
|
73
|
-
Sarah signs in for the first time without a tenant scope (no
|
|
74
|
-
tenant exists yet for her), then immediately creates a fresh
|
|
75
|
-
subscription tenant. The tenant's server-derived `slug` is
|
|
76
|
-
captured into the closure-scoped `tenantSlug` slot for downstream
|
|
77
|
-
steps.
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
ctx.sarahTenantlessSession = await createBootstrapSession({
|
|
81
|
-
baseUrl: process.env.ALVERA_BASE_URL!,
|
|
82
|
-
email: ctx.sarahEmail,
|
|
83
|
-
password: ctx.sarahPassword,
|
|
84
|
-
})
|
|
85
|
-
ctx.sarahTenantlessApi = createIsolatedPlatformApi({
|
|
86
|
-
baseUrl: process.env.ALVERA_BASE_URL!,
|
|
87
|
-
sessionToken: ctx.sarahTenantlessSession.sessionToken,
|
|
88
|
-
apiKey: '',
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
const tenantResp = await ctx.sarahTenantlessApi.tenants.create({
|
|
92
|
-
name: `Cookbook Subscription ${runSuffix}`,
|
|
93
|
-
})
|
|
94
|
-
tenantSlug = tenantResp.data.slug!
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
## 003 — sarah signs in tenant-scoped — the canonical client
|
|
98
|
-
|
|
99
|
-
Re-mint sarah's session with the new tenant slug. This bearer is
|
|
100
|
-
the canonical tenant-scoped client every subsequent step uses; it
|
|
101
|
-
is assigned to the closure-scoped `api` slot so the scenario
|
|
102
|
-
cookbook's steps inherit it via inlining.
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
// A tenant-scoped login requires X-API-Key — mint a public_api key for
|
|
106
|
-
// the fresh tenant via the platform-admin side door (root Bearer). In the
|
|
107
|
-
// web console this is Settings -> API Keys; any of the tenant's keys
|
|
108
|
-
// satisfies the login gate, and the minted Bearer inherits this key's
|
|
109
|
-
// origin policy.
|
|
110
|
-
const { data: mintedKey } = await ctx.rootApi.admin.createTenantApiKey(tenantSlug, {
|
|
111
|
-
name: `Cookbook Bootstrap Key ${runSuffix}`,
|
|
112
|
-
data_access_mode: 'unregulated',
|
|
113
|
-
})
|
|
114
|
-
ctx.tenantApiKey = mintedKey.api_key
|
|
115
|
-
|
|
116
|
-
const sarahTenantSession = await createSession({
|
|
117
|
-
baseUrl: process.env.ALVERA_BASE_URL!,
|
|
118
|
-
email: ctx.sarahEmail,
|
|
119
|
-
password: ctx.sarahPassword,
|
|
120
|
-
tenantSlug,
|
|
121
|
-
apiKey: ctx.tenantApiKey,
|
|
122
|
-
})
|
|
123
|
-
api = createIsolatedPlatformApi({
|
|
124
|
-
baseUrl: process.env.ALVERA_BASE_URL!,
|
|
125
|
-
sessionToken: sarahTenantSession.sessionToken,
|
|
126
|
-
apiKey: ctx.tenantApiKey,
|
|
127
|
-
})
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## 004 — sarah creates the subscription datalake
|
|
131
|
-
|
|
132
|
-
Provision an subscription-domain datalake on the new
|
|
133
|
-
tenant. The DB schemas are scoped to the cookbook run via
|
|
134
|
-
`runSuffix` so parallel cookbook runs do not collide on schema
|
|
135
|
-
names. Local-dev defaults (`postgres` on `localhost:5432`,
|
|
136
|
-
`alvera_dev_subscription`) match the seeded `dev.exs`
|
|
137
|
-
setup; LocalStack S3 (`localhost:4566`) serves the unregulated +
|
|
138
|
-
regulated buckets. The datalake's server-derived `slug` is
|
|
139
|
-
captured into `datalakeSlug`.
|
|
140
|
-
|
|
141
|
-
```typescript
|
|
142
|
-
const DB_HOST = 'localhost'
|
|
143
|
-
const DB_PORT = 5432
|
|
144
|
-
const DB_USER = 'postgres'
|
|
145
|
-
const DB_PASS = 'postgres'
|
|
146
|
-
const DB_NAME = 'alvera_dev_subscription'
|
|
147
|
-
const UNREG_SCHEMA = `cookbook_${runSuffix}_unreg`
|
|
148
|
-
const REG_SCHEMA = `cookbook_${runSuffix}_reg`
|
|
149
|
-
|
|
150
|
-
const S3 = {
|
|
151
|
-
cloud_storage_type: 'aws' as const,
|
|
152
|
-
region: 'us-east-1',
|
|
153
|
-
access_key_id: 'test',
|
|
154
|
-
secret_access_key: 'test',
|
|
155
|
-
endpoint: 'http://localhost:4566',
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const datalakeResp = await api.datalakes.create(tenantSlug, {
|
|
159
|
-
name: `Cookbook Subscription Datalake ${runSuffix}`,
|
|
160
|
-
description: 'Subscription datalake provisioned by cookbook doctest.',
|
|
161
|
-
data_domain: 'subscription',
|
|
162
|
-
timezone: 'America/New_York',
|
|
163
|
-
pool_size: 5,
|
|
164
|
-
|
|
165
|
-
unregulated_db_writer_host: DB_HOST,
|
|
166
|
-
unregulated_db_writer_port: DB_PORT,
|
|
167
|
-
unregulated_db_writer_name: DB_NAME,
|
|
168
|
-
unregulated_db_writer_schema: UNREG_SCHEMA,
|
|
169
|
-
unregulated_db_writer_auth_method: 'password',
|
|
170
|
-
unregulated_db_writer_user: DB_USER,
|
|
171
|
-
unregulated_db_writer_pass: DB_PASS,
|
|
172
|
-
unregulated_db_writer_enable_ssl: false,
|
|
173
|
-
unregulated_db_reader_host: DB_HOST,
|
|
174
|
-
unregulated_db_reader_port: DB_PORT,
|
|
175
|
-
unregulated_db_reader_name: DB_NAME,
|
|
176
|
-
unregulated_db_reader_schema: UNREG_SCHEMA,
|
|
177
|
-
unregulated_db_reader_auth_method: 'password',
|
|
178
|
-
unregulated_db_reader_user: DB_USER,
|
|
179
|
-
unregulated_db_reader_pass: DB_PASS,
|
|
180
|
-
unregulated_db_reader_enable_ssl: false,
|
|
181
|
-
|
|
182
|
-
regulated_data_db_writer_host: DB_HOST,
|
|
183
|
-
regulated_data_db_writer_port: DB_PORT,
|
|
184
|
-
regulated_data_db_writer_name: DB_NAME,
|
|
185
|
-
regulated_data_db_writer_schema: REG_SCHEMA,
|
|
186
|
-
regulated_data_db_writer_auth_method: 'password',
|
|
187
|
-
regulated_data_db_writer_user: DB_USER,
|
|
188
|
-
regulated_data_db_writer_pass: DB_PASS,
|
|
189
|
-
regulated_data_db_writer_enable_ssl: false,
|
|
190
|
-
regulated_data_db_reader_host: DB_HOST,
|
|
191
|
-
regulated_data_db_reader_port: DB_PORT,
|
|
192
|
-
regulated_data_db_reader_name: DB_NAME,
|
|
193
|
-
regulated_data_db_reader_schema: REG_SCHEMA,
|
|
194
|
-
regulated_data_db_reader_auth_method: 'password',
|
|
195
|
-
regulated_data_db_reader_user: DB_USER,
|
|
196
|
-
regulated_data_db_reader_pass: DB_PASS,
|
|
197
|
-
regulated_data_db_reader_enable_ssl: false,
|
|
198
|
-
|
|
199
|
-
unregulated_cloud_storage: { ...S3, bucket: 'subscription-lake-unregulated' },
|
|
200
|
-
regulated_cloud_storage: { ...S3, bucket: 'subscription-lake-regulated' },
|
|
201
|
-
})
|
|
202
|
-
datalakeSlug = datalakeResp.data.slug!
|
|
203
|
-
// `tools.create` and a few other endpoints accept the datalake by
|
|
204
|
-
// UUID in the request body rather than only the slug in the URL,
|
|
205
|
-
// so keep it on `ctx` for downstream steps that need it.
|
|
206
|
-
ctx.datalakeId = datalakeResp.data.id!
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
## 005 — enqueue the datalake migrations
|
|
210
|
-
|
|
211
|
-
`datalakes.create` persists the datalake row in `:new` status but
|
|
212
|
-
does not itself run the schema migrations. Migration is a
|
|
213
|
-
separately-triggered async job so the operator controls when the
|
|
214
|
-
(potentially slow) per-industry DDL runs. `datalakes.migrate`
|
|
215
|
-
enqueues the `DatalakeMigrationWorker` Oban job and returns
|
|
216
|
-
immediately with `status: 'enqueued'` plus the Oban `job_id`. The
|
|
217
|
-
poll in §006 then waits for that worker to finish — without this
|
|
218
|
-
call the datalake would sit at `:new` forever.
|
|
219
|
-
|
|
220
|
-
```typescript
|
|
221
|
-
const migrateResp = await api.datalakes.migrate(tenantSlug, datalakeSlug)
|
|
222
|
-
if (migrateResp.data.status !== 'enqueued') {
|
|
223
|
-
throw new Error(
|
|
224
|
-
`datalake migration not enqueued (status: ${migrateResp.data.status})`,
|
|
225
|
-
)
|
|
226
|
-
}
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
## 006 — poll the datalake until status is ready
|
|
230
|
-
|
|
231
|
-
Datalake migrations are async — the §005 migrate call enqueued a
|
|
232
|
-
`DatalakeMigrationWorker` Oban job that runs the per-industry
|
|
233
|
-
schema migrations (the regulated customer + invoice tables and
|
|
234
|
-
their join tables), deploys PostgREST roles, and provisions the
|
|
235
|
-
service-account bot user. Downstream resource creates (tools,
|
|
236
|
-
workflows) only need `datalakeId` to be persisted (which it is
|
|
237
|
-
the moment §004 returns), but any scenario step that ingests into
|
|
238
|
-
the regulated/unregulated DBs or runs a dataset search requires
|
|
239
|
-
`status: 'ready'`. Polling here makes every subscription
|
|
240
|
-
scenario cookbook deterministic regardless of how long the cold
|
|
241
|
-
migration takes on a given host; the loop exits the moment the
|
|
242
|
-
datalake reaches `:ready`, so the 5-minute cap is paid only in
|
|
243
|
-
failure mode.
|
|
244
|
-
|
|
245
|
-
```typescript
|
|
246
|
-
const READY_TIMEOUT_MS = 5 * 60_000
|
|
247
|
-
const READY_POLL_MS = 5_000
|
|
248
|
-
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
249
|
-
let datalakeStatus: string | undefined
|
|
250
|
-
while (Date.now() < deadline) {
|
|
251
|
-
const { data } = await api.datalakes.get(tenantSlug, ctx.datalakeId)
|
|
252
|
-
datalakeStatus = data.status
|
|
253
|
-
if (datalakeStatus === 'ready') break
|
|
254
|
-
await new Promise((r) => setTimeout(r, READY_POLL_MS))
|
|
255
|
-
}
|
|
256
|
-
if (datalakeStatus !== 'ready') {
|
|
257
|
-
throw new Error(
|
|
258
|
-
`datalake did not reach :ready within ${READY_TIMEOUT_MS}ms (last status: ${datalakeStatus})`,
|
|
259
|
-
)
|
|
260
|
-
}
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
## 007 — a reusable "wait until the run has fired" helper
|
|
264
|
-
|
|
265
|
-
`workflows.run` only **schedules** a run. It records it and returns
|
|
266
|
-
immediately with `workflow_run_id` / `status` / `scheduled_at`; the
|
|
267
|
-
`workflow_run_log_id` and `batch_id` a scenario needs are written
|
|
268
|
-
later, when the run actually fires, and are read back from
|
|
269
|
-
`workflowRuns.get`.
|
|
270
|
-
|
|
271
|
-
Two traps live in that gap, so every subscription scenario shares one
|
|
272
|
-
helper rather than re-deriving it:
|
|
273
|
-
|
|
274
|
-
- **Poll until `workflow_run_log_id` is a string — NOT until `status`
|
|
275
|
-
leaves `'scheduled'`.** Those are different moments: the run reaches
|
|
276
|
-
`processing` first and writes the log id a beat later. A predicate on
|
|
277
|
-
status alone releases you to read a `null`, and because
|
|
278
|
-
`typeof null === 'object'` the symptom is a baffling *"expected
|
|
279
|
-
string, got object"* rather than an obvious nil.
|
|
280
|
-
- **Raise on `failed` carrying `failure_reason` rather than polling to
|
|
281
|
-
the deadline.** A scenario blocked on a run that will never fire
|
|
282
|
-
should say why on the first read, not thirty seconds later behind a
|
|
283
|
-
generic timeout.
|
|
284
|
-
|
|
285
|
-
The helper is stored on the shared `ctx` bag rather than declared as a
|
|
286
|
-
plain function because each numbered step compiles into its own `it()`
|
|
287
|
-
block — a bare `function` here would not be in scope for the steps that
|
|
288
|
-
call it.
|
|
289
|
-
|
|
290
|
-
```typescript
|
|
291
|
-
const FIRED_TIMEOUT_MS = 60_000
|
|
292
|
-
const FIRED_POLL_MS = 1_000
|
|
293
|
-
|
|
294
|
-
ctx.waitForFiredRun = async (
|
|
295
|
-
runDatalakeSlug: string,
|
|
296
|
-
runId: string,
|
|
297
|
-
timeoutMs: number = FIRED_TIMEOUT_MS,
|
|
298
|
-
): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
|
|
299
|
-
const deadline = Date.now() + timeoutMs
|
|
300
|
-
let lastStatus: string | undefined
|
|
301
|
-
while (Date.now() < deadline) {
|
|
302
|
-
const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
|
|
303
|
-
lastStatus = data.status
|
|
304
|
-
if (data.status === 'failed') {
|
|
305
|
-
throw new Error(
|
|
306
|
-
`workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
|
|
307
|
-
)
|
|
308
|
-
}
|
|
309
|
-
if (typeof data.workflow_run_log_id === 'string') {
|
|
310
|
-
return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
|
|
311
|
-
}
|
|
312
|
-
await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
|
|
313
|
-
}
|
|
314
|
-
throw new Error(
|
|
315
|
-
`workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
|
|
316
|
-
)
|
|
317
|
-
}
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
# Rollback
|
|
321
|
-
|
|
322
|
-
The cookbook doctest harness does not currently tear down the
|
|
323
|
-
created tenant / datalake / user — each run mints fresh names via
|
|
324
|
-
`runSuffix` so reruns do not collide, and the seeded local DB is
|
|
325
|
-
cheap to reset (`mix ecto.reset` on the platform). When the
|
|
326
|
-
validator graduates to continuous integration at Milestone 18
|
|
327
|
-
(running against the mockoon-backed mock env at `ALVERA_ENV=mock`),
|
|
328
|
-
no real state is created at all.
|
|
329
|
-
|
|
330
|
-
# Outcome
|
|
331
|
-
|
|
332
|
-
After this setup runs, the closure-scoped slots are populated as:
|
|
333
|
-
|
|
334
|
-
- `api` — a tenant-scoped `PlatformApi` client authenticated as
|
|
335
|
-
the industry-admin user
|
|
336
|
-
- `tenantSlug` — server-derived slug of the fresh
|
|
337
|
-
subscription tenant
|
|
338
|
-
- `datalakeSlug` — server-derived slug of the fresh
|
|
339
|
-
subscription datalake
|
|
340
|
-
- `ctx.sarahEmail` / `ctx.sarahPassword` — the per-run admin
|
|
341
|
-
credentials (kept on `ctx` because no scenario cookbook needs
|
|
342
|
-
to re-authenticate by default)
|
|
343
|
-
- `ctx.tenantApiKey` — the tenant's publishable API key (minted in
|
|
344
|
-
setup via `admin.createTenantApiKey`); thread it into any
|
|
345
|
-
additional tenant-scoped `createSession` a scenario performs
|
|
346
|
-
- `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
|
|
347
|
-
until a scheduled run has actually fired, then returns its
|
|
348
|
-
`{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
|
|
349
|
-
reading `workflow_run_log_id` off the run response returns `null`
|
|
350
|
-
because run-workflow only schedules (see §007)
|
|
351
|
-
|
|
352
|
-
Scenario cookbooks under `industry: subscription` start at
|
|
353
|
-
their own `§001` with these slots already populated.
|
|
354
|
-
|
|
355
|
-
# See also
|
|
356
|
-
|
|
357
|
-
- `.agent/datalakes.md` — datalake create body shape (regulated +
|
|
358
|
-
unregulated tier configuration)
|
|
359
|
-
- `.agent/AGENTS.md` § SDK auth + client construction — root vs
|
|
360
|
-
tenantless vs tenant-scoped session scopes
|
|
361
|
-
- `integration-tests/tests/subscription/bootstrap.test.ts` —
|
|
362
|
-
the green test these snippets are lifted from (the sibling
|
|
363
|
-
datalake creation and the sanity probes are out of scope for
|
|
364
|
-
this lean cookbook setup)
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Capability: reconcile delivery status of the messages you send"
|
|
3
|
-
summary: A capability walk for action status updaters. Register a poller tool that reads a delivery-log source (here CloudWatch via LocalStack) and an action status updater that runs on a cron, maps log events back to the messages that produced them, and updates their status (`api.actionStatusUpdaters.create`). So a "sent" SMS becomes "delivered" / "failed" without you polling by hand.
|
|
4
|
-
industry: subscription
|
|
5
|
-
slug: action-status-updaters
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/subscription/action-status-updaters.test.ts
|
|
8
|
-
- integration-tests/tests/subscription/bootstrap.test.ts
|
|
9
|
-
status: green
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# Capability
|
|
13
|
-
|
|
14
|
-
**What you get:** the messages you send (SMS, etc.) get their real delivery
|
|
15
|
-
outcome written back automatically — "sent" becomes "delivered" or "failed" —
|
|
16
|
-
on a schedule, with no manual polling.
|
|
17
|
-
|
|
18
|
-
An **action status updater (ASU)** is a scheduled reconciler:
|
|
19
|
-
|
|
20
|
-
- a **poller tool** reads a delivery-log source (CloudWatch log group here;
|
|
21
|
-
LocalStack stands in locally),
|
|
22
|
-
- the ASU runs on a `cron_expression`,
|
|
23
|
-
- its `message_config` Liquid maps each log event back to the message it came
|
|
24
|
-
from (by `external_id`) and the new `status`,
|
|
25
|
-
- `sender_tool_ids` scopes it to the messages a given sender tool produced.
|
|
26
|
-
|
|
27
|
-
This is **global** — the same shape reconciles any sender's delivery status. See
|
|
28
|
-
`action_status_updaters.md` for the wire reference and `tools.md` for the
|
|
29
|
-
poller-tool body.
|
|
30
|
-
|
|
31
|
-
# Walkthrough
|
|
32
|
-
|
|
33
|
-
The `_setup/subscription.md` bootstrap left `api`, `tenantSlug`,
|
|
34
|
-
`datalakeSlug`, and `ctx.datalakeId` populated.
|
|
35
|
-
|
|
36
|
-
## 001 — register a data source for the tools
|
|
37
|
-
|
|
38
|
-
Tools attach to a data source — the origin registration. Create one for the SMS
|
|
39
|
-
gateway / log source.
|
|
40
|
-
|
|
41
|
-
```typescript
|
|
42
|
-
const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
43
|
-
name: `ASU Source ${runSuffix}`,
|
|
44
|
-
uri: 'sns.local',
|
|
45
|
-
description: 'SNS / CloudWatch origin for the dunning-SMS delivery reconciler.',
|
|
46
|
-
status: 'active',
|
|
47
|
-
is_default: false,
|
|
48
|
-
})
|
|
49
|
-
dataSourceId = ds.id!
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## 002 — create the SMS sender tool
|
|
53
|
-
|
|
54
|
-
The ASU reconciles the delivery status of messages a sender tool produced. Create
|
|
55
|
-
an SNS-backed SMS tool (pointed at LocalStack locally) to be that sender.
|
|
56
|
-
|
|
57
|
-
```typescript
|
|
58
|
-
const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
59
|
-
name: `ASU SMS Sender ${runSuffix}`,
|
|
60
|
-
description: 'SNS-backed SMS sender whose delivery status the ASU reconciles.',
|
|
61
|
-
intent: 'sms',
|
|
62
|
-
status: 'active',
|
|
63
|
-
datalake_id: ctx.datalakeId,
|
|
64
|
-
data_source_id: dataSourceId,
|
|
65
|
-
body: {
|
|
66
|
-
tool_body_type: 'sns',
|
|
67
|
-
auth_method: 'access_key',
|
|
68
|
-
region: 'us-east-1',
|
|
69
|
-
phone_number: '+15551234567',
|
|
70
|
-
endpoint_url: 'http://localhost:4566',
|
|
71
|
-
access_key_id: 'test',
|
|
72
|
-
secret_access_key: 'test',
|
|
73
|
-
},
|
|
74
|
-
})
|
|
75
|
-
ctx.smsToolId = smsTool.id!
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## 003 — create the CloudWatch poller tool
|
|
79
|
-
|
|
80
|
-
The poller tool supplies the credentials + region for reading the delivery log
|
|
81
|
-
group. `intent: 'status_poller'` tags it as a reconciler source (not a sender),
|
|
82
|
-
and `tool_body_type: 'cloud_watch_log_group'` selects the CloudWatch reader.
|
|
83
|
-
|
|
84
|
-
```typescript
|
|
85
|
-
const { data: cwTool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
86
|
-
name: `ASU CloudWatch Poller ${runSuffix}`,
|
|
87
|
-
description: 'CloudWatch log-group poller — reads SNS delivery events for reconciliation.',
|
|
88
|
-
intent: 'status_poller',
|
|
89
|
-
status: 'active',
|
|
90
|
-
datalake_id: ctx.datalakeId,
|
|
91
|
-
data_source_id: dataSourceId,
|
|
92
|
-
body: {
|
|
93
|
-
tool_body_type: 'cloud_watch_log_group',
|
|
94
|
-
auth_method: 'access_key',
|
|
95
|
-
region: 'us-east-1',
|
|
96
|
-
endpoint_url: 'http://localhost:4566',
|
|
97
|
-
access_key_id: 'test',
|
|
98
|
-
secret_access_key: 'test',
|
|
99
|
-
},
|
|
100
|
-
})
|
|
101
|
-
ctx.cwToolId = cwTool.id!
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
## 004 — create the action status updater
|
|
105
|
-
|
|
106
|
-
The ASU ties it together: a `cron_expression` schedule, the `updater_tool_id`
|
|
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`, 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
|
|
111
|
-
`message_config` Liquid is rendered ONCE PER EVENT, with the event itself as the
|
|
112
|
-
assigns (there is no `events` list to loop), and emits a FLAT object: the
|
|
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`).
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
121
|
-
name: `Dunning SMS Delivery Updater ${runSuffix}`,
|
|
122
|
-
cron_expression: '*/30 * * * *',
|
|
123
|
-
updater_type: 'cloud_watch',
|
|
124
|
-
updater_tool_id: ctx.cwToolId,
|
|
125
|
-
sender_tool_ids: [ctx.smsToolId],
|
|
126
|
-
datalake_id: ctx.datalakeId,
|
|
127
|
-
updater_body: {
|
|
128
|
-
updater_body_type: 'cloud_watch_request',
|
|
129
|
-
log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
|
|
130
|
-
start_time: '{{ now_msec | minutes_ago: 45 }}',
|
|
131
|
-
end_time: '{{ now_msec }}',
|
|
132
|
-
},
|
|
133
|
-
message_config: {
|
|
134
|
-
type: 'custom',
|
|
135
|
-
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
136
|
-
},
|
|
137
|
-
action_log_config: {
|
|
138
|
-
type: 'custom',
|
|
139
|
-
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
140
|
-
},
|
|
141
|
-
})
|
|
142
|
-
actionStatusUpdaterId = asu.id!
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
## 005 — verify it persisted (read-back + checksum parity)
|
|
146
|
-
|
|
147
|
-
`get` returns the stored row; its fields echo what you sent. `checksum` recomputes
|
|
148
|
-
the fingerprint from the same body without persisting — it must equal the stored
|
|
149
|
-
row's `checksum`, which is how drift is detected.
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
const { data: row } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
153
|
-
if (row.cron_expression !== '*/30 * * * *') {
|
|
154
|
-
throw new Error(`cron mismatch on read-back: ${row.cron_expression}`)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalakeSlug, {
|
|
158
|
-
name: row.name,
|
|
159
|
-
cron_expression: row.cron_expression,
|
|
160
|
-
updater_type: 'cloud_watch',
|
|
161
|
-
updater_tool_id: ctx.cwToolId,
|
|
162
|
-
sender_tool_ids: [ctx.smsToolId],
|
|
163
|
-
datalake_id: ctx.datalakeId,
|
|
164
|
-
updater_body: {
|
|
165
|
-
updater_body_type: 'cloud_watch_request',
|
|
166
|
-
log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
|
|
167
|
-
start_time: '{{ now_msec | minutes_ago: 45 }}',
|
|
168
|
-
end_time: '{{ now_msec }}',
|
|
169
|
-
},
|
|
170
|
-
message_config: {
|
|
171
|
-
type: 'custom',
|
|
172
|
-
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
173
|
-
},
|
|
174
|
-
action_log_config: {
|
|
175
|
-
type: 'custom',
|
|
176
|
-
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
177
|
-
},
|
|
178
|
-
})
|
|
179
|
-
if (typeof ck.checksum !== 'string' || ck.checksum.length === 0) {
|
|
180
|
-
throw new Error('expected a checksum for the ASU body')
|
|
181
|
-
}
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
## 006 — discover it (list + metadata)
|
|
185
|
-
|
|
186
|
-
The ASU shows up in the paginated list, and `metadata` / `metadataDetails`
|
|
187
|
-
return the markdown catalog an agent reads to understand what reconcilers exist.
|
|
188
|
-
|
|
189
|
-
```typescript
|
|
190
|
-
const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
|
|
191
|
-
if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
|
|
192
|
-
throw new Error('created ASU not found in the list')
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const { data: catalog } = await api.actionStatusUpdaters.metadata(tenantSlug, datalakeSlug)
|
|
196
|
-
if (typeof catalog !== 'string' || catalog.length === 0) {
|
|
197
|
-
throw new Error('expected a non-empty ASU metadata catalog')
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
201
|
-
if (typeof detail !== 'string' || detail.length === 0) {
|
|
202
|
-
throw new Error('expected non-empty ASU metadata details')
|
|
203
|
-
}
|
|
204
|
-
```
|
|
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
|
-
|
|
235
|
-
# Gotchas
|
|
236
|
-
|
|
237
|
-
- **The poller tool's intent is `status_poller`, not the sender's intent.** The
|
|
238
|
-
reconciler reads logs; it doesn't send. `sender_tool_ids` is the separate list
|
|
239
|
-
of sender tools whose messages it updates.
|
|
240
|
-
- **`message_config` renders once per event, and emits a FLAT object.** The event
|
|
241
|
-
is the assigns — there is no `events` list to loop — and every top-level key
|
|
242
|
-
that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
|
|
243
|
-
no `set_params` wrapper. Both mistakes are rejected at create.
|
|
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.
|
|
269
|
-
- **`checksum` parity is your drift signal.** The fingerprint of the desired body
|
|
270
|
-
must match the stored row's; a mismatch means the resource drifted.
|
|
271
|
-
|
|
272
|
-
# See also
|
|
273
|
-
|
|
274
|
-
- `action_status_updaters.md` — wire shape, `updater_body` variants, `message_config`
|
|
275
|
-
- `tools.md` — `sns` sender body + `cloud_watch_log_group` poller body
|
|
276
|
-
- `_setup/subscription.md` — the bootstrap this walk starts from
|
|
277
|
-
- `integration-tests/tests/subscription/action-status-updaters.test.ts` —
|
|
278
|
-
the green test these calls are lifted from
|