@vellumai/assistant 0.3.15 → 0.3.18

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 (306) hide show
  1. package/ARCHITECTURE.md +211 -12
  2. package/Dockerfile +1 -1
  3. package/README.md +11 -5
  4. package/docs/architecture/http-token-refresh.md +274 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +328 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +19 -15
  22. package/src/__tests__/checker.test.ts +103 -48
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +356 -0
  25. package/src/__tests__/conversation-pairing.test.ts +127 -27
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +425 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-store.test.ts +182 -0
  39. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  40. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  41. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  42. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  43. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  44. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  45. package/src/__tests__/hooks-runner.test.ts +13 -4
  46. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  47. package/src/__tests__/intent-routing.test.ts +14 -0
  48. package/src/__tests__/ipc-snapshot.test.ts +23 -5
  49. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  50. package/src/__tests__/memory-regressions.test.ts +16 -12
  51. package/src/__tests__/non-member-access-request.test.ts +281 -0
  52. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  53. package/src/__tests__/notification-decision-strategy.test.ts +138 -1
  54. package/src/__tests__/notification-deep-link.test.ts +44 -1
  55. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  56. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  57. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  58. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  59. package/src/__tests__/recording-intent.test.ts +1 -0
  60. package/src/__tests__/recording-state-machine.test.ts +328 -17
  61. package/src/__tests__/registry.test.ts +17 -8
  62. package/src/__tests__/relay-server.test.ts +105 -0
  63. package/src/__tests__/reminder.test.ts +13 -0
  64. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  65. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  66. package/src/__tests__/server-history-render.test.ts +8 -8
  67. package/src/__tests__/session-agent-loop.test.ts +1 -0
  68. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -0
  70. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  71. package/src/__tests__/slack-channel-config.test.ts +230 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  73. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  74. package/src/__tests__/system-prompt.test.ts +43 -0
  75. package/src/__tests__/task-management-tools.test.ts +3 -3
  76. package/src/__tests__/task-tools.test.ts +3 -3
  77. package/src/__tests__/trust-store.test.ts +38 -22
  78. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +489 -0
  79. package/src/__tests__/trusted-contact-multichannel.test.ts +405 -0
  80. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  81. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  82. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  83. package/src/__tests__/update-bulletin.test.ts +323 -0
  84. package/src/__tests__/update-template-contract.test.ts +24 -0
  85. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  86. package/src/agent/loop.ts +2 -2
  87. package/src/amazon/client.ts +2 -3
  88. package/src/calls/call-controller.ts +241 -39
  89. package/src/calls/call-conversation-messages.ts +2 -2
  90. package/src/calls/call-domain.ts +10 -3
  91. package/src/calls/call-pointer-messages.ts +17 -5
  92. package/src/calls/guardian-action-sweep.ts +77 -36
  93. package/src/calls/guardian-dispatch.ts +8 -0
  94. package/src/calls/relay-server.ts +51 -12
  95. package/src/calls/twilio-routes.ts +3 -1
  96. package/src/calls/types.ts +1 -1
  97. package/src/calls/voice-session-bridge.ts +8 -6
  98. package/src/cli/core-commands.ts +43 -3
  99. package/src/cli/map.ts +8 -5
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  101. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  102. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  103. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  104. package/src/config/computer-use-prompt.ts +1 -0
  105. package/src/config/core-schema.ts +16 -0
  106. package/src/config/env-registry.ts +1 -0
  107. package/src/config/env.ts +16 -1
  108. package/src/config/memory-schema.ts +5 -0
  109. package/src/config/schema.ts +4 -0
  110. package/src/config/system-prompt.ts +69 -2
  111. package/src/config/templates/BOOTSTRAP.md +1 -1
  112. package/src/config/templates/IDENTITY.md +8 -4
  113. package/src/config/templates/SOUL.md +14 -0
  114. package/src/config/templates/UPDATES.md +15 -0
  115. package/src/config/templates/USER.md +5 -1
  116. package/src/config/types.ts +1 -0
  117. package/src/config/update-bulletin-format.ts +54 -0
  118. package/src/config/update-bulletin-state.ts +49 -0
  119. package/src/config/update-bulletin-template-path.ts +6 -0
  120. package/src/config/update-bulletin.ts +97 -0
  121. package/src/config/vellum-skills/catalog.json +6 -0
  122. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  123. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  124. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  125. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  126. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  127. package/src/context/window-manager.ts +43 -3
  128. package/src/daemon/config-watcher.ts +4 -2
  129. package/src/daemon/connection-policy.ts +21 -1
  130. package/src/daemon/daemon-control.ts +219 -8
  131. package/src/daemon/date-context.ts +174 -1
  132. package/src/daemon/guardian-action-generators.ts +175 -0
  133. package/src/daemon/guardian-verification-intent.ts +120 -0
  134. package/src/daemon/handlers/apps.ts +1 -3
  135. package/src/daemon/handlers/config-channels.ts +2 -2
  136. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  137. package/src/daemon/handlers/config-inbox.ts +55 -159
  138. package/src/daemon/handlers/config-ingress.ts +1 -1
  139. package/src/daemon/handlers/config-integrations.ts +1 -1
  140. package/src/daemon/handlers/config-platform.ts +1 -1
  141. package/src/daemon/handlers/config-scheduling.ts +2 -2
  142. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  143. package/src/daemon/handlers/config-telegram.ts +1 -1
  144. package/src/daemon/handlers/config-twilio.ts +1 -1
  145. package/src/daemon/handlers/config-voice.ts +100 -0
  146. package/src/daemon/handlers/config.ts +3 -0
  147. package/src/daemon/handlers/identity.ts +45 -25
  148. package/src/daemon/handlers/misc.ts +83 -5
  149. package/src/daemon/handlers/navigate-settings.ts +27 -0
  150. package/src/daemon/handlers/recording.ts +270 -144
  151. package/src/daemon/handlers/sessions.ts +100 -17
  152. package/src/daemon/handlers/subagents.ts +3 -3
  153. package/src/daemon/handlers/work-items.ts +10 -7
  154. package/src/daemon/ipc-contract/integrations.ts +9 -1
  155. package/src/daemon/ipc-contract/messages.ts +4 -0
  156. package/src/daemon/ipc-contract/sessions.ts +1 -1
  157. package/src/daemon/ipc-contract/settings.ts +26 -0
  158. package/src/daemon/ipc-contract/shared.ts +2 -0
  159. package/src/daemon/ipc-contract/work-items.ts +1 -7
  160. package/src/daemon/ipc-contract/workspace.ts +12 -1
  161. package/src/daemon/ipc-contract-inventory.json +6 -1
  162. package/src/daemon/ipc-contract.ts +5 -1
  163. package/src/daemon/lifecycle.ts +314 -266
  164. package/src/daemon/recording-intent.ts +0 -41
  165. package/src/daemon/response-tier.ts +2 -2
  166. package/src/daemon/server.ts +31 -9
  167. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  168. package/src/daemon/session-agent-loop.ts +15 -8
  169. package/src/daemon/session-history.ts +3 -2
  170. package/src/daemon/session-media-retry.ts +3 -0
  171. package/src/daemon/session-messaging.ts +38 -4
  172. package/src/daemon/session-notifiers.ts +2 -2
  173. package/src/daemon/session-process.ts +546 -59
  174. package/src/daemon/session-queue-manager.ts +2 -0
  175. package/src/daemon/session-runtime-assembly.ts +39 -0
  176. package/src/daemon/session-skill-tools.ts +13 -4
  177. package/src/daemon/session-tool-setup.ts +5 -6
  178. package/src/daemon/session.ts +19 -8
  179. package/src/daemon/tls-certs.ts +60 -13
  180. package/src/daemon/tool-side-effects.ts +13 -5
  181. package/src/gallery/default-gallery.ts +32 -9
  182. package/src/influencer/client.ts +2 -1
  183. package/src/memory/channel-delivery-store.ts +35 -567
  184. package/src/memory/channel-guardian-store.ts +63 -1317
  185. package/src/memory/conflict-store.ts +4 -4
  186. package/src/memory/conversation-attention-store.ts +0 -3
  187. package/src/memory/conversation-crud.ts +668 -0
  188. package/src/memory/conversation-queries.ts +361 -0
  189. package/src/memory/conversation-store.ts +44 -983
  190. package/src/memory/db-connection.ts +3 -0
  191. package/src/memory/db-init.ts +33 -0
  192. package/src/memory/delivery-channels.ts +175 -0
  193. package/src/memory/delivery-crud.ts +211 -0
  194. package/src/memory/delivery-status.ts +199 -0
  195. package/src/memory/embedding-backend.ts +70 -4
  196. package/src/memory/embedding-local.ts +12 -2
  197. package/src/memory/entity-extractor.ts +3 -8
  198. package/src/memory/fts-reconciler.ts +136 -0
  199. package/src/memory/guardian-action-store.ts +418 -5
  200. package/src/memory/guardian-approvals.ts +569 -0
  201. package/src/memory/guardian-bindings.ts +130 -0
  202. package/src/memory/guardian-rate-limits.ts +196 -0
  203. package/src/memory/guardian-verification.ts +521 -0
  204. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  205. package/src/memory/job-utils.ts +8 -5
  206. package/src/memory/jobs-store.ts +66 -6
  207. package/src/memory/jobs-worker.ts +23 -1
  208. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  209. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  210. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  211. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  212. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  213. package/src/memory/migrations/100-core-tables.ts +1 -1
  214. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  215. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  216. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  217. package/src/memory/migrations/113-late-migrations.ts +1 -1
  218. package/src/memory/migrations/116-messages-fts.ts +13 -0
  219. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  220. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  221. package/src/memory/migrations/index.ts +10 -3
  222. package/src/memory/migrations/validate-migration-state.ts +114 -15
  223. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  224. package/src/memory/retriever.ts +46 -13
  225. package/src/memory/schema-migration.ts +4 -0
  226. package/src/memory/schema.ts +31 -8
  227. package/src/memory/search/semantic.ts +8 -90
  228. package/src/notifications/README.md +159 -18
  229. package/src/notifications/broadcaster.ts +69 -33
  230. package/src/notifications/conversation-pairing.ts +99 -21
  231. package/src/notifications/decision-engine.ts +176 -8
  232. package/src/notifications/deliveries-store.ts +39 -8
  233. package/src/notifications/emit-signal.ts +1 -0
  234. package/src/notifications/preferences-store.ts +7 -7
  235. package/src/notifications/thread-candidates.ts +269 -0
  236. package/src/notifications/types.ts +19 -0
  237. package/src/permissions/checker.ts +1 -16
  238. package/src/permissions/defaults.ts +25 -5
  239. package/src/permissions/prompter.ts +17 -0
  240. package/src/permissions/trust-store.ts +2 -0
  241. package/src/providers/failover.ts +19 -0
  242. package/src/providers/registry.ts +46 -1
  243. package/src/runtime/approval-message-composer.ts +1 -1
  244. package/src/runtime/channel-guardian-service.ts +15 -3
  245. package/src/runtime/channel-retry-sweep.ts +7 -2
  246. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  247. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  248. package/src/runtime/guardian-action-message-composer.ts +245 -0
  249. package/src/runtime/guardian-outbound-actions.ts +26 -6
  250. package/src/runtime/guardian-verification-templates.ts +15 -9
  251. package/src/runtime/http-errors.ts +93 -0
  252. package/src/runtime/http-server.ts +133 -44
  253. package/src/runtime/http-types.ts +53 -0
  254. package/src/runtime/ingress-service.ts +237 -0
  255. package/src/runtime/middleware/error-handler.ts +4 -3
  256. package/src/runtime/middleware/rate-limiter.ts +160 -0
  257. package/src/runtime/middleware/request-logger.ts +71 -0
  258. package/src/runtime/middleware/twilio-validation.ts +7 -6
  259. package/src/runtime/pending-interactions.ts +12 -0
  260. package/src/runtime/routes/access-request-decision.ts +215 -0
  261. package/src/runtime/routes/app-routes.ts +25 -18
  262. package/src/runtime/routes/approval-routes.ts +18 -47
  263. package/src/runtime/routes/attachment-routes.ts +15 -41
  264. package/src/runtime/routes/call-routes.ts +20 -20
  265. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  266. package/src/runtime/routes/contact-routes.ts +4 -9
  267. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  268. package/src/runtime/routes/conversation-routes.ts +26 -57
  269. package/src/runtime/routes/debug-routes.ts +71 -0
  270. package/src/runtime/routes/events-routes.ts +3 -2
  271. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  272. package/src/runtime/routes/identity-routes.ts +14 -10
  273. package/src/runtime/routes/inbound-conversation.ts +3 -2
  274. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  275. package/src/runtime/routes/ingress-routes.ts +174 -0
  276. package/src/runtime/routes/integration-routes.ts +78 -16
  277. package/src/runtime/routes/pairing-routes.ts +11 -10
  278. package/src/runtime/routes/secret-routes.ts +10 -18
  279. package/src/runtime/verification-rate-limiter.ts +83 -0
  280. package/src/schedule/schedule-store.ts +13 -1
  281. package/src/schedule/scheduler.ts +1 -1
  282. package/src/security/secret-ingress.ts +5 -2
  283. package/src/security/secret-scanner.ts +72 -6
  284. package/src/subagent/manager.ts +6 -4
  285. package/src/swarm/plan-validator.ts +4 -1
  286. package/src/tasks/task-runner.ts +3 -1
  287. package/src/tools/browser/api-map.ts +9 -6
  288. package/src/tools/calls/call-start.ts +20 -0
  289. package/src/tools/executor.ts +50 -568
  290. package/src/tools/permission-checker.ts +271 -0
  291. package/src/tools/registry.ts +14 -6
  292. package/src/tools/reminder/reminder-store.ts +7 -7
  293. package/src/tools/reminder/reminder.ts +6 -3
  294. package/src/tools/secret-detection-handler.ts +301 -0
  295. package/src/tools/subagent/message.ts +1 -1
  296. package/src/tools/system/voice-config.ts +62 -0
  297. package/src/tools/tasks/index.ts +3 -3
  298. package/src/tools/tasks/work-item-list.ts +3 -3
  299. package/src/tools/tasks/work-item-update.ts +4 -5
  300. package/src/tools/tool-approval-handler.ts +192 -0
  301. package/src/tools/tool-manifest.ts +2 -0
  302. package/src/version.ts +29 -2
  303. package/src/watcher/watcher-store.ts +9 -9
  304. package/src/work-items/work-item-runner.ts +9 -6
  305. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  306. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -4,6 +4,7 @@ import { getLogger } from '../../util/logger.js';
