@byok-sdk/cloud-dataplane 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import pg from 'pg';
2
2
  import { DEDUP_RING_CAPACITY, NONCE_TTL_MS, validateActivityAppend, projectTimelineEvents, ByokCloudError, validateApprovalTimelineAppend, ApprovalObservationSchema, AllowAllRateLimiter, TruthCommitError, TruthCommitResponseSchema, truthRecordMetadata, TRUTH_REQUEST_ID_MAX_LENGTH, parseTimelineEvents, activityCursor, parseApprovalObservations, approvalTimelineCursor } from '@byok-sdk/cloud';
3
- import { ByokCoreError, assertCanonicalTimestamp, contentHash, isContentHash, tenantObjectKey, objectKeyPrefix, CoreConflictError, isLegalBoardTransition, checkSkillPackManifest, checkSkillPackEntry, SKILL_PACK_ENTRY_PATH, SKILL_PACK_MANIFEST_SCHEMA_ID } from '@byok-sdk/core';
3
+ import { contentHash, ByokCoreError, assertCanonicalTimestamp, isContentHash, tenantObjectKey, objectKeyPrefix, CoreConflictError, isLegalBoardTransition, checkSkillPackManifest, checkSkillPackEntry, SKILL_PACK_ENTRY_PATH, SKILL_PACK_MANIFEST_SCHEMA_ID } from '@byok-sdk/core';
4
4
  import { AwsClient } from 'aws4fetch';
5
5
  import { XMLParser } from 'fast-xml-parser';
6
6
 
@@ -591,7 +591,7 @@ var R2ObjectMaintenanceStore = class {
591
591
  `A ListObjectsV2 page limit of ${String(limit)} is not a whole number in [1, 1000].`
592
592
  );
593
593
  }
594
- const prefix = `${tenant}/sha256/`;
594
+ const prefix = tenantObjectKey(tenant, LIST_PREFIX_HASH, this.#keyPrefix).slice(0, -LIST_HASH_HEX_LENGTH);
595
595
  const url = new URL(`${this.#origin}/${this.#bucket}`);
596
596
  url.searchParams.set("list-type", "2");
597
597
  url.searchParams.set("prefix", prefix);
@@ -775,6 +775,8 @@ function parseListObjectsV2(xml, prefix, attempts) {
775
775
  return { objects, nextContinuationToken };
776
776
  }
777
777
  var HASH_KEY_SUFFIX = /^[0-9a-f]{64}$/;
778
+ var LIST_HASH_HEX_LENGTH = 64;
779
+ var LIST_PREFIX_HASH = contentHash(`sha256:${"0".repeat(LIST_HASH_HEX_LENGTH)}`);
778
780
  function asRecord(value) {
779
781
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
780
782
  }
@@ -786,8 +788,6 @@ function requiredText(record, field, attempts) {
786
788
  attempts
787
789
  );
788
790
  }
