@vellumai/assistant 0.4.33 → 0.4.35

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 (149) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/access-request-decision.test.ts +2 -3
  3. package/src/__tests__/actor-token-service.test.ts +4 -11
  4. package/src/__tests__/approval-primitive.test.ts +0 -45
  5. package/src/__tests__/assistant-id-boundary-guard.test.ts +169 -0
  6. package/src/__tests__/callback-handoff-copy.test.ts +0 -1
  7. package/src/__tests__/channel-approval-routes.test.ts +5 -45
  8. package/src/__tests__/channel-guardian.test.ts +122 -345
  9. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +4 -3
  10. package/src/__tests__/contacts-tools.test.ts +4 -5
  11. package/src/__tests__/conversation-attention-store.test.ts +2 -65
  12. package/src/__tests__/conversation-attention-telegram.test.ts +0 -2
  13. package/src/__tests__/conversation-pairing.test.ts +0 -1
  14. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -2
  15. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -7
  16. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -74
  17. package/src/__tests__/guardian-action-late-reply.test.ts +1 -8
  18. package/src/__tests__/guardian-grant-minting.test.ts +0 -1
  19. package/src/__tests__/guardian-routing-state.test.ts +0 -3
  20. package/src/__tests__/inbound-invite-redemption.test.ts +0 -3
  21. package/src/__tests__/non-member-access-request.test.ts +0 -7
  22. package/src/__tests__/notification-broadcaster.test.ts +1 -2
  23. package/src/__tests__/notification-decision-fallback.test.ts +0 -2
  24. package/src/__tests__/notification-decision-strategy.test.ts +0 -1
  25. package/src/__tests__/relay-server.test.ts +11 -83
  26. package/src/__tests__/scoped-approval-grants.test.ts +9 -40
  27. package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -36
  28. package/src/__tests__/send-endpoint-busy.test.ts +0 -1
  29. package/src/__tests__/send-notification-tool.test.ts +0 -1
  30. package/src/__tests__/slack-inbound-verification.test.ts +2 -4
  31. package/src/__tests__/thread-seed-composer.test.ts +0 -1
  32. package/src/__tests__/tool-approval-handler.test.ts +0 -1
  33. package/src/__tests__/tool-grant-request-escalation.test.ts +0 -4
  34. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -5
  35. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +1 -17
  36. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -13
  37. package/src/__tests__/trusted-contact-verification.test.ts +3 -15
  38. package/src/__tests__/twilio-routes.test.ts +2 -2
  39. package/src/__tests__/voice-invite-redemption.test.ts +0 -1
  40. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -37
  41. package/src/approvals/approval-primitive.ts +0 -15
  42. package/src/approvals/guardian-decision-primitive.ts +0 -3
  43. package/src/approvals/guardian-request-resolvers.ts +0 -5
  44. package/src/calls/call-domain.ts +0 -3
  45. package/src/calls/call-store.ts +0 -3
  46. package/src/calls/guardian-action-sweep.ts +2 -1
  47. package/src/calls/guardian-dispatch.ts +1 -2
  48. package/src/calls/relay-access-wait.ts +0 -4
  49. package/src/calls/relay-server.ts +3 -11
  50. package/src/calls/relay-setup-router.ts +1 -2
  51. package/src/calls/relay-verification.ts +0 -1
  52. package/src/calls/twilio-routes.ts +0 -3
  53. package/src/calls/types.ts +0 -1
  54. package/src/calls/voice-session-bridge.ts +0 -1
  55. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +100 -171
  56. package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -1
  57. package/src/contacts/contact-store.ts +13 -88
  58. package/src/contacts/contacts-write.ts +3 -11
  59. package/src/contacts/types.ts +0 -1
  60. package/src/daemon/handlers/config-channels.ts +16 -42
  61. package/src/daemon/handlers/config-inbox.ts +6 -6
  62. package/src/daemon/handlers/contacts.ts +3 -11
  63. package/src/daemon/handlers/index.ts +0 -2
  64. package/src/daemon/session-process.ts +0 -4
  65. package/src/memory/conversation-attention-store.ts +4 -19
  66. package/src/memory/conversation-crud.ts +0 -2
  67. package/src/memory/db-init.ts +4 -0
  68. package/src/memory/guardian-action-store.ts +0 -12
  69. package/src/memory/guardian-approvals.ts +35 -80
  70. package/src/memory/guardian-rate-limits.ts +1 -14
  71. package/src/memory/guardian-verification.ts +6 -34
  72. package/src/memory/invite-store.ts +5 -14
  73. package/src/memory/migrations/026-guardian-verification-sessions.ts +28 -9
  74. package/src/memory/migrations/027a-guardian-bootstrap-token.ts +16 -3
  75. package/src/memory/migrations/038-actor-token-records.ts +8 -1
  76. package/src/memory/migrations/039-actor-refresh-token-records.ts +11 -2
  77. package/src/memory/migrations/110-channel-guardian.ts +27 -6
  78. package/src/memory/migrations/112-assistant-inbox.ts +39 -15
  79. package/src/memory/migrations/114-notifications.ts +37 -15
  80. package/src/memory/migrations/117-conversation-attention.ts +33 -9
  81. package/src/memory/migrations/134-contacts-notes-column.ts +64 -45
  82. package/src/memory/migrations/136-drop-assistant-id-columns.ts +263 -0
  83. package/src/memory/migrations/index.ts +1 -0
  84. package/src/memory/migrations/registry.ts +14 -1
  85. package/src/memory/migrations/schema-introspection.ts +18 -0
  86. package/src/memory/schema/calls.ts +0 -7
  87. package/src/memory/schema/contacts.ts +0 -8
  88. package/src/memory/schema/guardian.ts +0 -5
  89. package/src/memory/schema/infrastructure.ts +0 -2
  90. package/src/memory/schema/notifications.ts +3 -17
  91. package/src/memory/scoped-approval-grants.ts +2 -24
  92. package/src/notifications/adapters/sms.ts +2 -1
  93. package/src/notifications/broadcaster.ts +1 -6
  94. package/src/notifications/decision-engine.ts +3 -4
  95. package/src/notifications/deliveries-store.ts +0 -4
  96. package/src/notifications/destination-resolver.ts +4 -6
  97. package/src/notifications/deterministic-checks.ts +1 -6
  98. package/src/notifications/emit-signal.ts +4 -11
  99. package/src/notifications/events-store.ts +7 -17
  100. package/src/notifications/preference-summary.ts +2 -2
  101. package/src/notifications/preferences-store.ts +2 -9
  102. package/src/notifications/signal.ts +0 -1
  103. package/src/notifications/thread-candidates.ts +1 -11
  104. package/src/notifications/types.ts +0 -3
  105. package/src/runtime/access-request-helper.ts +3 -10
  106. package/src/runtime/actor-refresh-token-store.ts +0 -6
  107. package/src/runtime/actor-token-store.ts +3 -16
  108. package/src/runtime/actor-trust-resolver.ts +1 -4
  109. package/src/runtime/auth/__tests__/credential-service.test.ts +0 -9
  110. package/src/runtime/auth/credential-service.ts +1 -15
  111. package/src/runtime/auth/require-bound-guardian.ts +1 -4
  112. package/src/runtime/channel-guardian-service.ts +15 -46
  113. package/src/runtime/channel-invite-transport.ts +8 -0
  114. package/src/runtime/channel-invite-transports/email.ts +4 -0
  115. package/src/runtime/channel-invite-transports/slack.ts +6 -0
  116. package/src/runtime/channel-invite-transports/sms.ts +4 -0
  117. package/src/runtime/channel-invite-transports/telegram.ts +6 -0
  118. package/src/runtime/confirmation-request-guardian-bridge.ts +0 -1
  119. package/src/runtime/guardian-action-followup-executor.ts +3 -2
  120. package/src/runtime/guardian-action-grant-minter.ts +0 -1
  121. package/src/runtime/guardian-outbound-actions.ts +2 -12
  122. package/src/runtime/guardian-vellum-migration.ts +2 -3
  123. package/src/runtime/http-server.ts +3 -10
  124. package/src/runtime/http-types.ts +13 -1
  125. package/src/runtime/invite-redemption-service.ts +1 -14
  126. package/src/runtime/local-actor-identity.ts +2 -5
  127. package/src/runtime/routes/access-request-decision.ts +0 -1
  128. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +0 -9
  129. package/src/runtime/routes/channel-readiness-routes.ts +29 -18
  130. package/src/runtime/routes/contact-routes.ts +15 -40
  131. package/src/runtime/routes/conversation-attention-routes.ts +0 -2
  132. package/src/runtime/routes/global-search-routes.ts +0 -2
  133. package/src/runtime/routes/guardian-bootstrap-routes.ts +6 -7
  134. package/src/runtime/routes/guardian-expiry-sweep.ts +3 -2
  135. package/src/runtime/routes/inbound-message-handler.ts +0 -3
  136. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +7 -43
  137. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -4
  138. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -6
  139. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +0 -1
  140. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +0 -1
  141. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -7
  142. package/src/runtime/routes/pairing-routes.ts +4 -4
  143. package/src/runtime/routes/surface-content-routes.ts +104 -0
  144. package/src/runtime/tool-grant-request-helper.ts +0 -1
  145. package/src/tools/browser/browser-manager.ts +22 -21
  146. package/src/tools/browser/runtime-check.ts +111 -6
  147. package/src/tools/calls/call-start.ts +1 -3
  148. package/src/tools/followups/followup_create.ts +1 -2
  149. package/src/tools/tool-approval-handler.ts +0 -2
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Create the actor_refresh_token_records table for hash-only refresh token persistence.
@@ -34,10 +35,18 @@ export function createActorRefreshTokenRecordsTable(database: DrizzleDb): void {
34
35
  // Unique active refresh token per device binding.
35
36
  // DROP first so that databases that already created the older non-unique
36
37
  // index with the same name get upgraded to UNIQUE.
37
- database.run(/*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`);
38
- database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
38
+ if (tableHasColumn(database, "actor_refresh_token_records", "assistant_id")) {
39
+ database.run(
40
+ /*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`,
41
+ );
42
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
39
43
  ON actor_refresh_token_records(assistant_id, guardian_principal_id, hashed_device_id)
40
44
  WHERE status = 'active'`);
45
+ } else {
46
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
47
+ ON actor_refresh_token_records(guardian_principal_id, hashed_device_id)
48
+ WHERE status = 'active'`);
49
+ }
41
50
 
42
51
  // Family lookup for replay detection (revoke entire family)
43
52
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Channel guardian tables: bindings, verification challenges, approval requests,
@@ -43,9 +44,21 @@ export function createChannelGuardianTables(database: DrizzleDb): void {
43
44
  )
44
45
  `);
45
46
 
46
- database.run(
47
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(assistant_id, channel, challenge_hash, status)`,
48
- );
47
+ if (
48
+ tableHasColumn(
49
+ database,
50
+ "channel_guardian_verification_challenges",
51
+ "assistant_id",
52
+ )
53
+ ) {
54
+ database.run(
55
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(assistant_id, channel, challenge_hash, status)`,
56
+ );
57
+ } else {
58
+ database.run(
59
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(channel, challenge_hash, status)`,
60
+ );
61
+ }
49
62
 
50
63
  database.run(/*sql*/ `
51
64
  CREATE TABLE IF NOT EXISTS channel_guardian_approval_requests (
@@ -139,7 +152,15 @@ export function createChannelGuardianTables(database: DrizzleDb): void {
139
152
  /* already exists */
140
153
  }
141
154
 
142
- database.run(
143
- /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(assistant_id, channel, actor_external_user_id, actor_chat_id)`,
144
- );
155
+ if (
156
+ tableHasColumn(database, "channel_guardian_rate_limits", "assistant_id")
157
+ ) {
158
+ database.run(
159
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(assistant_id, channel, actor_external_user_id, actor_chat_id)`,
160
+ );
161
+ } else {
162
+ database.run(
163
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(channel, actor_external_user_id, actor_chat_id)`,
164
+ );
165
+ }
145
166
  }