4
4
  import { getDb, rawExec } from '../db.js';
5
5
  import { asString, BackendUnavailableError } from '../job-utils.js';
6
6
  import { enqueueMemoryJob, type MemoryJob } from '../jobs-store.js';
7
+ import { withQdrantBreaker } from '../qdrant-circuit-breaker.js';
7
8
  import { getQdrantClient } from '../qdrant-client.js';
8
9
  import { memoryEmbeddings, memoryItems, memorySegments, memorySummaries } from '../schema.js';
9
10
 
@@ -50,6 +51,6 @@ export async function deleteQdrantVectorsJob(job: MemoryJob): Promise<void> {
50
51
  throw new BackendUnavailableError('Qdrant client not initialized');
51
52
  }
52
53
 
53
- await qdrant.deleteByTarget(targetType, targetId);
54
+ await withQdrantBreaker(() => qdrant.deleteByTarget(targetType, targetId));
54
55
  log.info({ targetType, targetId }, 'Retried Qdrant vector deletion succeeded');
55
56
  }
@@ -7,6 +7,7 @@ import { BackendUnavailableError } from '../util/errors.js';
7
7
  import { getLogger } from '../util/logger.js';
8
8
  import { getDb } from './db.js';
9
9
  import { embedWithBackend, getMemoryBackendStatus } from './embedding-backend.js';