789
-
790
- // src/stores/devices.ts
791
791
  function toRecord(row) {
792
792
  return {
793
793
  tenantId: row.tenant_id,
@@ -803,8 +803,10 @@ function toRecord(row) {
803
803
  var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked";
804
804
  var PostgresDeviceDirectory = class {
805
805
  #pool;
806
- constructor(pool) {
806
+ #clock;
807
+ constructor(pool, clock) {
807
808
  this.#pool = pool;
809
+ this.#clock = clock;
808
810
  }
809
811
  async register(tenant, input) {
810
812
  const result = await this.#pool.query(
@@ -854,6 +856,73 @@ var PostgresDeviceDirectory = class {
854
856
  );
855
857
  return result.rows.map(toRecord);
856
858
  }
859
+ async readiness(tenant, _presence) {
860
+ const result = await this.#pool.query(
861
+ `SELECT
862
+ d.device_id,
863
+ d.product_id,
864
+ d.device_name,
865
+ d.revoked,
866
+ CASE WHEN NOT d.revoked THEN p.level END AS presence_level,
867
+ CASE WHEN NOT d.revoked THEN p.detail END AS presence_detail,
868
+ CASE WHEN NOT d.revoked THEN p.configured_toolsets END AS presence_configured_toolsets,
869
+ CASE WHEN NOT d.revoked THEN p.client_version END AS presence_client_version,
870
+ CASE WHEN NOT d.revoked THEN p.protocol_versions END AS presence_protocol_versions,
871
+ CASE WHEN NOT d.revoked THEN p.runtimes END AS presence_runtimes,
872
+ CASE WHEN NOT d.revoked THEN p.observed_at END AS presence_observed_at,
873
+ CASE WHEN NOT d.revoked THEN p.expires_at END AS presence_expires_at,
874
+ (COUNT(*) FILTER (WHERE NOT d.revoked) OVER ())::int AS active_paired_device_count,
875
+ (COUNT(*) FILTER (WHERE d.revoked) OVER ())::int AS revoked_device_count,
876
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.device_id IS NOT NULL) OVER ())::int AS observed_presence_count,
877
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'online') OVER ())::int AS observed_online_count,
878
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'thinking') OVER ())::int AS observed_thinking_count,
879
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'working') OVER ())::int AS observed_working_count,
880
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'error') OVER ())::int AS observed_error_count,
881
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'offline') OVER ())::int AS observed_offline_count
882
+ FROM device d
883
+ LEFT JOIN device_presence p
884
+ ON p.tenant_id = d.tenant_id
885
+ AND p.device_id = d.device_id
886
+ AND p.expires_at > $2
887
+ WHERE d.tenant_id = $1
888
+ ORDER BY d.device_id`,
889
+ [tenant, this.#clock?.now().toISOString() ?? (/* @__PURE__ */ new Date()).toISOString()]
890
+ );
891
+ const row = result.rows[0];
892
+ const count = (value) => Number(value);
893
+ const devices = result.rows.map((device) => ({
894
+ deviceId: device.device_id,
895
+ productId: device.product_id,
896
+ deviceName: device.device_name,
897
+ revoked: device.revoked,
898
+ ...device.presence_level === null ? {} : {
899
+ presence: {
900
+ level: device.presence_level,
901
+ ...device.presence_detail === null ? {} : { detail: device.presence_detail },
902
+ ...device.presence_configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...device.presence_configured_toolsets]) },
903
+ ...device.presence_client_version === null ? {} : { clientVersion: device.presence_client_version },
904
+ ...device.presence_protocol_versions === null ? {} : { protocolVersions: Object.freeze([...device.presence_protocol_versions]) },
905
+ ...device.presence_runtimes === null ? {} : { runtimes: Object.freeze(device.presence_runtimes.map((runtime) => Object.freeze({ ...runtime }))) },
906
+ observedAt: device.presence_observed_at,
907
+ expiresAt: device.presence_expires_at
908
+ }
909
+ }
910
+ }));
911
+ return {
912
+ tenantId: tenant,
913
+ activePairedDeviceCount: count(row?.active_paired_device_count ?? 0),
914
+ revokedDeviceCount: count(row?.revoked_device_count ?? 0),
915
+ observedPresenceCount: count(row?.observed_presence_count ?? 0),
916
+ observedPresenceByLevel: {
917
+ online: count(row?.observed_online_count ?? 0),
918
+ thinking: count(row?.observed_thinking_count ?? 0),
919
+ working: count(row?.observed_working_count ?? 0),
920
+ error: count(row?.observed_error_count ?? 0),
921
+ offline: count(row?.observed_offline_count ?? 0)
922
+ },
923
+ devices
924
+ };
925
+ }
857
926
  async resolveByDeviceId(deviceId) {
858
927
  const result = await this.#pool.query(
859
928
  `SELECT ${SELECT_COLUMNS} FROM device WHERE device_id = $1`,
@@ -1077,8 +1146,8 @@ var PostgresProofRequestReceiptStore = class {
1077
1146
  };
1078
1147
 
1079
1148
  // src/stores/task-attempts.ts
1080
- var SELECT_COLUMNS4 = "tenant_id, task_id, device_id, owner_device_id, status, updated_at";
1081
- function toAttempt(row) {
1149
+ var TASK_SELECT_COLUMNS = "tenant_id, task_id, device_id, owner_device_id, status, cancel_requested_at, cancel_reason, cancel_message_id, updated_at";
1150
+ function taskRowToAttempt(row) {
1082
1151
  return {
1083
1152
  tenantId: row.tenant_id,
1084
1153
  taskId: row.task_id,
@@ -1089,6 +1158,12 @@ function toAttempt(row) {
1089
1158
  // former.
1090
1159
  ...row.owner_device_id === null ? {} : { ownerDeviceId: row.owner_device_id },
1091
1160
  status: row.status,
1161
+ ...row.cancel_requested_at === null ? {} : {
1162
+ cancellation: {
1163
+ requestedAt: row.cancel_requested_at.toISOString(),
1164
+ ...row.cancel_reason === null ? {} : { reason: row.cancel_reason }
1165
+ }
1166
+ },
1092
1167
  updatedAt: row.updated_at.toISOString()
1093
1168
  };
1094
1169
  }
@@ -1104,33 +1179,42 @@ var PostgresTaskAttemptStore = class {
1104
1179
  `INSERT INTO task (tenant_id, task_id, device_id, owner_device_id, status, updated_at)
1105
1180
  VALUES ($1, $2, $3, NULL, 'offered', $4)
1106
1181
  ON CONFLICT (tenant_id, task_id) DO NOTHING
1107
- RETURNING ${SELECT_COLUMNS4}`,
1182
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1108
1183
  [tenant, input.taskId, input.deviceId, this.#now()]
1109
1184
  );
1110
1185
  const created = inserted.rows[0];
1111
- if (created !== void 0) return toAttempt(created);
1186
+ if (created !== void 0) return taskRowToAttempt(created);
1112
1187
  const existing = await this.get(tenant, input.taskId);
1113
1188
  if (existing === void 0) throw new Error(`task ${input.taskId} vanished during open`);
1114
1189
  return existing;
1115
1190
  }
1116
1191
  async get(tenant, taskId) {
1117
1192
  const result = await this.#pool.query(
1118
- `SELECT ${SELECT_COLUMNS4} FROM task WHERE tenant_id = $1 AND task_id = $2`,
1193
+ `SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = $2`,
1119
1194
  [tenant, taskId]
1120
1195
  );
1121
1196
  const row = result.rows[0];
1122
- return row === void 0 ? void 0 : toAttempt(row);
1197
+ return row === void 0 ? void 0 : taskRowToAttempt(row);
1198
+ }
1199
+ async getMany(tenant, taskIds) {
1200
+ if (taskIds.length === 0) return [];
1201
+ const result = await this.#pool.query(
1202
+ `SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = ANY($2::text[])`,
1203
+ [tenant, [...new Set(taskIds)]]
1204
+ );
1205
+ return result.rows.map(taskRowToAttempt);
1123
1206
  }
1124
1207
  async claim(tenant, input) {
1125
1208
  const claimed = await this.#pool.query(
1126
1209
  `UPDATE task
1127
1210
  SET owner_device_id = $3, status = 'claimed', updated_at = $4
1128
1211
  WHERE tenant_id = $1 AND task_id = $2 AND owner_device_id IS NULL
1129
- RETURNING ${SELECT_COLUMNS4}`,
1212
+ AND cancel_requested_at IS NULL AND status = 'offered'
1213
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1130
1214
  [tenant, input.taskId, input.deviceId, this.#now()]
1131
1215
  );
1132
1216
  const won = claimed.rows[0];
1133
- if (won !== void 0) return toAttempt(won);
1217
+ if (won !== void 0) return taskRowToAttempt(won);
1134
1218
  return this.get(tenant, input.taskId);
1135
1219
  }
1136
1220
  async recordStatus(tenant, input) {
@@ -1138,16 +1222,336 @@ var PostgresTaskAttemptStore = class {
1138
1222
  `UPDATE task
1139
1223
  SET status = $3, updated_at = $4
1140
1224
  WHERE tenant_id = $1 AND task_id = $2
1141
- RETURNING ${SELECT_COLUMNS4}`,
1225
+ AND (
1226
+ (cancel_requested_at IS NULL AND status NOT IN ('complete', 'failed', 'cancelled'))
1227
+ OR (cancel_requested_at IS NOT NULL AND $3 = 'cancelled' AND status <> 'cancelled')
1228
+ )
1229
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1142
1230
  [tenant, input.taskId, input.status, this.#now()]
1143
1231
  );
1144
1232
  const row = result.rows[0];
1145
- return row === void 0 ? void 0 : toAttempt(row);
1233
+ return row === void 0 ? this.get(tenant, input.taskId) : taskRowToAttempt(row);
1234
+ }
1235
+ #now() {
1236
+ return this.#clock.now().toISOString();
1237
+ }
1238
+ };
1239
+
1240
+ // src/stores/core/mailbox-sequence.ts
1241
+ async function allocateMailboxSequence(client, tenant, deviceId, now) {
1242
+ const allocation = await client.query(
1243
+ `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1244
+ VALUES ($1, $2, 2, 0, $3)
1245
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1246
+ SET next_seq = device_stream.next_seq + 1
1247
+ RETURNING next_seq - 1 AS seq`,
1248
+ [tenant, deviceId, now]
1249
+ );
1250
+ return Number(allocation.rows[0].seq);
1251
+ }
1252
+
1253
+ // src/stores/core/mailbox.ts
1254
+ var DEFAULT_READ_LIMIT = 50;
1255
+ var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1256
+ function toMailboxMessage(row) {
1257
+ return {
1258
+ tenantId: row.tenant_id,
1259
+ deviceId: row.device_id,
1260
+ // `seq` is bigint in the column and `number` on the port, because it is the
1261
+ // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1262
+ // into a redelivery bug; the narrowing happens once, here.
1263
+ seq: Number(row.seq),
1264
+ messageId: row.message_id,
1265
+ body: row.body,
1266
+ bodyHash: row.body_hash,
1267
+ byteSize: row.byte_size,
1268
+ state: row.state,
1269
+ appendedAt: row.appended_at
1270
+ };
1271
+ }
1272
+ var PostgresMailboxStore = class {
1273
+ #pool;
1274
+ #clock;
1275
+ constructor(pool, clock) {
1276
+ this.#pool = pool;
1277
+ this.#clock = clock;
1278
+ }
1279
+ async append(tenant, input) {
1280
+ this.#requireDeviceId(input.deviceId);
1281
+ const client = await this.#pool.connect();
1282
+ try {
1283
+ await client.query("BEGIN");
1284
+ const existing = await client.query(
1285
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1286
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1287
+ [tenant, input.deviceId, input.messageId]
1288
+ );
1289
+ const replayed = existing.rows[0];
1290
+ if (replayed !== void 0) {
1291
+ await client.query("COMMIT");
1292
+ return toMailboxMessage(replayed);
1293
+ }
1294
+ const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1295
+ const serializedExisting = await client.query(
1296
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1297
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1298
+ [tenant, input.deviceId, input.messageId]
1299
+ );
1300
+ const winnerAfterLock = serializedExisting.rows[0];
1301
+ if (winnerAfterLock !== void 0) {
1302
+ await client.query("ROLLBACK");
1303
+ return toMailboxMessage(winnerAfterLock);
1304
+ }
1305
+ const materialized = await input.materialize(seq);
1306
+ const now = this.#now();
1307
+ const inserted = await client.query(
1308
+ `INSERT INTO outbox (${OUTBOX_COLUMNS})
1309
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1310
+ ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1311
+ RETURNING ${OUTBOX_COLUMNS}`,
1312
+ [
1313
+ tenant,
1314
+ input.deviceId,
1315
+ seq,
1316
+ input.messageId,
1317
+ materialized.body,
1318
+ materialized.bodyHash,
1319
+ materialized.byteSize,
1320
+ now
1321
+ ]
1322
+ );
1323
+ const row = inserted.rows[0];
1324
+ if (row !== void 0) {
1325
+ await client.query("COMMIT");
1326
+ return toMailboxMessage(row);
1327
+ }
1328
+ const winner = await client.query(
1329
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1330
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1331
+ [tenant, input.deviceId, input.messageId]
1332
+ );
1333
+ await client.query("ROLLBACK");
1334
+ const won = winner.rows[0];
1335
+ if (won === void 0) {
1336
+ throw new ByokCoreError(
1337
+ "mailbox_message_not_found",
1338
+ `Message ${input.messageId} vanished during an idempotent append.`
1339
+ );
1340
+ }
1341
+ return toMailboxMessage(won);
1342
+ } catch (cause) {
1343
+ await client.query("ROLLBACK").catch(() => {
1344
+ });
1345
+ throw cause;
1346
+ } finally {
1347
+ client.release();
1348
+ }
1349
+ }
1350
+ async readAfter(tenant, query) {
1351
+ const limit = query.limit ?? DEFAULT_READ_LIMIT;
1352
+ const result = await this.#pool.query(
1353
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1354
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1355
+ ORDER BY seq
1356
+ LIMIT $4`,
1357
+ [tenant, query.deviceId, query.afterSeq, limit + 1]
1358
+ );
1359
+ const page = result.rows.slice(0, limit).map(toMailboxMessage);
1360
+ return {
1361
+ messages: page,
1362
+ // Nothing above was mutated, so an identical call replays the same page.
1363
+ // The returned position is a READ cursor and moves no ack.
1364
+ nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1365
+ hasMore: result.rows.length > page.length
1366
+ };
1367
+ }
1368
+ async advanceCursor(tenant, input) {
1369
+ this.#requireDeviceId(input.deviceId);
1370
+ const now = this.#now();
1371
+ const moved = await this.#pool.query(
1372
+ `WITH moved AS (
1373
+ INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1374
+ VALUES ($1, $2, 1, $3::bigint, $4)
1375
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1376
+ SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1377
+ WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1378
+ RETURNING acked_seq, acked_at
1379
+ ), marked AS (
1380
+ UPDATE outbox
1381
+ SET state = 'acked'
1382
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1383
+ AND seq <= (SELECT acked_seq FROM moved)
1384
+ RETURNING 1
1385
+ )
1386
+ SELECT acked_seq, acked_at FROM moved`,
1387
+ [tenant, input.deviceId, input.ackedSeq, now]
1388
+ );
1389
+ const row = moved.rows[0];
1390
+ if (row !== void 0) {
1391
+ return {
1392
+ tenantId: tenant,
1393
+ deviceId: input.deviceId,
1394
+ ackedSeq: Number(row.acked_seq),
1395
+ updatedAt: row.acked_at ?? now
1396
+ };
1397
+ }
1398
+ const current = await this.readCursor(tenant, input.deviceId);
1399
+ throw new CoreConflictError(
1400
+ "mailbox_cursor_regression",
1401
+ `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1402
+ current,
1403
+ this.#now()
1404
+ );
1405
+ }
1406
+ async readCursor(tenant, deviceId) {
1407
+ const result = await this.#pool.query(
1408
+ `SELECT acked_seq, acked_at FROM device_stream
1409
+ WHERE tenant_id = $1 AND device_id = $2`,
1410
+ [tenant, deviceId]
1411
+ );
1412
+ const row = result.rows[0];
1413
+ return {
1414
+ tenantId: tenant,
1415
+ deviceId,
1416
+ ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1417
+ updatedAt: row?.acked_at ?? this.#now()
1418
+ };
1419
+ }
1420
+ async collectRetired(tenant, input) {
1421
+ assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1422
+ assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1423
+ const swept = await this.#pool.query(
1424
+ `WITH deleted AS (
1425
+ DELETE FROM outbox
1426
+ WHERE tenant_id = $1
1427
+ AND ($2::text IS NULL OR device_id = $2::text)
1428
+ AND state = 'acked'
1429
+ AND appended_at < $3
1430
+ RETURNING byte_size
1431
+ ), expired AS (
1432
+ UPDATE outbox
1433
+ SET state = 'expired'
1434
+ WHERE tenant_id = $1
1435
+ AND ($2::text IS NULL OR device_id = $2::text)
1436
+ AND state = 'pending'
1437
+ AND appended_at < $4
1438
+ RETURNING 1
1439
+ )
1440
+ SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1441
+ (SELECT count(*) FROM expired) AS expired_count,
1442
+ (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1443
+ [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
1444
+ );
1445
+ const row = swept.rows[0];
1446
+ return {
1447
+ deletedCount: Number(row.deleted_count),
1448
+ expiredCount: Number(row.expired_count),
1449
+ releasedBytes: row.released_bytes
1450
+ };
1451
+ }
1452
+ /**
1453
+ * The in-memory reference refuses an empty device id rather than opening a
1454
+ * mailbox nothing can address. Kept here so the two compositions answer the
1455
+ * same way; the table itself would happily store the row.
1456
+ */
1457
+ #requireDeviceId(deviceId) {
1458
+ if (deviceId.length === 0) {
1459
+ throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
1460
+ }
1146
1461
  }
1147
1462
  #now() {
1148
1463
  return this.#clock.now().toISOString();
1149
1464
  }
1150
1465
  };
1466
+
1467
+ // src/stores/task-cancellations.ts
1468
+ var PostgresTaskCancellationStore = class {
1469
+ #pool;
1470
+ #clock;
1471
+ constructor(pool, clock) {
1472
+ this.#pool = pool;
1473
+ this.#clock = clock;
1474
+ }
1475
+ async request(tenant, input) {
1476
+ const client = await this.#pool.connect();
1477
+ try {
1478
+ await client.query("BEGIN");
1479
+ const selected = await client.query(
1480
+ `SELECT ${TASK_SELECT_COLUMNS} FROM task
1481
+ WHERE tenant_id = $1 AND task_id = $2
1482
+ FOR UPDATE`,
1483
+ [tenant, input.taskId]
1484
+ );
1485
+ const current = selected.rows[0];
1486
+ if (current === void 0) {
1487
+ await client.query("ROLLBACK");
1488
+ return void 0;
1489
+ }
1490
+ if (current.cancel_requested_at === null && (current.status === "complete" || current.status === "failed" || current.status === "cancelled")) {
1491
+ await client.query("COMMIT");
1492
+ return { attempt: taskRowToAttempt(current) };
1493
+ }
1494
+ if (current.cancel_message_id !== null) {
1495
+ const replayed = await client.query(
1496
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1497
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1498
+ [tenant, current.device_id, current.cancel_message_id]
1499
+ );
1500
+ const message = replayed.rows[0];
1501
+ if (message === void 0) {
1502
+ if (current.status === "cancelled") {
1503
+ await client.query("COMMIT");
1504
+ return { attempt: taskRowToAttempt(current) };
1505
+ }
1506
+ throw new Error(`Cancellation delivery ${current.cancel_message_id} is missing for task ${input.taskId}`);
1507
+ }
1508
+ await client.query("COMMIT");
1509
+ return { attempt: taskRowToAttempt(current), message: toMailboxMessage(message) };
1510
+ }
1511
+ const now = this.#clock.now().toISOString();
1512
+ const messageId = input.proposedMessageId;
1513
+ const seq = await allocateMailboxSequence(client, tenant, current.device_id, now);
1514
+ const materialized = await input.materialize(seq, messageId);
1515
+ const insertedMessage = await client.query(
1516
+ `INSERT INTO outbox (${OUTBOX_COLUMNS})
1517
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1518
+ RETURNING ${OUTBOX_COLUMNS}`,
1519
+ [
1520
+ tenant,
1521
+ current.device_id,
1522
+ seq,
1523
+ messageId,
1524
+ materialized.body,
1525
+ materialized.bodyHash,
1526
+ materialized.byteSize,
1527
+ now
1528
+ ]
1529
+ );
1530
+ const updated = await client.query(
1531
+ `UPDATE task
1532
+ SET status = CASE WHEN owner_device_id IS NULL THEN 'cancelled' ELSE 'cancel_requested' END,
1533
+ cancel_requested_at = $3,
1534
+ cancel_reason = $4,
1535
+ cancel_message_id = $5,
1536
+ updated_at = $3
1537
+ WHERE tenant_id = $1 AND task_id = $2
1538
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1539
+ [tenant, input.taskId, now, input.reason ?? null, messageId]
1540
+ );
1541
+ await client.query("COMMIT");
1542
+ return {
1543
+ attempt: taskRowToAttempt(updated.rows[0]),
1544
+ message: toMailboxMessage(insertedMessage.rows[0])
1545
+ };
1546
+ } catch (cause) {
1547
+ await client.query("ROLLBACK").catch(() => {
1548
+ });
1549
+ throw cause;
1550
+ } finally {
1551
+ client.release();
1552
+ }
1553
+ }
1554
+ };
1151
1555
  function toTail(row) {
1152
1556
  const entries = parseTimelineEvents(row.entries);
1153
1557
  const cursor = activityCursor(entries);
@@ -1382,17 +1786,67 @@ var PostgresApprovalTimelineStore = class {
1382
1786
  }
1383
1787
  };