@@ -1,5 +1,6 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
2
  import { migrateBackfillInboxThreadStateFromBindings } from "./014-backfill-inbox-thread-state.js";
3
+ import { tableHasColumn } from "./schema-introspection.js";
3
4
 
4
5
  /**
5
6
  * Assistant inbox tables: ingress invites, ingress members, inbox thread state.
@@ -28,12 +29,21 @@ export function createAssistantInboxTables(database: DrizzleDb): void {
28
29
  database.run(
29
30
  /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ingress_invites_token_hash ON assistant_ingress_invites(token_hash)`,
30
31
  );
31
- database.run(
32
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(assistant_id, source_channel, status, expires_at)`,
33
- );
34
- database.run(
35
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(assistant_id, source_channel, created_at)`,
36
- );
32
+ if (tableHasColumn(database, "assistant_ingress_invites", "assistant_id")) {
33
+ database.run(
34
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(assistant_id, source_channel, status, expires_at)`,
35
+ );
36
+ database.run(
37
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(assistant_id, source_channel, created_at)`,
38
+ );
39
+ } else {
40
+ database.run(
41
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(source_channel, status, expires_at)`,
42
+ );
43
+ database.run(
44
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(source_channel, created_at)`,
45
+ );
46
+ }
37
47
 
38
48
  database.run(/*sql*/ `
39
49
  CREATE TABLE IF NOT EXISTS assistant_ingress_members (
@@ -90,15 +100,29 @@ export function createAssistantInboxTables(database: DrizzleDb): void {
90
100
  )
91
101
  `);