10
+ import { withQdrantBreaker } from './qdrant-circuit-breaker.js';
10
11
  import { getQdrantClient } from './qdrant-client.js';
11
12
  import { memoryEmbeddings } from './schema.js';
12
13
 
@@ -213,11 +214,13 @@ export async function embedAndUpsert(
213
214
  }
214
215
 
215
216
  try {
216
- await qdrant.upsert(targetType, targetId, vector, {
217
- text,
218
- created_at: (extraPayload?.created_at as number) ?? now,
219
- ...(extraPayload as Record<string, unknown> | undefined),
220
- });
217
+ await withQdrantBreaker(() =>
218
+ qdrant.upsert(targetType, targetId, vector, {
219
+ text,
220
+ created_at: (extraPayload?.created_at as number) ?? now,
221
+ ...(extraPayload as Record<string, unknown> | undefined),
222
+ }),
223
+ );
221
224
  } catch (err) {
222
225
  log.warn({ err, targetType, targetId }, 'Failed to upsert embedding to Qdrant');
223
226
  throw err;
@@ -1,10 +1,13 @@
1
1
  import { and, asc, eq, inArray,lte, notInArray } from 'drizzle-orm';
2
2
  import { v4 as uuid } from 'uuid';
3
3
 
4
+ import { getLogger } from '../util/logger.js';
4
5
  import { truncate } from '../util/truncate.js';
5
- import { getDb, rawAll,rawGet } from './db.js';
6
+ import { getDb, rawAll, rawChanges, rawGet } from './db.js';
6
7
  import { memoryJobs } from './schema.js';
7
8
 
9
+ const log = getLogger('memory-jobs-store');
10
+
8
11
  export type MemoryJobType =
9
12
  | 'embed_segment'
10
13
  | 'embed_item'
@@ -22,6 +25,7 @@ export type MemoryJobType =
22
25
  | 'build_conversation_summary'
23
26
  | 'backfill'
24
27
  | 'rebuild_index'
28
+ | 'reconcile_fts'
25
29
  | 'delete_qdrant_vectors'
26
30
  | 'media_processing';
27
31
 
@@ -36,6 +40,7 @@ export interface MemoryJob<T = Record<string, unknown>> {
36
40
  deferrals: number;
37
41
  runAfter: number;
38
42
  lastError: string | null;
43
+ startedAt: number | null;
39
44
  createdAt: number;
40
45
  updatedAt: number;
41
46
  }
@@ -244,6 +249,21 @@ export function enqueuePruneOldConversationsJob(retentionDays?: number): string
244
249
  return enqueueMemoryJob('prune_old_conversations', payload);
245
250
  }