1384
1788
 
1789
+ // src/stores/device-assertion-replay.ts
1790
+ var PostgresDeviceAssertionReplayAuthority = class {
1791
+ #pool;
1792
+ constructor(pool) {
1793
+ this.#pool = pool;
1794
+ }
1795
+ async consume(input) {
1796
+ if (!Number.isFinite(Date.parse(input.expiresAt))) {
1797
+ throw new Error("device assertion replay expiry is invalid");
1798
+ }
1799
+ const result = await this.#pool.query(
1800
+ `INSERT INTO device_assertion_replay (
1801
+ tenant_id, issuer, product_id, device_id, audience, jti, expires_at
1802
+ )
1803
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
1804
+ ON CONFLICT (tenant_id, issuer, product_id, device_id, audience, jti) DO NOTHING
1805
+ RETURNING jti`,
1806
+ [
1807
+ input.tenantId,
1808
+ input.issuer,
1809
+ input.productId,
1810
+ input.deviceId,
1811
+ input.audience,
1812
+ input.jti,
1813
+ input.expiresAt
1814
+ ]
1815
+ );
1816
+ return result.rowCount === 1;
1817
+ }
1818
+ /** Bounded retention cleanup; callers choose cadence and batch size. */
1819
+ async deleteExpired(before, limit) {
1820
+ if (!Number.isFinite(before.getTime()) || !Number.isSafeInteger(limit) || limit <= 0) {
1821
+ throw new Error("device assertion replay cleanup bounds are invalid");
1822
+ }
1823
+ const result = await this.#pool.query(
1824
+ `DELETE FROM device_assertion_replay
1825
+ WHERE ctid IN (
1826
+ SELECT ctid
1827
+ FROM device_assertion_replay
1828
+ WHERE expires_at <= $1
1829
+ ORDER BY expires_at
1830
+ LIMIT $2
1831
+ )`,
1832
+ [before.toISOString(), limit]
1833
+ );
1834
+ return result.rowCount ?? 0;
1835
+ }
1836
+ };
1837
+
1385
1838
  // src/stores/index.ts
