@byok-sdk/cloud-dataplane 0.5.0 → 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);
1146
1234
  }
1147
1235
  #now() {
1148
1236
  return this.#clock.now().toISOString();
1149
1237
  }
1150
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
+ }
1461
+ }
1462
+ #now() {
1463
+ return this.#clock.now().toISOString();
1464
+ }
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);
@@ -1437,11 +1841,12 @@ function createPostgresCloudStores(options) {
1437
1841
  return {
1438
1842
  activity: new PostgresActivityStore(pool, clock),
1439
1843
  approvals: new PostgresApprovalTimelineStore(pool, clock),
1440
- devices: new PostgresDeviceDirectory(pool),
1844
+ devices: new PostgresDeviceDirectory(pool, clock),
1441
1845
  pairingCodes: new PostgresPairingCodeStore(pool, clock),
1442
1846
  nonces: new PostgresNonceStore(pool, clock, crypto),
1443
1847
  dedup: new PostgresInboundDedupStore(pool),
1444
1848
  tasks: new PostgresTaskAttemptStore(pool, clock),
1849
+ cancellations: new PostgresTaskCancellationStore(pool, clock),
1445
1850
  receipts: new PostgresRequestReceiptStore(pool, clock),
1446
1851
  proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
1447
1852
  // A second `PostgresObjectStore` instance, not a shared one: it is a
@@ -1455,7 +1860,7 @@ function createPostgresCloudStores(options) {
1455
1860
  rateLimiter: new AllowAllRateLimiter()
1456
1861
  };
1457
1862
  }
1458
- 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";
1459
1864
  function toHint(row) {
1460
1865
  return {
1461
1866
  tenantId: row.tenant_id,
@@ -1463,6 +1868,19 @@ function toHint(row) {
1463
1868
  level: row.level,
1464
1869
  ...row.detail === null ? {} : { detail: row.detail },
1465
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
+ },
1466
1884
  observedAt: row.observed_at,
1467
1885
  expiresAt: row.expires_at
1468
1886
  };
@@ -1498,15 +1916,18 @@ var PostgresPresenceStore = class {
1498
1916
  const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
1499
1917
  const result = await this.#pool.query(
1500
1918
  `INSERT INTO device_presence (${PRESENCE_COLUMNS})
1501
- VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
1919
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8::jsonb, $9, $10)
1502
1920
  ON CONFLICT (tenant_id, device_id) DO UPDATE
1503
1921
  SET level = EXCLUDED.level,
1504
1922
  detail = EXCLUDED.detail,
1505
1923
  configured_toolsets = EXCLUDED.configured_toolsets,
1924
+ client_version = EXCLUDED.client_version,
1925
+ protocol_versions = EXCLUDED.protocol_versions,
1926
+ runtimes = EXCLUDED.runtimes,
1506
1927
  observed_at = EXCLUDED.observed_at,
1507
1928
  expires_at = EXCLUDED.expires_at
1508
1929
  WHERE device_presence.expires_at <= EXCLUDED.observed_at
1509
- OR device_presence.observed_at <= $8
1930
+ OR device_presence.observed_at <= $11
1510
1931
  RETURNING ${PRESENCE_COLUMNS}`,
1511
1932
  [
1512
1933
  tenant,
@@ -1514,6 +1935,9 @@ var PostgresPresenceStore = class {
1514
1935
  input.level,
1515
1936
  input.detail ?? null,
1516
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),
1517
1941
  observedAt,
1518
1942
  new Date(now.getTime() + input.ttlMs).toISOString(),
1519
1943
  allowedBefore
@@ -1781,233 +2205,6 @@ var PostgresBoardStore = class {
1781
2205
  return this.#clock.now().toISOString();
1782
2206
  }
1783
2207
  };
1784
-
1785
- // src/stores/core/mailbox-sequence.ts
1786
- async function allocateMailboxSequence(client, tenant, deviceId, now) {
1787
- const allocation = await client.query(
1788
- `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1789
- VALUES ($1, $2, 2, 0, $3)
1790
- ON CONFLICT (tenant_id, device_id) DO UPDATE
1791
- SET next_seq = device_stream.next_seq + 1
1792
- RETURNING next_seq - 1 AS seq`,
1793
- [tenant, deviceId, now]
1794
- );
1795
- return Number(allocation.rows[0].seq);
1796
- }
1797
-
1798
- // src/stores/core/mailbox.ts
1799
- var DEFAULT_READ_LIMIT = 50;
1800
- var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1801
- function toMessage(row) {
1802
- return {
1803
- tenantId: row.tenant_id,
1804
- deviceId: row.device_id,
1805
- // `seq` is bigint in the column and `number` on the port, because it is the
1806
- // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1807
- // into a redelivery bug; the narrowing happens once, here.
1808
- seq: Number(row.seq),
1809
- messageId: row.message_id,
1810
- body: row.body,
1811
- bodyHash: row.body_hash,
1812
- byteSize: row.byte_size,
1813
- state: row.state,
1814
- appendedAt: row.appended_at
1815
- };
1816
- }
1817
- var PostgresMailboxStore = class {
1818
- #pool;
1819
- #clock;
1820
- constructor(pool, clock) {
1821
- this.#pool = pool;
1822
- this.#clock = clock;
1823
- }
1824
- async append(tenant, input) {
1825
- this.#requireDeviceId(input.deviceId);
1826
- const client = await this.#pool.connect();
1827
- try {
1828
- await client.query("BEGIN");
1829
- const existing = await client.query(
1830
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1831
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1832
- [tenant, input.deviceId, input.messageId]
1833
- );
1834
- const replayed = existing.rows[0];
1835
- if (replayed !== void 0) {
1836
- await client.query("COMMIT");
1837
- return toMessage(replayed);
1838
- }
1839
- const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1840
- const serializedExisting = await client.query(
1841
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1842
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1843
- [tenant, input.deviceId, input.messageId]
1844
- );
1845
- const winnerAfterLock = serializedExisting.rows[0];
1846
- if (winnerAfterLock !== void 0) {
1847
- await client.query("ROLLBACK");
1848
- return toMessage(winnerAfterLock);
1849
- }
1850
- const materialized = await input.materialize(seq);
1851
- const now = this.#now();
1852
- const inserted = await client.query(
1853
- `INSERT INTO outbox (${OUTBOX_COLUMNS})
1854
- VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1855
- ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1856
- RETURNING ${OUTBOX_COLUMNS}`,
1857
- [
1858
- tenant,
1859
- input.deviceId,
1860
- seq,
1861
- input.messageId,
1862
- materialized.body,
1863
- materialized.bodyHash,
1864
- materialized.byteSize,
1865
- now
1866
- ]
1867
- );
1868
- const row = inserted.rows[0];
1869
- if (row !== void 0) {
1870
- await client.query("COMMIT");
1871
- return toMessage(row);
1872
- }
1873
- const winner = await client.query(
1874
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1875
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1876
- [tenant, input.deviceId, input.messageId]
1877
- );
1878
- await client.query("ROLLBACK");
1879
- const won = winner.rows[0];
1880
- if (won === void 0) {
1881
- throw new ByokCoreError(
1882
- "mailbox_message_not_found",
1883
- `Message ${input.messageId} vanished during an idempotent append.`
1884
- );
1885
- }
1886
- return toMessage(won);
1887
- } catch (cause) {
1888
- await client.query("ROLLBACK").catch(() => {
1889
- });
1890
- throw cause;
1891
- } finally {
1892
- client.release();
1893
- }
1894
- }
1895
- async readAfter(tenant, query) {
1896
- const limit = query.limit ?? DEFAULT_READ_LIMIT;
1897
- const result = await this.#pool.query(
1898
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1899
- WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1900
- ORDER BY seq
1901
- LIMIT $4`,
1902
- [tenant, query.deviceId, query.afterSeq, limit + 1]
1903
- );
1904
- const page = result.rows.slice(0, limit).map(toMessage);
1905
- return {
1906
- messages: page,
1907
- // Nothing above was mutated, so an identical call replays the same page.
1908
- // The returned position is a READ cursor and moves no ack.
1909
- nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1910
- hasMore: result.rows.length > page.length
1911
- };
1912
- }
1913
- async advanceCursor(tenant, input) {
1914
- this.#requireDeviceId(input.deviceId);
1915
- const now = this.#now();
1916
- const moved = await this.#pool.query(
1917
- `WITH moved AS (
1918
- INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1919
- VALUES ($1, $2, 1, $3::bigint, $4)
1920
- ON CONFLICT (tenant_id, device_id) DO UPDATE
1921
- SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1922
- WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1923
- RETURNING acked_seq, acked_at
1924
- ), marked AS (
1925
- UPDATE outbox
1926
- SET state = 'acked'
1927
- WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1928
- AND seq <= (SELECT acked_seq FROM moved)
1929
- RETURNING 1
1930
- )
1931
- SELECT acked_seq, acked_at FROM moved`,
1932
- [tenant, input.deviceId, input.ackedSeq, now]
1933
- );
1934
- const row = moved.rows[0];
1935
- if (row !== void 0) {
1936
- return {
1937
- tenantId: tenant,
1938
- deviceId: input.deviceId,
1939
- ackedSeq: Number(row.acked_seq),
1940
- updatedAt: row.acked_at ?? now
1941
- };
1942
- }
1943
- const current = await this.readCursor(tenant, input.deviceId);
1944
- throw new CoreConflictError(
1945
- "mailbox_cursor_regression",
1946
- `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1947
- current,
1948
- this.#now()
1949
- );
1950
- }
1951
- async readCursor(tenant, deviceId) {
1952
- const result = await this.#pool.query(
1953
- `SELECT acked_seq, acked_at FROM device_stream
1954
- WHERE tenant_id = $1 AND device_id = $2`,
1955
- [tenant, deviceId]
1956
- );
1957
- const row = result.rows[0];
1958
- return {
1959
- tenantId: tenant,
1960
- deviceId,
1961
- ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1962
- updatedAt: row?.acked_at ?? this.#now()
1963
- };
1964
- }
1965
- async collectRetired(tenant, input) {
1966
- assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1967
- assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1968
- const swept = await this.#pool.query(
1969
- `WITH deleted AS (
1970
- DELETE FROM outbox
1971
- WHERE tenant_id = $1
1972
- AND ($2::text IS NULL OR device_id = $2::text)
1973
- AND state = 'acked'
1974
- AND appended_at < $3
1975
- RETURNING byte_size
1976
- ), expired AS (
1977
- UPDATE outbox
1978
- SET state = 'expired'
1979
- WHERE tenant_id = $1
1980
- AND ($2::text IS NULL OR device_id = $2::text)
1981
- AND state = 'pending'
1982
- AND appended_at < $4
1983
- RETURNING 1
1984
- )
1985
- SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1986
- (SELECT count(*) FROM expired) AS expired_count,
1987
- (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1988
- [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
1989
- );
1990
- const row = swept.rows[0];
1991
- return {
1992
- deletedCount: Number(row.deleted_count),
1993
- expiredCount: Number(row.expired_count),
1994
- releasedBytes: row.released_bytes
1995
- };
1996
- }
1997
- /**
1998
- * The in-memory reference refuses an empty device id rather than opening a
1999
- * mailbox nothing can address. Kept here so the two compositions answer the
2000
- * same way; the table itself would happily store the row.
2001
- */
2002
- #requireDeviceId(deviceId) {
2003
- if (deviceId.length === 0) {
2004
- throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
2005
- }
2006
- }
2007
- #now() {
2008
- return this.#clock.now().toISOString();
2009
- }
2010
- };
2011
2208
  var WARNING_NUMERATOR = 80n;
2012
2209
  var WARNING_DENOMINATOR = 100n;
2013
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";
@@ -3353,6 +3550,6 @@ var PostgresTruthCommitter = class {
3353
3550
  }
3354
3551
  };
3355
3552
 
3356
- 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, 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 };
3357
3554
  //# sourceMappingURL=runtime.js.map
3358
3555
  //# sourceMappingURL=runtime.js.map