@sema-agent/server 7.115.0 → 7.116.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.
Files changed (116) hide show
  1. package/MIGRATION.md +167 -4
  2. package/USAGE.md +6 -3
  3. package/dist/approval-ask-audit-store.d.ts +25 -6
  4. package/dist/approval-ask-audit-store.js +6 -1
  5. package/dist/approval-card.d.ts +8 -4
  6. package/dist/approval-card.js +2 -0
  7. package/dist/approval-crash-convergence.d.ts +60 -0
  8. package/dist/approval-crash-convergence.js +75 -0
  9. package/dist/approval-deny-reasons.d.ts +13 -0
  10. package/dist/approval-deny-reasons.js +2 -0
  11. package/dist/approval.js +2 -1
  12. package/dist/boot/coordinators.js +0 -12
  13. package/dist/boot/execution-env.js +2 -1
  14. package/dist/boot/resolve-spec.js +1 -0
  15. package/dist/boot/stage-07-capability-layer.js +21 -15
  16. package/dist/boot/stores.js +4 -4
  17. package/dist/capabilities/hands-lane.d.ts +0 -23
  18. package/dist/capabilities/hands-lane.js +3 -2
  19. package/dist/capabilities/send-user-file-tool.d.ts +10 -22
  20. package/dist/capabilities/send-user-file-tool.js +1 -8
  21. package/dist/capability-facts.d.ts +296 -0
  22. package/dist/capability-facts.js +354 -0
  23. package/dist/config-center/apply-effective.js +1 -1
  24. package/dist/config.d.ts +16 -13
  25. package/dist/config.js +12 -12
  26. package/dist/http/admission.d.ts +6 -0
  27. package/dist/http/admission.js +22 -5
  28. package/dist/http/principal-gate.d.ts +3 -0
  29. package/dist/http/principal-gate.js +3 -0
  30. package/dist/http/resume-legs.js +10 -1
  31. package/dist/http/retired-skill-manifest.d.ts +23 -0
  32. package/dist/http/retired-skill-manifest.js +22 -0
  33. package/dist/http/route-table.d.ts +3 -2
  34. package/dist/http/route-table.js +5 -5
  35. package/dist/http/routes/a2a-serve.js +14 -21
  36. package/dist/http/routes/admin-config-refresh.js +7 -2
  37. package/dist/http/routes/admin-drain.js +7 -2
  38. package/dist/http/routes/admin-memory-optout.js +20 -6
  39. package/dist/http/routes/adoption.js +19 -5
  40. package/dist/http/routes/agents-roster.js +4 -2
  41. package/dist/http/routes/approvals-assistant.js +41 -18
  42. package/dist/http/routes/background.d.ts +0 -2
  43. package/dist/http/routes/background.js +4 -5
  44. package/dist/http/routes/capabilities.d.ts +2 -1
  45. package/dist/http/routes/capabilities.js +62 -65
  46. package/dist/http/routes/config-catalog.d.ts +4 -5
  47. package/dist/http/routes/config-catalog.js +16 -2
  48. package/dist/http/routes/devices.js +17 -11
  49. package/dist/http/routes/diagnostics.js +6 -1
  50. package/dist/http/routes/fleet.js +4 -2
  51. package/dist/http/routes/images.js +30 -3
  52. package/dist/http/routes/leader.js +7 -1
  53. package/dist/http/routes/mcp-reconnect.js +17 -10
  54. package/dist/http/routes/memory-bundle.js +20 -6
  55. package/dist/http/routes/memory-compliance.js +19 -5
  56. package/dist/http/routes/memory-consolidation.d.ts +9 -12
  57. package/dist/http/routes/memory-consolidation.js +17 -3
  58. package/dist/http/routes/memory-origin.js +19 -5
  59. package/dist/http/routes/notify-wake.js +5 -3
  60. package/dist/http/routes/observability.js +19 -5
  61. package/dist/http/routes/retention-ops.js +6 -1
  62. package/dist/http/routes/rules.js +49 -21
  63. package/dist/http/routes/run-memory-capture.js +5 -2
  64. package/dist/http/routes/runs.js +90 -60
  65. package/dist/http/routes/session-sync.js +64 -53
  66. package/dist/http/routes/sessions-list.js +8 -5
  67. package/dist/http/routes/sessions.js +39 -24
  68. package/dist/http/routes/shared-memory.js +5 -3
  69. package/dist/http/routes/tasks.js +2 -1
  70. package/dist/http/routes/trace-usage.js +18 -11
  71. package/dist/http/routes/workflows.js +36 -13
  72. package/dist/http/server.js +4 -1
  73. package/dist/http/wire-types.d.ts +6 -0
  74. package/dist/namespaced-rule-name.d.ts +54 -0
  75. package/dist/namespaced-rule-name.js +33 -0
  76. package/dist/observability/corrupt-read-seat.d.ts +4 -3
  77. package/dist/observability/corrupt-read-seat.js +4 -3
  78. package/dist/observability/fail-open.d.ts +5 -1
  79. package/dist/observability/fail-open.js +5 -1
  80. package/dist/observability/permission-rule-events.d.ts +3 -0
  81. package/dist/observability/permission-rule-events.js +4 -0
  82. package/dist/plugins/file-run-store.d.ts +7 -1
  83. package/dist/plugins/file-run-store.js +20 -7
  84. package/dist/plugins/memory-run-store.js +2 -1
  85. package/dist/plugins/one-time-migrations.d.ts +4 -2
  86. package/dist/plugins/one-time-migrations.js +26 -3
  87. package/dist/plugins/pg-session-storage.js +1 -1
  88. package/dist/plugins/run-store-sql.js +4 -4
  89. package/dist/plugins/session-policy-store-sql.js +4 -2
  90. package/dist/plugins/store-backend.d.ts +6 -1
  91. package/dist/plugins/store-backend.js +2 -1
  92. package/dist/plugins/store-contracts.d.ts +27 -8
  93. package/dist/plugins/store-contracts.js +12 -3
  94. package/dist/plugins/tidb-session-store.js +1 -1
  95. package/dist/request-key-closure.js +1 -1
  96. package/dist/run-local.d.ts +37 -3
  97. package/dist/run-local.js +48 -5
  98. package/dist/runs.d.ts +0 -58
  99. package/dist/runs.js +15 -20
  100. package/dist/session-policy-wire.d.ts +40 -60
  101. package/dist/session-policy-wire.js +60 -32
  102. package/dist/spec-fields.d.ts +32 -0
  103. package/dist/spec-fields.js +31 -16
  104. package/dist/standing-answer-grant.d.ts +8 -0
  105. package/dist/standing-answer-grant.js +5 -0
  106. package/dist/task-settings.d.ts +13 -3
  107. package/dist/tool-approval.d.ts +16 -0
  108. package/dist/tool-approval.js +29 -7
  109. package/dist/trace/core-keyset-guard.d.ts +4 -4
  110. package/dist/trace/self-minted-wire.d.ts +42 -0
  111. package/dist/trace/self-minted-wire.js +13 -0
  112. package/dist/user-input-ledger.d.ts +8 -5
  113. package/package.json +4 -2
  114. package/scripts/drain-check-local.mjs +115 -25
  115. package/scripts/drain-check.mysql.sql +67 -0
  116. package/scripts/drain-check.pg.sql +71 -0