1386
1839
  function createPostgresCloudStores(options) {
1387
1840
  const { pool, clock, crypto } = options;
1388
1841
  return {
1389
1842
  activity: new PostgresActivityStore(pool, clock),
1390
1843
  approvals: new PostgresApprovalTimelineStore(pool, clock),
1391
- devices: new PostgresDeviceDirectory(pool),
1844
+ devices: new PostgresDeviceDirectory(pool, clock),
1392
1845
  pairingCodes: new PostgresPairingCodeStore(pool, clock),
1393
1846
  nonces: new PostgresNonceStore(pool, clock, crypto),
1394
1847
  dedup: new PostgresInboundDedupStore(pool),
1395
1848
  tasks: new PostgresTaskAttemptStore(pool, clock),
1849
+ cancellations: new PostgresTaskCancellationStore(pool, clock),
1396
1850
  receipts: new PostgresRequestReceiptStore(pool, clock),
1397
1851
  proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
1398
1852
  // A second `PostgresObjectStore` instance, not a shared one: it is a
@@ -1406,7 +1860,7 @@ function createPostgresCloudStores(options) {
1406
1860
  rateLimiter: new AllowAllRateLimiter()
1407
1861
  };
1408
1862
  }
1409
- var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
1863
+ var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, client_version, protocol_versions, runtimes, observed_at, expires_at";
1410
1864
  function toHint(row) {
1411
1865
  return {
1412
1866
  tenantId: row.tenant_id,
@@ -1414,6 +1868,19 @@ function toHint(row) {
1414
1868
  level: row.level,
1415
1869
  ...row.detail === null ? {} : { detail: row.detail },
1416
1870
  ...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
1871
+ ...row.client_version === null ? {} : { clientVersion: row.client_version },
1872
+ ...row.protocol_versions === null ? {} : { protocolVersions: Object.freeze([...row.protocol_versions]) },
1873
+ ...row.runtimes === null ? {} : {
1874
+ runtimes: Object.freeze(
1875
+ row.runtimes.map(
1876
+ (runtime) => Object.freeze({
1877
+ id: runtime.id,
1878
+ ...runtime.version === void 0 ? {} : { version: runtime.version },
1879
+ ...runtime.authPresent === void 0 ? {} : { authPresent: runtime.authPresent }
1880
+ })
1881
+ )
1882
+ )
1883
+ },
1417
1884
  observedAt: row.observed_at,
1418
1885
  expiresAt: row.expires_at
1419
1886
  };
@@ -1449,15 +1916,18 @@ var PostgresPresenceStore = class {
1449
1916
  const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
1450
1917
  const result = await this.#pool.query(
1451
1918
  `INSERT INTO device_presence (${PRESENCE_COLUMNS})
1452
- VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
1919
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8::jsonb, $9, $10)
1453
1920
  ON CONFLICT (tenant_id, device_id) DO UPDATE
1454
1921
  SET level = EXCLUDED.level,
1455
1922
  detail = EXCLUDED.detail,
1456
1923
  configured_toolsets = EXCLUDED.configured_toolsets,
1924
+ client_version = EXCLUDED.client_version,
1925
+ protocol_versions = EXCLUDED.protocol_versions,
1926
+ runtimes = EXCLUDED.runtimes,
1457
1927
  observed_at = EXCLUDED.observed_at,
1458
1928
  expires_at = EXCLUDED.expires_at
1459
1929
  WHERE device_presence.expires_at <= EXCLUDED.observed_at
1460
- OR device_presence.observed_at <= $8
1930
+ OR device_presence.observed_at <= $11
1461
1931
  RETURNING ${PRESENCE_COLUMNS}`,
1462
1932
  [
1463
1933
  tenant,
@@ -1465,6 +1935,9 @@ var PostgresPresenceStore = class {
1465
1935
  input.level,
1466
1936
  input.detail ?? null,
1467
1937
  input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
1938
+ input.clientVersion ?? null,
1939
+ input.protocolVersions === void 0 ? null : JSON.stringify(input.protocolVersions),
1940
+ input.runtimes === void 0 ? null : JSON.stringify(input.runtimes),
1468
1941
  observedAt,
1469
1942
  new Date(now.getTime() + input.ttlMs).toISOString(),
1470
1943
  allowedBefore
@@ -1732,233 +2205,6 @@ var PostgresBoardStore = class {
1732
2205
  return this.#clock.now().toISOString();
1733
2206
  }
1734
2207
  };
1735
-
1736
- // src/stores/core/mailbox-sequence.ts
1737
- async function allocateMailboxSequence(client, tenant, deviceId, now) {
1738
- const allocation = await client.query(
1739
- `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1740
- VALUES ($1, $2, 2, 0, $3)
1741
- ON CONFLICT (tenant_id, device_id) DO UPDATE
1742
- SET next_seq = device_stream.next_seq + 1
1743
- RETURNING next_seq - 1 AS seq`,
1744
- [tenant, deviceId, now]
1745
- );
1746
- return Number(allocation.rows[0].seq);
1747
- }
1748
-
1749
- // src/stores/core/mailbox.ts
1750
- var DEFAULT_READ_LIMIT = 50;
1751
- var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1752
- function toMessage(row) {
1753
- return {
1754
- tenantId: row.tenant_id,
1755
- deviceId: row.device_id,
1756
- // `seq` is bigint in the column and `number` on the port, because it is the
1757
- // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1758
- // into a redelivery bug; the narrowing happens once, here.
1759
- seq: Number(row.seq),
1760
- messageId: row.message_id,
1761
- body: row.body,
1762
- bodyHash: row.body_hash,
1763
- byteSize: row.byte_size,
1764
- state: row.state,
1765
- appendedAt: row.appended_at
1766
- };
1767
- }
1768
- var PostgresMailboxStore = class {
1769
- #pool;
1770
- #clock;
1771
- constructor(pool, clock) {
1772
- this.#pool = pool;
1773
- this.#clock = clock;
1774
- }
1775
- async append(tenant, input) {
1776
- this.#requireDeviceId(input.deviceId);
1777
- const client = await this.#pool.connect();
1778
- try {
1779
- await client.query("BEGIN");
1780
- const existing = await client.query(
1781
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1782
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1783
- [tenant, input.deviceId, input.messageId]
1784
- );
1785
- const replayed = existing.rows[0];
1786
- if (replayed !== void 0) {
1787
- await client.query("COMMIT");
1788
- return toMessage(replayed);
1789
- }
1790
- const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1791
- const serializedExisting = await client.query(
1792
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1793
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1794
- [tenant, input.deviceId, input.messageId]
1795
- );
1796
- const winnerAfterLock = serializedExisting.rows[0];
1797
- if (winnerAfterLock !== void 0) {
1798
- await client.query("ROLLBACK");
1799
- return toMessage(winnerAfterLock);
1800
- }
1801
- const materialized = await input.materialize(seq);
1802
- const now = this.#now();
1803
- const inserted = await client.query(
1804
- `INSERT INTO outbox (${OUTBOX_COLUMNS})
1805
- VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1806
- ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1807
- RETURNING ${OUTBOX_COLUMNS}`,
1808
- [
1809
- tenant,
1810
- input.deviceId,
1811
- seq,
1812
- input.messageId,
1813
- materialized.body,
1814
- materialized.bodyHash,
1815
- materialized.byteSize,
1816
- now
1817
- ]
1818
- );
1819
- const row = inserted.rows[0];
1820
- if (row !== void 0) {
1821
- await client.query("COMMIT");
1822
- return toMessage(row);
1823
- }
1824
- const winner = await client.query(
1825
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1826
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1827
- [tenant, input.deviceId, input.messageId]
1828
- );
1829
- await client.query("ROLLBACK");
1830
- const won = winner.rows[0];
1831
- if (won === void 0) {
1832
- throw new ByokCoreError(
1833
- "mailbox_message_not_found",
1834
- `Message ${input.messageId} vanished during an idempotent append.`
1835
- );
1836
- }
1837
- return toMessage(won);
1838
- } catch (cause) {
1839
- await client.query("ROLLBACK").catch(() => {
1840
- });
1841
- throw cause;
1842
- } finally {
1843
- client.release();
1844
- }
1845
- }
1846
- async readAfter(tenant, query) {
1847
- const limit = query.limit ?? DEFAULT_READ_LIMIT;
1848
- const result = await this.#pool.query(
1849
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1850
- WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1851
- ORDER BY seq
1852
- LIMIT $4`,
1853
- [tenant, query.deviceId, query.afterSeq, limit + 1]
1854
- );
1855
- const page = result.rows.slice(0, limit).map(toMessage);
1856
- return {
1857
- messages: page,
1858
- // Nothing above was mutated, so an identical call replays the same page.
1859
- // The returned position is a READ cursor and moves no ack.
1860
- nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1861
- hasMore: result.rows.length > page.length
1862
- };
1863
- }
1864
- async advanceCursor(tenant, input) {
1865
- this.#requireDeviceId(input.deviceId);
1866
- const now = this.#now();
1867
- const moved = await this.#pool.query(
1868
- `WITH moved AS (
1869
- INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1870
- VALUES ($1, $2, 1, $3::bigint, $4)
1871
- ON CONFLICT (tenant_id, device_id) DO UPDATE
1872
- SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1873
- WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1874
- RETURNING acked_seq, acked_at
1875
- ), marked AS (
1876
- UPDATE outbox
1877
- SET state = 'acked'
1878
- WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1879
- AND seq <= (SELECT acked_seq FROM moved)
1880
- RETURNING 1
1881
- )
1882
- SELECT acked_seq, acked_at FROM moved`,
1883
- [tenant, input.deviceId, input.ackedSeq, now]
1884
- );
1885
- const row = moved.rows[0];
1886
- if (row !== void 0) {
1887
- return {
1888
- tenantId: tenant,
1889
- deviceId: input.deviceId,
1890
- ackedSeq: Number(row.acked_seq),
1891
- updatedAt: row.acked_at ?? now
1892
- };
1893
- }
1894
- const current = await this.readCursor(tenant, input.deviceId);
1895
- throw new CoreConflictError(
1896
- "mailbox_cursor_regression",
1897
- `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1898
- current,
1899
- this.#now()
1900
- );
1901
- }
1902
- async readCursor(tenant, deviceId) {
1903
- const result = await this.#pool.query(
1904
- `SELECT acked_seq, acked_at FROM device_stream
1905
- WHERE tenant_id = $1 AND device_id = $2`,
1906
- [tenant, deviceId]
1907
- );
1908
- const row = result.rows[0];
1909
- return {
1910
- tenantId: tenant,
1911
- deviceId,
1912
- ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1913
- updatedAt: row?.acked_at ?? this.#now()
1914
- };
1915
- }
1916
- async collectRetired(tenant, input) {
1917
- assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1918
- assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1919
- const swept = await this.#pool.query(
1920
- `WITH deleted AS (
1921
- DELETE FROM outbox
1922
- WHERE tenant_id = $1
1923
- AND ($2::text IS NULL OR device_id = $2::text)
1924
- AND state = 'acked'
1925
- AND appended_at < $3
1926
- RETURNING byte_size
1927
- ), expired AS (
1928
- UPDATE outbox
1929
- SET state = 'expired'
1930
- WHERE tenant_id = $1
1931
- AND ($2::text IS NULL OR device_id = $2::text)
1932
- AND state = 'pending'
1933
- AND appended_at < $4
1934
- RETURNING 1
1935
- )
1936
- SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1937
- (SELECT count(*) FROM expired) AS expired_count,
1938
- (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1939
- [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
1940
- );
1941
- const row = swept.rows[0];
1942
- return {
1943
- deletedCount: Number(row.deleted_count),
1944
- expiredCount: Number(row.expired_count),
1945
- releasedBytes: row.released_bytes
1946
- };
1947
- }
1948
- /**
1949
- * The in-memory reference refuses an empty device id rather than opening a
1950
- * mailbox nothing can address. Kept here so the two compositions answer the
1951
- * same way; the table itself would happily store the row.
1952
- */
1953
- #requireDeviceId(deviceId) {
1954
- if (deviceId.length === 0) {
1955
- throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
1956
- }
1957
- }
1958
- #now() {
1959
- return this.#clock.now().toISOString();
1960
- }
1961
- };
1962
2208
  var WARNING_NUMERATOR = 80n;
1963
2209
  var WARNING_DENOMINATOR = 100n;
1964
2210
  var ENTITLEMENT_COLUMNS = "tenant_id, version, hard_limit_bytes, max_object_bytes, max_inline_bytes, mailbox_limit_bytes, retention_policy_id, downgrade_grace_until";
@@ -3304,6 +3550,6 @@ var PostgresTruthCommitter = class {
3304
3550
  }
3305
3551
  };
3306
3552
 
3307
- export { DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudStores, createPostgresCoreStores };
3553
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresDeviceAssertionReplayAuthority, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTaskCancellationStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudStores, createPostgresCoreStores };
3308
3554
  //# sourceMappingURL=runtime.js.map
3309
3555
  //# sourceMappingURL=runtime.js.map