@desplega.ai/agent-swarm 1.156.0 → 1.158.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 (263) hide show
  1. package/dist/{acp-adapter-c1msptrw.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-wd41qtdc.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-tz2qrnah.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-c3q1zj2t.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-0be1krkm.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-r00j03f3.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-9j4hd0vw.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-ncgxz1r2.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-112w28qc.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-k7pb5kfz.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-mx3bzbv8.js → cli-20cj37vt.js} +1968 -44
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-24w39x6n.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-90x9ewbz.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-08n8jxcw.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-7j2098n5.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-b05d7szr.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-9w4ex8vh.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-28wkjpdp.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-7pgcpcrg.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-jk26c1sz.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-865swy5p.js → cli-c07r9a2m.js} +3 -2
  29. package/dist/{cli-nyqza443.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-9pyah9c4.js → cli-ftfvggzx.js} +633 -166
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-y8bfxjtw.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-7vj52450.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-tp0d7vzb.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-s8hpxeg4.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-hc2fkhdv.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-t1cd3mhp.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-xa4j64zh.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-szcyv1mm.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +30 -16
  47. package/dist/{codex-adapter-g4tj1f2g.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-ad8ha7zt.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-z3gd8ppc.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-bezm97r9.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-a97hc33y.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-wtdy1x02.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-7edgmzng.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fvs58a5b.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-phnf93hp.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-7x131mmf.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-1fktvwxc.js → index-a3vn96n8.js} +31 -28
  59. package/dist/{index-yp8rasxk.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-50jxgw0d.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-7jwk7nsm.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-nnz07ebd.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-mg7qcgww.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-ywmnqrpx.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-c63sqfs1.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-2zj8sqkq.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-jgdfm26f.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-3whntb69.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-3zptcc3p.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-dbvpyysc.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-qwx0j8z9.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-sjbcmvep.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-xezqdkb7.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +3 -2
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/codex-agents-md.ts +140 -17
  149. package/src/providers/modelsdev-reasoning.json +551 -0
  150. package/src/rbac/legacy-policy.ts +16 -0
  151. package/src/rbac/permissions.ts +4 -0
  152. package/src/scheduler/deferred-task-waits.ts +28 -0
  153. package/src/scheduler/schedule-task.ts +3 -0
  154. package/src/scheduler/scheduler.ts +14 -0
  155. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  156. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  157. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  158. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  159. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  160. package/src/server.ts +12 -7
  161. package/src/slack/app.ts +35 -4
  162. package/src/slack/connection-state.ts +17 -0
  163. package/src/tasks/context-key.ts +1 -0
  164. package/src/tasks/worker-follow-up.ts +6 -2
  165. package/src/tests/agent-task-activity.test.ts +74 -0
  166. package/src/tests/agents-list-model-display.test.ts +6 -1
  167. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  168. package/src/tests/approval-requests.test.ts +395 -0
  169. package/src/tests/approval-sweeps-config.test.ts +47 -0
  170. package/src/tests/approval-sweeps.test.ts +508 -0
  171. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  172. package/src/tests/apps-spike.test.ts +56 -1
  173. package/src/tests/apps-sync-engine.test.ts +20 -9
  174. package/src/tests/boot-seeds.test.ts +170 -0
  175. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  176. package/src/tests/claude-managed-adapter.test.ts +6 -0
  177. package/src/tests/codex-adapter.test.ts +126 -1
  178. package/src/tests/config-key-filter.test.ts +80 -0
  179. package/src/tests/context-window.test.ts +1 -0
  180. package/src/tests/db-client-busy-retry.test.ts +96 -0
  181. package/src/tests/db-client.test.ts +60 -1
  182. package/src/tests/db-task-read-contract.test.ts +10 -0
  183. package/src/tests/db-task-write-contract.test.ts +9 -0
  184. package/src/tests/deferred-schedule-reaper.test.ts +193 -0
  185. package/src/tests/docs-index-footprint.test.js +3 -3
  186. package/src/tests/events-http.test.ts +103 -0
  187. package/src/tests/extensions-catalog.test.ts +22 -17
  188. package/src/tests/extensions-pre-task.test.ts +107 -0
  189. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  190. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  191. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  192. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  193. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  194. package/src/tests/heartbeat.test.ts +99 -3
  195. package/src/tests/hook-mcp-config.test.ts +29 -0
  196. package/src/tests/http-api-integration.test.ts +16 -2
  197. package/src/tests/kv-storage.test.ts +62 -0
  198. package/src/tests/linear-outbound-sync.test.ts +120 -5
  199. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  200. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  201. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  202. package/src/tests/memory-hybrid.test.ts +161 -1
  203. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  204. package/src/tests/memory.test.ts +22 -0
  205. package/src/tests/onboard-container-engine.test.ts +240 -0
  206. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  207. package/src/tests/pricing-routes.test.ts +14 -0
  208. package/src/tests/profile-sync-warning.test.ts +8 -4
  209. package/src/tests/promote-draft-task-route.test.ts +19 -0
  210. package/src/tests/provider-registration.test.ts +296 -42
  211. package/src/tests/rbac-engine.test.ts +30 -0
  212. package/src/tests/reasoning-effort.test.ts +8 -0
  213. package/src/tests/script-connections.test.ts +7 -0
  214. package/src/tests/sdk-allowlist.test.ts +39 -0
  215. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  216. package/src/tests/sessions-detail-batch.test.ts +250 -0
  217. package/src/tests/sessions.test.ts +118 -0
  218. package/src/tests/slack-config.test.ts +22 -0
  219. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  220. package/src/tests/status.test.ts +36 -1
  221. package/src/tests/swarm-diff.test.ts +316 -10
  222. package/src/tests/task-completion-idempotency.test.ts +2 -1
  223. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  224. package/src/tests/tool-annotations.test.ts +1 -1
  225. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  226. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  227. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  228. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  229. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  230. package/src/tests/workflow-http-v2.test.ts +99 -8
  231. package/src/tests/workflow-retry-validation.test.ts +56 -0
  232. package/src/tests/workflow-tla-races.test.ts +389 -0
  233. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  234. package/src/tools/cancel-approval-request.ts +57 -0
  235. package/src/tools/memory-search.ts +1 -0
  236. package/src/tools/request-human-input.ts +4 -2
  237. package/src/tools/schedules/list-schedules.ts +4 -2
  238. package/src/tools/send-task.ts +4 -0
  239. package/src/tools/templates.ts +19 -0
  240. package/src/tools/tool-config.ts +2 -1
  241. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  242. package/src/types.ts +38 -0
  243. package/src/utils/context-window.ts +1 -0
  244. package/src/workflows/approval-cancel.ts +152 -0
  245. package/src/workflows/approval-notifications.ts +84 -0
  246. package/src/workflows/checkpoint.ts +10 -5
  247. package/src/workflows/definition.ts +21 -0
  248. package/src/workflows/engine.ts +152 -21
  249. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  250. package/src/workflows/recovery.ts +100 -45
  251. package/src/workflows/resume.ts +49 -18
  252. package/src/workflows/retry-poller.ts +71 -21
  253. package/templates/extensions/README.md +1 -0
  254. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  255. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  256. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  257. package/templates/skills/apps/content.md +20 -1
  258. package/templates/skills/code-quality/content.md +26 -1
  259. package/templates/skills/pages/content.md +16 -4
  260. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  261. package/templates/skills/scheduling/content.md +1 -1
  262. package/dist/cli-2daksqps.js +0 -378
  263. package/dist/rbac-roles-3zw68x72.js +0 -37
package/src/be/db.ts CHANGED
@@ -154,6 +154,7 @@ import {
154
154
  reservedRoleViolation,
155
155
  rowToAgent,
156
156
  } from "./db/agents";
157
+ import { type ApprovalRequestListFilters, approvalRequestListClause } from "./db/approvals";
157
158
  import {
158
159
  computeContentHash,
159
160
  createContextVersion,
@@ -184,6 +185,7 @@ export {
184
185
  extensionAgentAssignmentError,
185
186
  getActiveTaskCount,
186
187
  getAgentById,
188
+ getAgentDailyTaskCounts,
187
189
  getAgentHarnessProviders,
188
190
  getAllAgents,
189
191
  getLeadAgent,
@@ -210,6 +212,7 @@ export {
210
212
  updateAgentStatus,
211
213
  updateAgentStatusFromCapacity,
212
214
  } from "./db/agents";
215
+ export { type ApprovalRequestSummary, listApprovalRequestSummaries } from "./db/approvals";
213
216
  export {
214
217
  computeContentHash,
215
218
  createContextVersion,
@@ -256,6 +259,7 @@ export {
256
259
  getPendingSlackRelayTasks,
257
260
  getRecentlyFinishedWorkerTasks,
258
261
  getSlackTasksMissingTree,
262
+ getSupersededTasksWithoutResume,
259
263
  getTaskById,
260
264
  getTaskStats,
261
265
  getTasksByAgentId,
@@ -318,9 +322,13 @@ type TaskTelemetryContext = {
318
322
  harnessVersion?: string;
319
323
  };
320
324
 
325
+ /** LIKE pattern matching strings that start with `prefix` literally (use with `ESCAPE '\\'`). */
326
+ function literalPrefixPattern(prefix: string): string {
327
+ return `${prefix.replace(/[\\%_]/g, "\\$&")}%`;
328
+ }
329
+
321
330
  function assetKeyPrefixPattern(input: string): string {
322
- const canonical = normalizeAssetKey(input);
323
- return `${canonical.replace(/[\\%_]/g, "\\$&")}%`;
331
+ return literalPrefixPattern(normalizeAssetKey(input));
324
332
  }
325
333
 
326
334
  function emitTaskLifecycleTelemetryAfterCommit(
@@ -2486,7 +2494,15 @@ export async function createTaskExtended(
2486
2494
  if (parent.contextKey && !options.contextKey) {
2487
2495
  options.contextKey = parent.contextKey;
2488
2496
  }
2489
- if (parent.followUpConfig && !options.followUpConfig) {
2497
+ // followUpConfig is opt-in: only a continuation of the same work carries
2498
+ // the creator's onCompleted/onFailed forward. Lead follow-ups, new
2499
+ // delegations and thread replies all pass parentTaskId too, and
2500
+ // inheriting there spread one task's instructions down the thread.
2501
+ if (
2502
+ parent.followUpConfig &&
2503
+ !options.followUpConfig &&
2504
+ options.inheritParentFollowUpConfig === true
2505
+ ) {
2490
2506
  options.followUpConfig = parent.followUpConfig;
2491
2507
  }
2492
2508
  if (parent.routingAffinityInvalid && options.inheritParentRoutingAffinity !== false) {
@@ -7634,7 +7650,8 @@ type WorkflowRunRow = {
7634
7650
  status: string;
7635
7651
  triggerType: string;
7636
7652
  triggerData: string | null;
7637
- context: string | null;
7653
+ /** Absent from list rows: see `WORKFLOW_RUN_SUMMARY_COLUMNS`. */
7654
+ context?: string | null;
7638
7655
  error: string | null;
7639
7656
  created_by: string | null;
7640
7657
  startedAt: string;
@@ -7762,8 +7779,18 @@ export type WorkflowRunListOptions = {
7762
7779
  status?: WorkflowRunStatus;
7763
7780
  limit?: number;
7764
7781
  offset?: number;
7782
+ /**
7783
+ * Read the run `context`. Off by default: it is the one large column, and
7784
+ * `getWorkflowRun` serves it for a single run.
7785
+ */
7786
+ includeContext?: boolean;
7765
7787
  };
7766
7788
 
7789
+ // Everything but `context`. `triggerData` stays: it is small, and stored
7790
+ // scripts read it off list rows (dedupe by trigger, failure reports).
7791
+ const WORKFLOW_RUN_SUMMARY_COLUMNS =
7792
+ "id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt";
7793
+
7767
7794
  export type WorkflowRunPage = {
7768
7795
  runs: WorkflowRun[];
7769
7796
  page: {
@@ -7795,8 +7822,9 @@ export async function listWorkflowRuns(
7795
7822
  params.push(options.offset);
7796
7823
  }
7797
7824
 
7825
+ const columns = options.includeContext ? "*" : WORKFLOW_RUN_SUMMARY_COLUMNS;
7798
7826
  const rows = await getDbClient().query<WorkflowRunRow>(
7799
- `SELECT * FROM workflow_runs
7827
+ `SELECT ${columns} FROM workflow_runs
7800
7828
  WHERE ${conditions.join(" AND ")}
7801
7829
  ORDER BY startedAt DESC, id DESC${pagination}`,
7802
7830
  params,
@@ -7824,7 +7852,7 @@ export async function countWorkflowRuns(
7824
7852
  export async function listWorkflowRunsPage(
7825
7853
  workflowId: string,
7826
7854
  options: Required<Pick<WorkflowRunListOptions, "limit" | "offset">> &
7827
- Pick<WorkflowRunListOptions, "status">,
7855
+ Pick<WorkflowRunListOptions, "status" | "includeContext">,
7828
7856
  ): Promise<WorkflowRunPage> {
7829
7857
  const runs = await listWorkflowRuns(workflowId, options);
7830
7858
  const total = await countWorkflowRuns(workflowId, { status: options.status });
@@ -8056,11 +8084,13 @@ export async function getLastRunStart(workflowId: string): Promise<WorkflowRun |
8056
8084
  export async function getRetryableSteps(): Promise<WorkflowRunStep[]> {
8057
8085
  const now = new Date().toISOString();
8058
8086
  const rows = await getDbClient().query<WorkflowRunStepRow>(
8059
- `SELECT * FROM workflow_run_steps
8060
- WHERE status = 'failed'
8061
- AND nextRetryAt IS NOT NULL
8062
- AND nextRetryAt <= ?
8063
- ORDER BY nextRetryAt ASC`,
8087
+ `SELECT s.* FROM workflow_run_steps s
8088
+ JOIN workflow_runs r ON r.id = s.runId
8089
+ WHERE s.status = 'failed'
8090
+ AND s.nextRetryAt IS NOT NULL
8091
+ AND s.nextRetryAt <= ?
8092
+ AND r.status IN ('running', 'waiting', 'failed')
8093
+ ORDER BY s.nextRetryAt ASC`,
8064
8094
  [now],
8065
8095
  );
8066
8096
  return rows.map(rowToWorkflowRunStep);
@@ -8114,6 +8144,37 @@ export async function getLatestStepForNode(
8114
8144
  return row ? rowToWorkflowRunStep(row) : null;
8115
8145
  }
8116
8146
 
8147
+ /**
8148
+ * The node's latest step when it already covers the current predecessor
8149
+ * completions: it is running, waiting or completed, and no predecessor has
8150
+ * completed a step inserted after it. A loop iteration always has a newer
8151
+ * predecessor completion, so it never matches. Ordered by rowid, which
8152
+ * follows insert order; startedAt can tie within a millisecond.
8153
+ */
8154
+ export async function getCurrentStepForNode(
8155
+ runId: string,
8156
+ nodeId: string,
8157
+ predecessorIds: string[],
8158
+ ): Promise<WorkflowRunStep | null> {
8159
+ const row = await getDbClient().get<WorkflowRunStepRow>(
8160
+ "SELECT * FROM workflow_run_steps WHERE runId = ? AND nodeId = ? ORDER BY rowid DESC LIMIT 1",
8161
+ [runId, nodeId],
8162
+ );
8163
+ if (!row || !["running", "waiting", "completed"].includes(row.status)) return null;
8164
+ if (predecessorIds.length > 0) {
8165
+ const newer = await getDbClient().get<{ found: number }>(
8166
+ `SELECT 1 AS found FROM workflow_run_steps
8167
+ WHERE runId = ? AND status = 'completed'
8168
+ AND nodeId IN (${predecessorIds.map(() => "?").join(", ")})
8169
+ AND rowid > (SELECT rowid FROM workflow_run_steps WHERE id = ?)
8170
+ LIMIT 1`,
8171
+ [runId, ...predecessorIds, row.id],
8172
+ );
8173
+ if (newer) return null;
8174
+ }
8175
+ return rowToWorkflowRunStep(row);
8176
+ }
8177
+
8117
8178
  // --- Workflow Version History ---
8118
8179
 
8119
8180
  type WorkflowVersionRow = {
@@ -9550,6 +9611,7 @@ export async function resolveApprovalRequest(
9550
9611
  status: "approved" | "rejected" | "timeout";
9551
9612
  responses?: unknown;
9552
9613
  resolvedBy?: string;
9614
+ resolutionReason?: string;
9553
9615
  },
9554
9616
  options?: { requireActionableWorkflow?: boolean },
9555
9617
  ): Promise<ApprovalRequest | null> {
@@ -9574,7 +9636,8 @@ export async function resolveApprovalRequest(
9574
9636
  : "";
9575
9637
  const row = await getDbClient().get<ApprovalRequestRow>(
9576
9638
  `UPDATE approval_requests
9577
- SET status = ?, responses = ?, resolvedBy = ?, resolvedAt = ?, updatedAt = ?
9639
+ SET status = ?, responses = ?, resolvedBy = ?, resolutionReason = ?, resolvedAt = ?,
9640
+ updatedAt = ?
9578
9641
  WHERE id = ? AND status = 'pending'
9579
9642
  ${actionableWorkflowClause}
9580
9643
  RETURNING *`,
@@ -9582,6 +9645,7 @@ export async function resolveApprovalRequest(
9582
9645
  data.status,
9583
9646
  data.responses ? JSON.stringify(data.responses) : null,
9584
9647
  data.resolvedBy ?? null,
9648
+ data.resolutionReason ?? null,
9585
9649
  now,
9586
9650
  now,
9587
9651
  id,
@@ -9590,6 +9654,23 @@ export async function resolveApprovalRequest(
9590
9654
  return row ? rowToApprovalRequest(row) : null;
9591
9655
  }
9592
9656
 
9657
+ // Cancels 1 pending request. The `status = 'pending'` guard lets a concurrent
9658
+ // human answer win; null means the row was not pending.
9659
+ export async function cancelApprovalRequestById(
9660
+ id: string,
9661
+ data: { reason: string; resolvedBy: string | null },
9662
+ ): Promise<ApprovalRequest | null> {
9663
+ const now = new Date().toISOString();
9664
+ const row = await getDbClient().get<ApprovalRequestRow>(
9665
+ `UPDATE approval_requests
9666
+ SET status = 'cancelled', resolutionReason = ?, resolvedBy = ?, resolvedAt = ?, updatedAt = ?
9667
+ WHERE id = ? AND status = 'pending'
9668
+ RETURNING *`,
9669
+ [data.reason, data.resolvedBy, now, now, id],
9670
+ );
9671
+ return row ? rowToApprovalRequest(row) : null;
9672
+ }
9673
+
9593
9674
  export async function cancelPendingApprovalRequestsForRun(
9594
9675
  workflowRunId: string,
9595
9676
  reason: string,
@@ -9725,32 +9806,14 @@ export async function updateApprovalRequestNotifications(
9725
9806
  );
9726
9807
  }
9727
9808
 
9728
- export async function listApprovalRequests(filters?: {
9729
- status?: string;
9730
- workflowRunId?: string;
9731
- limit?: number;
9732
- }): Promise<ApprovalRequest[]> {
9733
- const conditions: string[] = [];
9734
- const params: (string | number)[] = [];
9735
-
9736
- if (filters?.status) {
9737
- conditions.push("status = ?");
9738
- params.push(filters.status);
9739
- }
9740
- if (filters?.workflowRunId) {
9741
- conditions.push("workflowRunId = ?");
9742
- params.push(filters.workflowRunId);
9743
- }
9744
-
9745
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
9746
- const limit = filters?.limit ?? 100;
9747
- params.push(limit);
9748
-
9809
+ export async function listApprovalRequests(
9810
+ filters?: ApprovalRequestListFilters,
9811
+ ): Promise<ApprovalRequest[]> {
9812
+ const { sql, params } = approvalRequestListClause(filters);
9749
9813
  const rows = await getDbClient().query<ApprovalRequestRow>(
9750
- `SELECT * FROM approval_requests ${where} ORDER BY createdAt DESC LIMIT ?`,
9814
+ `SELECT * FROM approval_requests ${sql}`,
9751
9815
  params,
9752
9816
  );
9753
-
9754
9817
  return rows.map(rowToApprovalRequest);
9755
9818
  }
9756
9819
 
@@ -9793,13 +9856,34 @@ export async function getApprovalRequestByStepId(stepId: string): Promise<Approv
9793
9856
  return row ? rowToApprovalRequest(row) : null;
9794
9857
  }
9795
9858
 
9796
- // TODO: Wire into a periodic cron/sweep to auto-timeout expired approval requests (Phase 2)
9797
- export async function getExpiredPendingApprovals(): Promise<ApprovalRequest[]> {
9859
+ // Called by timeoutExpiredApprovalRequests in src/be/approval-sweeps.ts on each heartbeat tick.
9860
+ export async function getExpiredPendingApprovals(opts?: {
9861
+ now?: string;
9862
+ }): Promise<ApprovalRequest[]> {
9798
9863
  const rows = await getDbClient().query<ApprovalRequestRow>(
9799
9864
  `SELECT * FROM approval_requests
9800
9865
  WHERE status = 'pending'
9801
9866
  AND expiresAt IS NOT NULL
9802
- AND expiresAt < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
9867
+ AND expiresAt < ?
9868
+ ORDER BY expiresAt ASC`,
9869
+ [opts?.now ?? new Date().toISOString()],
9870
+ );
9871
+ return rows.map(rowToApprovalRequest);
9872
+ }
9873
+
9874
+ // Pending requests with no explicit deadline created before `cutoff`. Served
9875
+ // by idx_approval_requests_pending_created (migration 166). Called by
9876
+ // autoCancelStaleApprovalRequests in src/be/approval-sweeps.ts.
9877
+ export async function getStaleApprovalRequests(opts: {
9878
+ cutoff: string;
9879
+ }): Promise<ApprovalRequest[]> {
9880
+ const rows = await getDbClient().query<ApprovalRequestRow>(
9881
+ `SELECT * FROM approval_requests
9882
+ WHERE status = 'pending'
9883
+ AND expiresAt IS NULL
9884
+ AND createdAt < ?
9885
+ ORDER BY createdAt ASC`,
9886
+ [opts.cutoff],
9803
9887
  );
9804
9888
  return rows.map(rowToApprovalRequest);
9805
9889
  }
@@ -12375,9 +12459,9 @@ export async function listTaskTemplates(opts?: {
12375
12459
  // ============================================================================
12376
12460
 
12377
12461
  /**
12378
- * Walk the parent→child chain rooted at `rootTaskId` via recursive CTE.
12379
- * Returns the chain ordered by `createdAt` (so the root is first; siblings
12380
- * appear in creation order; grand-children after their parents).
12462
+ * Walk the chain rooted at `rootTaskId`, ordered by `createdAt` (root first; `rowid` breaks
12463
+ * ties as idx_agent_tasks_created did). `CROSS JOIN` keeps the chain as the outer loop: with
12464
+ * a plain JOIN, SQLite scanned all of agent_tasks to skip the sort.
12381
12465
  */
12382
12466
  export async function getRootTaskChain(rootTaskId: string): Promise<AgentTask[]> {
12383
12467
  const rows = await getDbClient().query<AgentTaskRow>(
@@ -12387,9 +12471,9 @@ export async function getRootTaskChain(rootTaskId: string): Promise<AgentTask[]>
12387
12471
  SELECT t.id FROM agent_tasks t
12388
12472
  JOIN chain c ON t.parentTaskId = c.id
12389
12473
  )
12390
- SELECT t.* FROM agent_tasks t
12391
- JOIN chain ON chain.id = t.id
12392
- ORDER BY t.createdAt`,
12474
+ SELECT t.* FROM chain
12475
+ CROSS JOIN agent_tasks t ON t.id = chain.id
12476
+ ORDER BY t.createdAt, t.rowid`,
12393
12477
  [rootTaskId],
12394
12478
  );
12395
12479
  return rows.map(rowToAgentTask);
@@ -12434,6 +12518,12 @@ interface ListRecentSessionsOpts {
12434
12518
  q?: string;
12435
12519
  /** When set, restrict to root tasks where `requestedByUserId` equals this value. NULL rows are excluded. */
12436
12520
  requestedByUserId?: string;
12521
+ /**
12522
+ * When set, restrict to root tasks whose `contextKey` starts with this
12523
+ * literal prefix (`%`, `_` and `\` are escaped). Powers the UI contextual
12524
+ * session panel's per-page dropdown.
12525
+ */
12526
+ contextKeyPrefix?: string;
12437
12527
  /** When true, return slim `SessionListItemSummary` rows (default: full). */
12438
12528
  slim?: boolean;
12439
12529
  }
@@ -12469,6 +12559,10 @@ export async function listRecentSessions(
12469
12559
  conditions.push("r.requestedByUserId = ?");
12470
12560
  params.push(requestedByUserId);
12471
12561
  }
12562
+ if (opts?.contextKeyPrefix) {
12563
+ conditions.push(`r.contextKey LIKE ? ESCAPE '\\'`);
12564
+ params.push(literalPrefixPattern(opts.contextKeyPrefix));
12565
+ }
12472
12566
  params.push(limit, offset);
12473
12567
 
12474
12568
  const rootRows = await getDbClient().query<
@@ -12505,7 +12599,7 @@ export async function listRecentSessions(
12505
12599
  FROM agent_tasks r
12506
12600
  JOIN agg a ON a.root_id = r.id
12507
12601
  LEFT JOIN latest_status ls ON ls.root_id = r.id
12508
- ORDER BY a.lastActivityAt DESC
12602
+ ORDER BY a.lastActivityAt DESC, r.createdAt DESC, r.id DESC
12509
12603
  LIMIT ? OFFSET ?`,
12510
12604
  params,
12511
12605
  );
@@ -12540,7 +12634,7 @@ export async function listRecentSessions(
12540
12634
  * a plain count, no recursive chain walk needed.
12541
12635
  */
12542
12636
  export async function countSessions(
12543
- opts?: Pick<ListRecentSessionsOpts, "source" | "q" | "requestedByUserId">,
12637
+ opts?: Pick<ListRecentSessionsOpts, "source" | "q" | "requestedByUserId" | "contextKeyPrefix">,
12544
12638
  ): Promise<number> {
12545
12639
  const sources = opts?.source?.filter((s) => s.length > 0) ?? [];
12546
12640
  const q = opts?.q?.trim();
@@ -12562,6 +12656,10 @@ export async function countSessions(
12562
12656
  conditions.push("requestedByUserId = ?");
12563
12657
  params.push(requestedByUserId);
12564
12658
  }
12659
+ if (opts?.contextKeyPrefix) {
12660
+ conditions.push(`contextKey LIKE ? ESCAPE '\\'`);
12661
+ params.push(literalPrefixPattern(opts.contextKeyPrefix));
12662
+ }
12565
12663
 
12566
12664
  const row = await getDbClient().get<{ count: number }>(
12567
12665
  `SELECT COUNT(*) AS count FROM agent_tasks WHERE ${conditions.join(" AND ")}`,
@@ -13434,42 +13532,96 @@ export async function incrKv(namespace: string, key: string, by: number): Promis
13434
13532
  });
13435
13533
  }
13436
13534
 
13535
+ /**
13536
+ * Smallest string greater than every string that starts with `prefix`, in
13537
+ * code-point order (which is SQLite's BINARY order over UTF-8). `null` means
13538
+ * no upper bound exists (the prefix is all U+10FFFF).
13539
+ */
13540
+ export function kvPrefixUpperBound(prefix: string): string | null {
13541
+ const codePoints = Array.from(prefix);
13542
+ while (codePoints.length > 0) {
13543
+ const last = codePoints.pop()!.codePointAt(0)!;
13544
+ if (last < 0x10ffff) {
13545
+ const next = last + 1 === 0xd800 ? 0xe000 : last + 1;
13546
+ return codePoints.join("") + String.fromCodePoint(next);
13547
+ }
13548
+ }
13549
+ return null;
13550
+ }
13551
+
13552
+ /**
13553
+ * Key-prefix predicate as a range on the (namespace, key) primary key.
13554
+ * `key LIKE 'p%'` cannot use that index (LIKE is case-insensitive, the PK is
13555
+ * BINARY), so it scanned every row of the namespace. A range seeks straight
13556
+ * to the prefix and matches it exactly (case-sensitive).
13557
+ */
13558
+ function kvPrefixCondition(prefix: string | undefined): { sql: string; params: string[] } {
13559
+ if (prefix === undefined || prefix.length === 0) return { sql: "", params: [] };
13560
+ const upper = kvPrefixUpperBound(prefix);
13561
+ return upper === null
13562
+ ? { sql: "AND key >= ?", params: [prefix] }
13563
+ : { sql: "AND key >= ? AND key < ?", params: [prefix, upper] };
13564
+ }
13565
+
13566
+ /** Top-level JSON field equality, evaluated in SQLite before rows reach JS. */
13567
+ export type KvJsonFieldEquals = { field: string; value: string | number | boolean };
13568
+
13569
+ function kvJsonFieldConditions(filters: KvJsonFieldEquals[] | undefined): {
13570
+ sql: string;
13571
+ params: (string | number | boolean)[];
13572
+ } {
13573
+ const parts: string[] = [];
13574
+ const params: (string | number | boolean)[] = [];
13575
+ for (const filter of filters ?? []) {
13576
+ // JSON path quoting has no escape for these characters.
13577
+ if (/["\\]/.test(filter.field)) {
13578
+ throw new Error(`unsupported JSON field name: ${filter.field}`);
13579
+ }
13580
+ // Only safe integers compare exactly: SQLite parses a stored literal like
13581
+ // 1000000000000000100 as an exact INTEGER (and fractions with its own
13582
+ // float parser) while the bound JS number is a double, so SQL could drop
13583
+ // a row that JS `===` matches. Other numbers are left to the caller.
13584
+ if (typeof filter.value === "number" && !Number.isSafeInteger(filter.value)) continue;
13585
+ // json_valid guards the corrupt rows decodeKvRow tolerates: json_extract
13586
+ // would abort the whole query on one of them.
13587
+ parts.push(
13588
+ "AND (CASE WHEN value_type = 'json' AND json_valid(value) THEN json_extract(value, ?) END) = ?",
13589
+ );
13590
+ params.push(`$."${filter.field}"`, filter.value);
13591
+ }
13592
+ return { sql: parts.join(" "), params };
13593
+ }
13594
+
13437
13595
  /**
13438
13596
  * List entries in a namespace, optionally filtered by prefix. Expired rows
13439
13597
  * are filtered out by the SELECT (no inline DELETE — listing should be a
13440
13598
  * stable cursor; sweeping happens on point-reads instead).
13441
13599
  *
13600
+ * `jsonFieldEquals` narrows the rows in SQL so non-matching values are never
13601
+ * decoded. SQL equality is looser than JS `===` (JSON `true` equals `1`) and
13602
+ * numbers that are not safe integers are not filtered at all, so callers that
13603
+ * need exact semantics re-check the decoded values.
13604
+ *
13442
13605
  * `limit` is capped by the caller (HTTP enforces ≤1000); helper does no extra
13443
13606
  * bounds-check beyond what SQL accepts.
13444
13607
  */
13445
13608
  export async function listKv(
13446
13609
  namespace: string,
13447
- opts: { prefix?: string; limit: number; offset: number },
13610
+ opts: { prefix?: string; limit: number; offset: number; jsonFieldEquals?: KvJsonFieldEquals[] },
13448
13611
  ): Promise<KvEntry[]> {
13449
13612
  const now = Date.now();
13450
- if (opts.prefix !== undefined && opts.prefix.length > 0) {
13451
- // LIKE-escape `\` `%` `_` so a user-supplied prefix can't run wildcards.
13452
- const escaped = opts.prefix.replace(/[\\%_]/g, "\\$&");
13453
- const rows = await getDbClient().query<KvRow>(
13454
- `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
13455
- FROM kv_entries
13456
- WHERE namespace = ?
13457
- AND (expires_at IS NULL OR expires_at > ?)
13458
- AND key LIKE ? ESCAPE '\\'
13459
- ORDER BY key
13460
- LIMIT ? OFFSET ?`,
13461
- [namespace, now, `${escaped}%`, opts.limit, opts.offset],
13462
- );
13463
- return rows.map(decodeKvRow);
13464
- }
13613
+ const prefix = kvPrefixCondition(opts.prefix);
13614
+ const json = kvJsonFieldConditions(opts.jsonFieldEquals);
13465
13615
  const rows = await getDbClient().query<KvRow>(
13466
13616
  `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
13467
13617
  FROM kv_entries
13468
13618
  WHERE namespace = ?
13469
13619
  AND (expires_at IS NULL OR expires_at > ?)
13620
+ ${prefix.sql}
13621
+ ${json.sql}
13470
13622
  ORDER BY key
13471
13623
  LIMIT ? OFFSET ?`,
13472
- [namespace, now, opts.limit, opts.offset],
13624
+ [namespace, now, ...prefix.params, ...json.params, opts.limit, opts.offset],
13473
13625
  );
13474
13626
  return rows.map(decodeKvRow);
13475
13627
  }
@@ -13480,22 +13632,13 @@ export async function listKv(
13480
13632
  */
13481
13633
  export async function countKv(namespace: string, opts: { prefix?: string }): Promise<number> {
13482
13634
  const now = Date.now();
13483
- if (opts.prefix !== undefined && opts.prefix.length > 0) {
13484
- const escaped = opts.prefix.replace(/[\\%_]/g, "\\$&");
13485
- const row = await getDbClient().get<{ n: number }>(
13486
- `SELECT COUNT(*) AS n FROM kv_entries
13487
- WHERE namespace = ?
13488
- AND (expires_at IS NULL OR expires_at > ?)
13489
- AND key LIKE ? ESCAPE '\\'`,
13490
- [namespace, now, `${escaped}%`],
13491
- );
13492
- return row?.n ?? 0;
13493
- }
13635
+ const prefix = kvPrefixCondition(opts.prefix);
13494
13636
  const row = await getDbClient().get<{ n: number }>(
13495
13637
  `SELECT COUNT(*) AS n FROM kv_entries
13496
13638
  WHERE namespace = ?
13497
- AND (expires_at IS NULL OR expires_at > ?)`,
13498
- [namespace, now],
13639
+ AND (expires_at IS NULL OR expires_at > ?)
13640
+ ${prefix.sql}`,
13641
+ [namespace, now, ...prefix.params],
13499
13642
  );
13500
13643
  return row?.n ?? 0;
13501
13644
  }
package/src/be/events.ts CHANGED
@@ -238,6 +238,15 @@ export async function getEventsFiltered(filters: {
238
238
  since?: string;
239
239
  until?: string;
240
240
  limit?: number;
241
+ /** Match any of these event names (`event IN (...)`), ANDed with `event`. */
242
+ events?: EventName[];
243
+ /**
244
+ * Return only the newest row per (event, data.field) pair, for every pair of
245
+ * these event names and field values. Each pair is its own indexed
246
+ * `ORDER BY createdAt DESC LIMIT 1` lookup, so the cost does not grow with
247
+ * the agent's event history.
248
+ */
249
+ latestPerDataField?: { events: EventName[]; dataFields: string[] };
241
250
  }): Promise<SwarmEvent[]> {
242
251
  const conditions: string[] = [];
243
252
  const params: (string | number)[] = [];
@@ -250,6 +259,10 @@ export async function getEventsFiltered(filters: {
250
259
  conditions.push("event = ?");
251
260
  params.push(filters.event);
252
261
  }
262
+ if (filters.events && filters.events.length > 0) {
263
+ conditions.push(`event IN (${filters.events.map(() => "?").join(", ")})`);
264
+ params.push(...filters.events);
265
+ }
253
266
  if (filters.status) {
254
267
  conditions.push("status = ?");
255
268
  params.push(filters.status);
@@ -283,8 +296,27 @@ export async function getEventsFiltered(filters: {
283
296
  params.push(filters.until);
284
297
  }
285
298
 
286
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
287
299
  const limit = filters.limit ?? 100;
300
+
301
+ if (filters.latestPerDataField) {
302
+ const lookups: string[] = [];
303
+ const lookupParams: (string | number)[] = [];
304
+ for (const event of filters.latestPerDataField.events) {
305
+ for (const field of filters.latestPerDataField.dataFields) {
306
+ const where = [...conditions, "event = ?", "json_extract(data, '$.field') = ?"];
307
+ lookups.push(
308
+ `SELECT * FROM (SELECT * FROM events WHERE ${where.join(" AND ")} ORDER BY createdAt DESC LIMIT 1)`,
309
+ );
310
+ lookupParams.push(...params, event, field);
311
+ }
312
+ }
313
+ if (lookups.length === 0) return [];
314
+ const sql = `${lookups.join(" UNION ALL ")} ORDER BY createdAt DESC LIMIT ?`;
315
+ const rows = await getDbClient().query<EventRow>(sql, [...lookupParams, limit]);
316
+ return rows.map(rowToSwarmEvent);
317
+ }
318
+
319
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
288
320
  params.push(limit);
289
321
 
290
322
  const sql = `SELECT * FROM events ${where} ORDER BY createdAt DESC LIMIT ?`;
@@ -118,8 +118,9 @@ export async function expandCandidatesWithGraph(
118
118
  const parent = parentById.get(row.fromMemoryId);
119
119
  if (!parent) continue;
120
120
  const strength = typeof row.linkStrength === "number" ? row.linkStrength : 1.0;
121
- // Derive from the parent's RAW (pre-decay) similarity — fts/hybrid arms
122
- // ship `similarity` with the parent's recency decay already applied, and
121
+ // Derive from the parent's pre-decay match score on the shared [0,1]
122
+ // scale (vec cosine, hybrid fused cosine, fts rank). The fts arm ships
123
+ // `similarity` with the parent's recency decay already applied, and
123
124
  // rerank() will apply the NEIGHBOR's own decay to this candidate. Using
124
125
  // the decayed value would stack two decay factors on one score.
125
126
  const parentBase = parent.rawSimilarity ?? parent.similarity;
@@ -129,6 +130,7 @@ export async function expandCandidatesWithGraph(
129
130
  neighbors.set(row.id, {
130
131
  ...rowToCandidate(row, similarity),
131
132
  retrievalSource: "graph",
133
+ graphParentId: parent.id,
132
134
  // The neighbor's own decay is applied exactly once by rerank().
133
135
  recencyDecayApplied: false,
134
136
  });
@@ -153,7 +155,12 @@ export async function expandCandidatesWithGraph(
153
155
  if (existingIndex !== undefined) {
154
156
  // Dedupe against organic candidates: keep whichever entry the reranker
155
157
  // will score higher (same memory, so all non-similarity factors match).
156
- if (computeScore(neighbor, now) > computeScore(result[existingIndex]!, now)) {
158
+ // rerank() caps a graph entry at its parent's composite, so compare the
159
+ // capped graph score; otherwise a low-quality parent's link replaces a
160
+ // stronger organic match and rerank() then demotes it below the parent.
161
+ const parent = parentById.get(neighbor.graphParentId!)!;
162
+ const graphScore = Math.min(computeScore(neighbor, now), computeScore(parent, now));
163
+ if (graphScore > computeScore(result[existingIndex]!, now)) {
157
164
  result[existingIndex] = neighbor;
158
165
  }
159
166
  continue;