@@ -2,7 +2,8 @@ import { once } from "node:events";
2
2
  import { randomBytes, createHash } from "node:crypto";
3
3
  import { sessionLogDigest, sessionLogDigestsComparable, StreamingImportValidator, validateFileHistoryExport } from "@sema-agent/core";
4
4
  import { StringDecoder } from "node:string_decoder";
5
- import { exportSessionManifest, fileHistoryImportFace, replayFileHistory, replayPolicyRecords, overwriteWipeIncapable, wipeSessionAttendants } from "../../session-sync.js";
5
+ import { exportSessionManifest, fileHistoryImportFace, replayFileHistory, replayPolicyRecords, wipeSessionAttendants } from "../../session-sync.js";
6
+ import * as facts from "../../capability-facts.js";
6
7
  import { classifySyncRelationshipByIds, SyncConflictError } from "../../session-sync-kernel.js";
7
8
  import { isUuidShape } from "../../security.js";
8
9
  import { sendJson, sendError, sendSessionError, sendSessionPolicyError, msg } from "../send.js";
@@ -36,16 +37,13 @@ export async function handleSessionSync(req, res, match, ctx) {
36
37
  const { pendingStagings, importLeases, touchImportLease, cleanupStaging } = ctx.local.sessionSync;
37
38
  {
38
39
  {
39
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
40
- if (!deps.backend || !ownerOf) {
41
- sendError(res, 501, "capability.session_store_required", "session sync requires a durable session store");
42
- return;
43
- }
44
- const historyStore = deps.fileHistoryStore;
45
- if (historyStore === undefined) {
46
- sendError(res, 501, "capability.snapshot_store_required", "session sync is not available on this deployment: no file-history store is wired, so there is no file history to export or import");
40
+ const sync = facts.sessionSync(deps);
41
+ if (!sync.available) {
42
+ sendSessionSyncUnavailable(res, sync.why);
47
43
  return;
48
44
  }
45
+ const { backend, sessionStorage, historyStore } = sync;
46
+ const ownerOf = sessionStorage.ownerOf.bind(sessionStorage);
49
47
  const scope = sessionOwnerScopeForWrite(req);
50
48
  if (!scope.fleetWide && scope.gateOwner === null) {
51
49
  sendUnauthorized(res, "identity");
@@ -81,7 +79,7 @@ export async function handleSessionSync(req, res, match, ctx) {
81
79
  return;
82
80
  let out;
83
81
  try {
84
- out = await exportSessionManifest(sessionId, deps.backend, historyStore);
82
+ out = await exportSessionManifest(sessionId, backend, historyStore);
85
83
  }
86
84
  catch (e) {
87
85
  if (sendSessionPolicyError(res, e))
@@ -108,12 +106,7 @@ export async function handleSessionSync(req, res, match, ctx) {
108
106
  }
109
107
  afterSeq = n;
110
108
  }
111
- const stream = deps.sessionStorage?.exportEntriesStream?.bind(deps.sessionStorage);
112
- if (!stream) {
113
- sendError(res, 501, "capability.session_store_required", "session entry streaming requires a durable session store");
114
- return;
115
- }
116
- const it = await stream(sessionId, afterSeq === undefined ? undefined : { afterSeq });
109
+ const it = await sessionStorage.exportEntriesStream(sessionId, afterSeq === undefined ? undefined : { afterSeq });
117
110
  if (it === null) {
118
111
  sendError(res, 404, "not_found.session", SESSION_NOT_FOUND_MESSAGE);
119
112
  return;
@@ -138,6 +131,11 @@ export async function handleSessionSync(req, res, match, ctx) {
138
131
  return;
139
132
  }
140
133
  if (match.decl.id === "session-sync-history-blob") {
134
+ const blobs = facts.sessionSyncFileHistory(deps);
135
+ if (!blobs.available) {
136
+ sendSessionSyncUnavailable(res, blobs.why);
137
+ return;
138
+ }
141
139
  if (!pullGateOk())
142
140
  return;
143
141
  const hash = safeDecode(match.params.hash);
@@ -149,18 +147,14 @@ export async function handleSessionSync(req, res, match, ctx) {
149
147
  sendError(res, 400, "request.id_invalid", "blob hash must be 64 lowercase-hex chars");
150
148
  return;
151
149
  }
152
- const fs = deps.fileHistoryStore;
153
- if (fs?.syncBlobFaces !== true) {
154
- sendError(res, 404, "not_found.blob", "blob not found");
155
- return;
156
- }
150
+ const fs = blobs.historyStore;
157
151
  const envelope = typeof fs.exportHistory === "function" ? await fs.exportHistory(sessionId) : null;
158
152
  const referenced = envelope !== null && envelope !== undefined && envelope.tracked.some((t) => t.versions.some((v) => v.blobHash === hash));
159
153
  if (!referenced) {
160
154
  sendError(res, 404, "not_found.blob", "blob not found");
161
155
  return;
162
156
  }
163
- const bytes = await fs.getBlob?.(hash);
157
+ const bytes = await fs.getBlob(hash);
164
158
  if (!bytes) {
165
159
  sendError(res, 404, "not_found.blob", "blob not found");
166
160
  return;
@@ -172,6 +166,11 @@ export async function handleSessionSync(req, res, match, ctx) {
172
166
  if (match.decl.id === "session-sync-blob-put") {
173
167
  if (rateLimited(req, res))
174
168
  return;
169
+ const blobs = facts.sessionSyncFileHistory(deps);
170
+ if (!blobs.available) {
171
+ sendSessionSyncUnavailable(res, blobs.why);
172
+ return;
173
+ }
175
174
  if (!pushGateOk())
176
175
  return;
177
176
  const hash = safeDecode(match.params.hash);
@@ -179,11 +178,7 @@ export async function handleSessionSync(req, res, match, ctx) {
179
178
  sendError(res, 400, "request.id_invalid", "blob hash must be 64 lowercase-hex chars");
180
179
  return;
181
180
  }
182
- const fs = deps.fileHistoryStore;
183
- if (fs?.syncBlobFaces !== true || typeof fs.putBlob !== "function") {
184
- sendError(res, 501, "capability.snapshot_store_required", "blob upload requires a durable file-history store");
185
- return;
186
- }
181
+ const fs = blobs.historyStore;
187
182
  const body = await readRawBody(req, SYNC_BLOB_MAX_BYTES);
188
183
  if (deps.snapshotBlobSqlCapBytes !== undefined && body.byteLength > deps.snapshotBlobSqlCapBytes) {
189
184
  sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`);
@@ -213,10 +208,6 @@ export async function handleSessionSync(req, res, match, ctx) {
213
208
  }
214
209
  }).catch(() => { });
215
210
  }
216
- if (!deps.sessionStorage?.beginImportStaging) {
217
- sendError(res, 501, "capability.session_store_required", "staged session import requires a durable session store");
218
- return;
219
- }
220
211
  let body;
221
212
  try {
222
213
  body = (await readJson(req));
@@ -237,7 +228,7 @@ export async function handleSessionSync(req, res, match, ctx) {
237
228
  if (policyRead.problem !== undefined) {
238
229
  const legacy = legacyToolNameProblemOf(policyRead.problem);
239
230
  if (legacy !== undefined) {
240
- sendError(res, 400, "rules.legacy_tool_name", `policy: ${describeSessionRulesRecordProblem(policyRead.problem)}`, { field: legacy.field, name: legacy.name });
231
+ sendError(res, 400, "rules.legacy_tool_name", `policy: ${describeSessionRulesRecordProblem(policyRead.problem)}`, { ...legacy });
241
232
  return;
242
233
  }
243
234
  sendError(res, 400, "request.field_invalid", `policy: ${describeSessionRulesRecordProblem(policyRead.problem)}`);
@@ -261,7 +252,7 @@ export async function handleSessionSync(req, res, match, ctx) {
261
252
  const entryIds = body.entryIds;
262
253
  const policy = policyRead.records;
263
254
  const anchors = body.anchors;
264
- const dstIds = (await deps.sessionStorage.listEntryIds?.(sessionId)) ?? null;
255
+ const dstIds = (await sessionStorage.listEntryIds?.(sessionId)) ?? null;
265
256
  const relA = classifySyncRelationshipByIds(entryIds, dstIds);
266
257
  if ((relA.relation === "fork" || relA.relation === "stale") && resolution !== "overwrite-dst") {
267
258
  sendError(res, 409, "conflict", "sync conflict", { relation: relA });
@@ -270,7 +261,7 @@ export async function handleSessionSync(req, res, match, ctx) {
270
261
  let payloadVerified = false;
271
262
  let digestSaysDiffer = false;
272
263
  if (relA.relation === "identical" && srcLogDigest !== undefined) {
273
- const dstEntries = await deps.sessionStorage.exportEntries?.(sessionId).catch(() => null) ?? null;
264
+ const dstEntries = await sessionStorage.exportEntries(sessionId).catch(() => null) ?? null;
274
265
  const dstDigest = dstEntries === null ? undefined : sessionLogDigest(dstEntries);
275
266
  if (sessionLogDigestsComparable(srcLogDigest, dstDigest)) {
276
267
  if (srcLogDigest === dstDigest)
@@ -282,7 +273,8 @@ export async function handleSessionSync(req, res, match, ctx) {
282
273
  digestSaysDiffer = true;
283
274
  }
284
275
  if (relA.relation === "identical" && !digestSaysDiffer) {
285
- if (fileHistory !== null && deps.fileHistoryStore?.syncBlobFaces === true && fileHistoryImportFace(deps.fileHistoryStore) && deps.fileHistoryStore.getBlob) {
276
+ const blobFaces = facts.sessionSyncFileHistory(deps);
277
+ if (fileHistory !== null && blobFaces.available && fileHistoryImportFace(blobFaces.historyStore)) {
286
278
  if (resolution === "overwrite-dst") {
287
279
  const activeNow = await deps.runStore?.getActiveTaskId(sessionId);
288
280
  if (activeNow) {
@@ -290,8 +282,8 @@ export async function handleSessionSync(req, res, match, ctx) {
290
282
  return;
291
283
  }
292
284
  }
293
- const getBlobA = (h) => deps.fileHistoryStore.getBlob(h);
294
- const r = await replayFileHistory(deps.fileHistoryStore, sessionId, fileHistory, getBlobA, { allowReplace: resolution === "overwrite-dst" });
285
+ const getBlobA = (h) => blobFaces.historyStore.getBlob(h);
286
+ const r = await replayFileHistory(blobFaces.historyStore, sessionId, fileHistory, getBlobA, { allowReplace: resolution === "overwrite-dst" });
295
287
  const failCode = r.ok ? undefined : r.error.code === "not_found" ? "missing_blob" : r.error.code;
296
288
  if (!r.ok) {
297
289
  sendError(res, 422, failCode, `file history import failed (${r.error.code})`);
@@ -343,7 +335,7 @@ export async function handleSessionSync(req, res, match, ctx) {
343
335
  return;
344
336
  }
345
337
  }
346
- const fss = deps.fileHistoryStore;
338
+ const fss = historyStore;
347
339
  if (fileHistory !== null && fileHistoryImportFace(fss) !== undefined) {
348
340
  const distinct = new Set();
349
341
  for (const t of fileHistory.tracked)
@@ -351,7 +343,7 @@ export async function handleSessionSync(req, res, match, ctx) {
351
343
  if (v.blobHash !== null)
352
344
  distinct.add(v.blobHash);
353
345
  const all = [...distinct];
354
- if (typeof fss?.hasBlobs === "function") {
346
+ if (typeof fss.hasBlobs === "function") {
355
347
  const present = await fss.hasBlobs(all);
356
348
  const missing = all.find((h) => !present.has(h));
357
349
  if (missing !== undefined) {
@@ -359,7 +351,7 @@ export async function handleSessionSync(req, res, match, ctx) {
359
351
  return;
360
352
  }
361
353
  }
362
- else if (typeof fss?.getBlob === "function") {
354
+ else if (typeof fss.getBlob === "function") {
363
355
  for (const hash of all) {
364
356
  if (!(await fss.getBlob(hash))) {
365
357
  sendError(res, 422, "missing_blob", "missing blob; PUT all referenced blobs before import");
@@ -371,7 +363,7 @@ export async function handleSessionSync(req, res, match, ctx) {
371
363
  const importingPrincipal = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
372
364
  const operatorOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
373
365
  const token = randomBytes(8).toString("hex");
374
- const handle = deps.sessionStorage.beginImportStaging(sessionId, token);
366
+ const handle = sessionStorage.beginImportStaging(sessionId, token);
375
367
  const stagingId = handle.stagingId;
376
368
  importLeases.set(sessionId, { at: Date.now(), stagingId });
377
369
  pendingStagings.set(stagingId, {
@@ -396,7 +388,7 @@ export async function handleSessionSync(req, res, match, ctx) {
396
388
  sendError(res, 404, "not_found.staging", "staging not found");
397
389
  return;
398
390
  }
399
- const getBlob = (h) => deps.fileHistoryStore?.getBlob ? deps.fileHistoryStore.getBlob(h) : Promise.resolve(undefined);
391
+ const getBlob = (h) => historyStore.getBlob ? historyStore.getBlob(h) : Promise.resolve(undefined);
400
392
  const validator = new StreamingImportValidator();
401
393
  let lineBuf = "";
402
394
  let pendingBatch = [];
@@ -481,19 +473,13 @@ export async function handleSessionSync(req, res, match, ctx) {
481
473
  sendError(res, 409, "session_active", "session started an active run while the import streamed; sync after it settles", { activeTaskId: activeAtCommit });
482
474
  return;
483
475
  }
484
- if (pending.resolution !== undefined && overwriteWipeIncapable(deps.sessionPolicyStore)) {
485
- await pending.handle.abort().catch(() => undefined);
486
- cleanupStaging(stagingId);
487
- sendError(res, 501, "capability.policy_purge_required", "overwrite-dst cannot proceed: this deployment's session-policy store has no deleteBySession purge verb, so the destination's stale policy rows cannot be wiped and the import would land a MIXED state (the source's conversation plus the destination's leftover policy rows, which keep gating). Upgrade the policy store (every in-tree store carries deleteBySession since core 1.423), or re-run without resolution overwrite-dst");
488
- return;
489
- }
490
476
  try {
491
477
  const committed = await pending.handle.commit(pending.owner, pending.resolution ? { resolution: pending.resolution } : undefined);
492
478
  if (pending.resolution === "overwrite-dst" && (committed.relation === "fork" || committed.relation === "stale")) {
493
- await wipeSessionAttendants({ fileHistoryStore: deps.fileHistoryStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
479
+ await wipeSessionAttendants({ fileHistoryStore: historyStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
494
480
  }
495
- if (pending.fileHistory !== null && deps.fileHistoryStore && fileHistoryImportFace(deps.fileHistoryStore)) {
496
- const r = await replayFileHistory(deps.fileHistoryStore, sessionId, pending.fileHistory, getBlob, { allowReplace: pending.resolution === "overwrite-dst" });
481
+ if (pending.fileHistory !== null && fileHistoryImportFace(historyStore)) {
482
+ const r = await replayFileHistory(historyStore, sessionId, pending.fileHistory, getBlob, { allowReplace: pending.resolution === "overwrite-dst" });
497
483
  const failCode = r.ok ? undefined : r.error.code === "not_found" ? "missing_blob" : r.error.code;
498
484
  if (!r.ok) {
499
485
  sendError(res, 422, failCode, `file history import failed (${r.error.code})`);
@@ -508,7 +494,7 @@ export async function handleSessionSync(req, res, match, ctx) {
508
494
  if (anchorStore)
509
495
  for (const a of pending.anchors)
510
496
  await anchorStore.put(sessionId, a.eventId, a.entryId, pending.owner);
511
- deps.sessionStorage.evict?.(sessionId);
497
+ sessionStorage.evict?.(sessionId);
512
498
  cleanupStaging(stagingId);
513
499
  sendJson(res, 200, { relation: committed.relation });
514
500
  }
@@ -541,7 +527,7 @@ export async function handleSessionSync(req, res, match, ctx) {
541
527
  sendError(res, 400, "request.field_invalid", "entryIds must be an array of strings");
542
528
  return;
543
529
  }
544
- const dst = await deps.sessionStorage?.exportEntries?.(sessionId);
530
+ const dst = await sessionStorage.exportEntries(sessionId);
545
531
  const relation = classifySyncRelationshipByIds(body.entryIds, dst ? dst.map((e) => e.id) : null);
546
532
  sendJson(res, 200, relation);
547
533
  return;
@@ -550,6 +536,31 @@ export async function handleSessionSync(req, res, match, ctx) {
550
536
  }
551
537
  }
552
538
  }
539
+ function sendSessionSyncUnavailable(res, why) {
540
+ switch (why) {
541
+ case "store_backend_absent":
542
+ case "session_owner_face_absent":
543
+ sendError(res, 501, "capability.session_store_required", "session sync requires a durable session store");
544
+ return;
545
+ case "session_export_absent":
546
+ sendError(res, 501, "capability.session_store_required", "session entry streaming requires a durable session store");
547
+ return;
548
+ case "session_import_staging_absent":
549
+ sendError(res, 501, "capability.session_store_required", "staged session import requires a durable session store");
550
+ return;
551
+ case "file_history_store_absent":
552
+ sendError(res, 501, "capability.snapshot_store_required", "session sync is not available on this deployment: no file-history store is wired, so there is no file history to export or import");
553
+ return;
554
+ case "policy_purge_absent":
555
+ sendError(res, 501, "capability.policy_purge_required", "session sync is not available on this deployment: the session-policy store has no deleteBySession purge verb, so an overwrite-dst import could not wipe the destination's stale policy rows. Every in-tree policy store carries it since core 1.423 — upgrade the policy store");
556
+ return;
557
+ case "sync_blob_faces_absent":
558
+ sendError(res, 501, "capability.snapshot_store_required", "file-history blob transfer is not available on this deployment: its file-history store has no blob faces (SQL backends only; capabilities.sessionSyncFileHistory is false here). Sync the conversation without fileHistory");
559
+ return;
560
+ default:
561
+ return assertNever(why);
562
+ }
563
+ }
553
564
  export const SESSION_SYNC_ROUTES = [
554
565
  { id: "session-sync-manifest", pattern: /^\/v1\/sessions\/(?<sessionId>[^/]+)\/sync\/manifest$/, label: "/v1/sessions/:id/sync/$verb", methods: ["GET"] },
555
566
  { id: "session-sync-entries", pattern: /^\/v1\/sessions\/(?<sessionId>[^/]+)\/sync\/entries$/, label: "/v1/sessions/:id/sync/$verb", methods: ["GET"] },
@@ -4,6 +4,7 @@ import { sendUnauthorized } from "../principal-gate.js";
4
4
  import { filterFingerprint } from "../cursor-fingerprint.js";
5
5
  import { sessionTempoOfRunStatus } from "../../plugins/store-contracts.js";
6
6
  import { assertNever } from "../route-ctx.js";
7
+ import * as facts from "../../capability-facts.js";
7
8
  export async function handleSessionsList(req, res, match, ctx) {
8
9
  const { deps } = ctx;
9
10
  const { isFleetWide, sessionOwnerScope } = ctx.helpers;
@@ -14,11 +15,12 @@ export async function handleSessionsList(req, res, match, ctx) {
14
15
  sendUnauthorized(res, "identity");
15
16
  return;
16
17
  }
17
- const lister = deps.sessionStorage?.listSessions?.bind(deps.sessionStorage) ?? deps.runStore?.listSessions?.bind(deps.runStore);
18
- if (!lister) {
18
+ const listing = facts.sessionList(deps);
19
+ if (!listing.available) {
19
20
  sendError(res, 501, "capability.session_store_required", "session list requires a durable session store (DB_BACKEND=mysql|pg|local)");
20
21
  return;
21
22
  }
23
+ const lister = listing.lister;
22
24
  const includeUnowned = !fleetWide && legacyUnownedAmnestyOpen(deps.config.requirePrincipal, gateOwner);
23
25
  await handleSessionList(res, lister, new URLSearchParams(match.search), gateOwner, includeUnowned);
24
26
  return;
@@ -29,7 +31,8 @@ export async function handleSessionsList(req, res, match, ctx) {
29
31
  sendUnauthorized(res, "identity");
30
32
  return;
31
33
  }
32
- if (!deps.sendFileLedger) {
34
+ const ledger = facts.sendFileLedger(deps);
35
+ if (!ledger.available) {
33
36
  sendError(res, 501, "capability.store_required", "sendfile ledger requires a store backend (DB_BACKEND=mysql|pg|local) and the SendUserFile issuer configured");
34
37
  return;
35
38
  }
@@ -64,7 +67,7 @@ export async function handleSessionsList(req, res, match, ctx) {
64
67
  scope = s;
65
68
  }
66
69
  }
67
- const rows = await deps.sendFileLedger.listByScope(scope, limit + 1, before);
70
+ const rows = await ledger.ledger.listByScope(scope, limit + 1, before);
68
71
  const hasMore = rows.length > limit;
69
72
  const page = hasMore ? rows.slice(0, limit) : rows;
70
73
  sendJson(res, 200, {
@@ -117,7 +120,7 @@ async function handleSessionList(res, lister, query, forceOwner, includeUnowned
117
120
  sendError(res, 400, "request.cursor_filter_mismatch", "cursor was issued under a different filter (owner/q) — restart from the first page without a cursor");
118
121
  return;
119
122
  }
120
- const rows = await lister({ ...(owner ? { owner } : {}), ...(includeUnowned ? { includeUnowned: true } : {}), ...(cursor ? { cursor } : {}), limit: limit + 1, ...(q ? { q } : {}) });
123
+ const rows = await lister({ ...(owner !== undefined ? { owner } : {}), ...(includeUnowned ? { includeUnowned: true } : {}), ...(cursor ? { cursor } : {}), limit: limit + 1, ...(q ? { q } : {}) });
121
124
  const hasMore = rows.length > limit;
122
125
  const page = hasMore ? rows.slice(0, limit) : rows;
123
126
  const last = page[page.length - 1];
@@ -10,6 +10,7 @@ import { readWritableSessionPermissionRules, describeSessionPermissionRulesProbl
10
10
  import { sendJson, sendError, sendSessionError, sendSessionPolicyError, sseHeaders } from "../send.js";
11
11
  import { gatedPrincipal, explicitOperatorOk, runOwnerReadable, sendPrincipalRefusal, sendUnauthorized } from "../principal-gate.js";
12
12
  import { assertNever, SESSION_NOT_FOUND_MESSAGE } from "../route-ctx.js";
13
+ import * as facts from "../../capability-facts.js";
13
14
  import { rosterAgentId } from "./agents-roster.js";
14
15
  const MCP_STATUS_TTL_MS = 5_000;
15
16
  const MCP_MATERIALIZE_TIMEOUT_MS = 10_000;
@@ -291,12 +292,13 @@ export async function handleSessions(req, res, match, ctx) {
291
292
  }
292
293
  {
293
294
  if (match.decl.id === "session-events") {
294
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
295
- const watch = deps.sessionWatch;
296
- if (!deps.sessionStorage?.getLeafId || !ownerOf || !watch) {
295
+ const evFact = facts.sessionEventStream(deps);
296
+ if (!evFact.available) {
297
297
  sendError(res, 501, "capability.session_store_required", "session events require a durable session store (DB_BACKEND=mysql|pg|local)");
298
298
  return;
299
299
  }
300
+ const ownerOf = evFact.sessionStorage.ownerOf.bind(evFact.sessionStorage);
301
+ const watch = evFact.watch;
300
302
  const evSession = safeDecode(match.params.sessionId);
301
303
  if (evSession === null) {
302
304
  sendError(res, 400, "request.path_malformed", "session id segment is not valid percent-encoding");
@@ -494,11 +496,12 @@ export async function handleSessions(req, res, match, ctx) {
494
496
  sendPrincipalRefusal(res, req, deps.config);
495
497
  return;
496
498
  }
497
- if (!deps.sessionAudit) {
499
+ const auditFact = facts.sessionAuditView(deps);
500
+ if (!auditFact.available) {
498
501
  sendError(res, 501, "capability.memory_engine_required", "memory provenance and erasure are not available on this deployment — they require a wired memory engine (MEMORY_ENGINE=on) whose backend owns the engine control plane (controlPlaneRoot); the file memory backend does, the pg/tidb memory backends do not (their control plane would land on a replica's local disk)");
499
502
  return;
500
503
  }
501
- if ((await readOwnedSessionView(deps.sessionAudit, sessionId)) === undefined)
504
+ if ((await readOwnedSessionView(auditFact.sessionAudit, sessionId)) === undefined)
502
505
  return;
503
506
  const faces = deps.memoryCompliance;
504
507
  if (faces === undefined) {
@@ -546,11 +549,12 @@ export async function handleSessions(req, res, match, ctx) {
546
549
  sendPrincipalRefusal(res, req, deps.config);
547
550
  return;
548
551
  }
549
- if (!deps.sessionAudit) {
552
+ const auditFact = facts.sessionAuditView(deps);
553
+ if (!auditFact.available) {
550
554
  sendError(res, 501, "capability.memory_engine_required", "session memory status is not available on this deployment — it requires a wired memory engine (MEMORY_ENGINE=on) whose backend owns the engine control plane (controlPlaneRoot); the file memory backend does, the pg/tidb memory backends do not (their control plane would land on a replica's local disk)");
551
555
  return;
552
556
  }
553
- if ((await readOwnedSessionView(deps.sessionAudit, sessionId)) === undefined)
557
+ if ((await readOwnedSessionView(auditFact.sessionAudit, sessionId)) === undefined)
554
558
  return;
555
559
  const face = deps.sessionMemoryStatus;
556
560
  if (face === undefined) {
@@ -590,11 +594,12 @@ export async function handleSessions(req, res, match, ctx) {
590
594
  }
591
595
  if (match.decl.id === "session-detail") {
592
596
  {
593
- if (!deps.sessionAudit) {
594
- sendError(res, 404, "not_found.route", "not found");
597
+ const auditFact = facts.sessionAuditView(deps);
598
+ if (!auditFact.available) {
599
+ sendError(res, 501, "capability.session_store_required", "the session audit view requires a durable session store (DB_BACKEND=mysql|pg|local)");
595
600
  return;
596
601
  }
597
- const audit = await readOwnedSessionView(deps.sessionAudit, match.params.sessionId);
602
+ const audit = await readOwnedSessionView(auditFact.sessionAudit, match.params.sessionId);
598
603
  if (audit === undefined)
599
604
  return;
600
605
  const q = new URLSearchParams(match.search);
@@ -647,12 +652,13 @@ export async function handleSessions(req, res, match, ctx) {
647
652
  }
648
653
  {
649
654
  if (match.decl.id === "session-fork") {
650
- const fork = deps.sessionStorage?.fork?.bind(deps.sessionStorage);
651
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
652
- if (!fork || !ownerOf) {
655
+ const forkFact = facts.sessionFork(deps);
656
+ if (!forkFact.available) {
653
657
  sendError(res, 501, "capability.session_store_required", "session fork requires a durable session store (DB_BACKEND=mysql|pg|local)");
654
658
  return;
655
659
  }
660
+ const fork = forkFact.sessionStorage.fork.bind(forkFact.sessionStorage);
661
+ const ownerOf = forkFact.sessionStorage.ownerOf.bind(forkFact.sessionStorage);
656
662
  const scope = sessionOwnerScopeForWrite(req);
657
663
  if (!scope.fleetWide && scope.gateOwner === null) {
658
664
  sendUnauthorized(res, "identity");
@@ -684,12 +690,13 @@ export async function handleSessions(req, res, match, ctx) {
684
690
  }
685
691
  {
686
692
  if (match.decl.id === "session-delete") {
687
- const purge = deps.purgeSession;
688
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
689
- if (!purge || !ownerOf) {
693
+ const deleteFact = facts.sessionDelete(deps);
694
+ if (!deleteFact.available) {
690
695
  sendError(res, 501, "capability.session_store_required", "session delete requires a durable session store (DB_BACKEND=mysql|pg|local)");
691
696
  return;
692
697
  }
698
+ const purge = deleteFact.purge;
699
+ const ownerOf = deleteFact.sessionStorage.ownerOf.bind(deleteFact.sessionStorage);
693
700
  const scope = sessionOwnerScopeForWrite(req);
694
701
  if (!scope.fleetWide && scope.gateOwner === null) {
695
702
  sendUnauthorized(res, "identity");
@@ -729,12 +736,13 @@ export async function handleSessions(req, res, match, ctx) {
729
736
  }
730
737
  {
731
738
  if (match.decl.id === "session-policy-get" || match.decl.id === "session-policy-put") {
732
- const store = deps.sessionPolicyStore;
733
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
734
- if (!store || !ownerOf) {
739
+ const policyFact = facts.sessionPolicy(deps);
740
+ if (!policyFact.available) {
735
741
  sendError(res, 501, "capability.session_store_required", "session policy requires a session-store backend");
736
742
  return;
737
743
  }
744
+ const store = policyFact.policyStore;
745
+ const ownerOf = policyFact.sessionStorage.ownerOf.bind(policyFact.sessionStorage);
738
746
  const principal = gatedPrincipal(req, deps.config);
739
747
  if (deps.config.requirePrincipal && principal === undefined) {
740
748
  sendUnauthorized(res, "identity");
@@ -789,7 +797,10 @@ export async function handleSessions(req, res, match, ctx) {
789
797
  sendError(res, 400, "request.field_invalid", describeSessionPermissionRulesProblem(read.problem));
790
798
  return;
791
799
  case "legacy-tool-name":
792
- sendError(res, 400, "rules.legacy_tool_name", describeSessionPermissionRulesProblem(read.problem), { field: read.problem.field, name: read.problem.name });
800
+ sendError(res, 400, "rules.legacy_tool_name", describeSessionPermissionRulesProblem(read.problem), { ...read.problem.refusal });
801
+ return;
802
+ case "unreachable-tool-name":
803
+ sendError(res, 400, "request.field_invalid", describeSessionPermissionRulesProblem(read.problem));
793
804
  return;
794
805
  default: {
795
806
  const unhandled = read.problem;
@@ -814,10 +825,14 @@ export async function handleSessions(req, res, match, ctx) {
814
825
  return;
815
826
  }
816
827
  }
817
- {
818
- if (match.decl.id === "session-workspace") {
819
- sendError(res, 501, "capability.workspace_retired", "workspace browse is retired: the whole-tree snapshot epoch it projected was replaced by per-edited-file rewind history (which holds only files the agent edited, not the workspace). There is no whole-tree manifest to browse anymore.");
820
- return;
828
+ if (match.decl.id === "session-workspace") {
829
+ const workspace = facts.workspaceBrowse();
830
+ switch (workspace.why) {
831
+ case "workspace_retired":
832
+ sendError(res, 501, "capability.workspace_retired", "workspace browse is retired: the whole-tree snapshot epoch it projected was replaced by per-edited-file rewind history (which holds only files the agent edited, not the workspace). There is no whole-tree manifest to browse anymore.");
833
+ return;
834
+ default:
835
+ return assertNever(workspace.why);
821
836
  }
822
837
  }
823
838
  return assertNever(match.decl.id);
@@ -2,6 +2,7 @@ import { SHARED_MEMORY_READ_CAP_BYTES, SharedMemoryStoreError } from "@sema-agen
2
2
  import { sendJson, sendError } from "../send.js";
3
3
  import { gatedPrincipal, sendUnauthorized } from "../principal-gate.js";
4
4
  import { assertNever } from "../route-ctx.js";
5
+ import * as facts from "../../capability-facts.js";
5
6
  const RETRY_AFTER_SEC = 10;
6
7
  const HTTP_LIST_LIMIT = 100;
7
8
  const FORBIDDEN_PATH_CHARS = /[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Default_Ignorable_Code_Point}\u2028\u2029\\]/u;
@@ -32,11 +33,12 @@ async function bindSnapshot(req, res, ctx) {
32
33
  sendUnauthorized(res, "identity");
33
34
  return undefined;
34
35
  }
35
- const provider = deps.sharedMemoryStore;
36
- if (!provider) {
37
- refuseStore(res);
36
+ const fact = facts.sharedMemoryRead(deps);
37
+ if (!fact.available) {
38
+ sendError(res, 501, "capability.shared_memory_required", "org shared memory is not available on this deployment (it needs both a shared memory store provider and an org membership directory)");
38
39
  return undefined;
39
40
  }
41
+ const provider = fact.provider;
40
42
  const snapshot = await provider.snapshot({ sessionId: `http:${principal ?? "anonymous"}`, taskId: "http-read", ...(principal ? { principal } : {}) }, {});
41
43
  if (snapshot.state === "unavailable") {
42
44
  refuseUnavailable(res, snapshot.message);
@@ -27,6 +27,7 @@ import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate
27
27
  import { failOpenTagForDroppedFrame, recordFailOpen } from "../../observability/fail-open.js";
28
28
  import { buildSyncSubmitBody } from "../sync-submit-body.js";
29
29
  import { assertNever } from "../route-ctx.js";
30
+ import * as facts from "../../capability-facts.js";
30
31
  import { buildCheckpointCtx } from "../checkpoint-ctx.js";
31
32
  import { withModelReachScope } from "../../model-reach-scope.js";
32
33
  import { createInstructionSelectionLeg } from "../../instruction-selection.js";
@@ -123,7 +124,7 @@ export async function handleTasks(req, res, match, ctx) {
123
124
  sendError(res, 400, "request.field_conflict", "verify / cascade are not supported on /v1/tasks/stream (both are multi-attempt, not a single stream) — use /v1/tasks or /v1/runs");
124
125
  return;
125
126
  }
126
- const earlyDurableTid = deps.runStore && prepared.spec.sessionId ? uuidv7() : undefined;
127
+ const earlyDurableTid = facts.durableRuns(deps).available && prepared.spec.sessionId ? uuidv7() : undefined;
127
128
  const streamLegStartedAt = Date.now();
128
129
  const detachOnDisconnect = headerStr(req.headers["x-detach-on-disconnect"]) === "true";
129
130
  if (detachOnDisconnect && earlyDurableTid === undefined) {
@@ -8,6 +8,7 @@ import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_
8
8
  import { filterFingerprint } from "../cursor-fingerprint.js";
9
9
  import { gatedPrincipal, sendUnauthorized } from "../principal-gate.js";
10
10
  import { assertNever } from "../route-ctx.js";
11
+ import * as facts from "../../capability-facts.js";
11
12
  const TOOL_RESULT_LIMIT_CAP = 262_144;
12
13
  const TOOL_RESULT_OFFSET_CAP = 2_147_483_646;
13
14
  export async function handleTraceUsage(req, res, match, ctx) {
@@ -18,13 +19,15 @@ export async function handleTraceUsage(req, res, match, ctx) {
18
19
  sendUnauthorized(res, "operator");
19
20
  return;
20
21
  }
21
- if (!deps.runStore) {
22
+ const runs = facts.durableRuns(deps);
23
+ if (!runs.available) {
22
24
  sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg|local)");
23
25
  return;
24
26
  }
27
+ const runStore = runs.runStore;
25
28
  const q = new URLSearchParams(match.search);
26
29
  const sinceSec = Math.min(30 * 86_400, Math.max(60, Number(q.get("sinceSec") ?? 86_400) || 86_400));
27
- sendJson(res, 200, { sinceSec, rows: await deps.runStore.sourceSummary(Date.now() - sinceSec * 1000) });
30
+ sendJson(res, 200, { sinceSec, rows: await runStore.sourceSummary(Date.now() - sinceSec * 1000) });
28
31
  return;
29
32
  }
30
33
  if (match.decl.id === "task-trace-list" ||
@@ -38,15 +41,17 @@ export async function handleTraceUsage(req, res, match, ctx) {
38
41
  sendUnauthorized(res, "identity");
39
42
  return;
40
43
  }
41
- if (!deps.runStore) {
44
+ const runs = facts.durableRuns(deps);
45
+ if (!runs.available) {
42
46
  sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg|local)");
43
47
  return;
44
48
  }
49
+ const runStore = runs.runStore;
45
50
  const query = new URLSearchParams(match.search);
46
51
  const taskId = match.params.taskId;
47
52
  let ownedRun;
48
53
  if (taskId && gateOwner !== null) {
49
- const run = await deps.runStore.getRun(taskId);
54
+ const run = await runStore.getRun(taskId);
50
55
  if (!run || (run.owner !== null && run.owner !== gateOwner)) {
51
56
  sendError(res, 404, "not_found.run", "task not found");
52
57
  return;
@@ -55,19 +60,19 @@ export async function handleTraceUsage(req, res, match, ctx) {
55
60
  }
56
61
  switch (match.decl.id) {
57
62
  case "task-trace-list":
58
- await handleTaskList(res, deps.runStore, query, gateOwner);
63
+ await handleTaskList(res, runStore, query, gateOwner);
59
64
  return;
60
65
  case "task-trace-turns":
61
- await handleTaskTurns(res, deps.runStore, taskId, query);
66
+ await handleTaskTurns(res, runStore, taskId, query);
62
67
  return;
63
68
  case "task-trace-stream":
64
- await streamTaskTrace(req, res, deps.runStore, taskId, deps.config.runStaleSec * 1000, match.search);
69
+ await streamTaskTrace(req, res, runStore, taskId, deps.config.runStaleSec * 1000, match.search);
65
70
  return;
66
71
  case "task-trace-artifacts":
67
- await handleTaskArtifacts(res, deps.runStore, taskId);
72
+ await handleTaskArtifacts(res, runStore, taskId);
68
73
  return;
69
74
  case "task-tool-result":
70
- await handleToolResult(res, deps.toolResultStore, deps.runStore, taskId, ownedRun, safeDecode(match.params.ref), query);
75
+ await handleToolResult(res, deps.toolResultStore, runStore, taskId, ownedRun, safeDecode(match.params.ref), query);
71
76
  return;
72
77
  default:
73
78
  return assertNever(match.decl.id);
@@ -78,10 +83,12 @@ export async function handleTraceUsage(req, res, match, ctx) {
78
83
  {
79
84
  const sub = match.params.sub;
80
85
  {
81
- if (!deps.runStore) {
86
+ const runs = facts.durableRuns(deps);
87
+ if (!runs.available) {
82
88
  sendError(res, 501, "capability.run_store_required", "usage analytics requires a run store");
83
89
  return;
84
90
  }
91
+ const runStore = runs.runStore;
85
92
  const fleetWide = isFleetWide(req);
86
93
  const jwtOwner = fleetWide ? undefined : gatedPrincipal(req, deps.config);
87
94
  if (!fleetWide && !jwtOwner) {
@@ -104,7 +111,7 @@ export async function handleTraceUsage(req, res, match, ctx) {
104
111
  }
105
112
  const owner = jwtOwner ?? (q.get("principal") ?? undefined);
106
113
  try {
107
- const scan = await deps.runStore.usageScan(from, to, owner !== undefined ? { owner } : undefined);
114
+ const scan = await runStore.usageScan(from, to, owner !== undefined ? { owner } : undefined);
108
115
  const base = { from: new Date(from).toISOString(), to: new Date(to).toISOString(), ...(scan.truncated ? { truncated: true } : {}) };
109
116
  if (sub === "summary") {
110
117
  sendJson(res, 200, { ...base, ...usageSummary(scan.rows) });