246
251
 
252
+ export function enqueueReconcileFtsJob(): string {
253
+ const db = getDb();
254
+ const existing = db
255
+ .select()
256
+ .from(memoryJobs)
257
+ .where(and(
258
+ eq(memoryJobs.type, 'reconcile_fts'),
259
+ inArray(memoryJobs.status, ['pending', 'running']),
260
+ ))
261
+ .orderBy(asc(memoryJobs.createdAt))
262
+ .get();
263
+ if (existing) return existing.id;
264
+ return enqueueMemoryJob('reconcile_fts', {});
265
+ }
266
+
247
267
  export function claimMemoryJobs(limit: number): MemoryJob[] {
248
268
  if (limit <= 0) return [];
249
269
  const db = getDb();
@@ -275,14 +295,15 @@ export function claimMemoryJobs(limit: number): MemoryJob[] {
275
295
 
276
296
  const claimed: MemoryJob[] = [];
277
297
  for (const row of candidates) {
278
- const result = db.update(memoryJobs)
279
- .set({ status: 'running', updatedAt: now })
298
+ db.update(memoryJobs)
299
+ .set({ status: 'running', startedAt: now, updatedAt: now })
280
300
  .where(and(eq(memoryJobs.id, row.id), eq(memoryJobs.status, 'pending')))
281
- .run() as unknown as { changes?: number };
282
- if ((result.changes ?? 0) === 0) continue;
301
+ .run();
302
+ if (rawChanges() === 0) continue;
283
303
  claimed.push(parseRow({
284
304
  ...row,
285
305
  status: 'running',
306
+ startedAt: now,
286
307
  updatedAt: now,
287
308
  }));
288
309
  }
@@ -298,7 +319,9 @@ export function completeMemoryJob(id: string): void {
298
319
  }
299
320
 
300
321
  /** Max times a job can be deferred before it is marked as failed. */
301
- const MAX_DEFERRALS = 200;
322
+ const MAX_DEFERRALS = 50;
323
+ /** Warn when deferrals reach 80% of the limit. */
324
+ const DEFER_WARNING_THRESHOLD = Math.floor(MAX_DEFERRALS * 0.8);
302
325
  /** Base delay in ms for deferred jobs (grows with exponential backoff). */
303
326
  const DEFER_BASE_DELAY_MS = 30_000;
304
327
  /** Maximum delay cap for deferred jobs (5 minutes). */
@@ -328,6 +351,7 @@ export function deferMemoryJob(id: string): 'deferred' | 'failed' {
328
351
  const now = Date.now();
329
352
 
330
353
  if (deferrals >= MAX_DEFERRALS) {
354
+ log.error({ jobId: id, type: row.type, deferrals }, 'Job exceeded max deferrals, marking as failed');
331
355
  db.update(memoryJobs)
332
356
  .set({
333
357
  status: 'failed',
@@ -340,6 +364,10 @@ export function deferMemoryJob(id: string): 'deferred' | 'failed' {
340
364
  return 'failed';
341
365
  }
342
366
 
367
+ if (deferrals >= DEFER_WARNING_THRESHOLD) {
368
+ log.warn({ jobId: id, type: row.type, deferrals, max: MAX_DEFERRALS }, 'Job approaching max deferral limit');
369
+ }
370
+
343
371
  // Exponential backoff: 30s, 60s, 120s, ... capped at 5 minutes
344
372
  const delay = Math.min(DEFER_BASE_DELAY_MS * Math.pow(2, Math.min(deferrals - 1, 10)), DEFER_MAX_DELAY_MS);
345
373
  db.update(memoryJobs)
@@ -403,6 +431,37 @@ export function resetRunningJobsToPending(): number {
403
431
  return runningRows.length;
404
432
  }
405
433
 
434
+ /**
435
+ * Fail running jobs whose `startedAt` is older than `timeoutMs` ago.
436
+ * Returns the number of jobs that were timed out.
437
+ */
438
+ export function failStalledJobs(timeoutMs: number): number {
439
+ const now = Date.now();
440
+ const cutoff = now - timeoutMs;
441
+ const stalled = rawAll<{ id: string; type: string }>(`
442
+ SELECT id, type
443
+ FROM memory_jobs
444
+ WHERE status = 'running'
445
+ AND started_at IS NOT NULL
446
+ AND started_at < ?
447
+ `, cutoff);
448
+ if (stalled.length === 0) return 0;
449
+
450
+ const db = getDb();
451
+ for (const row of stalled) {
452
+ db.update(memoryJobs)
453
+ .set({
454
+ status: 'failed',
455
+ updatedAt: now,
456
+ lastError: `Job timed out after ${Math.round(timeoutMs / 60_000)} minutes`,
457
+ })
458
+ .where(and(eq(memoryJobs.id, row.id), eq(memoryJobs.status, 'running')))
459
+ .run();
460
+ log.warn({ jobId: row.id, type: row.type, timeoutMs }, 'Failed stalled memory job due to timeout');
461
+ }
462
+ return stalled.length;
463
+ }
464
+
406
465
  export function getMemoryJobCounts(): Record<string, number> {
407
466
  const rows = rawAll<{ status: string; c: number }>(`
408
467
  SELECT status, COUNT(*) AS c
@@ -432,6 +491,7 @@ function parseRow(row: typeof memoryJobs.$inferSelect): MemoryJob {
432
491
  deferrals: row.deferrals,
433
492
  runAfter: row.runAfter,
434
493
  lastError: row.lastError,
494
+ startedAt: row.startedAt,
435
495
  createdAt: row.createdAt,
436
496
  updatedAt: row.updatedAt,
437
497
  };
@@ -2,6 +2,7 @@ import { getConfig } from '../config/loader.js';
2
2
  import type { AssistantConfig } from '../config/types.js';
3
3
  import { getLogger } from '../util/logger.js';
4
4
  import { rawRun } from './db.js';
5
+ import { reconcileFtsIndexes } from './fts-reconciler.js';
5
6
  import { backfillEntityRelationsJob,backfillJob } from './job-handlers/backfill.js';
6
7
  import { checkContradictionsJob, cleanupStaleSupersededItemsJob, pruneOldConversationsJob } from './job-handlers/cleanup.js';
7
8
  import { cleanupResolvedConflictsJob,resolvePendingConflictsForMessageJob } from './job-handlers/conflict.js';
@@ -24,10 +25,13 @@ import {
24
25
  enqueueCleanupResolvedConflictsJob,
25
26
  enqueueCleanupStaleSupersededItemsJob,
26
27
  enqueuePruneOldConversationsJob,
28
+ enqueueReconcileFtsJob,
27
29
  failMemoryJob,
30
+ failStalledJobs,
28
31
  type MemoryJob,
29
32
  resetRunningJobsToPending,
30
33
  } from './jobs-store.js';
34
+ import { QdrantCircuitOpenError } from './qdrant-circuit-breaker.js';
31
35
  import { bumpMemoryVersion } from './recall-cache.js';
32
36
 
33
37
  // Re-export public utilities consumed by tests and other modules
@@ -86,6 +90,12 @@ export async function runMemoryJobsOnce(
86
90
  // Periodic stale item sweep (throttled to at most once per hour)
87
91
  sweepStaleItems(config);
88
92
 
93
+ // Fail jobs that have been running longer than the configured timeout
94
+ const timedOut = failStalledJobs(config.memory.jobs.stalledJobTimeoutMs);
95
+ if (timedOut > 0) {
96
+ log.warn({ timedOut }, 'Timed out stalled memory jobs');
97
+ }
98
+
89
99
  const batchSize = Math.max(1, config.memory.jobs.batchSize);
90
100
  const concurrency = Math.max(1, config.memory.jobs.workerConcurrency);
91
101
  const jobs = claimMemoryJobs(batchSize);
@@ -174,10 +184,17 @@ function handleJobError(job: MemoryJob, err: unknown): void {
174
184
  if (err instanceof BackendUnavailableError) {
175
185
  const result = deferMemoryJob(job.id);
176
186
  if (result === 'failed') {
177
- log.warn({ jobId: job.id, type: job.type }, 'Embedding backend unavailable, job exceeded max deferrals');
187
+ log.error({ jobId: job.id, type: job.type }, 'Embedding backend unavailable, job exceeded max deferrals');
178
188
  } else {
179
189
  log.debug({ jobId: job.id, type: job.type }, 'Embedding backend unavailable, deferring job');
180
190
  }
191
+ } else if (err instanceof QdrantCircuitOpenError) {
192
+ const result = deferMemoryJob(job.id);
193
+ if (result === 'failed') {
194
+ log.error({ jobId: job.id, type: job.type }, 'Qdrant circuit breaker open, job exceeded max deferrals');
195
+ } else {
196
+ log.debug({ jobId: job.id, type: job.type }, 'Qdrant circuit breaker open, deferring job');
197
+ }
181
198
  } else {
182
199
  const message = err instanceof Error ? err.message : String(err);
183
200
  const category = classifyError(err);
@@ -247,6 +264,9 @@ async function processJob(job: MemoryJob, config: AssistantConfig): Promise<void
247
264
  case 'rebuild_index':
248
265
  rebuildIndexJob();
249
266
  return;
267
+ case 'reconcile_fts':
268
+ reconcileFtsIndexes();
269
+ return;
250
270
  case 'delete_qdrant_vectors':
251
271
  await deleteQdrantVectorsJob(job);
252
272
  return;
@@ -281,11 +301,13 @@ export function maybeEnqueueScheduledCleanupJobs(config: AssistantConfig, nowMs
281
301
  const pruneConversationsJobId = cleanup.conversationRetentionDays > 0
282
302
  ? enqueuePruneOldConversationsJob(cleanup.conversationRetentionDays)
283
303
  : null;
304
+ const reconcileFtsJobId = enqueueReconcileFtsJob();
284
305
  lastScheduledCleanupEnqueueMs = nowMs;
285
306
  log.debug({
286
307
  resolvedConflictsJobId,
287
308
  staleSupersededItemsJobId,
288
309
  pruneConversationsJobId,
310
+ reconcileFtsJobId,
289
311
  enqueueIntervalMs: cleanup.enqueueIntervalMs,
290
312
  resolvedConflictRetentionMs: cleanup.resolvedConflictRetentionMs,
291
313
  supersededItemRetentionMs: cleanup.supersededItemRetentionMs,
@@ -0,0 +1,21 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add follow-up lifecycle columns to guardian_action_requests.
5
+ *
6
+ * These columns track why a request expired (expired_reason), the
7
+ * post-timeout follow-up state machine (followup_state), and any
8
+ * late answer that arrived after the timeout.
9
+ *
10
+ * Uses ALTER TABLE ADD COLUMN with try/catch for idempotency.
11
+ */
12
+ export function migrateGuardianActionFollowup(database: DrizzleDb): void {
13
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN expired_reason TEXT`); } catch { /* already exists */ }
14
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN followup_state TEXT NOT NULL DEFAULT 'none'`); } catch { /* already exists */ }
15
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN late_answer_text TEXT`); } catch { /* already exists */ }
16
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN late_answered_at INTEGER`); } catch { /* already exists */ }
17
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN followup_action TEXT`); } catch { /* already exists */ }
18
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN followup_completed_at INTEGER`); } catch { /* already exists */ }
19
+
20
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_action_requests_followup_state ON guardian_action_requests(followup_state)`);
21
+ }
@@ -0,0 +1,17 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add verification_purpose column to channel_guardian_verification_challenges.
5
+ * Distinguishes guardian outbound verification from trusted contact verification
6
+ * so the consume path knows whether to create a guardian binding.
7
+ *
8
+ * Uses ALTER TABLE ADD COLUMN which is a no-op if the column already
9
+ * exists (caught by try/catch).
10
+ */
11
+ export function migrateGuardianVerificationPurpose(database: DrizzleDb): void {
12
+ try {
13
+ database.run(
14
+ /*sql*/ `ALTER TABLE channel_guardian_verification_challenges ADD COLUMN verification_purpose TEXT DEFAULT 'guardian'`,
15
+ );
16
+ } catch { /* already exists */ }
17
+ }
@@ -0,0 +1,5 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ export function migrateConversationsThreadTypeIndex(database: DrizzleDb): void {
4
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conversations_thread_type ON conversations(thread_type)`);
5
+ }
@@ -0,0 +1,15 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add index on guardian_action_deliveries.destination_conversation_id.
5
+ *
6
+ * Several lookup paths (getPendingDeliveryByConversation,
7
+ * getExpiredDeliveryByConversation, getFollowupDeliveryByConversation)
8
+ * filter deliveries by destination_conversation_id. Without an index
9
+ * these degrade to full table scans as delivery history grows.
10
+ */
11
+ export function migrateGuardianDeliveryConversationIndex(database: DrizzleDb): void {
12
+ database.run(
13
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_action_deliveries_dest_conversation ON guardian_action_deliveries(destination_conversation_id)`,
14
+ );
15
+ }
@@ -0,0 +1,20 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add thread decision audit columns to notification_deliveries.
5
+ *
6
+ * These columns record the model's per-channel thread action (start_new
7
+ * or reuse_existing), the target conversation ID for reuse, and whether
8
+ * a fallback to start_new was needed due to an invalid/stale target.
9
+ */
10
+ export function migrateNotificationDeliveryThreadDecision(database: DrizzleDb): void {
11
+ try {
12
+ database.run(/*sql*/ `ALTER TABLE notification_deliveries ADD COLUMN thread_action TEXT`);
13
+ } catch { /* Column already exists */ }
14
+ try {
15
+ database.run(/*sql*/ `ALTER TABLE notification_deliveries ADD COLUMN thread_target_conversation_id TEXT`);
16
+ } catch { /* Column already exists */ }
17
+ try {
18
+ database.run(/*sql*/ `ALTER TABLE notification_deliveries ADD COLUMN thread_decision_fallback_used INTEGER`);
19
+ } catch { /* Column already exists */ }
20
+ }
@@ -28,7 +28,7 @@ export function createCoreTables(database: DrizzleDb): void {
28
28
  database.run(/*sql*/ `
29
29
  CREATE TABLE IF NOT EXISTS messages (
30
30
  id TEXT PRIMARY KEY,
31
- conversation_id TEXT NOT NULL REFERENCES conversations(id),
31
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
32
32
  role TEXT NOT NULL,
33
33
  content TEXT NOT NULL,
34
34
  created_at INTEGER NOT NULL
@@ -108,6 +108,10 @@ export function createWatchersAndLogsTables(database: DrizzleDb): void {
108
108
  `);
109
109
 
110
110
  // FTS table for lexical retrieval over memory_segments.text.
111
+ // Triggers below are atomic with the triggering statement: if the FTS
112
+ // operation fails, the base table write rolls back too. A corrupted FTS
113
+ // table will block all memory_segments writes until rebuilt. See the
114
+ // analogous comment in 116-messages-fts.ts for recovery steps.
111
115
  database.run(/*sql*/ `
112
116
  CREATE VIRTUAL TABLE IF NOT EXISTS memory_segment_fts USING fts5(
113
117
  segment_id UNINDEXED,
@@ -22,7 +22,7 @@ export function createTasksAndWorkItemsTables(database: DrizzleDb): void {
22
22
  database.run(/*sql*/ `
23
23
  CREATE TABLE IF NOT EXISTS task_runs (
24
24
  id TEXT PRIMARY KEY,
25
- task_id TEXT NOT NULL REFERENCES tasks(id),
25
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
26
26
  conversation_id TEXT,
27
27
  status TEXT NOT NULL DEFAULT 'pending',
28
28
  started_at INTEGER,
@@ -40,7 +40,7 @@ export function createAssistantInboxTables(database: DrizzleDb): void {
40
40
  username TEXT,
41
41
  status TEXT NOT NULL DEFAULT 'pending',
42
42
  policy TEXT NOT NULL DEFAULT 'allow',
43
- invite_id TEXT REFERENCES assistant_ingress_invites(id),
43
+ invite_id TEXT REFERENCES assistant_ingress_invites(id) ON DELETE CASCADE,
44
44
  created_by_session_id TEXT,
45
45
  revoked_reason TEXT,
46
46
  blocked_reason TEXT,
@@ -9,7 +9,7 @@ import { migrateConversationStatusIndexes } from './021-conversation-status-inde
9
9
  import { migrateAddOriginInterface } from './022-add-origin-interface.js';
10
10
  import { migrateMemoryItemSourcesIndexes } from './023-memory-item-sources-indexes.js';
11
11
  import { migrateEmbeddingVectorBlob } from './024-embedding-vector-blob.js';
12
- import { migrateEmbeddingsNullableVectorJson } from './026-embeddings-nullable-vector-json.js';
12
+ import { migrateEmbeddingsNullableVectorJson } from './026a-embeddings-nullable-vector-json.js';
13
13
 
14
14
  /**
15
15
  * Late-stage migrations that must run after all tables and indexes exist:
@@ -8,6 +8,19 @@ import type { DrizzleDb } from '../db-connection.js';
8
8
  * (type, text, tool_use) are short common words that rarely matter as search
9
9
  * terms. The existing buildExcerpt() in conversation-store handles extracting
10
10
  * readable text from JSON for display after matching.
11
+ *
12
+ * ## Trigger atomicity and failure modes
13
+ *
14
+ * SQLite triggers execute atomically within the triggering statement's
15
+ * transaction. If the FTS trigger fails (e.g., corrupted FTS index), the
16
+ * entire statement — including the base table INSERT/UPDATE/DELETE — is
17
+ * rolled back. This means a trigger failure does NOT silently lose FTS
18
+ * data; instead, it prevents the base operation from succeeding at all.
19
+ *
20
+ * The real risk is the reverse: a corrupted FTS virtual table will cause
21
+ * ALL writes to the messages table to fail until the FTS table is rebuilt.
22
+ * If this happens, `messages_fts` should be dropped and recreated, then
23
+ * backfilled via `migrateMessagesFtsBackfill`.
11
24
  */
12
25
  export function createMessagesFts(database: DrizzleDb): void {
13
26
  database.run(/*sql*/ `
@@ -0,0 +1,37 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add indexes, a column, and a unique constraint for schema improvements:
5
+ * - Index on call_sessions(status) for status-based queries
6
+ * - Index on llm_usage_events(conversation_id) for per-conversation usage queries
7
+ * - startedAt column on memory_jobs for detecting stalled jobs
8
+ * - Unique index on notification_deliveries(notification_decision_id, channel)
9
+ */
10
+ export function migrateSchemaIndexesAndColumns(database: DrizzleDb): void {
11
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_call_sessions_status ON call_sessions(status)`);
12
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_conversation_id ON llm_usage_events(conversation_id)`);
13
+
14
+ try {
15
+ database.run(/*sql*/ `ALTER TABLE memory_jobs ADD COLUMN started_at INTEGER`);
16
+ } catch { /* already exists */ }
17
+
18
+ // Deduplicate before creating the unique index — the prior schema allowed
19
+ // multiple rows per (notification_decision_id, channel) via the wider
20
+ // (decision_id, channel, destination, attempt) unique index. Keep the
21
+ // row with the latest updated_at for each group.
22
+ database.run(/*sql*/ `
23
+ DELETE FROM notification_deliveries
24
+ WHERE id NOT IN (
25
+ SELECT id FROM (
26
+ SELECT id, ROW_NUMBER() OVER (
27
+ PARTITION BY notification_decision_id, channel
28
+ ORDER BY updated_at DESC
29
+ ) AS rn
30
+ FROM notification_deliveries
31
+ )
32
+ WHERE rn = 1
33
+ )
34
+ `);
35
+
36
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_deliveries_decision_channel ON notification_deliveries(notification_decision_id, channel)`);
37
+ }
@@ -0,0 +1,161 @@
1
+ import { type DrizzleDb, getSqliteFrom } from '../db-connection.js';
2
+
3
+ /**
4
+ * Rebuild messages, task_runs, and assistant_ingress_members tables to add
5
+ * ON DELETE CASCADE to their FK constraints. SQLite does not support
6
+ * ALTER TABLE to change FK behavior, so a table rebuild is required.
7
+ *
8
+ * Follows the same pattern as 002-tool-invocations-fk.ts: check if the
9
+ * DDL already contains ON DELETE CASCADE, and skip if so.
10
+ */
11
+ export function migrateFkCascadeRebuilds(database: DrizzleDb): void {
12
+ const raw = getSqliteFrom(database);
13
+
14
+ rebuildMessages(raw);
15
+ rebuildTaskRuns(raw);
16
+ rebuildAssistantIngressMembers(raw);
17
+ }
18
+
19
+ function hasCascade(raw: ReturnType<typeof getSqliteFrom>, tableName: string): boolean {
20
+ const row = raw.query(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`).get(tableName) as { sql: string } | null;
21
+ if (!row) return true; // table doesn't exist yet — will be created with correct DDL
22
+ return row.sql.includes('ON DELETE CASCADE');
23
+ }
24
+
25
+ function rebuildMessages(raw: ReturnType<typeof getSqliteFrom>): void {
26
+ if (hasCascade(raw, 'messages')) return;
27
+
28
+ raw.exec('PRAGMA foreign_keys = OFF');
29
+ try {
30
+ raw.exec(/*sql*/ `
31
+ BEGIN;
32
+
33
+ DROP TRIGGER IF EXISTS messages_fts_ai;
34
+ DROP TRIGGER IF EXISTS messages_fts_ad;
35
+ DROP TRIGGER IF EXISTS messages_fts_au;
36
+
37
+ CREATE TABLE messages_new (
38
+ id TEXT PRIMARY KEY,
39
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
40
+ role TEXT NOT NULL,
41
+ content TEXT NOT NULL,
42
+ created_at INTEGER NOT NULL,
43
+ metadata TEXT
44
+ );
45
+ INSERT INTO messages_new SELECT id, conversation_id, role, content, created_at, metadata FROM messages;
46
+ DROP TABLE messages;
47
+ ALTER TABLE messages_new RENAME TO messages;
48
+
49
+ CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
50
+ CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
51
+
52
+ CREATE TRIGGER IF NOT EXISTS messages_fts_ai
53
+ AFTER INSERT ON messages
54
+ BEGIN
55
+ INSERT INTO messages_fts(message_id, content) VALUES (new.id, new.content);
56
+ END;
57
+
58
+ CREATE TRIGGER IF NOT EXISTS messages_fts_ad
59
+ AFTER DELETE ON messages
60
+ BEGIN
61
+ DELETE FROM messages_fts WHERE message_id = old.id;
62
+ END;
63
+
64
+ CREATE TRIGGER IF NOT EXISTS messages_fts_au
65
+ AFTER UPDATE ON messages
66
+ BEGIN
67
+ DELETE FROM messages_fts WHERE message_id = old.id;
68
+ INSERT INTO messages_fts(message_id, content) VALUES (new.id, new.content);
69
+ END;
70
+
71
+ COMMIT;
72
+ `);
73
+ } catch (e) {
74
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
75
+ throw e;
76
+ } finally {
77
+ raw.exec('PRAGMA foreign_keys = ON');
78
+ }
79
+ }
80
+
81
+ function rebuildTaskRuns(raw: ReturnType<typeof getSqliteFrom>): void {
82
+ if (hasCascade(raw, 'task_runs')) return;
83
+
84
+ raw.exec('PRAGMA foreign_keys = OFF');
85
+ try {
86
+ raw.exec(/*sql*/ `
87
+ BEGIN;
88
+ CREATE TABLE task_runs_new (
89
+ id TEXT PRIMARY KEY,
90
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
91
+ conversation_id TEXT,
92
+ status TEXT NOT NULL DEFAULT 'pending',
93
+ started_at INTEGER,
94
+ finished_at INTEGER,
95
+ error TEXT,
96
+ principal_id TEXT,
97
+ memory_scope_id TEXT,
98
+ created_at INTEGER NOT NULL
99
+ );
100
+ INSERT INTO task_runs_new SELECT id, task_id, conversation_id, status, started_at, finished_at, error, principal_id, memory_scope_id, created_at FROM task_runs;
101
+ DROP TABLE task_runs;
102
+ ALTER TABLE task_runs_new RENAME TO task_runs;
103
+
104
+ CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
105
+ CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status);
106
+ CREATE INDEX IF NOT EXISTS idx_task_runs_conversation_status ON task_runs(conversation_id, status);
107
+
108
+ COMMIT;
109
+ `);
110
+ } catch (e) {
111
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
112
+ throw e;
113
+ } finally {
114
+ raw.exec('PRAGMA foreign_keys = ON');
115
+ }
116
+ }
117
+
118
+ function rebuildAssistantIngressMembers(raw: ReturnType<typeof getSqliteFrom>): void {
119
+ if (hasCascade(raw, 'assistant_ingress_members')) return;
120
+
121
+ raw.exec('PRAGMA foreign_keys = OFF');
122
+ try {
123
+ raw.exec(/*sql*/ `
124
+ BEGIN;
125
+ CREATE TABLE assistant_ingress_members_new (
126
+ id TEXT PRIMARY KEY,
127
+ assistant_id TEXT NOT NULL DEFAULT 'self',
128
+ source_channel TEXT NOT NULL,
129
+ external_user_id TEXT,
130
+ external_chat_id TEXT,
131
+ display_name TEXT,
132
+ username TEXT,
133
+ status TEXT NOT NULL DEFAULT 'pending',
134
+ policy TEXT NOT NULL DEFAULT 'allow',
135
+ invite_id TEXT REFERENCES assistant_ingress_invites(id) ON DELETE CASCADE,
136
+ created_by_session_id TEXT,
137
+ revoked_reason TEXT,
138
+ blocked_reason TEXT,
139
+ last_seen_at INTEGER,
140
+ created_at INTEGER NOT NULL,
141
+ updated_at INTEGER NOT NULL,
142
+ CHECK (external_user_id IS NOT NULL OR external_chat_id IS NOT NULL)
143
+ );
144
+ INSERT INTO assistant_ingress_members_new SELECT id, assistant_id, source_channel, external_user_id, external_chat_id, display_name, username, status, policy, invite_id, created_by_session_id, revoked_reason, blocked_reason, last_seen_at, created_at, updated_at FROM assistant_ingress_members;
145
+ DROP TABLE assistant_ingress_members;
146
+ ALTER TABLE assistant_ingress_members_new RENAME TO assistant_ingress_members;
147
+
148
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ingress_members_user ON assistant_ingress_members(assistant_id, source_channel, external_user_id) WHERE external_user_id IS NOT NULL;
149
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ingress_members_chat ON assistant_ingress_members(assistant_id, source_channel, external_chat_id) WHERE external_chat_id IS NOT NULL;
150
+ CREATE INDEX IF NOT EXISTS idx_ingress_members_status_policy ON assistant_ingress_members(assistant_id, source_channel, status, policy);
151
+ CREATE INDEX IF NOT EXISTS idx_ingress_members_updated ON assistant_ingress_members(assistant_id, source_channel, updated_at);
152
+
153
+ COMMIT;
154
+ `);
155
+ } catch (e) {
156
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
157
+ throw e;
158
+ } finally {
159
+ raw.exec('PRAGMA foreign_keys = ON');
160
+ }
161
+ }
@@ -23,12 +23,17 @@ export { migrateAddOriginInterface } from './022-add-origin-interface.js';
23
23
  export { migrateMemoryItemSourcesIndexes } from './023-memory-item-sources-indexes.js';
24
24
  export { migrateEmbeddingVectorBlob } from './024-embedding-vector-blob.js';
25
25
  export { migrateMessagesFtsBackfill } from './025-messages-fts-backfill.js';
26
- export { migrateEmbeddingsNullableVectorJson } from './026-embeddings-nullable-vector-json.js';
27
26
  export { migrateGuardianVerificationSessions } from './026-guardian-verification-sessions.js';
28
- export { migrateGuardianBootstrapToken } from './027-guardian-bootstrap-token.js';
27
+ export { migrateEmbeddingsNullableVectorJson } from './026a-embeddings-nullable-vector-json.js';
29
28
  export { migrateNotificationDeliveryPairingColumns } from './027-notification-delivery-pairing-columns.js';
29
+ export { migrateGuardianBootstrapToken } from './027a-guardian-bootstrap-token.js';
30
30
  export { migrateCallSessionMode } from './028-call-session-mode.js';
31
31
  export { migrateChannelInboundDeliveredSegments } from './029-channel-inbound-delivered-segments.js';
32
+ export { migrateGuardianActionFollowup } from './030-guardian-action-followup.js';
33
+ export { migrateGuardianVerificationPurpose } from './030-guardian-verification-purpose.js';
34
+ export { migrateConversationsThreadTypeIndex } from './031-conversations-thread-type-index.js';
35
+ export { migrateGuardianDeliveryConversationIndex } from './032-guardian-delivery-conversation-index.js';
36
+ export { migrateNotificationDeliveryThreadDecision } from './032-notification-delivery-thread-decision.js';
32
37
  export { createCoreTables } from './100-core-tables.js';
33
38
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
34
39
  export { addCoreColumns } from './102-alter-table-columns.js';
@@ -48,9 +53,11 @@ export { createSequenceTables } from './115-sequences.js';
48
53
  export { createMessagesFts } from './116-messages-fts.js';
49
54
  export { createConversationAttentionTables } from './117-conversation-attention.js';
50
55
  export { migrateReminderRoutingIntent } from './118-reminder-routing-intent.js';
56
+ export { migrateSchemaIndexesAndColumns } from './119-schema-indexes-and-columns.js';
57
+ export { migrateFkCascadeRebuilds } from './120-fk-cascade-rebuilds.js';
51
58
  export {
52
59
  MIGRATION_REGISTRY,
53
60
  type MigrationRegistryEntry,
54
61
  type MigrationValidationResult,
55
62
  } from './registry.js';
56
- export { validateMigrationState } from './validate-migration-state.js';
63
+ export { recoverCrashedMigrations, validateMigrationState, withCrashRecovery } from './validate-migration-state.js';