wake-bridge 0.9.0-preview.8

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.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +222 -0
  3. package/dist/src/adapters/botlingknows.d.ts +42 -0
  4. package/dist/src/adapters/botlingknows.js +141 -0
  5. package/dist/src/adapters/botlingknows.js.map +1 -0
  6. package/dist/src/adapters/group-chat.d.ts +43 -0
  7. package/dist/src/adapters/group-chat.js +120 -0
  8. package/dist/src/adapters/group-chat.js.map +1 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +637 -0
  11. package/dist/src/cli.js.map +1 -0
  12. package/dist/src/connector-catalog.d.ts +17 -0
  13. package/dist/src/connector-catalog.js +43 -0
  14. package/dist/src/connector-catalog.js.map +1 -0
  15. package/dist/src/connectors/botlingknows-mcp.d.ts +15 -0
  16. package/dist/src/connectors/botlingknows-mcp.js +130 -0
  17. package/dist/src/connectors/botlingknows-mcp.js.map +1 -0
  18. package/dist/src/connectors/group-chat-http.d.ts +17 -0
  19. package/dist/src/connectors/group-chat-http.js +226 -0
  20. package/dist/src/connectors/group-chat-http.js.map +1 -0
  21. package/dist/src/connectors/mcp-http-client.d.ts +11 -0
  22. package/dist/src/connectors/mcp-http-client.js +108 -0
  23. package/dist/src/connectors/mcp-http-client.js.map +1 -0
  24. package/dist/src/core.d.ts +170 -0
  25. package/dist/src/core.js +1720 -0
  26. package/dist/src/core.js.map +1 -0
  27. package/dist/src/daemon.d.ts +31 -0
  28. package/dist/src/daemon.js +605 -0
  29. package/dist/src/daemon.js.map +1 -0
  30. package/dist/src/db.d.ts +31 -0
  31. package/dist/src/db.js +513 -0
  32. package/dist/src/db.js.map +1 -0
  33. package/dist/src/event-sdk.d.ts +43 -0
  34. package/dist/src/event-sdk.js +152 -0
  35. package/dist/src/event-sdk.js.map +1 -0
  36. package/dist/src/host-adapter-registry.d.ts +21 -0
  37. package/dist/src/host-adapter-registry.js +59 -0
  38. package/dist/src/host-adapter-registry.js.map +1 -0
  39. package/dist/src/index.d.ts +5 -0
  40. package/dist/src/index.js +6 -0
  41. package/dist/src/index.js.map +1 -0
  42. package/dist/src/instance-config.d.ts +46 -0
  43. package/dist/src/instance-config.js +176 -0
  44. package/dist/src/instance-config.js.map +1 -0
  45. package/dist/src/mcp.d.ts +51 -0
  46. package/dist/src/mcp.js +728 -0
  47. package/dist/src/mcp.js.map +1 -0
  48. package/dist/src/operator-control.d.ts +82 -0
  49. package/dist/src/operator-control.js +220 -0
  50. package/dist/src/operator-control.js.map +1 -0
  51. package/dist/src/out-of-process-host.d.ts +37 -0
  52. package/dist/src/out-of-process-host.js +196 -0
  53. package/dist/src/out-of-process-host.js.map +1 -0
  54. package/dist/src/policy-control.d.ts +3 -0
  55. package/dist/src/policy-control.js +120 -0
  56. package/dist/src/policy-control.js.map +1 -0
  57. package/dist/src/release-lifecycle.d.ts +98 -0
  58. package/dist/src/release-lifecycle.js +378 -0
  59. package/dist/src/release-lifecycle.js.map +1 -0
  60. package/dist/src/sdk-error.d.ts +6 -0
  61. package/dist/src/sdk-error.js +12 -0
  62. package/dist/src/sdk-error.js.map +1 -0
  63. package/dist/src/source-adapter.d.ts +32 -0
  64. package/dist/src/source-adapter.js +164 -0
  65. package/dist/src/source-adapter.js.map +1 -0
  66. package/dist/src/source-connector.d.ts +50 -0
  67. package/dist/src/source-connector.js +358 -0
  68. package/dist/src/source-connector.js.map +1 -0
  69. package/dist/src/source-control-client.d.ts +38 -0
  70. package/dist/src/source-control-client.js +61 -0
  71. package/dist/src/source-control-client.js.map +1 -0
  72. package/dist/src/source-ingress.d.ts +16 -0
  73. package/dist/src/source-ingress.js +47 -0
  74. package/dist/src/source-ingress.js.map +1 -0
  75. package/dist/src/source-sdk.d.ts +6 -0
  76. package/dist/src/source-sdk.js +53 -0
  77. package/dist/src/source-sdk.js.map +1 -0
  78. package/dist/src/transport-sdk.d.ts +102 -0
  79. package/dist/src/transport-sdk.js +222 -0
  80. package/dist/src/transport-sdk.js.map +1 -0
  81. package/dist/src/transport.d.ts +23 -0
  82. package/dist/src/transport.js +71 -0
  83. package/dist/src/transport.js.map +1 -0
  84. package/dist/src/types.d.ts +522 -0
  85. package/dist/src/types.js +2 -0
  86. package/dist/src/types.js.map +1 -0
  87. package/dist/src/util.d.ts +26 -0
  88. package/dist/src/util.js +136 -0
  89. package/dist/src/util.js.map +1 -0
  90. package/dist/src/version.d.ts +1 -0
  91. package/dist/src/version.js +2 -0
  92. package/dist/src/version.js.map +1 -0
  93. package/docs/adapters/botlingknows.md +40 -0
  94. package/docs/adapters/gmail-planned.md +16 -0
  95. package/docs/adapters/group-chat.md +126 -0
  96. package/docs/operations/connector-catalog.md +40 -0
  97. package/docs/operations/operator-control.md +83 -0
  98. package/docs/operations/out-of-process-host-adapter.md +150 -0
  99. package/docs/operations/policies.md +106 -0
  100. package/docs/operations/public-sdk.md +87 -0
  101. package/docs/operations/release-lifecycle.md +154 -0
  102. package/docs/operations/source-connectors.md +163 -0
  103. package/docs/operations/source-ingress.md +108 -0
  104. package/docs/operations/templates/botlingknows-connector.env.example +5 -0
  105. package/docs/operations/templates/sources.json.example +15 -0
  106. package/docs/quickstart.md +143 -0
  107. package/docs/releases/0.9.0-preview.8.md +44 -0
  108. package/docs/verification.md +19 -0
  109. package/examples/policies/botlingknows-conservative.json +44 -0
  110. package/examples/reference-host-adapter/README.md +41 -0
  111. package/examples/reference-host-adapter/adapter.mjs +173 -0
  112. package/examples/reference-host-adapter/host-adapters.json.example +26 -0
  113. package/package.json +94 -0
  114. package/schemas/event-v1.schema.json +26 -0
  115. package/schemas/policy-v1.schema.json +66 -0
