@datalayer/agent-runtimes 1.5.44 → 1.5.46
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/dist-embed/chunks/{ChatView-kCmBbnDa.js → ChatView-Dy-IgGFL.js} +1 -1
- package/dist-embed/datalayer-app-main.js +99 -94
- package/lib/apps/plugins/chat/ChatView.js +2 -2
- package/lib/chat/ChatFloating.js +1 -1
- package/lib/chat/base/ChatBase.js +23 -8
- package/lib/chat/messages/ChatMessageList.d.ts +7 -1
- package/lib/chat/messages/ChatMessageList.js +8 -2
- package/lib/portable/hosts/__tests__/fixtures/lock-with-jupyter.json +40 -0
- package/lib/portable/hosts/__tests__/fixtures/npm-ls-with-jupyter.json +21 -0
- package/lib/portable/hosts/agentClient.d.ts +167 -0
- package/lib/portable/hosts/agentClient.js +346 -0
- package/lib/portable/hosts/approvalRenderers.d.ts +43 -0
- package/lib/portable/hosts/approvalRenderers.js +180 -0
- package/lib/portable/hosts/client.d.ts +95 -0
- package/lib/portable/hosts/client.js +174 -0
- package/lib/portable/hosts/contracts.d.ts +199 -0
- package/lib/portable/hosts/contracts.js +235 -0
- package/lib/portable/hosts/contracts.json +381 -0
- package/lib/portable/hosts/deadLetters.d.ts +41 -0
- package/lib/portable/hosts/deadLetters.js +124 -0
- package/lib/portable/hosts/deliveries.d.ts +76 -0
- package/lib/portable/hosts/deliveries.js +147 -0
- package/lib/portable/hosts/forbiddenGraph.d.ts +77 -0
- package/lib/portable/hosts/forbiddenGraph.js +174 -0
- package/lib/portable/hosts/health.d.ts +35 -0
- package/lib/portable/hosts/health.js +92 -0
- package/lib/portable/hosts/index.d.ts +40 -0
- package/lib/portable/hosts/index.js +45 -0
- package/lib/portable/hosts/observability.d.ts +67 -0
- package/lib/portable/hosts/observability.js +171 -0
- package/lib/portable/hosts/postgresQueue.d.ts +55 -0
- package/lib/portable/hosts/postgresQueue.js +217 -0
- package/lib/portable/hosts/queue.d.ts +47 -0
- package/lib/portable/hosts/queue.js +63 -0
- package/lib/portable/hosts/quotas.d.ts +81 -0
- package/lib/portable/hosts/quotas.js +147 -0
- package/lib/portable/hosts/rateLimit.d.ts +13 -0
- package/lib/portable/hosts/rateLimit.js +8 -0
- package/lib/portable/hosts/resolution.d.ts +54 -0
- package/lib/portable/hosts/resolution.js +46 -0
- package/lib/portable/hosts/schema.d.ts +19 -0
- package/lib/portable/hosts/schema.js +81 -0
- package/lib/portable/hosts/secretCipher.d.ts +23 -0
- package/lib/portable/hosts/secretCipher.js +45 -0
- package/lib/portable/hosts/sessionUid.d.ts +7 -0
- package/lib/portable/hosts/sessionUid.js +28 -0
- package/lib/portable/hosts/turnState.d.ts +45 -0
- package/lib/portable/hosts/turnState.js +113 -0
- package/lib/portable/index.d.ts +2 -0
- package/lib/portable/index.js +3 -0
- package/lib/portable/semantic-events.json +480 -0
- package/lib/portable/semantic.d.ts +78 -0
- package/lib/portable/semantic.js +100 -0
- package/lib/portable/semanticOfAgUi.d.ts +41 -0
- package/lib/portable/semanticOfAgUi.js +108 -0
- package/lib/protocols/A2AAdapter.js +19 -0
- package/lib/protocols/VercelAIAdapter.js +11 -0
- package/lib/specs/voices.js +62 -0
- package/lib/test-setup.js +17 -14
- package/lib/types/chat.d.ts +10 -0
- package/lib/voice/ComposerVoice.js +12 -3
- package/lib/voice/captions.d.ts +31 -0
- package/lib/voice/captions.js +156 -0
- package/lib/voice/handsFree.d.ts +12 -0
- package/lib/voice/handsFree.js +18 -0
- package/lib/voice/serverHearing.d.ts +29 -0
- package/lib/voice/serverHearing.js +78 -2
- package/package.json +1 -1
- package/scripts/voice/measure.py +77 -17
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"about": "The generic host contracts of plans/SLACK.md §4.3, as ai-agents and the runtime's session API answer them. Shared verbatim by ai-agents (tests/fixtures/hosts) and the Slack adapter (tech/datalayer/slack, test/contracts): each side's tests check its half against this file.",
|
|
4
|
+
"headers": {
|
|
5
|
+
"host_key": "X-Datalayer-Host-Key",
|
|
6
|
+
"signature": "X-Datalayer-Signature",
|
|
7
|
+
"timestamp": "X-Datalayer-Timestamp",
|
|
8
|
+
"delivery": "X-Datalayer-Delivery",
|
|
9
|
+
"idempotency_key": "Idempotency-Key"
|
|
10
|
+
},
|
|
11
|
+
"routes": {
|
|
12
|
+
"bind": "POST /api/ai-agents/v1/apps/hosts/bind",
|
|
13
|
+
"self": "GET /api/ai-agents/v1/apps/hosts/self",
|
|
14
|
+
"uninstalled": "DELETE /api/ai-agents/v1/apps/hosts/self",
|
|
15
|
+
"link_code": "POST /api/ai-agents/v1/apps/hosts/self/link-codes",
|
|
16
|
+
"link_state": "GET /api/ai-agents/v1/apps/hosts/self/links/{external_user_id}",
|
|
17
|
+
"unlink": "DELETE /api/ai-agents/v1/apps/hosts/self/links/{external_user_id}",
|
|
18
|
+
"exchange": "POST /api/ai-agents/v1/apps/hosts/self/token",
|
|
19
|
+
"webhook": "PUT /api/ai-agents/v1/apps/hosts/self/delivery",
|
|
20
|
+
"outbox": "GET /api/ai-agents/v1/apps/hosts/self/deliveries",
|
|
21
|
+
"ack": "POST /api/ai-agents/v1/apps/hosts/self/deliveries/{delivery_uid}/ack",
|
|
22
|
+
"bind_session": "PUT /api/ai-agents/v1/apps/hosts/sessions/{session_uid}",
|
|
23
|
+
"whoami": "GET /api/ai-agents/v1/apps/hosts/whoami",
|
|
24
|
+
"presentation": "GET /api/ai-agents/v1/tool-approvals/{approval_id}/presentation",
|
|
25
|
+
"decision": "POST /api/ai-agents/v1/apps/hosts/approvals/{approval_id}/decision",
|
|
26
|
+
"start_session": "POST {session_api.url}",
|
|
27
|
+
"send_message": "POST {session_api.url}/{session_uid}/messages",
|
|
28
|
+
"confirm_link": "POST /api/ai-agents/v1/apps/hosts/links",
|
|
29
|
+
"preview_link": "GET /api/ai-agents/v1/apps/hosts/links/preview?code={code}",
|
|
30
|
+
"change_host": "PATCH /api/ai-agents/v1/apps/hosts/{uid}",
|
|
31
|
+
"handoff": "POST /api/ai-agents/v1/apps/hosts/handoffs",
|
|
32
|
+
"redeem_handoff": "POST /api/ai-agents/v1/apps/hosts/handoffs/redeem"
|
|
33
|
+
},
|
|
34
|
+
"bind": {
|
|
35
|
+
"request": {
|
|
36
|
+
"code": "dlhc_…",
|
|
37
|
+
"external_id": "A0123:-:T0123",
|
|
38
|
+
"name": "Acme"
|
|
39
|
+
},
|
|
40
|
+
"response_keys": [
|
|
41
|
+
"success",
|
|
42
|
+
"host",
|
|
43
|
+
"host_key"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"link_code": {
|
|
47
|
+
"request": {
|
|
48
|
+
"external_user_id": "U0ALICE",
|
|
49
|
+
"external_user_name": "Alice"
|
|
50
|
+
},
|
|
51
|
+
"response_keys": [
|
|
52
|
+
"success",
|
|
53
|
+
"code",
|
|
54
|
+
"expires_in",
|
|
55
|
+
"confirm"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"exchange": {
|
|
59
|
+
"request": {
|
|
60
|
+
"external_user_id": "U0ALICE",
|
|
61
|
+
"deployment_uid": "dep-1"
|
|
62
|
+
},
|
|
63
|
+
"response_keys": [
|
|
64
|
+
"success",
|
|
65
|
+
"access_token",
|
|
66
|
+
"token_type",
|
|
67
|
+
"expires_in",
|
|
68
|
+
"exp",
|
|
69
|
+
"principal_uid",
|
|
70
|
+
"host_uid",
|
|
71
|
+
"deployment",
|
|
72
|
+
"session_api"
|
|
73
|
+
],
|
|
74
|
+
"deployment_keys": [
|
|
75
|
+
"uid",
|
|
76
|
+
"app_uid",
|
|
77
|
+
"app_name",
|
|
78
|
+
"version"
|
|
79
|
+
],
|
|
80
|
+
"session_api_keys": [
|
|
81
|
+
"url",
|
|
82
|
+
"agent"
|
|
83
|
+
],
|
|
84
|
+
"optional_keys": [
|
|
85
|
+
"user_token"
|
|
86
|
+
],
|
|
87
|
+
"token_claims": [
|
|
88
|
+
"jti",
|
|
89
|
+
"iss",
|
|
90
|
+
"iat",
|
|
91
|
+
"exp",
|
|
92
|
+
"aud",
|
|
93
|
+
"sub",
|
|
94
|
+
"azp",
|
|
95
|
+
"host_kind",
|
|
96
|
+
"link",
|
|
97
|
+
"deployment_uid",
|
|
98
|
+
"app_uid",
|
|
99
|
+
"scope"
|
|
100
|
+
],
|
|
101
|
+
"audience": "datalayer:app:host",
|
|
102
|
+
"max_ttl_seconds": 900,
|
|
103
|
+
"optional_request": {
|
|
104
|
+
"place": {
|
|
105
|
+
"kind": "channel",
|
|
106
|
+
"id": "C0"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"place_kinds": [
|
|
110
|
+
"channel",
|
|
111
|
+
"direct"
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
"whoami": {
|
|
115
|
+
"response_keys": [
|
|
116
|
+
"success",
|
|
117
|
+
"sub",
|
|
118
|
+
"azp",
|
|
119
|
+
"host_kind",
|
|
120
|
+
"deployment_uid",
|
|
121
|
+
"app_uid",
|
|
122
|
+
"link",
|
|
123
|
+
"scope",
|
|
124
|
+
"expires_in"
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"external_turn": {
|
|
128
|
+
"start_request": {
|
|
129
|
+
"agent": "agent-desk",
|
|
130
|
+
"deployment_uid": "dep-1",
|
|
131
|
+
"opener": "Summarize the launch risks",
|
|
132
|
+
"session": "slack-T0-C0-1700000000-000001",
|
|
133
|
+
"external_event_id": "Ev0123",
|
|
134
|
+
"user_token": ""
|
|
135
|
+
},
|
|
136
|
+
"message_request": {
|
|
137
|
+
"text": "And the costs?",
|
|
138
|
+
"external_event_id": "Ev0124"
|
|
139
|
+
},
|
|
140
|
+
"turn_event": {
|
|
141
|
+
"type": "CUSTOM",
|
|
142
|
+
"name": "loop.turn",
|
|
143
|
+
"value_keys": [
|
|
144
|
+
"turn",
|
|
145
|
+
"external_event_id",
|
|
146
|
+
"replayed",
|
|
147
|
+
"state"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
"replay": "The same Idempotency-Key (or, without one, the same external_event_id) and the same request answer the original session and turn with replayed=true, and run nothing; the same key with another request is 409."
|
|
151
|
+
},
|
|
152
|
+
"webhook": {
|
|
153
|
+
"request": {
|
|
154
|
+
"url": "https://slack.example/datalayer/deliveries",
|
|
155
|
+
"events": [
|
|
156
|
+
"approval.requested",
|
|
157
|
+
"approval.decided",
|
|
158
|
+
"turn.completed"
|
|
159
|
+
]
|
|
160
|
+
},
|
|
161
|
+
"response_keys": [
|
|
162
|
+
"success",
|
|
163
|
+
"url",
|
|
164
|
+
"events",
|
|
165
|
+
"version",
|
|
166
|
+
"signing_secret"
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
"delivery": {
|
|
170
|
+
"events": [
|
|
171
|
+
"approval.requested",
|
|
172
|
+
"approval.decided",
|
|
173
|
+
"turn.completed"
|
|
174
|
+
],
|
|
175
|
+
"payload_keys": {
|
|
176
|
+
"approval.requested": [
|
|
177
|
+
"approval_id",
|
|
178
|
+
"tool_name",
|
|
179
|
+
"presentation",
|
|
180
|
+
"session_uid",
|
|
181
|
+
"deployment_uid",
|
|
182
|
+
"target"
|
|
183
|
+
],
|
|
184
|
+
"approval.decided": [
|
|
185
|
+
"approval_id",
|
|
186
|
+
"status",
|
|
187
|
+
"decided_by",
|
|
188
|
+
"session_uid",
|
|
189
|
+
"deployment_uid",
|
|
190
|
+
"target"
|
|
191
|
+
],
|
|
192
|
+
"turn.completed": [
|
|
193
|
+
"record_entry",
|
|
194
|
+
"session_uid",
|
|
195
|
+
"deployment_uid",
|
|
196
|
+
"target"
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
"signing": {
|
|
200
|
+
"secret": "contract-signing-secret",
|
|
201
|
+
"timestamp": 1700000000,
|
|
202
|
+
"body": "{\"delivery\":\"d0c1\",\"event\":\"approval.requested\",\"payload\":{\"approval_id\":\"ap-1\",\"tool_name\":\"send_email\",\"presentation\":\"/api/ai-agents/v1/tool-approvals/ap-1/presentation\",\"session_uid\":\"slack-T0-C0-1700000000-000001\",\"deployment_uid\":\"dep-1\",\"target\":{\"channel\":\"C0\",\"thread_ts\":\"1700000000.000001\"}}}",
|
|
203
|
+
"signature": "v1=35630034244d3edab52b937cf4437542ec87958014351d587806b267f805b05f",
|
|
204
|
+
"rule": "v1= HMAC-SHA256(secret, \"<timestamp>.<raw body>\") in hex"
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
"presentation": {
|
|
208
|
+
"version": 1,
|
|
209
|
+
"response_keys": [
|
|
210
|
+
"success",
|
|
211
|
+
"presentation"
|
|
212
|
+
],
|
|
213
|
+
"keys": [
|
|
214
|
+
"version",
|
|
215
|
+
"approval_id",
|
|
216
|
+
"status",
|
|
217
|
+
"owner_uid",
|
|
218
|
+
"tool_name",
|
|
219
|
+
"action_class",
|
|
220
|
+
"session_uid",
|
|
221
|
+
"created_at",
|
|
222
|
+
"expires_at",
|
|
223
|
+
"fields",
|
|
224
|
+
"arguments",
|
|
225
|
+
"secret_fields",
|
|
226
|
+
"complete",
|
|
227
|
+
"host_may_decide",
|
|
228
|
+
"reasons",
|
|
229
|
+
"digest"
|
|
230
|
+
],
|
|
231
|
+
"action_classes": [
|
|
232
|
+
"read",
|
|
233
|
+
"send",
|
|
234
|
+
"write",
|
|
235
|
+
"execute",
|
|
236
|
+
"publish",
|
|
237
|
+
"delete",
|
|
238
|
+
"financial",
|
|
239
|
+
"unknown"
|
|
240
|
+
],
|
|
241
|
+
"host_decidable": [
|
|
242
|
+
"read",
|
|
243
|
+
"send",
|
|
244
|
+
"write"
|
|
245
|
+
],
|
|
246
|
+
"example": {
|
|
247
|
+
"version": 1,
|
|
248
|
+
"approval_id": "ap-1",
|
|
249
|
+
"status": "pending",
|
|
250
|
+
"owner_uid": "alice",
|
|
251
|
+
"tool_name": "send_email",
|
|
252
|
+
"action_class": "send",
|
|
253
|
+
"session_uid": "slack-T0-C0-1700000000-000001",
|
|
254
|
+
"created_at": "2026-10-07T10:00:00Z",
|
|
255
|
+
"expires_at": null,
|
|
256
|
+
"fields": [
|
|
257
|
+
{
|
|
258
|
+
"name": "to",
|
|
259
|
+
"label": "To",
|
|
260
|
+
"value": "release@example.com"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"name": "subject",
|
|
264
|
+
"label": "Subject",
|
|
265
|
+
"value": "Launch decision"
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"name": "body",
|
|
269
|
+
"label": "Body",
|
|
270
|
+
"value": "We go with option B."
|
|
271
|
+
}
|
|
272
|
+
],
|
|
273
|
+
"arguments": {
|
|
274
|
+
"to": "release@example.com",
|
|
275
|
+
"subject": "Launch decision",
|
|
276
|
+
"body": "We go with option B."
|
|
277
|
+
},
|
|
278
|
+
"secret_fields": [],
|
|
279
|
+
"complete": true,
|
|
280
|
+
"host_may_decide": true,
|
|
281
|
+
"reasons": [],
|
|
282
|
+
"digest": "0000000000000000000000000000000000000000000000000000000000000000"
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
"decision": {
|
|
286
|
+
"request": {
|
|
287
|
+
"approve": true,
|
|
288
|
+
"digest": "<presentation.digest>",
|
|
289
|
+
"note": null
|
|
290
|
+
},
|
|
291
|
+
"response_keys": [
|
|
292
|
+
"success",
|
|
293
|
+
"approval"
|
|
294
|
+
]
|
|
295
|
+
},
|
|
296
|
+
"self": {
|
|
297
|
+
"host_keys": [
|
|
298
|
+
"uid",
|
|
299
|
+
"state",
|
|
300
|
+
"deployments",
|
|
301
|
+
"default_deployment",
|
|
302
|
+
"channels",
|
|
303
|
+
"direct_only",
|
|
304
|
+
"paused"
|
|
305
|
+
],
|
|
306
|
+
"deployment_keys": [
|
|
307
|
+
"uid",
|
|
308
|
+
"app_uid",
|
|
309
|
+
"app_name",
|
|
310
|
+
"state",
|
|
311
|
+
"kept"
|
|
312
|
+
]
|
|
313
|
+
},
|
|
314
|
+
"preview": {
|
|
315
|
+
"response_keys": [
|
|
316
|
+
"success",
|
|
317
|
+
"host",
|
|
318
|
+
"deployments",
|
|
319
|
+
"external_user_id",
|
|
320
|
+
"external_user_name",
|
|
321
|
+
"expires_in"
|
|
322
|
+
],
|
|
323
|
+
"deployment_keys": [
|
|
324
|
+
"uid",
|
|
325
|
+
"app_name",
|
|
326
|
+
"default"
|
|
327
|
+
]
|
|
328
|
+
},
|
|
329
|
+
"change_host": {
|
|
330
|
+
"request": {
|
|
331
|
+
"channels": [
|
|
332
|
+
{
|
|
333
|
+
"id": "C0",
|
|
334
|
+
"state": "deny"
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"id": "C1",
|
|
338
|
+
"state": "allow",
|
|
339
|
+
"deployment": "dep-1"
|
|
340
|
+
}
|
|
341
|
+
],
|
|
342
|
+
"direct_only": [
|
|
343
|
+
"dep-1"
|
|
344
|
+
],
|
|
345
|
+
"paused": false
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
"handoff": {
|
|
349
|
+
"request": {
|
|
350
|
+
"kind": "approval",
|
|
351
|
+
"target": "ap-1"
|
|
352
|
+
},
|
|
353
|
+
"kinds": [
|
|
354
|
+
"session",
|
|
355
|
+
"approval"
|
|
356
|
+
],
|
|
357
|
+
"response_keys": [
|
|
358
|
+
"success",
|
|
359
|
+
"code",
|
|
360
|
+
"expires_in",
|
|
361
|
+
"path"
|
|
362
|
+
],
|
|
363
|
+
"code_prefix": "dlho_",
|
|
364
|
+
"redeem_request": {
|
|
365
|
+
"code": "dlho_…"
|
|
366
|
+
},
|
|
367
|
+
"redeem_response_keys": [
|
|
368
|
+
"success",
|
|
369
|
+
"kind",
|
|
370
|
+
"target",
|
|
371
|
+
"deployment_uid",
|
|
372
|
+
"path"
|
|
373
|
+
],
|
|
374
|
+
"refusals": {
|
|
375
|
+
"unauthenticated": 401,
|
|
376
|
+
"another_account": 403,
|
|
377
|
+
"expired": 410,
|
|
378
|
+
"spent_or_unknown": 404
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Logger } from './observability';
|
|
2
|
+
import { PostgresWorkQueue } from './postgresQueue';
|
|
3
|
+
import { type ConnectionPool } from './queue';
|
|
4
|
+
export type DeadLetter = Readonly<{
|
|
5
|
+
id: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
attempts: number;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
/** The last error, redacted: a credential in an error message is not shown. */
|
|
10
|
+
lastError: string;
|
|
11
|
+
/** sha256 of the payload: tells two jobs apart without showing either. */
|
|
12
|
+
payloadHash: string;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function payloadHash(payload: unknown): string;
|
|
15
|
+
export declare class DeadLetters {
|
|
16
|
+
#private;
|
|
17
|
+
readonly database: ConnectionPool;
|
|
18
|
+
readonly queue: PostgresWorkQueue<unknown>;
|
|
19
|
+
readonly logger: Logger;
|
|
20
|
+
constructor(database: ConnectionPool, queue: PostgresWorkQueue<unknown>, logger?: Logger);
|
|
21
|
+
list(limit?: number): Promise<DeadLetter[]>;
|
|
22
|
+
replay(id: string, operator: string): Promise<boolean>;
|
|
23
|
+
/** A payload, for a stated reason, recorded and audited; secrets still redacted. */
|
|
24
|
+
payload(id: string, operator: string, reason: string): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
/** The dead letters of a host's queue (`<prefix>_jobs`). */
|
|
27
|
+
export declare function deadLetters(database: ConnectionPool, tablePrefix: string, logger?: Logger): DeadLetters;
|
|
28
|
+
/** What the dead-letters bin prints or says, and its exit code. */
|
|
29
|
+
export type CliOutput = {
|
|
30
|
+
table: (rows: unknown) => void;
|
|
31
|
+
info: (line: string) => void;
|
|
32
|
+
error: (line: string) => void;
|
|
33
|
+
};
|
|
34
|
+
export declare const DEAD_LETTERS_USAGE = "Usage: dead-letters list | replay <id> | payload <id> <reason>";
|
|
35
|
+
/**
|
|
36
|
+
* `dead-letters list | replay <id> | payload <id> <reason…>`: metadata and
|
|
37
|
+
* hashes by default (S-405). Who acts is said in `operator`
|
|
38
|
+
* (`DATALAYER_OPERATOR`), never taken from the container's user: a replay
|
|
39
|
+
* and a payload read are kept with this name. Answers the exit code.
|
|
40
|
+
*/
|
|
41
|
+
export declare function runDeadLettersCli(letters: DeadLetters, argv: readonly string[], operator: string, out?: CliOutput): Promise<number>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Dead letters (SLACK §9, S-405; DISCORD DC-403): jobs that failed five
|
|
7
|
+
* times. By default an operator sees their metadata and a hash of their
|
|
8
|
+
* payload — never the words of a prompt or a delivery. Replaying puts one
|
|
9
|
+
* back to work, once. Reading a payload is privileged: a dead job's only, it
|
|
10
|
+
* needs a reason, is recorded in `<prefix>_payload_access` and audited, and
|
|
11
|
+
* its credentials are still redacted. Every replay is kept in
|
|
12
|
+
* `<prefix>_job_replays` with who asked. Moved from the Slack service
|
|
13
|
+
* (plans/DISCORD.md DC-004); each service keeps only the bin that opens its
|
|
14
|
+
* database and calls `runDeadLettersCli`.
|
|
15
|
+
*
|
|
16
|
+
* @module portable/hosts/deadLetters
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { Logger, redact, redactCredentials } from './observability';
|
|
20
|
+
import { PostgresWorkQueue } from './postgresQueue';
|
|
21
|
+
import { tablePrefixOf } from './queue';
|
|
22
|
+
export function payloadHash(payload) {
|
|
23
|
+
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
export class DeadLetters {
|
|
26
|
+
database;
|
|
27
|
+
queue;
|
|
28
|
+
logger;
|
|
29
|
+
#access;
|
|
30
|
+
#queueName;
|
|
31
|
+
constructor(database, queue, logger = new Logger()) {
|
|
32
|
+
this.database = database;
|
|
33
|
+
this.queue = queue;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
const prefix = tablePrefixOf(queue.tablePrefix);
|
|
36
|
+
this.#access = `${prefix}_payload_access`;
|
|
37
|
+
this.#queueName = `${prefix}-queue`;
|
|
38
|
+
}
|
|
39
|
+
async list(limit = 50) {
|
|
40
|
+
const dead = await this.queue.dead(limit);
|
|
41
|
+
return Promise.all(dead.map(async (one) => ({
|
|
42
|
+
attempts: one.attempts,
|
|
43
|
+
createdAt: one.createdAt,
|
|
44
|
+
id: one.id,
|
|
45
|
+
kind: one.kind,
|
|
46
|
+
lastError: String(redact(one.lastError ?? '')),
|
|
47
|
+
payloadHash: payloadHash(await this.queue.payloadOf(one.id)),
|
|
48
|
+
})));
|
|
49
|
+
}
|
|
50
|
+
async replay(id, operator) {
|
|
51
|
+
if (!operator.trim()) {
|
|
52
|
+
throw new Error('Name who replays the job.');
|
|
53
|
+
}
|
|
54
|
+
const replayed = await this.queue.replay(id, operator);
|
|
55
|
+
this.logger.audit('admin.changed', {
|
|
56
|
+
by: operator,
|
|
57
|
+
installation: this.#queueName,
|
|
58
|
+
job: id,
|
|
59
|
+
reason: replayed ? 'dead job replayed' : 'not a dead job',
|
|
60
|
+
});
|
|
61
|
+
return replayed;
|
|
62
|
+
}
|
|
63
|
+
/** A payload, for a stated reason, recorded and audited; secrets still redacted. */
|
|
64
|
+
async payload(id, operator, reason) {
|
|
65
|
+
if (reason.trim().length < 10) {
|
|
66
|
+
throw new Error('Say why the payload is read: ten characters at least.');
|
|
67
|
+
}
|
|
68
|
+
if (!operator.trim()) {
|
|
69
|
+
throw new Error('Name who reads the payload.');
|
|
70
|
+
}
|
|
71
|
+
// A dead job's only: a live job or an unknown id is refused, and no read
|
|
72
|
+
// of it is recorded.
|
|
73
|
+
const payload = await this.queue.payloadOf(id);
|
|
74
|
+
if (payload === undefined) {
|
|
75
|
+
throw new Error(`Job ${id} is not a dead job: there is no payload to read.`);
|
|
76
|
+
}
|
|
77
|
+
await this.database.query(`INSERT INTO ${this.#access} (job_id, operator, reason) VALUES ($1, $2, $3)`, [id, operator, reason.slice(0, 1_000)]);
|
|
78
|
+
this.logger.audit('payload.read', {
|
|
79
|
+
by: operator,
|
|
80
|
+
installation: this.#queueName,
|
|
81
|
+
job: id,
|
|
82
|
+
reason: 'privileged payload access',
|
|
83
|
+
});
|
|
84
|
+
return redactCredentials(payload);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** The dead letters of a host's queue (`<prefix>_jobs`). */
|
|
88
|
+
export function deadLetters(database, tablePrefix, logger) {
|
|
89
|
+
return new DeadLetters(database, new PostgresWorkQueue(database, tablePrefix), logger);
|
|
90
|
+
}
|
|
91
|
+
export const DEAD_LETTERS_USAGE = 'Usage: dead-letters list | replay <id> | payload <id> <reason>';
|
|
92
|
+
/**
|
|
93
|
+
* `dead-letters list | replay <id> | payload <id> <reason…>`: metadata and
|
|
94
|
+
* hashes by default (S-405). Who acts is said in `operator`
|
|
95
|
+
* (`DATALAYER_OPERATOR`), never taken from the container's user: a replay
|
|
96
|
+
* and a payload read are kept with this name. Answers the exit code.
|
|
97
|
+
*/
|
|
98
|
+
export async function runDeadLettersCli(letters, argv, operator, out = {
|
|
99
|
+
table: rows => console.table(rows),
|
|
100
|
+
info: line => console.info(line),
|
|
101
|
+
error: line => console.error(line),
|
|
102
|
+
}) {
|
|
103
|
+
const [command, id, ...rest] = argv;
|
|
104
|
+
if (!operator.trim()) {
|
|
105
|
+
out.error('DATALAYER_OPERATOR is required: the name kept with every replay and payload read.');
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
if (command === 'list') {
|
|
109
|
+
out.table(await letters.list());
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
if (command === 'replay' && id) {
|
|
113
|
+
out.info((await letters.replay(id, operator))
|
|
114
|
+
? `Job ${id} is back to work.`
|
|
115
|
+
: `Job ${id} is not a dead job.`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (command === 'payload' && id) {
|
|
119
|
+
out.info(JSON.stringify(await letters.payload(id, operator, rest.join(' ')), null, 2));
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
out.error(DEAD_LETTERS_USAGE);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datalayer's deliveries to a chat host (SLACK §4.3, 3; DISCORD §3.8): the
|
|
3
|
+
* signed webhook ai-agents pushes to, the outbox read when a push was
|
|
4
|
+
* missed, and each delivery handled once then acknowledged. What a delivery
|
|
5
|
+
* shows is the host's own (its `DeliveryHandler`); where its installations
|
|
6
|
+
* and claims are kept is its store, seen here as a `DeliverySink` and a
|
|
7
|
+
* `DeliveryClaimStore`. Moved from the Slack service (plans/DISCORD.md
|
|
8
|
+
* DC-004).
|
|
9
|
+
*
|
|
10
|
+
* @module portable/hosts/deliveries
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
|
+
import type { DatalayerHostClient } from './client';
|
|
14
|
+
import { type HostDelivery } from './contracts';
|
|
15
|
+
/** Where Datalayer pushes an installation's deliveries, under the service's public address. */
|
|
16
|
+
export declare const DELIVERY_PATH = "/datalayer/deliveries";
|
|
17
|
+
export declare const DELIVERY_ROUTE = "/datalayer/deliveries/:hostUid";
|
|
18
|
+
export type DeliveryJob = Readonly<{
|
|
19
|
+
delivery: HostDelivery;
|
|
20
|
+
hostUid: string;
|
|
21
|
+
}>;
|
|
22
|
+
/** A host installation bound at Datalayer: its uid and key. */
|
|
23
|
+
export type BoundHostKey = Readonly<{
|
|
24
|
+
hostKey: string;
|
|
25
|
+
hostUid: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Where a host keeps its installations, as deliveries read them. */
|
|
28
|
+
export interface DeliverySink {
|
|
29
|
+
/** What Datalayer signs an installation's pushes with, or `undefined` for an unknown one. */
|
|
30
|
+
deliverySecretOf(hostUid: string): Promise<string | undefined>;
|
|
31
|
+
/** The installation's key, or `undefined` once it was removed (late work is dropped). */
|
|
32
|
+
hostKeyOf(hostUid: string): Promise<string | undefined>;
|
|
33
|
+
/** Every installation bound at Datalayer: whose outbox is read. */
|
|
34
|
+
boundHosts(): Promise<readonly BoundHostKey[]>;
|
|
35
|
+
}
|
|
36
|
+
/** Where a host keeps what it handled once: a claim taken, or released when handling failed. */
|
|
37
|
+
export interface DeliveryClaimStore {
|
|
38
|
+
claimEvent(id: string): Promise<boolean>;
|
|
39
|
+
releaseEvent(id: string): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Handle one delivery of an installation once (claimed by its uid), then
|
|
43
|
+
* acknowledge it at Datalayer. A removed installation's delivery is dropped;
|
|
44
|
+
* a handling that fails releases its claim, so the queue tries again.
|
|
45
|
+
*/
|
|
46
|
+
export declare function receiveDelivery(job: DeliveryJob, input: {
|
|
47
|
+
sink: DeliverySink;
|
|
48
|
+
claims: DeliveryClaimStore;
|
|
49
|
+
client: Pick<DatalayerHostClient, 'acknowledge'>;
|
|
50
|
+
handle: (delivery: HostDelivery) => Promise<void>;
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* The signed webhook Datalayer pushes to (`POST /datalayer/deliveries/{host}`,
|
|
54
|
+
* registered with `PUT …/self/delivery`): verified with that installation's
|
|
55
|
+
* secret — HMAC over `<timestamp>.<body>`, five minutes at most — then queued
|
|
56
|
+
* and acknowledged with 2xx at once; the work runs from the queue.
|
|
57
|
+
*/
|
|
58
|
+
export declare function deliveryRoute(sink: Pick<DeliverySink, 'deliverySecretOf'>, enqueue: (job: DeliveryJob) => Promise<void>, nowSeconds?: () => number): (request: IncomingMessage & {
|
|
59
|
+
params?: Record<string, string | string[]>;
|
|
60
|
+
}, response: ServerResponse) => Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* The outbox, read when a push was missed or no address is registered
|
|
63
|
+
* (`GET …/self/deliveries`): each delivery of every bound installation
|
|
64
|
+
* queued, handled once, then acknowledged.
|
|
65
|
+
*/
|
|
66
|
+
export declare class DeliveryPoller {
|
|
67
|
+
#private;
|
|
68
|
+
readonly sink: Pick<DeliverySink, 'boundHosts'>;
|
|
69
|
+
readonly client: Pick<DatalayerHostClient, 'outbox'>;
|
|
70
|
+
readonly enqueue: (job: DeliveryJob) => Promise<void>;
|
|
71
|
+
readonly logger: Pick<Console, 'warn'>;
|
|
72
|
+
constructor(sink: Pick<DeliverySink, 'boundHosts'>, client: Pick<DatalayerHostClient, 'outbox'>, enqueue: (job: DeliveryJob) => Promise<void>, logger?: Pick<Console, 'warn'>);
|
|
73
|
+
pollOnce(): Promise<number>;
|
|
74
|
+
start(intervalSeconds: number): void;
|
|
75
|
+
stop(): void;
|
|
76
|
+
}
|