92
102
 
93
- database.run(
94
- /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(assistant_id, source_channel, external_chat_id)`,
95
- );
96
- database.run(
97
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(assistant_id, last_message_at)`,
98
- );
99
- database.run(
100
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(assistant_id, has_pending_escalation, last_message_at)`,
101
- );
103
+ if (
104
+ tableHasColumn(database, "assistant_inbox_thread_state", "assistant_id")
105
+ ) {
106
+ database.run(
107
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(assistant_id, source_channel, external_chat_id)`,
108
+ );
109
+ database.run(
110
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(assistant_id, last_message_at)`,
111
+ );
112
+ database.run(
113
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(assistant_id, has_pending_escalation, last_message_at)`,
114
+ );
115
+ } else {
116
+ database.run(
117
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(source_channel, external_chat_id)`,
118
+ );
119
+ database.run(
120
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(last_message_at)`,
121
+ );
122
+ database.run(
123
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(has_pending_escalation, last_message_at)`,
124
+ );
125
+ }
102
126
 
103
127
  migrateBackfillInboxThreadStateFromBindings(database);
104
128
  }
@@ -2,6 +2,7 @@ import type { DrizzleDb } from "../db-connection.js";
2
2
  import { migrateNotificationTablesSchema } from "./019-notification-tables-schema-migration.js";
3
3
  import { migrateNotificationDeliveryPairingColumns } from "./027-notification-delivery-pairing-columns.js";
4
4
  import { migrateNotificationDeliveryClientAck } from "./028-notification-delivery-client-ack.js";
5
+ import { tableHasColumn } from "./schema-introspection.js";
5
6
 
6
7
  /**
7
8
  * Notification system tables: preferences, events, decisions, and deliveries.
@@ -24,12 +25,18 @@ export function createNotificationTables(database: DrizzleDb): void {
24
25
  )
25
26
  `);
26
27
 
27
- database.run(
28
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_id ON notification_preferences(assistant_id)`,
29
- );
30
- database.run(
31
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_priority ON notification_preferences(assistant_id, priority DESC)`,
32
- );
28
+ if (tableHasColumn(database, "notification_preferences", "assistant_id")) {
29
+ database.run(
30
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_id ON notification_preferences(assistant_id)`,
31
+ );
32
+ database.run(
33
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_priority ON notification_preferences(assistant_id, priority DESC)`,
34
+ );
35
+ } else {
36
+ database.run(
37
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_priority ON notification_preferences(priority DESC)`,
38
+ );
39
+ }
33
40
 
34
41
  database.run(/*sql*/ `
35
42
  CREATE TABLE IF NOT EXISTS notification_events (
@@ -46,12 +53,21 @@ export function createNotificationTables(database: DrizzleDb): void {
46
53
  )
47
54
  `);
48
55
 
49
- database.run(
50
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_assistant_event_created ON notification_events(assistant_id, source_event_name, created_at)`,
51
- );
52
- database.run(
53
- /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(assistant_id, dedupe_key) WHERE dedupe_key IS NOT NULL`,
54
- );
56
+ if (tableHasColumn(database, "notification_events", "assistant_id")) {
57
+ database.run(
58
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_assistant_event_created ON notification_events(assistant_id, source_event_name, created_at)`,
59
+ );
60
+ database.run(
61
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(assistant_id, dedupe_key) WHERE dedupe_key IS NOT NULL`,
62
+ );
63
+ } else {
64
+ database.run(
65
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_event_created ON notification_events(source_event_name, created_at)`,
66
+ );
67
+ database.run(
68
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(dedupe_key) WHERE dedupe_key IS NOT NULL`,
69
+ );
70
+ }
55
71
 
56
72
  database.run(/*sql*/ `
57
73
  CREATE TABLE IF NOT EXISTS notification_decisions (
@@ -97,9 +113,15 @@ export function createNotificationTables(database: DrizzleDb): void {
97
113
  database.run(
98
114
  /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_decision_id ON notification_deliveries(notification_decision_id)`,
99
115
  );
100
- database.run(
101
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_assistant_status ON notification_deliveries(assistant_id, status)`,
102
- );
116
+ if (tableHasColumn(database, "notification_deliveries", "assistant_id")) {
117
+ database.run(
118
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_assistant_status ON notification_deliveries(assistant_id, status)`,
119
+ );
120
+ } else {
121
+ database.run(
122
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries(status)`,
123
+ );
124
+ }
103
125
 
104
126
  // Add conversation pairing audit columns (idempotent ALTER TABLE)
105
127
  migrateNotificationDeliveryPairingColumns(database);
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Conversation attention tables: append-only evidence log and single-row
@@ -24,9 +25,17 @@ export function createConversationAttentionTables(database: DrizzleDb): void {
24
25
  database.run(
25
26
  /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_conv_observed ON conversation_attention_events(conversation_id, observed_at DESC)`,
26
27
  );
27
- database.run(
28
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_assistant_observed ON conversation_attention_events(assistant_id, observed_at DESC)`,
29
- );
28
+ if (
29
+ tableHasColumn(database, "conversation_attention_events", "assistant_id")
30
+ ) {
31
+ database.run(
32
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_assistant_observed ON conversation_attention_events(assistant_id, observed_at DESC)`,
33
+ );
34
+ } else {
35
+ database.run(
36
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_observed ON conversation_attention_events(observed_at)`,
37
+ );
38
+ }
30
39
  database.run(
31
40
  /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_channel_observed ON conversation_attention_events(source_channel, observed_at DESC)`,
32
41
  );
@@ -50,10 +59,25 @@ export function createConversationAttentionTables(database: DrizzleDb): void {
50
59
  )
51
60
  `);
52
61
 
53
- database.run(
54
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_latest_msg ON conversation_assistant_attention_state(assistant_id, latest_assistant_message_at DESC)`,
55
- );
56
- database.run(
57
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_last_seen ON conversation_assistant_attention_state(assistant_id, last_seen_assistant_message_at DESC)`,
58
- );
62
+ if (
63
+ tableHasColumn(
64
+ database,
65
+ "conversation_assistant_attention_state",
66
+ "assistant_id",
67
+ )
68
+ ) {
69
+ database.run(
70
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_latest_msg ON conversation_assistant_attention_state(assistant_id, latest_assistant_message_at DESC)`,
71
+ );
72
+ database.run(
73
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_last_seen ON conversation_assistant_attention_state(assistant_id, last_seen_assistant_message_at DESC)`,
74
+ );
75
+ } else {
76
+ database.run(
77
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_latest_msg ON conversation_assistant_attention_state(latest_assistant_message_at)`,
78
+ );
79
+ database.run(
80
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_last_seen ON conversation_assistant_attention_state(last_seen_assistant_message_at)`,
81
+ );
82
+ }
59
83
  }
@@ -1,68 +1,87 @@
1
1
  import { getLogger } from "../../util/logger.js";
2
2
  import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
3
+ import { withCrashRecovery } from "./validate-migration-state.js";
3
4
 
4
5
  const log = getLogger("migration-134");
5
6
 
6
7
  export function migrateContactsNotesColumn(database: DrizzleDb): void {
7
- const raw = getSqliteFrom(database);
8
+ withCrashRecovery(database, "migration_contacts_notes_column_v1", () => {
9
+ const raw = getSqliteFrom(database);
8
10
 
9
- try {
10
- raw.exec(/*sql*/ `ALTER TABLE contacts ADD COLUMN notes TEXT`);
11
- } catch {
12
- /* already exists */
13
- }
11
+ try {
12
+ raw.exec(/*sql*/ `ALTER TABLE contacts ADD COLUMN notes TEXT`);
13
+ } catch {
14
+ /* already exists */
15
+ }
16
+
17
+ // Check which legacy columns still exist — handles partial completion
18
+ // if a previous run crashed after dropping some columns but not all.
19
+ const cols = new Set(
20
+ (
21
+ raw.query(`PRAGMA table_info(contacts)`).all() as Array<{
22
+ name: string;
23
+ }>
24
+ ).map((c) => c.name),
25
+ );
14
26
 
15
- // Check if legacy columns still exist before attempting backfill + drop
16
- const cols = new Set(
17
- (
18
- raw.query(`PRAGMA table_info(contacts)`).all() as Array<{ name: string }>
19
- ).map((c) => c.name),
20
- );
27
+ const legacyCols = [
28
+ "relationship",
29
+ "importance",
30
+ "response_expectation",
31
+ "preferred_tone",
32
+ ] as const;
33
+ const remaining = legacyCols.filter((c) => cols.has(c));
21
34
 
22
- if (cols.has("relationship")) {
23
- const rows = raw
24
- .query(
25
- `SELECT id, relationship, importance, response_expectation, preferred_tone
35
+ // Backfill notes from legacy columns if any are still present and notes
36
+ // haven't been populated yet (only run once, before any columns are dropped).
37
+ if (remaining.length === legacyCols.length) {
38
+ const rows = raw
39
+ .query(
40
+ `SELECT id, relationship, importance, response_expectation, preferred_tone
26
41
  FROM contacts
27
42
  WHERE relationship IS NOT NULL
28
43
  OR importance != 0.5
29
44
  OR response_expectation IS NOT NULL
30
45
  OR preferred_tone IS NOT NULL`,
31
- )
32
- .all() as Array<{
33
- id: string;
34
- relationship: string | null;
35
- importance: number;
36
- response_expectation: string | null;
37
- preferred_tone: string | null;
38
- }>;
46
+ )
47
+ .all() as Array<{
48
+ id: string;
49
+ relationship: string | null;
50
+ importance: number;
51
+ response_expectation: string | null;
52
+ preferred_tone: string | null;
53
+ }>;
39
54
 
40
- const update = raw.prepare(`UPDATE contacts SET notes = ? WHERE id = ?`);
55
+ const update = raw.prepare(`UPDATE contacts SET notes = ? WHERE id = ?`);
41
56
 
42
- for (const row of rows) {
43
- const parts: string[] = [];
44
- if (row.relationship) parts.push(`Relationship: ${row.relationship}`);
45
- if (row.importance !== 0.5) parts.push(`Importance: ${row.importance}`);
46
- if (row.response_expectation)
47
- parts.push(`Response expectation: ${row.response_expectation}`);
48
- if (row.preferred_tone)
49
- parts.push(`Preferred tone: ${row.preferred_tone}`);
50
- if (parts.length > 0) {
51
- update.run(parts.join("\n"), row.id);
57
+ for (const row of rows) {
58
+ const parts: string[] = [];
59
+ if (row.relationship) parts.push(`Relationship: ${row.relationship}`);
60
+ if (row.importance !== 0.5) parts.push(`Importance: ${row.importance}`);
61
+ if (row.response_expectation)
62
+ parts.push(`Response expectation: ${row.response_expectation}`);
63
+ if (row.preferred_tone)
64
+ parts.push(`Preferred tone: ${row.preferred_tone}`);
65
+ if (parts.length > 0) {
66
+ update.run(parts.join("\n"), row.id);
67
+ }
52
68
  }
53
- }
54
69
 
55
- const migrated = rows.length;
56
- if (migrated > 0) {
57
- log.info({ migrated }, "Migrated contact metadata to notes field");
70
+ const migrated = rows.length;
71
+ if (migrated > 0) {
72
+ log.info({ migrated }, "Migrated contact metadata to notes field");
73
+ }
58
74
  }
59
75
 
60
- // Drop indexes that reference columns we're about to remove
76
+ // Drop indexes that reference columns we're about to remove.
77
+ // Must happen before the column drops — SQLite rejects dropping a column
78
+ // that is still referenced by an index.
61
79
  raw.exec(/*sql*/ `DROP INDEX IF EXISTS idx_contacts_importance`);
62
80
 
63
- raw.exec(/*sql*/ `ALTER TABLE contacts DROP COLUMN relationship`);
64
- raw.exec(/*sql*/ `ALTER TABLE contacts DROP COLUMN importance`);
65
- raw.exec(/*sql*/ `ALTER TABLE contacts DROP COLUMN response_expectation`);
66
- raw.exec(/*sql*/ `ALTER TABLE contacts DROP COLUMN preferred_tone`);
67
- }
81
+ // Drop each legacy column individually so partial completion is safe:
82
+ // on crash recovery the loop picks up only the columns that remain.
83
+ for (const col of remaining) {
84
+ raw.exec(/*sql*/ `ALTER TABLE contacts DROP COLUMN ${col}`);
85
+ }
86
+ });
68
87
  }