@@ -0,0 +1,1720 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { SqliteDatabase, sqlJson, sqlValue } from "./db.js";
3
+ import { addMs, asDate, assertFiniteMs, getPath, id, isoNow, nextLocalClock, parseJson, quietEndAfter, quietWindowAt, sha256, stableJson, } from "./util.js";
4
+ import { MockTransport } from "./transport.js";
5
+ import { validateWakeEventInput } from "./event-sdk.js";
6
+ import { WakeBridgeSdkError } from "./sdk-error.js";
7
+ export class BridgeError extends WakeBridgeSdkError {
8
+ code;
9
+ status;
10
+ constructor(message, code = "invalid_request", status = 400) {
11
+ super(message, code, status);
12
+ this.code = code;
13
+ this.status = status;
14
+ this.name = "BridgeError";
15
+ }
16
+ }
17
+ const DEFAULT_POLICY = {
18
+ id: "default",
19
+ version: 1,
20
+ enabled: true,
21
+ order: -1_000_000,
22
+ match: {},
23
+ delivery: {
24
+ mode: "immediate",
25
+ quiet_hours_policy: "defer",
26
+ foreground_presence_policy: "defer",
27
+ },
28
+ batch: { coalesce_by: "coalesce_key", max_events: 20, window_ms: 0 },
29
+ target: { attention_channel: "${attention_channel_hint}" },
30
+ reason_code: "event",
31
+ };
32
+ function validateResource(resource) {
33
+ if (!resource || typeof resource !== "object" || typeof resource.uri !== "string") {
34
+ throw new BridgeError("resource.uri is required", "invalid_resource", 400);
35
+ }
36
+ if (resource.uri.length === 0 || resource.uri.length > 2048 || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(resource.uri)) {
37
+ throw new BridgeError("resource.uri must be a bounded absolute URI", "invalid_resource", 400);
38
+ }
39
+ if (JSON.stringify(resource).length > 4096)
40
+ throw new BridgeError("resource is too large", "oversized_event", 413);
41
+ }
42
+ export function normalizePolicy(policy) {
43
+ if (!policy || typeof policy.id !== "string" || !/^[A-Za-z][A-Za-z0-9._-]{0,127}$/u.test(policy.id)
44
+ || !Number.isSafeInteger(policy.version) || policy.version < 1) {
45
+ throw new BridgeError("policy id and positive integer version are required", "invalid_policy", 400);
46
+ }
47
+ if (policy.enabled != null && typeof policy.enabled !== "boolean") {
48
+ throw new BridgeError("policy enabled must be boolean", "invalid_policy", 400);
49
+ }
50
+ const delivery = policy.delivery;
51
+ if (!delivery || !["immediate", "scheduled", "suppress"].includes(String(delivery.mode))) {
52
+ throw new BridgeError(`unsupported delivery mode: ${String(delivery?.mode)}`, "unsupported_policy_mode", 400);
53
+ }
54
+ const rawDelivery = delivery;
55
+ const unsupportedDelivery = ["debounce_ms", "cadence_ms", "max_delay_ms"].filter((key) => Object.prototype.hasOwnProperty.call(rawDelivery, key));
56
+ const rawTarget = (policy.target ?? {});
57
+ const unsupportedTarget = ["prefer_transport", "busy_behavior"].filter((key) => Object.prototype.hasOwnProperty.call(rawTarget, key));
58
+ if (unsupportedDelivery.length || unsupportedTarget.length) {
59
+ throw new BridgeError(`unsupported policy field(s): ${[...unsupportedDelivery, ...unsupportedTarget].join(", ")}`, "unsupported_policy_field", 400);
60
+ }
61
+ if (delivery.mode === "scheduled" && (typeof delivery.scheduled_local_time !== "string"
62
+ || !/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(delivery.scheduled_local_time))) {
63
+ throw new BridgeError("scheduled policy requires scheduled_local_time in HH:MM form", "invalid_policy", 400);
64
+ }
65
+ if (delivery.mode !== "scheduled" && delivery.scheduled_local_time != null) {
66
+ throw new BridgeError("scheduled_local_time is only valid for scheduled mode", "invalid_policy", 400);
67
+ }
68
+ if (delivery.quiet_hours_policy != null && !["defer", "bypass"].includes(delivery.quiet_hours_policy)) {
69
+ throw new BridgeError("quiet_hours_policy must be defer or bypass", "invalid_policy", 400);
70
+ }
71
+ if (delivery.foreground_presence_policy != null && !["defer", "bypass"].includes(delivery.foreground_presence_policy)) {
72
+ throw new BridgeError("foreground_presence_policy must be defer or bypass", "invalid_policy", 400);
73
+ }
74
+ if (policy.order != null && (!Number.isSafeInteger(policy.order) || Math.abs(policy.order) > 1_000_000)) {
75
+ throw new BridgeError("policy order must be an integer between -1000000 and 1000000", "invalid_policy", 400);
76
+ }
77
+ if (policy.match != null && (typeof policy.match !== "object" || Array.isArray(policy.match) || JSON.stringify(policy.match).length > 8192)) {
78
+ throw new BridgeError("policy match must be a bounded object", "invalid_policy", 400);
79
+ }
80
+ if (policy.batch?.max_events != null && (!Number.isSafeInteger(policy.batch.max_events)
81
+ || policy.batch.max_events < 1 || policy.batch.max_events > 1000)) {
82
+ throw new BridgeError("batch.max_events must be an integer between 1 and 1000", "invalid_policy", 400);
83
+ }
84
+ if (policy.batch?.window_ms != null && (!Number.isSafeInteger(policy.batch.window_ms)
85
+ || policy.batch.window_ms < 0 || policy.batch.window_ms > 7 * 24 * 60 * 60_000)) {
86
+ throw new BridgeError("batch.window_ms must be an integer between 0 and 604800000", "invalid_policy", 400);
87
+ }
88
+ if (policy.batch?.coalesce_by != null && (typeof policy.batch.coalesce_by !== "string"
89
+ || !policy.batch.coalesce_by || policy.batch.coalesce_by.length > 256)) {
90
+ throw new BridgeError("batch.coalesce_by must be a non-empty bounded string", "invalid_policy", 400);
91
+ }
92
+ if (policy.target?.attention_channel != null && (typeof policy.target.attention_channel !== "string"
93
+ || !policy.target.attention_channel || policy.target.attention_channel.length > 200)) {
94
+ throw new BridgeError("target.attention_channel must be a non-empty string of at most 200 characters", "invalid_policy", 400);
95
+ }
96
+ if (policy.reason_code != null && (typeof policy.reason_code !== "string" || !policy.reason_code || policy.reason_code.length > 128)) {
97
+ throw new BridgeError("reason_code must be a non-empty string of at most 128 characters", "invalid_policy", 400);
98
+ }
99
+ if (policy.expires_after_ms != null && (!Number.isSafeInteger(policy.expires_after_ms) || policy.expires_after_ms < 0)) {
100
+ throw new BridgeError("expires_after_ms must be a non-negative safe integer", "invalid_policy", 400);
101
+ }
102
+ if (policy.id === DEFAULT_POLICY.id && (policy.enabled === false || stableJson(policy.match ?? {}) !== "{}")) {
103
+ throw new BridgeError("default policy must be enabled and have an empty match", "invalid_policy", 400);
104
+ }
105
+ return {
106
+ ...policy,
107
+ enabled: policy.enabled !== false,
108
+ order: policy.order ?? 0,
109
+ match: policy.match ?? {},
110
+ delivery: {
111
+ ...delivery,
112
+ quiet_hours_policy: delivery.quiet_hours_policy ?? "defer",
113
+ foreground_presence_policy: delivery.foreground_presence_policy ?? "defer",
114
+ },
115
+ batch: {
116
+ coalesce_by: policy.batch?.coalesce_by ?? "coalesce_key",
117
+ max_events: policy.batch?.max_events ?? 20,
118
+ window_ms: policy.batch?.window_ms ?? 0,
119
+ },
120
+ target: {
121
+ attention_channel: policy.target?.attention_channel ?? "${attention_channel_hint}",
122
+ },
123
+ reason_code: policy.reason_code ?? policy.id,
124
+ };
125
+ }
126
+ function normalizeStoredPolicy(policy) {
127
+ if (!policy || typeof policy.id !== "string" || !/^[A-Za-z][A-Za-z0-9._-]{0,127}$/u.test(policy.id)
128
+ || !Number.isSafeInteger(policy.version) || policy.version < 1) {
129
+ throw new BridgeError("stored policy id or version is invalid", "invalid_policy", 500);
130
+ }
131
+ const delivery = policy.delivery;
132
+ if (!delivery || !["immediate", "debounce", "digest", "scheduled", "suppress"].includes(String(delivery.mode))) {
133
+ throw new BridgeError(`stored policy has unsupported delivery mode: ${String(delivery?.mode)}`, "invalid_policy", 500);
134
+ }
135
+ if (delivery.mode === "scheduled" && (typeof delivery.scheduled_local_time !== "string"
136
+ || !/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(delivery.scheduled_local_time))) {
137
+ throw new BridgeError("stored scheduled policy has invalid local time", "invalid_policy", 500);
138
+ }
139
+ const values = [delivery.debounce_ms, delivery.cadence_ms, delivery.max_delay_ms, policy.batch?.max_events, policy.batch?.window_ms];
140
+ if (values.some((value) => value !== undefined && value !== null && (!Number.isFinite(value) || value < 0))) {
141
+ throw new BridgeError("stored policy has invalid durations or batch limits", "invalid_policy", 500);
142
+ }
143
+ return {
144
+ ...policy,
145
+ enabled: policy.enabled !== false,
146
+ order: policy.order ?? 0,
147
+ match: policy.match ?? {},
148
+ delivery: {
149
+ ...delivery,
150
+ quiet_hours_policy: delivery.quiet_hours_policy ?? "defer",
151
+ foreground_presence_policy: delivery.foreground_presence_policy ?? "defer",
152
+ },
153
+ batch: {
154
+ coalesce_by: policy.batch?.coalesce_by ?? "coalesce_key",
155
+ max_events: policy.batch?.max_events ?? 20,
156
+ window_ms: policy.batch?.window_ms ?? 0,
157
+ },
158
+ target: {
159
+ attention_channel: policy.target?.attention_channel ?? "${attention_channel_hint}",
160
+ ...(policy.target?.prefer_transport == null ? {} : { prefer_transport: policy.target.prefer_transport }),
161
+ ...(policy.target?.busy_behavior == null ? {} : { busy_behavior: policy.target.busy_behavior }),
162
+ },
163
+ reason_code: policy.reason_code ?? policy.id,
164
+ };
165
+ }
166
+ function currentInstalledPolicy(policy) {
167
+ if (!["immediate", "scheduled", "suppress"].includes(policy.delivery.mode))
168
+ return false;
169
+ try {
170
+ normalizePolicy(policy);
171
+ return true;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
177
+ function activeInstalledPolicyVersions(policies) {
178
+ const latest = new Map();
179
+ for (const raw of policies) {
180
+ const policy = normalizeStoredPolicy(raw);
181
+ const current = latest.get(policy.id);
182
+ if (!current || policy.version > current.version)
183
+ latest.set(policy.id, policy);
184
+ }
185
+ return [...latest.values()].sort((a, b) => (b.order ?? 0) - (a.order ?? 0) || a.id.localeCompare(b.id));
186
+ }
187
+ export function activePolicyVersions(policies) {
188
+ const latest = new Map();
189
+ for (const raw of policies) {
190
+ const policy = normalizePolicy(raw);
191
+ const current = latest.get(policy.id);
192
+ if (!current || policy.version > current.version)
193
+ latest.set(policy.id, policy);
194
+ }
195
+ return [...latest.values()].sort((a, b) => (b.order ?? 0) - (a.order ?? 0) || a.id.localeCompare(b.id));
196
+ }
197
+ function jsonRecord(value, fallback) {
198
+ return parseJson(value, fallback);
199
+ }
200
+ const ABSOLUTE_RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
201
+ function isoOrThrow(value, field) {
202
+ if (typeof value !== "string" || !ABSOLUTE_RFC3339.test(value)) {
203
+ throw new BridgeError(`${field} must be an absolute RFC3339 timestamp with Z or numeric offset`, "invalid_timestamp", 400);
204
+ }
205
+ const date = asDate(value);
206
+ return date.toISOString();
207
+ }
208
+ function arrayWithoutDuplicates(values) {
209
+ return [...new Set(values)];
210
+ }
211
+ export class WakeBridge {
212
+ config;
213
+ db;
214
+ transports = new Map();
215
+ mockTransport;
216
+ clock;
217
+ constructor(config, options = {}) {
218
+ if (!config?.instance_id || !config.owner_id || !config.db_path) {
219
+ throw new BridgeError("instance_id, owner_id and db_path are required", "invalid_config", 400);
220
+ }
221
+ const quietConfig = config.quiet_hours;
222
+ const unsupportedQuiet = ["resume_spread_ms", "default_action"].filter((field) => quietConfig && Object.prototype.hasOwnProperty.call(quietConfig, field));
223
+ if (unsupportedQuiet.length) {
224
+ throw new BridgeError(`unsupported quiet_hours field(s): ${unsupportedQuiet.join(", ")}`, "unsupported_config_field", 400);
225
+ }
226
+ const timezone = config.timezone ?? config.quiet_hours?.timezone ?? "UTC";
227
+ for (const candidate of new Set([timezone, config.quiet_hours?.timezone].filter((value) => Boolean(value)))) {
228
+ try {
229
+ new Intl.DateTimeFormat("en", { timeZone: candidate }).format(new Date(0));
230
+ }
231
+ catch {
232
+ throw new BridgeError(`timezone is invalid: ${candidate}`, "invalid_timezone", 400);
233
+ }
234
+ }
235
+ if (config.quiet_hours && (!Array.isArray(config.quiet_hours.windows)
236
+ || config.quiet_hours.windows.some((window) => !window || !/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(window.start)
237
+ || !/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(window.end)))) {
238
+ throw new BridgeError("quiet_hours.windows must contain HH:MM start/end values", "invalid_quiet_hours", 400);
239
+ }
240
+ if (config.quiet_hours?.windows.length) {
241
+ const covered = new Array(24 * 60).fill(false);
242
+ for (const window of config.quiet_hours.windows) {
243
+ const [startHour, startMinute] = window.start.split(":").map(Number);
244
+ const [endHour, endMinute] = window.end.split(":").map(Number);
245
+ const start = startHour * 60 + startMinute;
246
+ const end = endHour * 60 + endMinute;
247
+ for (let minute = 0; minute < covered.length; minute += 1) {
248
+ if (start === end || (start < end ? minute >= start && minute < end : minute >= start || minute < end))
249
+ covered[minute] = true;
250
+ }
251
+ }
252
+ if (covered.every(Boolean)) {
253
+ throw new BridgeError("quiet_hours must leave at least one open local minute", "invalid_quiet_hours", 400);
254
+ }
255
+ }
256
+ this.config = {
257
+ ...config,
258
+ timezone,
259
+ presence_ttl_ms: config.presence_ttl_ms ?? 20 * 60_000,
260
+ activity_ttl_ms: config.activity_ttl_ms ?? 2 * 60_000,
261
+ endpoint_lease_ms: config.endpoint_lease_ms ?? 60 * 60_000,
262
+ dispatch_lease_ms: config.dispatch_lease_ms ?? 30_000,
263
+ retry_delay_ms: config.retry_delay_ms ?? 5_000,
264
+ batch_window_ms: config.batch_window_ms ?? 0,
265
+ };
266
+ this.clock = options.clock ?? (() => new Date());
267
+ this.db = new SqliteDatabase(config.db_path, { allowSchemaUpgrade: options.allowSchemaUpgrade });
268
+ this.mockTransport = new MockTransport();
269
+ if (options.autoMockTransport !== false)
270
+ this.registerTransport(this.mockTransport);
271
+ for (const transport of options.transports ?? [])
272
+ this.registerTransport(transport);
273
+ this.installPolicies([...(config.policies ?? []), ...(config.default_policy ? [config.default_policy] : [])]);
274
+ if (options.recoverDispatchLeases !== false)
275
+ this.recover();
276
+ }
277
+ close() {
278
+ this.db.close();
279
+ }
280
+ now() {
281
+ const date = new Date(this.clock().getTime());
282
+ if (Number.isNaN(date.getTime()))
283
+ throw new BridgeError("clock returned invalid date", "invalid_clock", 500);
284
+ return date;
285
+ }
286
+ nowIso() {
287
+ return isoNow(this.now());
288
+ }
289
+ registerTransport(transport) {
290
+ if (!transport?.kind)
291
+ throw new BridgeError("transport.kind is required", "invalid_transport", 400);
292
+ this.transports.set(transport.kind, transport);
293
+ }
294
+ unregisterTransport(kind, expected) {
295
+ if (expected && this.transports.get(kind) !== expected)
296
+ return;
297
+ this.transports.delete(kind);
298
+ }
299
+ listPolicies() {
300
+ return this.db.query("SELECT rule_json FROM policies ORDER BY order_no DESC, id ASC, version DESC;")
301
+ .map((row) => jsonRecord(row.rule_json, DEFAULT_POLICY));
302
+ }
303
+ listActivePolicies() {
304
+ return activeInstalledPolicyVersions(this.listPolicies());
305
+ }
306
+ listPolicyStatuses() {
307
+ const policies = this.listPolicies();
308
+ const active = new Map(this.listActivePolicies().map((policy) => [policy.id, policy.version]));
309
+ return policies.map((policy) => {
310
+ const isActive = active.get(policy.id) === policy.version;
311
+ return {
312
+ id: policy.id,
313
+ version: policy.version,
314
+ active: isActive,
315
+ enabled: policy.enabled !== false,
316
+ new_event_matching: !isActive ? "historical"
317
+ : policy.enabled === false ? "disabled"
318
+ : currentInstalledPolicy(policy) ? "eligible"
319
+ : "legacy_unsupported",
320
+ };
321
+ });
322
+ }
323
+ installPolicies(policies) {
324
+ const all = policies.map(normalizePolicy);
325
+ const requested = new Map();
326
+ for (const policy of all) {
327
+ const key = `${policy.id}\u0000${policy.version}`;
328
+ const duplicate = requested.get(key);
329
+ if (duplicate && stableJson(duplicate) !== stableJson(policy)) {
330
+ throw new BridgeError(`policy ${policy.id}@${policy.version} appears more than once with different contents`, "policy_version_conflict", 409);
331
+ }
332
+ requested.set(key, policy);
333
+ }
334
+ const installed = this.listPolicies();
335
+ const installedByVersion = new Map(installed.map((policy) => [`${policy.id}\u0000${policy.version}`, normalizeStoredPolicy(policy)]));
336
+ const maxVersion = new Map();
337
+ for (const policy of installed)
338
+ maxVersion.set(policy.id, Math.max(maxVersion.get(policy.id) ?? 0, policy.version));
339
+ const additions = [];
340
+ for (const policy of [...requested.values()].sort((a, b) => a.id.localeCompare(b.id) || a.version - b.version)) {
341
+ const key = `${policy.id}\u0000${policy.version}`;
342
+ const existing = installedByVersion.get(key);
343
+ if (existing) {
344
+ if (stableJson(existing) !== stableJson(policy)) {
345
+ throw new BridgeError(`policy ${policy.id}@${policy.version} is immutable and already has different contents`, "policy_version_conflict", 409);
346
+ }
347
+ continue;
348
+ }
349
+ const highest = maxVersion.get(policy.id) ?? 0;
350
+ if (policy.version <= highest) {
351
+ throw new BridgeError(`policy ${policy.id}@${policy.version} is older than installed version ${highest}`, "policy_version_regression", 409);
352
+ }
353
+ additions.push(policy);
354
+ maxVersion.set(policy.id, policy.version);
355
+ }
356
+ if (additions.length) {
357
+ const installedAt = this.nowIso();
358
+ this.db.transaction(additions.map((policy) => `INSERT INTO policies(id, version, enabled, order_no, rule_json, installed_at)
359
+ VALUES(${sqlValue(policy.id)}, ${policy.version}, ${policy.enabled === false ? 0 : 1}, ${Number(policy.order ?? 0)}, ${sqlJson(policy)}, ${sqlValue(installedAt)});`));
360
+ }
361
+ this.ensureDefaultPolicy();
362
+ }
363
+ ensureDefaultPolicy() {
364
+ if (this.listPolicies().some((policy) => policy.id === DEFAULT_POLICY.id))
365
+ return;
366
+ const policy = normalizePolicy(DEFAULT_POLICY);
367
+ this.db.exec(`INSERT INTO policies(id, version, enabled, order_no, rule_json, installed_at)
368
+ VALUES(${sqlValue(policy.id)}, ${policy.version}, 1, ${Number(policy.order ?? 0)}, ${sqlJson(policy)}, ${sqlValue(this.nowIso())});`);
369
+ }
370
+ updatePolicy(policy) {
371
+ const normalized = normalizePolicy(policy);
372
+ this.installPolicies([normalized]);
373
+ return normalized;
374
+ }
375
+ policyRows() {
376
+ return this.listActivePolicies().filter((policy) => policy.enabled !== false).filter(currentInstalledPolicy);
377
+ }
378
+ policyForEvent(source, input, candidates) {
379
+ const event = { ...input, source };
380
+ const rows = candidates ? activePolicyVersions(candidates).filter((policy) => policy.enabled !== false) : this.policyRows();
381
+ for (const policy of rows) {
382
+ if (this.matches(policy.match ?? {}, event))
383
+ return policy;
384
+ }
385
+ // A legacy default row may remain append-only but cannot match new events.
386
+ // The clean built-in default preserves current behavior without rewriting
387
+ // that history; it also guards a manually edited database.
388
+ return normalizePolicy(DEFAULT_POLICY);
389
+ }
390
+ testPolicy(source, input, candidates) {
391
+ const clean = this.validateEvent(source, input);
392
+ const matched = this.policyForEvent(source, clean, candidates);
393
+ return { source, type: clean.type, matched_policy: matched };
394
+ }
395
+ previewPolicy(source, input, candidates) {
396
+ const clean = this.validateEvent(source, input);
397
+ const policy = this.policyForEvent(source, clean, candidates);
398
+ const nowDate = this.now();
399
+ const now = nowDate.toISOString();
400
+ const suppressed = policy.delivery.mode === "suppress";
401
+ const channel = suppressed ? null : this.targetChannel(policy, clean);
402
+ const quietActive = Boolean(quietWindowAt(nowDate, this.config.quiet_hours, this.config.timezone ?? "UTC"));
403
+ const quietAction = policy.delivery.quiet_hours_policy ?? "defer";
404
+ const quietUntil = quietActive && quietAction === "defer"
405
+ ? quietEndAfter(nowDate, this.config.quiet_hours, this.config.timezone ?? "UTC").toISOString()
406
+ : null;
407
+ const presence = channel ? this.presenceActive(channel, now) : null;
408
+ const presenceAction = policy.delivery.foreground_presence_policy ?? "defer";
409
+ const coalesceBy = policy.batch?.coalesce_by ?? "coalesce_key";
410
+ let coalesceKey = null;
411
+ if (!suppressed && coalesceBy !== "none") {
412
+ if (coalesceBy === "type")
413
+ coalesceKey = clean.type;
414
+ else if (coalesceBy === "source")
415
+ coalesceKey = source;
416
+ else if (coalesceBy.startsWith("metadata."))
417
+ coalesceKey = String(getPath(clean.metadata, coalesceBy.slice("metadata.".length)) ?? "");
418
+ else
419
+ coalesceKey = clean.coalesce_key ?? null;
420
+ }
421
+ return {
422
+ source,
423
+ type: clean.type,
424
+ evaluated_at: now,
425
+ matched_policy: policy,
426
+ suppressed,
427
+ claim: suppressed || !channel ? null : {
428
+ attention_channel: channel,
429
+ eligible_after: this.claimEligibleAfter(policy, nowDate),
430
+ expires_at: this.claimExpiresAt(policy, nowDate),
431
+ reason_code: policy.reason_code ?? policy.id,
432
+ defer_while_presence: presenceAction !== "bypass",
433
+ },
434
+ gates: {
435
+ quiet_hours: { action: quietAction, active: quietActive, defer_until: quietUntil },
436
+ foreground_presence: {
437
+ action: presenceAction,
438
+ active: Boolean(presence),
439
+ defer_until: presence && presenceAction === "defer" ? presence.expires_at : null,
440
+ },
441
+ },
442
+ batch: suppressed ? null : {
443
+ coalesce_by: coalesceBy,
444
+ coalesce_key: coalesceKey,
445
+ max_events: policy.batch?.max_events ?? 20,
446
+ window_ms: policy.batch?.window_ms ?? 0,
447
+ },
448
+ };
449
+ }
450
+ matches(selector, event) {
451
+ for (const [key, expected] of Object.entries(selector)) {
452
+ if (key === "metadata" && expected && typeof expected === "object") {
453
+ for (const [metadataKey, metadataExpected] of Object.entries(expected)) {
454
+ if (stableJson(getPath(event.metadata, metadataKey)) !== stableJson(metadataExpected))
455
+ return false;
456
+ }
457
+ continue;
458
+ }
459
+ const actual = key.startsWith("metadata.") ? getPath(event.metadata, key.slice("metadata.".length)) : getPath(event, key);
460
+ if (stableJson(actual) !== stableJson(expected))
461
+ return false;
462
+ }
463
+ return true;
464
+ }
465
+ targetChannel(policy, input) {
466
+ const configured = policy.target?.attention_channel ?? "${attention_channel_hint}";
467
+ const channel = configured.replaceAll("${attention_channel_hint}", input.attention_channel_hint || "default");
468
+ if (!channel || channel.length > 200)
469
+ throw new BridgeError("attention channel is invalid", "invalid_policy", 400);
470
+ return channel;
471
+ }
472
+ claimEligibleAfter(policy, now) {
473
+ const mode = policy.delivery.mode;
474
+ if (mode === "scheduled")
475
+ return nextLocalClock(now, policy.delivery.scheduled_local_time, this.config.timezone ?? "UTC").toISOString();
476
+ return now.toISOString();
477
+ }
478
+ claimExpiresAt(policy, now) {
479
+ return policy.expires_after_ms == null ? null : addMs(now, assertFiniteMs(policy.expires_after_ms, "expires_after_ms", 0));
480
+ }
481
+ validateEvent(source, raw) {
482
+ if (!source || !/^[A-Za-z0-9_.:-]{1,128}$/.test(source))
483
+ throw new BridgeError("source identity is invalid", "invalid_source", 400);
484
+ try {
485
+ return validateWakeEventInput(raw, this.now());
486
+ }
487
+ catch (error) {
488
+ if (error instanceof WakeBridgeSdkError)
489
+ throw new BridgeError(error.message, error.code, error.status);
490
+ throw error;
491
+ }
492
+ }
493
+ duplicateEmitResult(source, dedupeKey, eventId) {
494
+ const row = (eventId
495
+ ? this.db.query(`SELECT id FROM events WHERE instance_id=${sqlValue(this.config.instance_id)} AND id=${sqlValue(eventId)} LIMIT 1;`)[0]
496
+ : undefined)
497
+ || this.db.query(`SELECT id FROM events WHERE instance_id=${sqlValue(this.config.instance_id)} AND source=${sqlValue(source)} AND dedupe_key=${sqlValue(dedupeKey)} LIMIT 1;`)[0];
498
+ if (!row)
499
+ return null;
500
+ const event = this.getEvent(String(row.id));
501
+ if (!event)
502
+ throw new BridgeError("dedupe record points to missing event", "storage_corrupt", 500);
503
+ const claim = this.listClaims().find((item) => item.event_ids.includes(event.id));
504
+ return { event, claim, duplicate: true, suppressed: event.state === "suppressed" };
505
+ }
506
+ isUniqueConflict(error) {
507
+ return /UNIQUE constraint failed/i.test(error instanceof Error ? error.message : String(error));
508
+ }
509
+ /** Accept both emitEvent(source, input) and emitEvent(input, source). */
510
+ emitEvent(sourceOrInput, inputOrSource) {
511
+ const source = typeof sourceOrInput === "string" ? sourceOrInput : typeof inputOrSource === "string" ? inputOrSource : "manual";
512
+ const input = (typeof sourceOrInput === "string" ? inputOrSource : sourceOrInput);
513
+ const clean = this.validateEvent(source, input);
514
+ const idempotencyRow = clean.idempotency_key
515
+ ? this.db.query(`SELECT result_json FROM idempotency_keys WHERE scope=${sqlValue(`event:${source}`)} AND key=${sqlValue(clean.idempotency_key)} LIMIT 1;`)[0]
516
+ : undefined;
517
+ const idempotentEventId = idempotencyRow ? jsonRecord(idempotencyRow.result_json, {}).event_id : undefined;
518
+ const deterministicEventId = clean.idempotency_key ? `evt_${sha256(`event:${this.config.instance_id}:${source}:${clean.idempotency_key}`).slice(0, 32)}` : undefined;
519
+ const existing = this.duplicateEmitResult(source, clean.dedupe_key, idempotentEventId || deterministicEventId);
520
+ if (existing)
521
+ return existing;
522
+ const now = this.nowIso();
523
+ const policy = this.policyForEvent(source, clean);
524
+ const eventId = deterministicEventId || id("evt");
525
+ const eventState = policy.delivery.mode === "suppress" ? "suppressed" : "matched";
526
+ const claimId = eventState === "matched" ? id("ac") : null;
527
+ const channel = claimId ? this.targetChannel(policy, clean) : null;
528
+ const claimState = "pending";
529
+ const statements = [
530
+ `INSERT INTO events(id, instance_id, owner_id, source, type, schema_version, occurred_at, received_at, dedupe_key, coalesce_key, priority_hint, attention_channel_hint, actor_ref, resource_json, metadata_json, payload_preview, matched_policy_id, matched_policy_version)
531
+ VALUES(${sqlValue(eventId)}, ${sqlValue(this.config.instance_id)}, ${sqlValue(this.config.owner_id)}, ${sqlValue(source)}, ${sqlValue(clean.type)}, ${Number(clean.schema_version ?? 1)}, ${sqlValue(clean.occurred_at)}, ${sqlValue(now)}, ${sqlValue(clean.dedupe_key)}, ${sqlValue(clean.coalesce_key)}, ${sqlValue(clean.priority_hint)}, ${sqlValue(clean.attention_channel_hint)}, ${sqlValue(clean.actor_ref)}, ${sqlJson(clean.resource)}, ${sqlJson(clean.metadata ?? {})}, ${sqlValue(clean.payload_preview)}, ${sqlValue(policy.id)}, ${policy.version});`,
532
+ `INSERT INTO event_status(event_id, state, updated_at) VALUES(${sqlValue(eventId)}, ${sqlValue("received")}, ${sqlValue(now)});`,
533
+ `INSERT INTO event_transitions(event_id, from_state, to_state, reason, at) VALUES(${sqlValue(eventId)}, NULL, ${sqlValue("received")}, ${sqlValue("ingest")}, ${sqlValue(now)});`,
534
+ `UPDATE events SET matched_policy_id=${sqlValue(policy.id)}, matched_policy_version=${policy.version} WHERE id=${sqlValue(eventId)};`,
535
+ `UPDATE event_status SET state=${sqlValue(eventState)}, updated_at=${sqlValue(now)} WHERE event_id=${sqlValue(eventId)};`,
536
+ `INSERT INTO event_transitions(event_id, from_state, to_state, reason, at) VALUES(${sqlValue(eventId)}, ${sqlValue("received")}, ${sqlValue(eventState)}, ${sqlValue(policy.delivery.mode)}, ${sqlValue(now)});`,
537
+ ];
538
+ if (claimId && channel) {
539
+ const eligibleAfter = this.claimEligibleAfter(policy, this.now());
540
+ const deferPresence = policy.delivery.foreground_presence_policy !== "bypass";
541
+ const expiresAt = this.claimExpiresAt(policy, this.now());
542
+ statements.push(`INSERT INTO claims(id, instance_id, origin, event_ids_json, resource_json, policy_id, policy_version, attention_channel, eligible_after, expires_at, defer_while_presence, state, reason_code, note, created_at, updated_at, snooze_until, consumed_result_json, dismissed_reason)
543
+ VALUES(${sqlValue(claimId)}, ${sqlValue(this.config.instance_id)}, ${sqlValue("policy")}, ${sqlJson([eventId])}, ${sqlJson(clean.resource)}, ${sqlValue(policy.id)}, ${policy.version}, ${sqlValue(channel)}, ${sqlValue(eligibleAfter)}, ${sqlValue(expiresAt)}, ${deferPresence ? 1 : 0}, ${sqlValue(claimState)}, ${sqlValue(policy.reason_code ?? policy.id)}, NULL, ${sqlValue(now)}, ${sqlValue(now)}, NULL, NULL, NULL);`);
544
+ statements.push(`INSERT INTO claim_transitions(claim_id, from_state, to_state, reason, at) VALUES(${sqlValue(claimId)}, NULL, ${sqlValue(claimState)}, ${sqlValue("policy")}, ${sqlValue(now)});`);
545
+ }
546
+ if (clean.idempotency_key) {
547
+ statements.push(`INSERT OR IGNORE INTO idempotency_keys(scope, key, result_json, created_at) VALUES(${sqlValue(`event:${source}`)}, ${sqlValue(clean.idempotency_key)}, ${sqlJson({ event_id: eventId })}, ${sqlValue(now)});`);
548
+ }
549
+ try {
550
+ this.db.transaction(statements);
551
+ }
552
+ catch (error) {
553
+ // Two source workers may pass the pre-read concurrently. The unique
554
+ // event key is the arbiter; return its durable result instead of
555
+ // surfacing a raw sqlite constraint error.
556
+ if (this.isUniqueConflict(error)) {
557
+ const duplicate = this.duplicateEmitResult(source, clean.dedupe_key, deterministicEventId);
558
+ if (duplicate)
559
+ return duplicate;
560
+ }
561
+ throw error;
562
+ }
563
+ const event = this.getEvent(eventId);
564
+ const claim = claimId ? this.getClaim(claimId) ?? undefined : undefined;
565
+ return { event, claim, duplicate: false, suppressed: eventState === "suppressed" };
566
+ }
567
+ emit(source, input) {
568
+ return this.emitEvent(source, input);
569
+ }
570
+ scheduleClaim(input) {
571
+ validateResource(input.resource);
572
+ const eligibleAfter = isoOrThrow(input.eligible_after, "eligible_after");
573
+ const expiresAt = input.expires_at == null ? null : isoOrThrow(input.expires_at, "expires_at");
574
+ const key = input.idempotency_key || `resource:${sha256(stableJson({ resource: input.resource, eligibleAfter, channel: input.attention_channel || "default", reason: input.reason_code || "reconsider" }))}`;
575
+ const source = "self_commitment";
576
+ const dedupeKey = `schedule:${key}`;
577
+ const existing = this.db.query(`SELECT id FROM events WHERE instance_id=${sqlValue(this.config.instance_id)} AND source=${sqlValue(source)} AND dedupe_key=${sqlValue(dedupeKey)} LIMIT 1;`)[0];
578
+ if (existing) {
579
+ const event = this.getEvent(String(existing.id));
580
+ const claim = this.listClaims().find((item) => item.event_ids.includes(event.id));
581
+ if (!claim)
582
+ throw new BridgeError("self commitment event has no claim", "storage_corrupt", 500);
583
+ return { event, claim, duplicate: true };
584
+ }
585
+ const now = this.nowIso();
586
+ const eventId = `evt_${sha256(`schedule:${this.config.instance_id}:${key}`).slice(0, 32)}`;
587
+ const claimId = `ac_${sha256(`schedule-claim:${this.config.instance_id}:${key}`).slice(0, 32)}`;
588
+ const channel = input.attention_channel || "default";
589
+ if (channel.length > 200)
590
+ throw new BridgeError("attention channel is invalid", "invalid_claim", 400);
591
+ const metadata = { origin: "self_commitment", reason_code: input.reason_code || "reconsider" };
592
+ const statements = [
593
+ `INSERT INTO events(id, instance_id, owner_id, source, type, schema_version, occurred_at, received_at, dedupe_key, coalesce_key, priority_hint, attention_channel_hint, actor_ref, resource_json, metadata_json, payload_preview, matched_policy_id, matched_policy_version)
594
+ VALUES(${sqlValue(eventId)}, ${sqlValue(this.config.instance_id)}, ${sqlValue(this.config.owner_id)}, ${sqlValue(source)}, ${sqlValue("attention.schedule")}, 1, ${sqlValue(now)}, ${sqlValue(now)}, ${sqlValue(dedupeKey)}, ${sqlValue(channel)}, ${sqlValue("normal")}, ${sqlValue(channel)}, ${sqlValue(this.config.owner_id)}, ${sqlJson(input.resource)}, ${sqlJson(metadata)}, NULL, ${sqlValue("self_commitment")}, 1);`,
595
+ `INSERT INTO event_status(event_id, state, updated_at) VALUES(${sqlValue(eventId)}, ${sqlValue("matched")}, ${sqlValue(now)});`,
596
+ `INSERT INTO event_transitions(event_id, from_state, to_state, reason, at) VALUES(${sqlValue(eventId)}, NULL, ${sqlValue("matched")}, ${sqlValue("self_commitment")}, ${sqlValue(now)});`,
597
+ `INSERT INTO claims(id, instance_id, origin, event_ids_json, resource_json, policy_id, policy_version, attention_channel, eligible_after, expires_at, defer_while_presence, state, reason_code, note, created_at, updated_at, snooze_until, consumed_result_json, dismissed_reason)
598
+ VALUES(${sqlValue(claimId)}, ${sqlValue(this.config.instance_id)}, ${sqlValue("self_commitment")}, ${sqlJson([eventId])}, ${sqlJson(input.resource)}, ${sqlValue("self_commitment")}, 1, ${sqlValue(channel)}, ${sqlValue(eligibleAfter)}, ${sqlValue(expiresAt)}, ${sqlValue(input.defer_while_presence !== false)}, ${sqlValue("pending")}, ${sqlValue(input.reason_code || "reconsider")}, ${sqlValue(input.note)}, ${sqlValue(now)}, ${sqlValue(now)}, NULL, NULL, NULL);`,
599
+ `INSERT INTO claim_transitions(claim_id, from_state, to_state, reason, at) VALUES(${sqlValue(claimId)}, NULL, ${sqlValue("pending")}, ${sqlValue("self_commitment")}, ${sqlValue(now)});`,
600
+ `INSERT OR IGNORE INTO idempotency_keys(scope, key, result_json, created_at) VALUES(${sqlValue("claim.schedule")}, ${sqlValue(key)}, ${sqlJson({ event_id: eventId, claim_id: claimId })}, ${sqlValue(now)});`,
601
+ ];
602
+ try {
603
+ this.db.transaction(statements);
604
+ }
605
+ catch (error) {
606
+ if (this.isUniqueConflict(error)) {
607
+ const duplicateEvent = this.db.query(`SELECT id FROM events WHERE instance_id=${sqlValue(this.config.instance_id)} AND source=${sqlValue(source)} AND dedupe_key=${sqlValue(dedupeKey)} LIMIT 1;`)[0];
608
+ if (duplicateEvent) {
609
+ const event = this.getEvent(String(duplicateEvent.id));
610
+ const claim = this.listClaims().find((item) => item.event_ids.includes(event.id));
611
+ if (claim)
612
+ return { event, claim, duplicate: true };
613
+ }
614
+ }
615
+ throw error;
616
+ }
617
+ return { event: this.getEvent(eventId), claim: this.getClaim(claimId), duplicate: false };
618
+ }
619
+ getEvent(eventId) {
620
+ const row = this.db.query(`SELECT e.*, s.state FROM events e JOIN event_status s ON s.event_id=e.id WHERE e.id=${sqlValue(eventId)} AND e.instance_id=${sqlValue(this.config.instance_id)} LIMIT 1;`)[0];
621
+ return row ? this.eventFromRow(row) : null;
622
+ }
623
+ listEvents(filters = {}) {
624
+ const clauses = [`e.instance_id=${sqlValue(this.config.instance_id)}`];
625
+ if (filters.state)
626
+ clauses.push(`s.state=${sqlValue(filters.state)}`);
627
+ if (filters.source)
628
+ clauses.push(`e.source=${sqlValue(filters.source)}`);
629
+ if (filters.after)
630
+ clauses.push(`e.received_at>${sqlValue(filters.after)}`);
631
+ const limit = Math.min(1000, Math.max(1, Math.floor(filters.limit ?? 100)));
632
+ return this.db.query(`SELECT e.*, s.state FROM events e JOIN event_status s ON s.event_id=e.id WHERE ${clauses.join(" AND ")} ORDER BY e.received_at ASC LIMIT ${limit};`).map((row) => this.eventFromRow(row));
633
+ }
634
+ eventFromRow(row) {
635
+ return {
636
+ id: String(row.id),
637
+ instance_id: String(row.instance_id),
638
+ owner_id: String(row.owner_id),
639
+ source: String(row.source),
640
+ type: String(row.type),
641
+ schema_version: Number(row.schema_version),
642
+ occurred_at: String(row.occurred_at),
643
+ received_at: String(row.received_at),
644
+ dedupe_key: String(row.dedupe_key),
645
+ coalesce_key: row.coalesce_key == null ? null : String(row.coalesce_key),
646
+ priority_hint: row.priority_hint == null ? null : String(row.priority_hint),
647
+ attention_channel_hint: row.attention_channel_hint == null ? null : String(row.attention_channel_hint),
648
+ actor_ref: row.actor_ref == null ? null : String(row.actor_ref),
649
+ resource: jsonRecord(row.resource_json, { uri: "unknown:" }),
650
+ metadata: jsonRecord(row.metadata_json, {}),
651
+ payload_preview: row.payload_preview == null ? null : String(row.payload_preview),
652
+ idempotency_key: null,
653
+ state: String(row.state),
654
+ matched_policy_id: row.matched_policy_id == null ? null : String(row.matched_policy_id),
655
+ matched_policy_version: row.matched_policy_version == null ? null : Number(row.matched_policy_version),
656
+ };
657
+ }
658
+ getClaim(claimId) {
659
+ const row = this.db.query(`SELECT * FROM claims WHERE id=${sqlValue(claimId)} AND instance_id=${sqlValue(this.config.instance_id)} LIMIT 1;`)[0];
660
+ return row ? this.claimFromRow(row) : null;
661
+ }
662
+ listClaims(filters = {}) {
663
+ const clauses = [`c.instance_id=${sqlValue(this.config.instance_id)}`];
664
+ if (filters.state)
665
+ clauses.push(`c.state=${sqlValue(filters.state)}`);
666
+ if (filters.channel)
667
+ clauses.push(`c.attention_channel=${sqlValue(filters.channel)}`);
668
+ const limit = Math.min(100_000, Math.max(1, Math.floor(filters.limit ?? 1000)));
669
+ let claims = this.db.query(`SELECT c.* FROM claims c WHERE ${clauses.join(" AND ")} ORDER BY c.created_at ASC LIMIT ${limit};`).map((row) => this.claimFromRow(row));
670
+ if (filters.source) {
671
+ claims = claims.filter((claim) => claim.event_ids.some((eventId) => this.getEvent(eventId)?.source === filters.source));
672
+ }
673
+ return claims;
674
+ }
675
+ claimFromRow(row) {
676
+ return {
677
+ id: String(row.id),
678
+ instance_id: String(row.instance_id),
679
+ origin: String(row.origin),
680
+ event_ids: jsonRecord(row.event_ids_json, []),
681
+ resource: jsonRecord(row.resource_json, { uri: "unknown:" }),
682
+ policy_id: String(row.policy_id),
683
+ policy_version: Number(row.policy_version),
684
+ attention_channel: String(row.attention_channel),
685
+ eligible_after: String(row.eligible_after),
686
+ expires_at: row.expires_at == null ? null : String(row.expires_at),
687
+ defer_while_presence: Boolean(Number(row.defer_while_presence)),
688
+ state: String(row.state),
689
+ reason_code: String(row.reason_code),
690
+ note: row.note == null ? null : String(row.note),
691
+ created_at: String(row.created_at),
692
+ updated_at: String(row.updated_at),
693
+ snooze_until: row.snooze_until == null ? null : String(row.snooze_until),
694
+ consumed_result: row.consumed_result_json == null ? null : jsonRecord(row.consumed_result_json, null),
695
+ dismissed_reason: row.dismissed_reason == null ? null : String(row.dismissed_reason),
696
+ };
697
+ }
698
+ policy(policyId, version) {
699
+ const row = this.db.query(`SELECT rule_json FROM policies WHERE id=${sqlValue(policyId)} AND version=${version} LIMIT 1;`)[0];
700
+ return row ? normalizeStoredPolicy(jsonRecord(row.rule_json, DEFAULT_POLICY)) : null;
701
+ }
702
+ getBinding(channel) {
703
+ const row = this.db.query(`SELECT * FROM bindings WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)} LIMIT 1;`)[0];
704
+ return row ? { instance_id: String(row.instance_id), attention_channel: String(row.attention_channel), endpoint_id: String(row.endpoint_id), generation: Number(row.generation), bound_at: String(row.bound_at) } : null;
705
+ }
706
+ listBindings() {
707
+ return this.db.query(`SELECT * FROM bindings WHERE instance_id=${sqlValue(this.config.instance_id)} ORDER BY attention_channel;`).map((row) => ({ instance_id: String(row.instance_id), attention_channel: String(row.attention_channel), endpoint_id: String(row.endpoint_id), generation: Number(row.generation), bound_at: String(row.bound_at) }));
708
+ }
709
+ endpointFromRow(row, includeToken = false, token, includeRouteSecrets = true) {
710
+ const routes = jsonRecord(row.routes_json, []);
711
+ return {
712
+ id: String(row.id),
713
+ instance_id: String(row.instance_id),
714
+ host_kind: String(row.host_kind),
715
+ session_ref: String(row.session_ref),
716
+ lease_token: includeToken ? token : undefined,
717
+ lease_expires_at: String(row.lease_expires_at),
718
+ capabilities: jsonRecord(row.capabilities_json, { cold_push: false }),
719
+ routes: includeRouteSecrets ? routes : routes.map((route) => ({
720
+ ...route,
721
+ address: Object.fromEntries(Object.entries(route.address).map(([key, value]) => [
722
+ key,
723
+ /token|secret|authorization|bearer/i.test(key) ? "[redacted]" : value,
724
+ ])),
725
+ })),
726
+ registered_at: String(row.registered_at),
727
+ updated_at: String(row.updated_at),
728
+ };
729
+ }
730
+ getEndpoint(endpointId) {
731
+ const row = this.db.query(`SELECT * FROM endpoints WHERE id=${sqlValue(endpointId)} AND instance_id=${sqlValue(this.config.instance_id)} LIMIT 1;`)[0];
732
+ return row ? this.endpointFromRow(row) : null;
733
+ }
734
+ listEndpoints() {
735
+ return this.db.query(`SELECT * FROM endpoints WHERE instance_id=${sqlValue(this.config.instance_id)} ORDER BY id;`).map((row) => this.endpointFromRow(row, false, undefined, false));
736
+ }
737
+ registerEndpoint(registration) {
738
+ if (!registration?.host_kind || !registration.session_ref)
739
+ throw new BridgeError("host_kind and session_ref are required", "invalid_endpoint", 400);
740
+ const now = this.nowIso();
741
+ const endpointId = registration.id || id("ep");
742
+ const token = randomBytes(32).toString("base64url");
743
+ const leaseMs = assertFiniteMs(registration.lease_ms, "lease_ms", this.config.endpoint_lease_ms);
744
+ const capabilities = {
745
+ cold_push: registration.host_kind === "mock",
746
+ warm_resume: false,
747
+ ...(registration.capabilities ?? {}),
748
+ };
749
+ const routes = registration.routes ?? (registration.host_kind === "mock"
750
+ ? [{ kind: "mock", priority: 100, address: {} }]
751
+ : []);
752
+ this.db.transaction([
753
+ `INSERT INTO endpoints(id, instance_id, host_kind, session_ref, lease_token_hash, lease_expires_at, capabilities_json, routes_json, registered_at, updated_at)
754
+ VALUES(${sqlValue(endpointId)}, ${sqlValue(this.config.instance_id)}, ${sqlValue(registration.host_kind)}, ${sqlValue(registration.session_ref)}, ${sqlValue(sha256(token))}, ${sqlValue(addMs(now, leaseMs))}, ${sqlJson(capabilities)}, ${sqlJson(routes)}, ${sqlValue(now)}, ${sqlValue(now)})
755
+ ON CONFLICT(id) DO UPDATE SET host_kind=excluded.host_kind, session_ref=excluded.session_ref, lease_token_hash=excluded.lease_token_hash, lease_expires_at=excluded.lease_expires_at, capabilities_json=excluded.capabilities_json, routes_json=excluded.routes_json, updated_at=excluded.updated_at;`,
756
+ ]);
757
+ const row = this.db.query(`SELECT * FROM endpoints WHERE id=${sqlValue(endpointId)};`)[0];
758
+ return this.endpointFromRow(row, true, token);
759
+ }
760
+ renewEndpoint(endpointId, leaseToken, leaseMs) {
761
+ const now = this.nowIso();
762
+ const duration = assertFiniteMs(leaseMs, "lease_ms", this.config.endpoint_lease_ms);
763
+ const result = this.db.query(`UPDATE endpoints SET lease_expires_at=${sqlValue(addMs(now, duration))}, updated_at=${sqlValue(now)} WHERE id=${sqlValue(endpointId)} AND instance_id=${sqlValue(this.config.instance_id)} AND lease_token_hash=${sqlValue(sha256(leaseToken))} RETURNING *;`)[0];
764
+ if (!result)
765
+ throw new BridgeError("endpoint lease token is invalid or endpoint is unknown", "endpoint_fenced", 409);
766
+ return this.endpointFromRow(result);
767
+ }
768
+ renewBoundEndpoint(endpointId, channel, expectedGeneration, leaseMs) {
769
+ const binding = this.getBinding(channel);
770
+ if (!binding || binding.endpoint_id !== endpointId || binding.generation !== expectedGeneration) {
771
+ throw new BridgeError("endpoint renewal does not match current binding generation", "stale_generation", 409);
772
+ }
773
+ if (!this.getEndpoint(endpointId))
774
+ throw new BridgeError("endpoint is unknown", "endpoint_not_found", 404);
775
+ const now = this.nowIso();
776
+ const duration = assertFiniteMs(leaseMs, "lease_ms", this.config.endpoint_lease_ms);
777
+ const result = this.db.query(`UPDATE endpoints SET lease_expires_at=${sqlValue(addMs(now, duration))}, updated_at=${sqlValue(now)} WHERE id=${sqlValue(endpointId)} AND instance_id=${sqlValue(this.config.instance_id)} RETURNING *;`)[0];
778
+ return this.endpointFromRow(result);
779
+ }
780
+ revokeEndpoint(endpointId, leaseToken) {
781
+ this.endpointAuthorized(endpointId, leaseToken);
782
+ return this.revokeEndpointState(endpointId);
783
+ }
784
+ revokeBoundEndpoint(endpointId, channel, expectedGeneration) {
785
+ const binding = this.getBinding(channel);
786
+ if (!binding || binding.endpoint_id !== endpointId || binding.generation !== expectedGeneration) {
787
+ throw new BridgeError("endpoint revoke does not match current binding generation", "stale_generation", 409);
788
+ }
789
+ if (!this.getEndpoint(endpointId))
790
+ throw new BridgeError("endpoint is unknown", "endpoint_not_found", 404);
791
+ return this.revokeEndpointState(endpointId);
792
+ }
793
+ revokeEndpointState(endpointId) {
794
+ const now = this.nowIso();
795
+ this.db.transaction([
796
+ `UPDATE endpoints SET lease_expires_at=${sqlValue(now)}, updated_at=${sqlValue(now)} WHERE id=${sqlValue(endpointId)} AND instance_id=${sqlValue(this.config.instance_id)};`,
797
+ `DELETE FROM presence_leases WHERE instance_id=${sqlValue(this.config.instance_id)} AND endpoint_id=${sqlValue(endpointId)};`,
798
+ `UPDATE activity_watches SET activity_lease_expires_at=NULL, updated_at=${sqlValue(now)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND endpoint_id=${sqlValue(endpointId)};`,
799
+ `UPDATE delivery_correlations SET state='discarded' WHERE endpoint_id=${sqlValue(endpointId)} AND state='outstanding';`,
800
+ ]);
801
+ return this.getEndpoint(endpointId);
802
+ }
803
+ takeover(channel, endpointId, expectedGeneration) {
804
+ if (!channel || channel.length > 200)
805
+ throw new BridgeError("attention channel is invalid", "invalid_binding", 400);
806
+ const endpoint = this.getEndpoint(endpointId);
807
+ if (!endpoint)
808
+ throw new BridgeError("endpoint is unknown", "endpoint_not_found", 404);
809
+ if (new Date(endpoint.lease_expires_at).getTime() <= this.now().getTime())
810
+ throw new BridgeError("endpoint lease has expired", "endpoint_fenced", 409);
811
+ const current = this.getBinding(channel);
812
+ const currentGeneration = current?.generation ?? 0;
813
+ if (expectedGeneration != null && expectedGeneration !== currentGeneration) {
814
+ throw new BridgeError(`generation mismatch: expected ${expectedGeneration}, current ${currentGeneration}`, "stale_generation", 409);
815
+ }
816
+ const generation = currentGeneration + 1;
817
+ const now = this.nowIso();
818
+ this.db.transaction([
819
+ `INSERT INTO bindings(instance_id, attention_channel, endpoint_id, generation, bound_at)
820
+ VALUES(${sqlValue(this.config.instance_id)}, ${sqlValue(channel)}, ${sqlValue(endpointId)}, ${generation}, ${sqlValue(now)})
821
+ ON CONFLICT(instance_id, attention_channel) DO UPDATE SET endpoint_id=excluded.endpoint_id, generation=excluded.generation, bound_at=excluded.bound_at;`,
822
+ `DELETE FROM presence_leases WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)};`,
823
+ `UPDATE activity_watches SET endpoint_id=NULL, binding_generation=NULL, epoch=epoch+1, armed_at=NULL, last_nonwake_activity_at=NULL, activity_lease_expires_at=NULL, fired_epoch=NULL, sequence=0, last_triggered_at=NULL, next_due_at=NULL, updated_at=${sqlValue(now)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)};`,
824
+ `UPDATE delivery_correlations SET state='discarded' WHERE state='outstanding' AND batch_id IN (SELECT id FROM batches WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)});`,
825
+ `UPDATE batches SET state='pending', binding_generation=NULL, updated_at=${sqlValue(now)}, last_error=NULL WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)} AND state='waiting_for_endpoint';`,
826
+ ]);
827
+ return this.getBinding(channel);
828
+ }
829
+ endpointAuthorized(endpointId, token) {
830
+ const row = this.db.query(`SELECT * FROM endpoints WHERE id=${sqlValue(endpointId)} AND instance_id=${sqlValue(this.config.instance_id)} AND lease_token_hash=${sqlValue(sha256(token))} LIMIT 1;`)[0];
831
+ if (!row)
832
+ throw new BridgeError("endpoint lease token is invalid", "endpoint_fenced", 409);
833
+ if (new Date(String(row.lease_expires_at)).getTime() <= this.now().getTime())
834
+ throw new BridgeError("endpoint lease has expired", "endpoint_fenced", 409);
835
+ return row;
836
+ }
837
+ renewPresence(input) {
838
+ if (!input?.attention_channel || !input.endpoint_id || !input.observed_by)
839
+ throw new BridgeError("presence channel, endpoint and observed_by are required", "invalid_presence", 400);
840
+ const endpoint = this.endpointAuthorized(input.endpoint_id, input.lease_token || "");
841
+ const capabilities = jsonRecord(endpoint.capabilities_json, { cold_push: false });
842
+ if (!capabilities.foreground_presence_observable) {
843
+ throw new BridgeError("bound host cannot observe foreground presence", "presence_unsupported", 409);
844
+ }
845
+ const binding = this.getBinding(input.attention_channel);
846
+ if (!binding || binding.endpoint_id !== input.endpoint_id || binding.generation !== input.generation) {
847
+ throw new BridgeError("presence renewal does not match current binding generation", "stale_generation", 409);
848
+ }
849
+ const now = this.nowIso();
850
+ const ttl = Math.min(assertFiniteMs(input.ttl_ms, "ttl_ms", this.config.presence_ttl_ms), 24 * 60 * 60_000);
851
+ const expires = addMs(now, ttl);
852
+ this.db.transaction([
853
+ `INSERT INTO presence_leases(instance_id, attention_channel, endpoint_id, binding_generation, renewed_at, expires_at, observed_by, observation)
854
+ VALUES(${sqlValue(this.config.instance_id)}, ${sqlValue(input.attention_channel)}, ${sqlValue(input.endpoint_id)}, ${input.generation}, ${sqlValue(now)}, ${sqlValue(expires)}, ${sqlValue(input.observed_by)}, ${sqlValue(input.observation || "user_message_accepted")})
855
+ ON CONFLICT(instance_id, attention_channel) DO UPDATE SET endpoint_id=excluded.endpoint_id, binding_generation=excluded.binding_generation, renewed_at=excluded.renewed_at, expires_at=excluded.expires_at, observed_by=excluded.observed_by, observation=excluded.observation;`,
856
+ ]);
857
+ return this.getPresence(input.attention_channel);
858
+ }
859
+ configureActivityWatch(input) {
860
+ const channel = input?.attention_channel?.trim();
861
+ if (!channel || channel.length > 200)
862
+ throw new BridgeError("attention channel is invalid", "invalid_activity_watch", 400);
863
+ const idleAfter = Number(input.idle_after_ms);
864
+ if (!Number.isSafeInteger(idleAfter) || idleAfter < 60_000 || idleAfter > 7 * 24 * 60 * 60_000) {
865
+ throw new BridgeError("idle_after_ms must be an integer between 60000 and 604800000", "invalid_activity_watch", 400);
866
+ }
867
+ const existing = this.getActivityWatch(channel);
868
+ const mode = input.mode ?? existing?.mode ?? "once";
869
+ if (!["once", "repeat"].includes(mode)) {
870
+ throw new BridgeError("mode must be once or repeat", "invalid_activity_watch", 400);
871
+ }
872
+ const repeatAfter = Number(input.repeat_after_ms ?? existing?.repeat_after_ms ?? idleAfter);
873
+ if (!Number.isSafeInteger(repeatAfter) || repeatAfter < 60_000 || repeatAfter > 7 * 24 * 60 * 60_000) {
874
+ throw new BridgeError("repeat_after_ms must be an integer between 60000 and 604800000", "invalid_activity_watch", 400);
875
+ }
876
+ if (existing && existing.enabled === input.enabled && existing.idle_after_ms === idleAfter
877
+ && existing.mode === mode && existing.repeat_after_ms === repeatAfter)
878
+ return existing;
879
+ const now = this.nowIso();
880
+ const binding = this.getBinding(channel);
881
+ if (!existing) {
882
+ this.db.transaction([
883
+ `INSERT INTO activity_watches(instance_id, attention_channel, enabled, idle_after_ms, endpoint_id, binding_generation, epoch, armed_at, last_nonwake_activity_at, activity_lease_expires_at, fired_epoch, mode, repeat_after_ms, sequence, last_triggered_at, next_due_at, updated_at)
884
+ VALUES(${sqlValue(this.config.instance_id)}, ${sqlValue(channel)}, ${sqlValue(input.enabled)}, ${idleAfter}, ${sqlValue(binding?.endpoint_id)}, ${sqlValue(binding?.generation)}, 1, NULL, NULL, NULL, NULL, ${sqlValue(mode)}, ${repeatAfter}, 0, NULL, NULL, ${sqlValue(now)});`,
885
+ ]);
886
+ }
887
+ else if (!existing.enabled && input.enabled) {
888
+ this.db.transaction([
889
+ `UPDATE activity_watches SET enabled=1, idle_after_ms=${idleAfter}, mode=${sqlValue(mode)}, repeat_after_ms=${repeatAfter}, endpoint_id=${sqlValue(binding?.endpoint_id)}, binding_generation=${sqlValue(binding?.generation)}, epoch=epoch+1, armed_at=NULL, last_nonwake_activity_at=NULL, activity_lease_expires_at=NULL, fired_epoch=NULL, sequence=0, last_triggered_at=NULL, next_due_at=NULL, updated_at=${sqlValue(now)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)};`,
890
+ ]);
891
+ }
892
+ else {
893
+ const nextDueAt = input.enabled && mode === "repeat" && existing.fired_epoch === existing.epoch
894
+ ? addMs(now, repeatAfter)
895
+ : null;
896
+ this.db.transaction([
897
+ `UPDATE activity_watches SET enabled=${sqlValue(input.enabled)}, idle_after_ms=${idleAfter}, mode=${sqlValue(mode)}, repeat_after_ms=${repeatAfter}, activity_lease_expires_at=${sqlValue(input.enabled ? existing.activity_lease_expires_at : null)}, next_due_at=${sqlValue(nextDueAt)}, updated_at=${sqlValue(now)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)};`,
898
+ ]);
899
+ }
900
+ return this.getActivityWatch(channel);
901
+ }
902
+ getActivityWatch(channel) {
903
+ const row = this.db.query(`SELECT * FROM activity_watches WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)} LIMIT 1;`)[0];
904
+ return row ? this.activityWatchFromRow(row) : null;
905
+ }
906
+ listActivityWatches() {
907
+ return this.db.query(`SELECT * FROM activity_watches WHERE instance_id=${sqlValue(this.config.instance_id)} ORDER BY attention_channel;`).map((row) => this.activityWatchFromRow(row));
908
+ }
909
+ activityWatchFromRow(row) {
910
+ const enabled = Number(row.enabled) === 1;
911
+ const binding = this.getBinding(String(row.attention_channel));
912
+ const endpoint = binding ? this.getEndpoint(binding.endpoint_id) : null;
913
+ const matchesBinding = Boolean(binding && row.endpoint_id === binding.endpoint_id && Number(row.binding_generation) === binding.generation);
914
+ const lastActivity = row.last_nonwake_activity_at == null ? null : String(row.last_nonwake_activity_at);
915
+ const leaseExpires = row.activity_lease_expires_at == null ? null : String(row.activity_lease_expires_at);
916
+ const now = this.nowIso();
917
+ const conditionStatus = !enabled ? "disabled"
918
+ : !binding || !endpoint || endpoint.lease_expires_at <= now ? "waiting_for_endpoint"
919
+ : !endpoint.capabilities.session_activity_observable ? "unsupported"
920
+ : !matchesBinding || !lastActivity ? "waiting_for_activity"
921
+ : leaseExpires && leaseExpires > now ? "watching"
922
+ : new Date(lastActivity).getTime() + Number(row.idle_after_ms) <= new Date(now).getTime() ? "inactive"
923
+ : "watching";
924
+ return {
925
+ instance_id: String(row.instance_id),
926
+ attention_channel: String(row.attention_channel),
927
+ enabled,
928
+ idle_after_ms: Number(row.idle_after_ms),
929
+ endpoint_id: row.endpoint_id == null ? null : String(row.endpoint_id),
930
+ binding_generation: row.binding_generation == null ? null : Number(row.binding_generation),
931
+ epoch: Number(row.epoch),
932
+ armed_at: row.armed_at == null ? null : String(row.armed_at),
933
+ last_nonwake_activity_at: row.last_nonwake_activity_at == null ? null : String(row.last_nonwake_activity_at),
934
+ activity_lease_expires_at: row.activity_lease_expires_at == null ? null : String(row.activity_lease_expires_at),
935
+ fired_epoch: row.fired_epoch == null ? null : Number(row.fired_epoch),
936
+ mode: String(row.mode),
937
+ repeat_after_ms: Number(row.repeat_after_ms),
938
+ sequence: Number(row.sequence),
939
+ last_triggered_at: row.last_triggered_at == null ? null : String(row.last_triggered_at),
940
+ next_due_at: row.next_due_at == null ? null : String(row.next_due_at),
941
+ condition_status: conditionStatus,
942
+ limits: { min_interval_ms: 60_000, max_claims_per_tick: 16 },
943
+ updated_at: String(row.updated_at),
944
+ };
945
+ }
946
+ observeActivity(input) {
947
+ if (!input?.attention_channel || !input.endpoint_id || !input.observation_id)
948
+ throw new BridgeError("activity channel, endpoint and observation_id are required", "invalid_activity", 400);
949
+ if (input.observation_id.length > 256)
950
+ throw new BridgeError("observation_id is too long", "invalid_activity", 400);
951
+ const kinds = new Set(["owner_message_accepted", "activity_started", "activity_progress", "activity_settled", "wake_started", "wake_progress", "wake_settled"]);
952
+ if (!kinds.has(input.kind))
953
+ throw new BridgeError("activity kind is invalid", "invalid_activity", 400);
954
+ this.endpointAuthorized(input.endpoint_id, input.lease_token || "");
955
+ const binding = this.getBinding(input.attention_channel);
956
+ if (!binding || binding.endpoint_id !== input.endpoint_id || binding.generation !== input.generation)
957
+ throw new BridgeError("activity observation does not match current binding generation", "stale_generation", 409);
958
+ const receivedAt = this.nowIso();
959
+ const observedAt = input.observed_at == null ? receivedAt : isoOrThrow(input.observed_at, "observed_at");
960
+ const skew = new Date(observedAt).getTime() - new Date(receivedAt).getTime();
961
+ if (skew > 5 * 60_000 || skew < -7 * 24 * 60 * 60_000)
962
+ throw new BridgeError("observed_at is outside the accepted window", "invalid_activity", 400);
963
+ const ttl = Math.min(assertFiniteMs(input.ttl_ms, "ttl_ms", this.config.activity_ttl_ms), 60 * 60_000);
964
+ const leaseExpires = addMs(receivedAt, ttl);
965
+ const configuredWatch = this.getActivityWatch(input.attention_channel);
966
+ if (!configuredWatch?.enabled)
967
+ return { duplicate: false, watch: configuredWatch };
968
+ const insert = `INSERT OR IGNORE INTO activity_observations(instance_id, endpoint_id, binding_generation, observation_id, attention_channel, kind, observed_at, received_at)
969
+ VALUES(${sqlValue(this.config.instance_id)}, ${sqlValue(input.endpoint_id)}, ${input.generation}, ${sqlValue(input.observation_id)}, ${sqlValue(input.attention_channel)}, ${sqlValue(input.kind)}, ${sqlValue(observedAt)}, ${sqlValue(receivedAt)});`;
970
+ const nonWakeActivity = input.kind === "owner_message_accepted"
971
+ || input.kind === "activity_started"
972
+ || input.kind === "activity_progress"
973
+ || input.kind === "activity_settled";
974
+ // A host is not required to observe the beginning of every independent
975
+ // turn. In particular, polling adapters may first see a progress or
976
+ // settled sample after takeover. Any trusted non-Wake observation can
977
+ // therefore establish the current-generation baseline (and start a new
978
+ // epoch after a previous one fired). Wake-origin observations remain
979
+ // unable to arm or rearm the watch.
980
+ const establishBaseline = nonWakeActivity && (configuredWatch.endpoint_id !== input.endpoint_id
981
+ || configuredWatch.binding_generation !== input.generation
982
+ || !configuredWatch.last_nonwake_activity_at
983
+ || configuredWatch.fired_epoch === configuredWatch.epoch);
984
+ const repeatNotBefore = addMs(receivedAt, configuredWatch.repeat_after_ms);
985
+ let update = "SELECT changes();";
986
+ if (establishBaseline) {
987
+ update = `UPDATE activity_watches SET endpoint_id=${sqlValue(input.endpoint_id)}, binding_generation=${input.generation}, epoch=CASE WHEN fired_epoch=epoch THEN epoch+1 ELSE epoch END, armed_at=${sqlValue(observedAt)}, last_nonwake_activity_at=${sqlValue(observedAt)}, activity_lease_expires_at=${sqlValue(input.kind === "activity_started" || input.kind === "activity_progress" ? leaseExpires : null)}, fired_epoch=NULL, sequence=0, last_triggered_at=NULL, next_due_at=NULL, updated_at=${sqlValue(receivedAt)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(input.attention_channel)} AND enabled=1 AND changes()>0;`;
988
+ }
989
+ else if (input.kind === "owner_message_accepted" || input.kind === "activity_started") {
990
+ update = `UPDATE activity_watches SET last_nonwake_activity_at=${sqlValue(observedAt)}, activity_lease_expires_at=${sqlValue(input.kind === "activity_started" ? leaseExpires : null)}, updated_at=${sqlValue(receivedAt)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(input.attention_channel)} AND enabled=1 AND endpoint_id=${sqlValue(input.endpoint_id)} AND binding_generation=${input.generation} AND changes()>0;`;
991
+ }
992
+ else if (input.kind === "activity_progress") {
993
+ update = `UPDATE activity_watches SET activity_lease_expires_at=${sqlValue(leaseExpires)}, updated_at=${sqlValue(receivedAt)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(input.attention_channel)} AND enabled=1 AND endpoint_id=${sqlValue(input.endpoint_id)} AND binding_generation=${input.generation} AND fired_epoch IS NOT epoch AND changes()>0;`;
994
+ }
995
+ else if (input.kind === "activity_settled") {
996
+ update = `UPDATE activity_watches SET last_nonwake_activity_at=CASE WHEN fired_epoch IS NOT epoch THEN ${sqlValue(observedAt)} ELSE last_nonwake_activity_at END, activity_lease_expires_at=NULL, updated_at=${sqlValue(receivedAt)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(input.attention_channel)} AND enabled=1 AND endpoint_id=${sqlValue(input.endpoint_id)} AND binding_generation=${input.generation} AND changes()>0;`;
997
+ }
998
+ else if (input.kind === "wake_started" || input.kind === "wake_progress") {
999
+ update = `UPDATE activity_watches SET activity_lease_expires_at=${sqlValue(leaseExpires)}, updated_at=${sqlValue(receivedAt)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(input.attention_channel)} AND enabled=1 AND endpoint_id=${sqlValue(input.endpoint_id)} AND binding_generation=${input.generation} AND changes()>0;`;
1000
+ }
1001
+ else if (input.kind === "wake_settled") {
1002
+ update = `UPDATE activity_watches SET activity_lease_expires_at=NULL, next_due_at=CASE WHEN mode='repeat' AND fired_epoch=epoch THEN ${sqlValue(repeatNotBefore)} ELSE next_due_at END, updated_at=${sqlValue(receivedAt)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(input.attention_channel)} AND enabled=1 AND endpoint_id=${sqlValue(input.endpoint_id)} AND binding_generation=${input.generation} AND changes()>0;`;
1003
+ }
1004
+ const before = this.db.query(`SELECT 1 AS present FROM activity_observations WHERE instance_id=${sqlValue(this.config.instance_id)} AND endpoint_id=${sqlValue(input.endpoint_id)} AND binding_generation=${input.generation} AND observation_id=${sqlValue(input.observation_id)} LIMIT 1;`)[0];
1005
+ this.db.transaction([
1006
+ `DELETE FROM activity_observations WHERE instance_id=${sqlValue(this.config.instance_id)} AND received_at<${sqlValue(addMs(receivedAt, -7 * 24 * 60 * 60_000))};`,
1007
+ insert,
1008
+ update,
1009
+ ]);
1010
+ return { duplicate: Boolean(before), watch: this.getActivityWatch(input.attention_channel) };
1011
+ }
1012
+ consumeWakeEcho(input) {
1013
+ if (!input?.attention_channel || !input.endpoint_id || !input.observation_id || input.observation_id.length > 256
1014
+ || !Number.isSafeInteger(input.generation) || input.generation < 1) {
1015
+ throw new BridgeError("wake echo identity is invalid", "invalid_activity", 400);
1016
+ }
1017
+ if (!input?.delivery_nonce || input.delivery_nonce.length > 256) {
1018
+ throw new BridgeError("delivery nonce is required and bounded", "invalid_delivery_nonce", 400);
1019
+ }
1020
+ const now = this.nowIso();
1021
+ const observedAt = input.observed_at == null ? now : isoOrThrow(input.observed_at, "observed_at");
1022
+ const skew = new Date(observedAt).getTime() - new Date(now).getTime();
1023
+ if (skew > 5 * 60_000 || skew < -7 * 24 * 60 * 60_000) {
1024
+ throw new BridgeError("observed_at is outside the accepted window", "invalid_activity", 400);
1025
+ }
1026
+ if (input.ttl_ms != null)
1027
+ assertFiniteMs(input.ttl_ms, "ttl_ms", this.config.activity_ttl_ms);
1028
+ this.endpointAuthorized(input.endpoint_id, input.lease_token || "");
1029
+ const binding = this.getBinding(input.attention_channel);
1030
+ if (!binding || binding.endpoint_id !== input.endpoint_id || binding.generation !== input.generation) {
1031
+ throw new BridgeError("wake echo does not match current binding generation", "stale_generation", 409);
1032
+ }
1033
+ const nonceHash = sha256(input.delivery_nonce);
1034
+ const correlation = this.db.query(`SELECT dc.* FROM delivery_correlations dc JOIN batches b ON b.id=dc.batch_id
1035
+ WHERE b.instance_id=${sqlValue(this.config.instance_id)} AND dc.endpoint_id=${sqlValue(input.endpoint_id)}
1036
+ AND dc.binding_generation=${input.generation} AND dc.nonce_hash=${sqlValue(nonceHash)} LIMIT 1;`)[0];
1037
+ if (!correlation)
1038
+ throw new BridgeError("delivery nonce is unknown", "unknown_delivery_nonce", 409);
1039
+ if (String(correlation.state) !== "outstanding")
1040
+ throw new BridgeError("delivery nonce was already consumed or discarded", "delivery_nonce_replayed", 409);
1041
+ if (String(correlation.expires_at) <= now) {
1042
+ this.db.exec(`UPDATE delivery_correlations SET state='expired' WHERE attempt_id=${sqlValue(correlation.attempt_id)} AND state='outstanding';`);
1043
+ throw new BridgeError("delivery nonce has expired", "delivery_nonce_expired", 409);
1044
+ }
1045
+ const changed = this.db.query(`UPDATE delivery_correlations SET state='consumed', consumed_at=${sqlValue(now)}
1046
+ WHERE attempt_id=${sqlValue(correlation.attempt_id)} AND state='outstanding' RETURNING attempt_id;`)[0];
1047
+ if (!changed)
1048
+ throw new BridgeError("delivery nonce was already consumed", "delivery_nonce_replayed", 409);
1049
+ return this.observeActivity({
1050
+ attention_channel: input.attention_channel,
1051
+ endpoint_id: input.endpoint_id,
1052
+ generation: input.generation,
1053
+ lease_token: input.lease_token,
1054
+ observation_id: input.observation_id,
1055
+ kind: "wake_started",
1056
+ observed_at: observedAt,
1057
+ ttl_ms: input.ttl_ms,
1058
+ });
1059
+ }
1060
+ listDeliveryCorrelations() {
1061
+ return this.db.query(`SELECT dc.* FROM delivery_correlations dc JOIN batches b ON b.id=dc.batch_id
1062
+ WHERE b.instance_id=${sqlValue(this.config.instance_id)} ORDER BY dc.created_at ASC;`).map((row) => ({
1063
+ attempt_id: String(row.attempt_id),
1064
+ batch_id: String(row.batch_id),
1065
+ endpoint_id: String(row.endpoint_id),
1066
+ binding_generation: Number(row.binding_generation),
1067
+ transport_kind: String(row.transport_kind),
1068
+ nonce_hash: String(row.nonce_hash),
1069
+ state: String(row.state),
1070
+ created_at: String(row.created_at),
1071
+ expires_at: String(row.expires_at),
1072
+ consumed_at: row.consumed_at == null ? null : String(row.consumed_at),
1073
+ }));
1074
+ }
1075
+ getPresence(channel) {
1076
+ const row = this.db.query(`SELECT * FROM presence_leases WHERE instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(channel)} LIMIT 1;`)[0];
1077
+ return row ? this.presenceFromRow(row) : null;
1078
+ }
1079
+ listPresence() {
1080
+ return this.db.query(`SELECT * FROM presence_leases WHERE instance_id=${sqlValue(this.config.instance_id)} ORDER BY attention_channel;`).map((row) => this.presenceFromRow(row));
1081
+ }
1082
+ presenceFromRow(row) {
1083
+ return {
1084
+ instance_id: String(row.instance_id),
1085
+ attention_channel: String(row.attention_channel),
1086
+ endpoint_id: String(row.endpoint_id),
1087
+ binding_generation: Number(row.binding_generation),
1088
+ renewed_at: String(row.renewed_at),
1089
+ expires_at: String(row.expires_at),
1090
+ observed_by: String(row.observed_by),
1091
+ observation: String(row.observation),
1092
+ };
1093
+ }
1094
+ presenceActive(channel, now) {
1095
+ const presence = this.getPresence(channel);
1096
+ if (!presence || presence.expires_at <= now)
1097
+ return null;
1098
+ const binding = this.getBinding(channel);
1099
+ if (!binding || binding.endpoint_id !== presence.endpoint_id || binding.generation !== presence.binding_generation)
1100
+ return null;
1101
+ const endpoint = this.getEndpoint(presence.endpoint_id);
1102
+ if (!endpoint || endpoint.lease_expires_at <= now)
1103
+ return null;
1104
+ return presence;
1105
+ }
1106
+ recover() {
1107
+ const now = this.nowIso();
1108
+ this.db.transaction([
1109
+ `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at)
1110
+ SELECT id, 'needs_attention', 'cancelled', 'all_claims_finalized_reconciled', ${sqlValue(now)} FROM batches
1111
+ WHERE instance_id=${sqlValue(this.config.instance_id)} AND state='needs_attention' AND last_error='all_claims_finalized'
1112
+ AND claim_ids_json='[]' AND event_ids_json='[]';`,
1113
+ `UPDATE batches SET state='cancelled', updated_at=${sqlValue(now)}
1114
+ WHERE instance_id=${sqlValue(this.config.instance_id)} AND state='needs_attention' AND last_error='all_claims_finalized'
1115
+ AND claim_ids_json='[]' AND event_ids_json='[]';`,
1116
+ `UPDATE batches SET state='retry_wait', not_before=${sqlValue(now)}, lease_expires_at=NULL, last_error='dispatcher_recovered', updated_at=${sqlValue(now)} WHERE instance_id=${sqlValue(this.config.instance_id)} AND state='dispatching' AND lease_expires_at IS NOT NULL AND lease_expires_at<${sqlValue(now)};`,
1117
+ `UPDATE outbox_attempts SET state='expired', finished_at=${sqlValue(now)}, error_class='dispatcher_recovered' WHERE state='leased' AND leased_until IS NOT NULL AND leased_until<${sqlValue(now)};`,
1118
+ `UPDATE delivery_correlations SET state='expired' WHERE state='outstanding' AND expires_at<${sqlValue(now)};`,
1119
+ ]);
1120
+ }
1121
+ materializeDueActivityWatches(now) {
1122
+ const watches = this.listActivityWatches().filter((watch) => {
1123
+ if (!watch.enabled || !watch.last_nonwake_activity_at)
1124
+ return false;
1125
+ if (watch.activity_lease_expires_at && watch.activity_lease_expires_at > now)
1126
+ return false;
1127
+ if (watch.fired_epoch !== watch.epoch) {
1128
+ return new Date(watch.last_nonwake_activity_at).getTime() + watch.idle_after_ms <= new Date(now).getTime();
1129
+ }
1130
+ return watch.mode === "repeat" && Boolean(watch.next_due_at) && watch.next_due_at <= now;
1131
+ }).slice(0, 16);
1132
+ const created = [];
1133
+ for (const watch of watches) {
1134
+ const binding = this.getBinding(watch.attention_channel);
1135
+ if (!binding || binding.endpoint_id !== watch.endpoint_id || binding.generation !== watch.binding_generation)
1136
+ continue;
1137
+ const endpoint = this.getEndpoint(binding.endpoint_id);
1138
+ if (!endpoint || endpoint.lease_expires_at <= now || !endpoint.capabilities.session_activity_observable)
1139
+ continue;
1140
+ const sequence = watch.fired_epoch === watch.epoch ? watch.sequence + 1 : 1;
1141
+ const identity = `${this.config.instance_id}:${watch.attention_channel}:${binding.generation}:${watch.epoch}:${sequence}`;
1142
+ const eventId = `evt_${sha256(`activity-watch:${identity}`).slice(0, 32)}`;
1143
+ const claimId = `ac_${sha256(`activity-watch-claim:${identity}`).slice(0, 32)}`;
1144
+ const dedupeKey = `activity-watch:${watch.attention_channel}:${binding.generation}:${watch.epoch}:${sequence}`;
1145
+ const resource = { uri: `wakebridge://activity-watch/${encodeURIComponent(watch.attention_channel)}/${watch.epoch}/${sequence}` };
1146
+ const initialPredicate = `(fired_epoch IS NULL OR fired_epoch<>epoch) AND sequence=${watch.sequence}`;
1147
+ const repeatPredicate = `fired_epoch=epoch AND mode='repeat' AND sequence=${watch.sequence} AND next_due_at IS NOT NULL AND next_due_at<=${sqlValue(now)}`;
1148
+ const predicate = `instance_id=${sqlValue(this.config.instance_id)} AND attention_channel=${sqlValue(watch.attention_channel)} AND enabled=1 AND epoch=${watch.epoch} AND ${watch.fired_epoch === watch.epoch ? repeatPredicate : initialPredicate} AND endpoint_id=${sqlValue(binding.endpoint_id)} AND binding_generation=${binding.generation}`;
1149
+ this.db.transaction([
1150
+ `INSERT OR IGNORE INTO events(id, instance_id, owner_id, source, type, schema_version, occurred_at, received_at, dedupe_key, coalesce_key, priority_hint, attention_channel_hint, actor_ref, resource_json, metadata_json, payload_preview, matched_policy_id, matched_policy_version)
1151
+ SELECT ${sqlValue(eventId)}, ${sqlValue(this.config.instance_id)}, ${sqlValue(this.config.owner_id)}, 'wakebridge.core', 'channel.inactive', 1, ${sqlValue(now)}, ${sqlValue(now)}, ${sqlValue(dedupeKey)}, ${sqlValue(watch.attention_channel)}, 'normal', ${sqlValue(watch.attention_channel)}, ${sqlValue(this.config.owner_id)}, ${sqlJson(resource)}, ${sqlJson({ origin: "inactivity_watch", reason_code: "host_inactive", epoch: watch.epoch, idle_after_ms: watch.idle_after_ms, mode: watch.mode, sequence })}, NULL, 'inactivity_watch', 1 FROM activity_watches WHERE ${predicate};`,
1152
+ `INSERT OR IGNORE INTO event_status(event_id, state, updated_at) SELECT ${sqlValue(eventId)}, 'matched', ${sqlValue(now)} FROM events WHERE id=${sqlValue(eventId)};`,
1153
+ `INSERT INTO event_transitions(event_id, from_state, to_state, reason, at) SELECT ${sqlValue(eventId)}, NULL, 'matched', 'inactivity_watch', ${sqlValue(now)} WHERE EXISTS (SELECT 1 FROM events WHERE id=${sqlValue(eventId)}) AND NOT EXISTS (SELECT 1 FROM event_transitions WHERE event_id=${sqlValue(eventId)});`,
1154
+ `INSERT OR IGNORE INTO claims(id, instance_id, origin, event_ids_json, resource_json, policy_id, policy_version, attention_channel, eligible_after, expires_at, defer_while_presence, state, reason_code, note, created_at, updated_at, snooze_until, consumed_result_json, dismissed_reason)
1155
+ SELECT ${sqlValue(claimId)}, ${sqlValue(this.config.instance_id)}, 'inactivity_watch', ${sqlJson([eventId])}, ${sqlJson(resource)}, 'inactivity_watch', 1, ${sqlValue(watch.attention_channel)}, ${sqlValue(now)}, NULL, 1, 'pending', 'host_inactive', NULL, ${sqlValue(now)}, ${sqlValue(now)}, NULL, NULL, NULL FROM events WHERE id=${sqlValue(eventId)};`,
1156
+ `INSERT INTO claim_transitions(claim_id, from_state, to_state, reason, at) SELECT ${sqlValue(claimId)}, NULL, 'pending', 'inactivity_watch', ${sqlValue(now)} WHERE EXISTS (SELECT 1 FROM claims WHERE id=${sqlValue(claimId)}) AND NOT EXISTS (SELECT 1 FROM claim_transitions WHERE claim_id=${sqlValue(claimId)});`,
1157
+ `UPDATE activity_watches SET fired_epoch=epoch, sequence=${sequence}, last_triggered_at=${sqlValue(now)}, next_due_at=${sqlValue(watch.mode === "repeat" ? addMs(now, watch.repeat_after_ms) : null)}, activity_lease_expires_at=NULL, updated_at=${sqlValue(now)} WHERE ${predicate} AND EXISTS (SELECT 1 FROM claims WHERE id=${sqlValue(claimId)});`,
1158
+ ]);
1159
+ const after = this.getActivityWatch(watch.attention_channel);
1160
+ if (after?.sequence === sequence && this.getClaim(claimId))
1161
+ created.push(claimId);
1162
+ }
1163
+ return created;
1164
+ }
1165
+ /** Advance due claims and freeze eligible claims into durable batches. */
1166
+ tick() {
1167
+ const nowDate = this.now();
1168
+ const now = nowDate.toISOString();
1169
+ const result = { inactivity_claims_created: this.materializeDueActivityWatches(now), expired_claims: [], deferred_claims: [], eligible_claims: [], batches_created: [], batches_updated: [] };
1170
+ const claims = this.listClaims();
1171
+ const transitions = [];
1172
+ for (const claim of claims) {
1173
+ if (["consumed", "dismissed", "expired", "batched"].includes(claim.state))
1174
+ continue;
1175
+ if (claim.expires_at && claim.expires_at <= now) {
1176
+ transitions.push(...this.claimStateSql(claim, "expired", now, "expiry"));
1177
+ for (const eventId of claim.event_ids)
1178
+ transitions.push(...this.eventStateSql(eventId, "expired", now, "claim_expired"));
1179
+ result.expired_claims.push(claim.id);
1180
+ continue;
1181
+ }
1182
+ if (claim.eligible_after > now)
1183
+ continue;
1184
+ const policy = this.policy(claim.policy_id, claim.policy_version) ?? normalizePolicy(DEFAULT_POLICY);
1185
+ if (policy && policy.delivery.quiet_hours_policy !== "bypass" && quietWindowAt(nowDate, this.config.quiet_hours, this.config.timezone ?? "UTC")) {
1186
+ const end = quietEndAfter(nowDate, this.config.quiet_hours, this.config.timezone ?? "UTC").toISOString();
1187
+ transitions.push(...this.claimDeferSql(claim, end, now, "quiet_hours"));
1188
+ result.deferred_claims.push(claim.id);
1189
+ continue;
1190
+ }
1191
+ if (claim.defer_while_presence) {
1192
+ const presence = this.presenceActive(claim.attention_channel, now);
1193
+ if (presence) {
1194
+ transitions.push(...this.claimDeferSql(claim, presence.expires_at, now, "foreground_presence"));
1195
+ result.deferred_claims.push(claim.id);
1196
+ continue;
1197
+ }
1198
+ }
1199
+ if (claim.state !== "eligible") {
1200
+ transitions.push(...this.claimStateSql(claim, "eligible", now, "gates_open"));
1201
+ result.eligible_claims.push(claim.id);
1202
+ }
1203
+ }
1204
+ if (transitions.length)
1205
+ this.db.transaction(transitions);
1206
+ const eligible = this.listClaims({ state: "eligible", limit: 100_000 });
1207
+ const events = new Map();
1208
+ for (const claim of eligible) {
1209
+ for (const eventId of claim.event_ids) {
1210
+ const event = this.getEvent(eventId);
1211
+ if (event)
1212
+ events.set(event.id, event);
1213
+ }
1214
+ }
1215
+ const open = this.listBatches({ states: ["pending", "waiting_for_endpoint", "retry_wait"] });
1216
+ const openByKey = new Map();
1217
+ for (const batch of open) {
1218
+ if (!batch.deadline || batch.deadline > now)
1219
+ openByKey.set(this.batchKey(batch.policy_id, batch.policy_version, batch.attention_channel, batch.coalesce_key ?? null), batch);
1220
+ }
1221
+ const batchStatements = [];
1222
+ for (const claim of eligible) {
1223
+ const policy = this.policy(claim.policy_id, claim.policy_version) ?? normalizePolicy(DEFAULT_POLICY);
1224
+ const event = events.get(claim.event_ids[0]);
1225
+ const coalesce = this.coalesceKey(policy, claim, event);
1226
+ const key = this.batchKey(claim.policy_id, claim.policy_version, claim.attention_channel, coalesce);
1227
+ const maxEvents = Math.max(1, Math.floor(policy.batch?.max_events ?? 20));
1228
+ let batch = openByKey.get(key);
1229
+ if (batch && batch.claim_ids.length < maxEvents && batch.deadline && batch.deadline > now) {
1230
+ const claimIds = arrayWithoutDuplicates([...batch.claim_ids, claim.id]);
1231
+ const eventIds = arrayWithoutDuplicates([...batch.event_ids, ...claim.event_ids]);
1232
+ batchStatements.push(...this.batchUpdateArraysSql(batch, claimIds, eventIds, now));
1233
+ batch.claim_ids = claimIds;
1234
+ batch.event_ids = eventIds;
1235
+ batch.updated_at = now;
1236
+ batchStatements.push(...this.claimStateSql(claim, "batched", now, `batch:${batch.id}`));
1237
+ for (const eventId of claim.event_ids)
1238
+ batchStatements.push(...this.eventStateSql(eventId, "batched", now, `batch:${batch.id}`));
1239
+ result.batches_updated.push(batch.id);
1240
+ continue;
1241
+ }
1242
+ const windowMs = assertFiniteMs(policy.batch?.window_ms, "batch.window_ms", this.config.batch_window_ms);
1243
+ const batchId = id("wb");
1244
+ const deadline = addMs(nowDate, windowMs);
1245
+ const newBatch = {
1246
+ id: batchId,
1247
+ instance_id: this.config.instance_id,
1248
+ policy_id: claim.policy_id,
1249
+ policy_version: claim.policy_version,
1250
+ attention_channel: claim.attention_channel,
1251
+ claim_ids: [claim.id],
1252
+ event_ids: [...claim.event_ids],
1253
+ coalesce_key: coalesce,
1254
+ state: "pending",
1255
+ not_before: now,
1256
+ deadline,
1257
+ attempt: 0,
1258
+ binding_generation: null,
1259
+ created_at: now,
1260
+ updated_at: now,
1261
+ lease_expires_at: null,
1262
+ last_error: null,
1263
+ };
1264
+ batchStatements.push(this.batchInsertSql(newBatch));
1265
+ batchStatements.push(`INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) VALUES(${sqlValue(batchId)}, NULL, 'pending', 'freeze', ${sqlValue(now)});`);
1266
+ batchStatements.push(...this.claimStateSql(claim, "batched", now, `batch:${batchId}`));
1267
+ for (const eventId of claim.event_ids)
1268
+ batchStatements.push(...this.eventStateSql(eventId, "batched", now, `batch:${batchId}`));
1269
+ openByKey.set(key, newBatch);
1270
+ result.batches_created.push(batchId);
1271
+ }
1272
+ if (batchStatements.length)
1273
+ this.db.transaction(batchStatements);
1274
+ return result;
1275
+ }
1276
+ claimStateSql(claim, state, now, reason) {
1277
+ return [
1278
+ `UPDATE claims SET state=${sqlValue(state)}, updated_at=${sqlValue(now)} WHERE id=${sqlValue(claim.id)} AND state=${sqlValue(claim.state)};`,
1279
+ `INSERT INTO claim_transitions(claim_id, from_state, to_state, reason, at) SELECT ${sqlValue(claim.id)}, ${sqlValue(claim.state)}, ${sqlValue(state)}, ${sqlValue(reason)}, ${sqlValue(now)} WHERE changes()>0;`,
1280
+ ];
1281
+ }
1282
+ claimDeferSql(claim, eligibleAfter, now, reason) {
1283
+ return [
1284
+ `UPDATE claims SET state='deferred', eligible_after=${sqlValue(eligibleAfter)}, updated_at=${sqlValue(now)} WHERE id=${sqlValue(claim.id)} AND state IN ('pending','deferred','eligible');`,
1285
+ `INSERT INTO claim_transitions(claim_id, from_state, to_state, reason, at) SELECT ${sqlValue(claim.id)}, ${sqlValue(claim.state)}, 'deferred', ${sqlValue(reason)}, ${sqlValue(now)} WHERE changes()>0;`,
1286
+ ];
1287
+ }
1288
+ eventStateSql(eventId, state, now, reason) {
1289
+ const current = this.getEvent(eventId)?.state;
1290
+ if (!current || current === state)
1291
+ return [];
1292
+ return [
1293
+ `UPDATE event_status SET state=${sqlValue(state)}, updated_at=${sqlValue(now)} WHERE event_id=${sqlValue(eventId)};`,
1294
+ `INSERT INTO event_transitions(event_id, from_state, to_state, reason, at) VALUES(${sqlValue(eventId)}, ${sqlValue(current)}, ${sqlValue(state)}, ${sqlValue(reason)}, ${sqlValue(now)});`,
1295
+ ];
1296
+ }
1297
+ coalesceKey(policy, claim, event) {
1298
+ const by = policy.batch?.coalesce_by ?? "coalesce_key";
1299
+ if (by === "none")
1300
+ return claim.id;
1301
+ if (by === "type")
1302
+ return event?.type ?? claim.id;
1303
+ if (by === "source")
1304
+ return event?.source ?? claim.id;
1305
+ if (by.startsWith("metadata.") && event)
1306
+ return String(getPath(event.metadata, by.slice("metadata.".length)) ?? claim.id);
1307
+ return event?.coalesce_key ?? claim.id;
1308
+ }
1309
+ batchKey(policyId, version, channel, coalesce) {
1310
+ return `${policyId}:${version}:${channel}:${coalesce ?? ""}`;
1311
+ }
1312
+ batchInsertSql(batch) {
1313
+ return `INSERT INTO batches(id, instance_id, policy_id, policy_version, attention_channel, claim_ids_json, event_ids_json, coalesce_key, state, not_before, deadline, attempt, binding_generation, created_at, updated_at, lease_expires_at, last_error)
1314
+ VALUES(${sqlValue(batch.id)}, ${sqlValue(batch.instance_id)}, ${sqlValue(batch.policy_id)}, ${batch.policy_version}, ${sqlValue(batch.attention_channel)}, ${sqlJson(batch.claim_ids)}, ${sqlJson(batch.event_ids)}, ${sqlValue(batch.coalesce_key)}, ${sqlValue(batch.state)}, ${sqlValue(batch.not_before)}, ${sqlValue(batch.deadline)}, ${batch.attempt}, NULL, ${sqlValue(batch.created_at)}, ${sqlValue(batch.updated_at)}, NULL, NULL);`;
1315
+ }
1316
+ batchUpdateArraysSql(batch, claimIds, eventIds, now) {
1317
+ return [
1318
+ `UPDATE batches SET claim_ids_json=${sqlJson(claimIds)}, event_ids_json=${sqlJson(eventIds)}, updated_at=${sqlValue(now)} WHERE id=${sqlValue(batch.id)} AND state IN ('pending','waiting_for_endpoint','retry_wait');`,
1319
+ `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) SELECT ${sqlValue(batch.id)}, state, state, 'append_claim', ${sqlValue(now)} FROM batches WHERE id=${sqlValue(batch.id)} AND changes()>0;`,
1320
+ ];
1321
+ }
1322
+ getBatch(batchId) {
1323
+ const row = this.db.query(`SELECT * FROM batches WHERE id=${sqlValue(batchId)} AND instance_id=${sqlValue(this.config.instance_id)} LIMIT 1;`)[0];
1324
+ return row ? this.batchFromRow(row) : null;
1325
+ }
1326
+ listBatches(filters = {}) {
1327
+ const clauses = [`instance_id=${sqlValue(this.config.instance_id)}`];
1328
+ if (filters.states?.length)
1329
+ clauses.push(`state IN (${filters.states.map(sqlValue).join(",")})`);
1330
+ if (filters.channel)
1331
+ clauses.push(`attention_channel=${sqlValue(filters.channel)}`);
1332
+ const limit = Math.min(1000, Math.max(1, Math.floor(filters.limit ?? 1000)));
1333
+ return this.db.query(`SELECT * FROM batches WHERE ${clauses.join(" AND ")} ORDER BY created_at ASC LIMIT ${limit};`).map((row) => this.batchFromRow(row));
1334
+ }
1335
+ batchFromRow(row) {
1336
+ return {
1337
+ id: String(row.id),
1338
+ instance_id: String(row.instance_id),
1339
+ policy_id: String(row.policy_id),
1340
+ policy_version: Number(row.policy_version),
1341
+ attention_channel: String(row.attention_channel),
1342
+ claim_ids: jsonRecord(row.claim_ids_json, []),
1343
+ event_ids: jsonRecord(row.event_ids_json, []),
1344
+ coalesce_key: row.coalesce_key == null ? null : String(row.coalesce_key),
1345
+ state: String(row.state),
1346
+ not_before: String(row.not_before),
1347
+ deadline: row.deadline == null ? null : String(row.deadline),
1348
+ attempt: Number(row.attempt),
1349
+ binding_generation: row.binding_generation == null ? null : Number(row.binding_generation),
1350
+ created_at: String(row.created_at),
1351
+ updated_at: String(row.updated_at),
1352
+ lease_expires_at: row.lease_expires_at == null ? null : String(row.lease_expires_at),
1353
+ last_error: row.last_error == null ? null : String(row.last_error),
1354
+ };
1355
+ }
1356
+ resolveBinding(channel, now) {
1357
+ const row = this.db.query(`SELECT b.*, e.* FROM bindings b JOIN endpoints e ON e.id=b.endpoint_id WHERE b.instance_id=${sqlValue(this.config.instance_id)} AND b.attention_channel=${sqlValue(channel)} AND e.lease_expires_at>${sqlValue(now)} LIMIT 1;`)[0];
1358
+ if (!row)
1359
+ return null;
1360
+ return {
1361
+ binding: { instance_id: String(row.instance_id), attention_channel: String(row.attention_channel), endpoint_id: String(row.endpoint_id), generation: Number(row.generation), bound_at: String(row.bound_at) },
1362
+ endpoint: this.endpointFromRow(row),
1363
+ };
1364
+ }
1365
+ chooseTransport(_batch, endpoint) {
1366
+ const routeKinds = [...endpoint.routes].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)).map((route) => route.kind);
1367
+ for (const kind of routeKinds) {
1368
+ const transport = this.transports.get(kind);
1369
+ if (transport && (endpoint.capabilities.cold_push !== false || transport.capabilities?.warm_resume))
1370
+ return transport;
1371
+ }
1372
+ return null;
1373
+ }
1374
+ buildPayload(batch, binding) {
1375
+ // A batch freezes exact claim/event references. Resolve those references
1376
+ // directly rather than through a bounded event index: an older event must
1377
+ // not disappear from a wake payload merely because the instance has more
1378
+ // than 1,000 retained events.
1379
+ const claims = batch.claim_ids.map((claimId) => this.getClaim(claimId)).filter((claim) => Boolean(claim) && claim.state === "batched");
1380
+ const events = new Map();
1381
+ for (const claim of claims) {
1382
+ for (const eventId of claim.event_ids) {
1383
+ const event = this.getEvent(eventId);
1384
+ if (event)
1385
+ events.set(event.id, event);
1386
+ }
1387
+ }
1388
+ return {
1389
+ schema_version: 1,
1390
+ instance_id: this.config.instance_id,
1391
+ wake_batch_id: batch.id,
1392
+ attention_channel: batch.attention_channel,
1393
+ claim_refs: claims.map((claim) => {
1394
+ const event = events.get(claim.event_ids[0]);
1395
+ return {
1396
+ claim_id: claim.id,
1397
+ event_id: event?.id,
1398
+ source: event?.source ?? claim.origin,
1399
+ type: event?.type ?? "attention.claim",
1400
+ resource: claim.resource,
1401
+ origin: claim.origin,
1402
+ reason_code: claim.reason_code,
1403
+ };
1404
+ }),
1405
+ binding_generation: binding.generation,
1406
+ };
1407
+ }
1408
+ markNoEndpoint(batch, now) {
1409
+ this.db.transaction([
1410
+ `UPDATE batches SET state='waiting_for_endpoint', updated_at=${sqlValue(now)}, last_error='no_current_binding' WHERE id=${sqlValue(batch.id)} AND state IN ('pending','retry_wait');`,
1411
+ `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) SELECT ${sqlValue(batch.id)}, state, 'waiting_for_endpoint', 'no_endpoint', ${sqlValue(now)} FROM batches WHERE id=${sqlValue(batch.id)} AND changes()>0;`,
1412
+ ]);
1413
+ return { batch_id: batch.id, status: "waiting_for_endpoint" };
1414
+ }
1415
+ acquireBatch(batch, binding, now) {
1416
+ const attemptId = id("attempt");
1417
+ const leaseUntil = addMs(now, this.config.dispatch_lease_ms);
1418
+ const fromState = batch.state;
1419
+ const rows = this.db.query([
1420
+ "BEGIN IMMEDIATE;",
1421
+ `UPDATE batches SET state='dispatching', attempt=attempt+1, binding_generation=${binding.generation}, lease_expires_at=${sqlValue(leaseUntil)}, updated_at=${sqlValue(now)}, last_error=${sqlValue(attemptId)}
1422
+ WHERE id=${sqlValue(batch.id)} AND instance_id=${sqlValue(this.config.instance_id)} AND state IN ('pending','retry_wait','waiting_for_endpoint')
1423
+ AND not_before<=${sqlValue(now)} AND (state='waiting_for_endpoint' OR deadline IS NULL OR deadline<=${sqlValue(now)});`,
1424
+ `INSERT INTO outbox_attempts(id, batch_id, attempt_no, state, transport_kind, endpoint_id, binding_generation, leased_until, started_at)
1425
+ SELECT ${sqlValue(attemptId)}, id, attempt, 'leased', NULL, ${sqlValue(binding.endpoint_id)}, ${binding.generation}, ${sqlValue(leaseUntil)}, ${sqlValue(now)} FROM batches
1426
+ WHERE id=${sqlValue(batch.id)} AND state='dispatching' AND lease_expires_at=${sqlValue(leaseUntil)} AND last_error=${sqlValue(attemptId)};`,
1427
+ `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at)
1428
+ SELECT ${sqlValue(batch.id)}, ${sqlValue(fromState)}, 'dispatching', 'worker_lease', ${sqlValue(now)} FROM batches WHERE id=${sqlValue(batch.id)} AND state='dispatching' AND lease_expires_at=${sqlValue(leaseUntil)} AND last_error=${sqlValue(attemptId)};`,
1429
+ `SELECT id, instance_id, policy_id, policy_version, attention_channel, claim_ids_json, event_ids_json, coalesce_key, state, not_before, deadline, attempt, binding_generation, created_at, updated_at, lease_expires_at, last_error FROM batches WHERE id=${sqlValue(batch.id)} AND state='dispatching' AND lease_expires_at=${sqlValue(leaseUntil)} AND last_error=${sqlValue(attemptId)};`,
1430
+ "COMMIT;",
1431
+ ].join("\n"));
1432
+ if (!rows.length)
1433
+ return null;
1434
+ return { batch: this.batchFromRow({ ...rows[0], state: "dispatching" }), attemptId };
1435
+ }
1436
+ bindingStillCurrent(binding, now) {
1437
+ const current = this.resolveBinding(binding.attention_channel, now);
1438
+ return Boolean(current && current.binding.endpoint_id === binding.endpoint_id && current.binding.generation === binding.generation);
1439
+ }
1440
+ finalizeAttempt(attemptId, batch, binding, result, transportKind, now) {
1441
+ const accepted = result.accepted === true;
1442
+ const retryable = result.retryable !== false;
1443
+ const nextState = accepted ? "dispatched" : retryable ? "retry_wait" : "dead_letter";
1444
+ const status = accepted ? "accepted" : retryable ? "retry_wait" : "dead_letter";
1445
+ const errorClass = result.error_class || (accepted ? null : "transport_rejected");
1446
+ const errorMessage = result.error_message || null;
1447
+ const requestedRetry = result.retry_after_ms;
1448
+ const retryDelay = requestedRetry !== undefined
1449
+ && Number.isFinite(requestedRetry)
1450
+ && requestedRetry >= 0
1451
+ ? Math.min(requestedRetry, 24 * 60 * 60_000)
1452
+ : this.config.retry_delay_ms;
1453
+ const nextNotBefore = accepted ? batch.not_before : addMs(now, retryDelay);
1454
+ const statements = [
1455
+ `UPDATE outbox_attempts SET state=${sqlValue(accepted ? "accepted" : "failed")}, transport_kind=${sqlValue(result.transport_kind || transportKind)}, finished_at=${sqlValue(now)}, error_class=${sqlValue(errorClass)}, error_message=${sqlValue(errorMessage)} WHERE id=${sqlValue(attemptId)} AND state='leased';`,
1456
+ ];
1457
+ if (!accepted)
1458
+ statements.push(`UPDATE delivery_correlations SET state='discarded' WHERE attempt_id=${sqlValue(attemptId)} AND state='outstanding';`);
1459
+ if (accepted) {
1460
+ // Insert the accepted receipt while the attempt id still owns the
1461
+ // dispatching row. A late worker whose lease was reclaimed cannot
1462
+ // satisfy this EXISTS predicate and therefore cannot insert a receipt
1463
+ // for a newer attempt.
1464
+ statements.push(`INSERT INTO receipts(id, batch_id, claim_id, stage, at, endpoint_id, binding_generation, transport_kind, details_json)
1465
+ SELECT ${sqlValue(id("rcpt"))}, ${sqlValue(batch.id)}, NULL, 'transport_accepted', ${sqlValue(now)}, ${sqlValue(binding.endpoint_id)}, ${binding.generation}, ${sqlValue(result.transport_kind || transportKind)}, ${sqlJson(result.receipt_details ?? null)}
1466
+ WHERE EXISTS (SELECT 1 FROM batches WHERE id=${sqlValue(batch.id)} AND state='dispatching' AND last_error=${sqlValue(attemptId)})
1467
+ AND EXISTS (SELECT 1 FROM outbox_attempts WHERE id=${sqlValue(attemptId)} AND state='accepted');`);
1468
+ }
1469
+ statements.push(`UPDATE batches SET state=${sqlValue(nextState)}, not_before=${sqlValue(nextNotBefore)}, lease_expires_at=NULL, updated_at=${sqlValue(now)}, last_error=${sqlValue(errorClass)} WHERE id=${sqlValue(batch.id)} AND state='dispatching' AND last_error=${sqlValue(attemptId)};`);
1470
+ statements.push(`INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) SELECT ${sqlValue(batch.id)}, 'dispatching', ${sqlValue(nextState)}, ${sqlValue(errorClass || "transport_accepted")}, ${sqlValue(now)} WHERE changes()>0;`);
1471
+ this.db.transaction(statements);
1472
+ const current = this.getBatch(batch.id);
1473
+ if (!current || current.state !== nextState || current.last_error !== errorClass) {
1474
+ return { batch_id: batch.id, status: "skipped", attempt_id: attemptId, error_class: "stale_attempt" };
1475
+ }
1476
+ return { batch_id: batch.id, status, attempt_id: attemptId, endpoint_id: binding.endpoint_id, generation: binding.generation, error_class: errorClass ?? undefined };
1477
+ }
1478
+ /** Dispatch due durable batches. The lease CAS prevents double-dispatch. */
1479
+ async dispatchDue(nowDate = this.now()) {
1480
+ const now = nowDate.toISOString();
1481
+ // A worker can disappear without a process restart in the daemon (for
1482
+ // example, an adapter promise is rejected). Expired leases are safe to
1483
+ // reclaim on every sweep; the attempt id CAS below still protects a live
1484
+ // worker from a second dispatcher.
1485
+ this.recover();
1486
+ const batches = this.listBatches({ states: ["pending", "waiting_for_endpoint", "retry_wait"] });
1487
+ const results = [];
1488
+ for (const listed of batches) {
1489
+ if (listed.not_before > now)
1490
+ continue;
1491
+ if (listed.state !== "waiting_for_endpoint" && listed.deadline && listed.deadline > now)
1492
+ continue;
1493
+ const resolved = this.resolveBinding(listed.attention_channel, now);
1494
+ if (!resolved) {
1495
+ results.push(this.markNoEndpoint(listed, now));
1496
+ continue;
1497
+ }
1498
+ const transport = this.chooseTransport(listed, resolved.endpoint);
1499
+ if (!transport) {
1500
+ this.db.transaction([
1501
+ `UPDATE batches SET state='waiting_for_endpoint', last_error='no_compatible_transport', updated_at=${sqlValue(now)} WHERE id=${sqlValue(listed.id)} AND state IN ('pending','retry_wait');`,
1502
+ `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) SELECT ${sqlValue(listed.id)}, state, 'waiting_for_endpoint', 'no_transport', ${sqlValue(now)} FROM batches WHERE id=${sqlValue(listed.id)} AND changes()>0;`,
1503
+ ]);
1504
+ results.push({ batch_id: listed.id, status: "waiting_for_endpoint", error_class: "no_compatible_transport" });
1505
+ continue;
1506
+ }
1507
+ const acquired = this.acquireBatch(listed, resolved.binding, now);
1508
+ if (!acquired) {
1509
+ results.push({ batch_id: listed.id, status: "skipped" });
1510
+ continue;
1511
+ }
1512
+ if (!this.bindingStillCurrent(resolved.binding, now)) {
1513
+ this.db.transaction([
1514
+ `UPDATE outbox_attempts SET state='failed', finished_at=${sqlValue(now)}, error_class='stale_generation', error_message='binding changed before dispatch' WHERE id=${sqlValue(acquired.attemptId)} AND state='leased';`,
1515
+ `UPDATE batches SET state='retry_wait', not_before=${sqlValue(addMs(now, this.config.retry_delay_ms))}, lease_expires_at=NULL, updated_at=${sqlValue(now)}, last_error='stale_generation' WHERE id=${sqlValue(listed.id)} AND state='dispatching';`,
1516
+ `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) SELECT ${sqlValue(listed.id)}, 'dispatching', 'retry_wait', 'stale_generation', ${sqlValue(now)} WHERE changes()>0;`,
1517
+ ]);
1518
+ results.push({ batch_id: listed.id, status: "stale_generation", attempt_id: acquired.attemptId, endpoint_id: resolved.binding.endpoint_id, generation: resolved.binding.generation, error_class: "stale_generation" });
1519
+ continue;
1520
+ }
1521
+ const payload = this.buildPayload(acquired.batch, resolved.binding);
1522
+ if (!payload.claim_refs.length) {
1523
+ this.db.transaction([
1524
+ `UPDATE outbox_attempts SET state='failed', finished_at=${sqlValue(now)}, error_class='empty_batch', error_message='all claims were finalized before dispatch' WHERE id=${sqlValue(acquired.attemptId)} AND state='leased';`,
1525
+ `UPDATE batches SET state='needs_attention', lease_expires_at=NULL, updated_at=${sqlValue(now)}, last_error='empty_batch' WHERE id=${sqlValue(listed.id)} AND state='dispatching';`,
1526
+ ]);
1527
+ results.push({ batch_id: listed.id, status: "needs_attention", attempt_id: acquired.attemptId, error_class: "empty_batch" });
1528
+ continue;
1529
+ }
1530
+ const deliveryNonce = randomBytes(24).toString("base64url");
1531
+ if (transport.capabilities?.session_activity_observable) {
1532
+ const correlationCreatedAt = this.nowIso();
1533
+ this.db.transaction([
1534
+ `INSERT INTO delivery_correlations(attempt_id, batch_id, endpoint_id, binding_generation, transport_kind, nonce_hash, state, created_at, expires_at, consumed_at)
1535
+ VALUES(${sqlValue(acquired.attemptId)}, ${sqlValue(acquired.batch.id)}, ${sqlValue(resolved.binding.endpoint_id)}, ${resolved.binding.generation}, ${sqlValue(transport.kind)}, ${sqlValue(sha256(deliveryNonce))}, 'outstanding', ${sqlValue(correlationCreatedAt)}, ${sqlValue(addMs(correlationCreatedAt, 60 * 60_000))}, NULL);`,
1536
+ ]);
1537
+ }
1538
+ const context = {
1539
+ batch: acquired.batch,
1540
+ endpoint: resolved.endpoint,
1541
+ binding: resolved.binding,
1542
+ payload,
1543
+ attempt_id: acquired.attemptId,
1544
+ delivery_nonce: deliveryNonce,
1545
+ };
1546
+ let transportResult;
1547
+ try {
1548
+ transportResult = await Promise.resolve(transport.dispatch(context));
1549
+ }
1550
+ catch (error) {
1551
+ transportResult = { accepted: false, retryable: true, error_class: "transport_exception", error_message: String(error) };
1552
+ }
1553
+ // A transport may legitimately remain open for minutes. Retry windows,
1554
+ // receipts and transitions must be based on completion time, not the
1555
+ // timestamp captured before the network/provider turn began.
1556
+ const finalizedAt = this.nowIso();
1557
+ const final = this.finalizeAttempt(acquired.attemptId, acquired.batch, resolved.binding, transportResult, transport.kind, finalizedAt);
1558
+ final.endpoint_id = resolved.binding.endpoint_id;
1559
+ final.generation = resolved.binding.generation;
1560
+ results.push(final);
1561
+ }
1562
+ return results;
1563
+ }
1564
+ receiptInsertSql(receipt) {
1565
+ return `INSERT INTO receipts(id, batch_id, claim_id, stage, at, endpoint_id, binding_generation, transport_kind, details_json)
1566
+ VALUES(${sqlValue(receipt.id)}, ${sqlValue(receipt.batch_id)}, ${sqlValue(receipt.claim_id)}, ${sqlValue(receipt.stage)}, ${sqlValue(receipt.at)}, ${sqlValue(receipt.endpoint_id)}, ${sqlValue(receipt.binding_generation)}, ${sqlValue(receipt.transport_kind)}, ${sqlJson(receipt.details)});`;
1567
+ }
1568
+ detachClaimFromOpenBatches(claim, now) {
1569
+ const statements = [];
1570
+ for (const batch of this.listBatches({ limit: 1000 })) {
1571
+ if (!batch.claim_ids.includes(claim.id) || !["pending", "waiting_for_endpoint", "retry_wait"].includes(batch.state))
1572
+ continue;
1573
+ const claimIds = batch.claim_ids.filter((idValue) => idValue !== claim.id);
1574
+ const eventIds = arrayWithoutDuplicates(batch.event_ids.filter((eventId) => !claim.event_ids.includes(eventId) || claimIds.some((otherClaimId) => this.getClaim(otherClaimId)?.event_ids.includes(eventId))));
1575
+ if (claimIds.length) {
1576
+ statements.push(...this.batchUpdateArraysSql(batch, claimIds, eventIds, now));
1577
+ }
1578
+ else {
1579
+ statements.push(`UPDATE batches SET claim_ids_json='[]', event_ids_json='[]', state='cancelled', updated_at=${sqlValue(now)}, last_error='all_claims_finalized' WHERE id=${sqlValue(batch.id)} AND state IN ('pending','waiting_for_endpoint','retry_wait');`, `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) SELECT ${sqlValue(batch.id)}, ${sqlValue(batch.state)}, 'cancelled', 'all_claims_finalized', ${sqlValue(now)} FROM batches WHERE id=${sqlValue(batch.id)} AND changes()>0;`);
1580
+ }
1581
+ }
1582
+ return statements;
1583
+ }
1584
+ listReceipts(batchId) {
1585
+ const where = [`b.instance_id=${sqlValue(this.config.instance_id)}`];
1586
+ if (batchId)
1587
+ where.push(`r.batch_id=${sqlValue(batchId)}`);
1588
+ return this.db.query(`SELECT r.* FROM receipts r JOIN batches b ON b.id=r.batch_id WHERE ${where.join(" AND ")} ORDER BY r.at ASC, r.id ASC;`).map((row) => ({
1589
+ id: String(row.id),
1590
+ batch_id: String(row.batch_id),
1591
+ claim_id: row.claim_id == null ? null : String(row.claim_id),
1592
+ stage: String(row.stage),
1593
+ at: String(row.at),
1594
+ endpoint_id: row.endpoint_id == null ? null : String(row.endpoint_id),
1595
+ binding_generation: row.binding_generation == null ? null : Number(row.binding_generation),
1596
+ transport_kind: row.transport_kind == null ? null : String(row.transport_kind),
1597
+ details: row.details_json == null ? null : jsonRecord(row.details_json, {}),
1598
+ }));
1599
+ }
1600
+ listAttempts(batchId) {
1601
+ const where = ["1=1"];
1602
+ if (batchId)
1603
+ where.push(`batch_id=${sqlValue(batchId)}`);
1604
+ return this.db.query(`SELECT * FROM outbox_attempts WHERE ${where.join(" AND ")} ORDER BY started_at ASC, attempt_no ASC;`);
1605
+ }
1606
+ acceptedAttempt(batchId, attemptNo) {
1607
+ const clauses = [`batch_id=${sqlValue(batchId)}`, "state='accepted'"];
1608
+ if (attemptNo != null)
1609
+ clauses.push(`attempt_no=${attemptNo}`);
1610
+ return this.db.query(`SELECT * FROM outbox_attempts WHERE ${clauses.join(" AND ")} ORDER BY attempt_no DESC LIMIT 1;`)[0] ?? null;
1611
+ }
1612
+ ackBatch(batchId, endpointId, generation) {
1613
+ const batch = this.getBatch(batchId);
1614
+ if (!batch)
1615
+ throw new BridgeError("batch not found", "batch_not_found", 404);
1616
+ if (batch.state === "seen")
1617
+ return batch;
1618
+ if (batch.state !== "dispatched")
1619
+ throw new BridgeError("batch acknowledgement is not available", "batch_not_ready", 409);
1620
+ // The accepted attempt must be the one that finalized this batch. A
1621
+ // previous accepted attempt may still be present after lease recovery;
1622
+ // accepting its late acknowledgement would incorrectly mark a retry as
1623
+ // seen.
1624
+ const accepted = this.acceptedAttempt(batchId, batch.attempt);
1625
+ if (!accepted || String(accepted.endpoint_id) !== endpointId || Number(accepted.binding_generation) !== generation) {
1626
+ throw new BridgeError("batch acknowledgement is fenced by endpoint/generation", "stale_generation", 409);
1627
+ }
1628
+ const binding = this.getBinding(batch.attention_channel);
1629
+ if (!binding || binding.endpoint_id !== endpointId || binding.generation !== generation)
1630
+ throw new BridgeError("batch acknowledgement targets stale binding", "stale_generation", 409);
1631
+ const now = this.nowIso();
1632
+ this.db.transaction([
1633
+ `UPDATE batches SET state='seen', updated_at=${sqlValue(now)}, lease_expires_at=NULL WHERE id=${sqlValue(batchId)} AND instance_id=${sqlValue(this.config.instance_id)} AND state='dispatched' AND attempt=${Number(accepted.attempt_no)} AND binding_generation=${generation};`,
1634
+ // Keep the transition INSERT immediately after the CAS so changes()
1635
+ // refers to that UPDATE. The receipt follows it and is conditional on
1636
+ // the transition INSERT, so a stale/concurrent ack cannot leave a
1637
+ // misleading agent_seen receipt behind.
1638
+ `INSERT INTO batch_transitions(batch_id, from_state, to_state, reason, at) SELECT ${sqlValue(batchId)}, 'dispatched', 'seen', 'agent_ack', ${sqlValue(now)} WHERE changes()>0;`,
1639
+ `INSERT INTO receipts(id, batch_id, claim_id, stage, at, endpoint_id, binding_generation, transport_kind, details_json)
1640
+ SELECT ${sqlValue(id("rcpt"))}, ${sqlValue(batchId)}, NULL, 'agent_seen', ${sqlValue(now)}, ${sqlValue(endpointId)}, ${generation}, ${sqlValue(String(accepted.transport_kind || "unknown"))}, NULL
1641
+ WHERE changes()>0;`,
1642
+ ]);
1643
+ const current = this.getBatch(batchId);
1644
+ if (!current)
1645
+ throw new BridgeError("batch not found", "batch_not_found", 404);
1646
+ if (current.state === "seen")
1647
+ return current;
1648
+ throw new BridgeError("batch acknowledgement was fenced by a concurrent state change", "batch_not_ready", 409);
1649
+ }
1650
+ recordReceipt(batchId, stage, details, endpointId, generation) {
1651
+ const batch = this.getBatch(batchId);
1652
+ if (!batch)
1653
+ throw new BridgeError("batch not found", "batch_not_found", 404);
1654
+ if (["agent_seen", "agent_consumed", "agent_acted"].includes(stage) && endpointId && generation != null) {
1655
+ const binding = this.getBinding(batch.attention_channel);
1656
+ if (!binding || binding.endpoint_id !== endpointId || binding.generation !== generation)
1657
+ throw new BridgeError("receipt target is stale", "stale_generation", 409);
1658
+ }
1659
+ const receipt = { id: id("rcpt"), batch_id: batchId, stage, at: this.nowIso(), endpoint_id: endpointId ?? null, binding_generation: generation ?? null, transport_kind: null, details: details ?? null };
1660
+ this.db.exec(this.receiptInsertSql(receipt));
1661
+ return receipt;
1662
+ }
1663
+ claimAction(claimId, action, value) {
1664
+ const claim = this.getClaim(claimId);
1665
+ if (!claim)
1666
+ throw new BridgeError("claim not found", "claim_not_found", 404);
1667
+ if (["consumed", "dismissed", "expired"].includes(claim.state))
1668
+ return claim;
1669
+ const now = this.nowIso();
1670
+ const nextState = action === "snooze" ? "pending" : action === "dismiss" ? "dismissed" : "consumed";
1671
+ const batch = this.listBatches({ limit: 1000 }).find((item) => item.claim_ids.includes(claimId));
1672
+ const statements = [
1673
+ `UPDATE claims SET state=${sqlValue(nextState)}, eligible_after=${sqlValue(action === "snooze" ? String(value) : claim.eligible_after)}, snooze_until=${sqlValue(action === "snooze" ? String(value) : null)}, consumed_result_json=${sqlJson(action === "consume" ? value : null)}, dismissed_reason=${sqlValue(action === "dismiss" ? String(value || "dismissed") : null)}, updated_at=${sqlValue(now)} WHERE id=${sqlValue(claimId)} AND state=${sqlValue(claim.state)};`,
1674
+ `INSERT INTO claim_transitions(claim_id, from_state, to_state, reason, at) SELECT ${sqlValue(claimId)}, ${sqlValue(claim.state)}, ${sqlValue(nextState)}, ${sqlValue(action)}, ${sqlValue(now)} WHERE changes()>0;`,
1675
+ ];
1676
+ statements.push(...this.detachClaimFromOpenBatches(claim, now));
1677
+ if (action !== "snooze") {
1678
+ for (const eventId of claim.event_ids)
1679
+ statements.push(...this.eventStateSql(eventId, "consumed", now, action));
1680
+ }
1681
+ if (batch && action !== "snooze") {
1682
+ statements.push(this.receiptInsertSql({ id: id("rcpt"), batch_id: batch.id, claim_id: claimId, stage: "agent_consumed", at: now, endpoint_id: null, binding_generation: null, transport_kind: null, details: action === "dismiss" ? { reason: String(value || "dismissed") } : { result: value ?? null } }));
1683
+ }
1684
+ this.db.transaction(statements);
1685
+ return this.getClaim(claimId);
1686
+ }
1687
+ snoozeClaim(claimId, until) {
1688
+ const due = isoOrThrow(until, "until");
1689
+ if (new Date(due).getTime() <= this.now().getTime())
1690
+ throw new BridgeError("snooze until must be in the future", "invalid_claim", 400);
1691
+ return this.claimAction(claimId, "snooze", due);
1692
+ }
1693
+ dismissClaim(claimId, reason = "dismissed") {
1694
+ if (reason.length > 500)
1695
+ throw new BridgeError("dismiss reason is too long", "invalid_claim", 400);
1696
+ return this.claimAction(claimId, "dismiss", reason);
1697
+ }
1698
+ consumeClaim(claimId, result) {
1699
+ if (result !== undefined && JSON.stringify(result).length > 4096)
1700
+ throw new BridgeError("consume result is too large", "oversized_event", 413);
1701
+ return this.claimAction(claimId, "consume", result ?? null);
1702
+ }
1703
+ inspect() {
1704
+ return {
1705
+ instance: { instance_id: this.config.instance_id, owner_id: this.config.owner_id, timezone: this.config.timezone },
1706
+ events: this.listEvents({ limit: 1000 }),
1707
+ claims: this.listClaims(),
1708
+ batches: this.listBatches(),
1709
+ bindings: this.listBindings(),
1710
+ endpoints: this.listEndpoints(),
1711
+ presence: this.listPresence(),
1712
+ activity_watches: this.listActivityWatches(),
1713
+ delivery_correlations: this.listDeliveryCorrelations(),
1714
+ receipts: this.listReceipts(),
1715
+ attempts: this.listAttempts(),
1716
+ };
1717
+ }
1718
+ }
1719
+ export { DEFAULT_POLICY };
1720
+ //# sourceMappingURL=core.js.map