@vellumai/assistant 0.9.0 → 0.9.1-staging.1

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 (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -0,0 +1,62 @@
1
+ import { getLogger } from "../../util/logger.js";
2
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
3
+
4
+ const log = getLogger("migration-291");
5
+
6
+ /**
7
+ * Restores original platform-provided casing into address from external_user_id
8
+ * for channels where the raw platform ID is the canonical identity (Slack,
9
+ * Telegram, etc.). Phone and WhatsApp channels are excluded because their
10
+ * canonical form (E.164) may differ from the raw external_user_id.
11
+ *
12
+ * Idempotent: rows where address already equals external_user_id are no-ops.
13
+ */
14
+ export function migrateContactChannelsRenormalizeAddresses(
15
+ database: DrizzleDb,
16
+ ): void {
17
+ const raw = getSqliteFrom(database);
18
+
19
+ // Remove rows that would block normalization due to cross-column collisions.
20
+ raw.run(/*sql*/ `
21
+ DELETE FROM contact_channels
22
+ WHERE external_user_id IS NULL
23
+ AND id IN (
24
+ SELECT blocker.id
25
+ FROM contact_channels AS blocker
26
+ INNER JOIN contact_channels AS normalizer
27
+ ON normalizer.type = blocker.type
28
+ AND normalizer.external_user_id = blocker.address COLLATE NOCASE
29
+ AND normalizer.address != normalizer.external_user_id
30
+ AND normalizer.external_user_id IS NOT NULL
31
+ AND normalizer.id != blocker.id
32
+ )
33
+ `);
34
+
35
+ // Non-phone, non-email channels: restore original platform casing from
36
+ // external_user_id. Phone/WhatsApp are excluded because their canonical
37
+ // form (E.164 with '+' prefix) may differ from the raw external_user_id.
38
+ const nonEmailResult = raw.run(/*sql*/ `
39
+ UPDATE OR IGNORE contact_channels
40
+ SET address = external_user_id
41
+ WHERE external_user_id IS NOT NULL
42
+ AND address != external_user_id
43
+ AND type NOT IN ('email', 'phone', 'whatsapp')
44
+ `);
45
+
46
+ // Email channels: ensure address is lowercased (canonical per RFC 5321).
47
+ const emailResult = raw.run(/*sql*/ `
48
+ UPDATE OR IGNORE contact_channels
49
+ SET address = LOWER(external_user_id)
50
+ WHERE type = 'email'
51
+ AND external_user_id IS NOT NULL
52
+ AND address != LOWER(external_user_id)
53
+ `);
54
+
55
+ const totalChanges = nonEmailResult.changes + emailResult.changes;
56
+ if (totalChanges > 0) {
57
+ log.info(
58
+ { rowsUpdated: totalChanges },
59
+ "Re-normalized contact_channels addresses from external_user_id",
60
+ );
61
+ }
62
+ }
@@ -0,0 +1,311 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import { getSqliteFrom } from "../../db-connection.js";
7
+ import * as schema from "../../schema.js";
8
+ import { migrateContactChannelsRenormalizeAddresses } from "../291-contact-channels-renormalize-addresses.js";
9
+
10
+ interface ChannelRow {
11
+ id: string;
12
+ contact_id: string;
13
+ type: string;
14
+ address: string;
15
+ external_user_id: string | null;
16
+ status: string;
17
+ }
18
+
19
+ function createTestDb() {
20
+ const sqlite = new Database(":memory:");
21
+ sqlite.exec("PRAGMA journal_mode=WAL");
22
+ sqlite.exec("PRAGMA foreign_keys = ON");
23
+ return drizzle(sqlite, { schema });
24
+ }
25
+
26
+ function bootstrap(db: ReturnType<typeof createTestDb>): void {
27
+ const raw = getSqliteFrom(db);
28
+ raw.exec(/*sql*/ `
29
+ CREATE TABLE contacts (
30
+ id TEXT PRIMARY KEY,
31
+ display_name TEXT NOT NULL,
32
+ created_at INTEGER NOT NULL,
33
+ updated_at INTEGER NOT NULL,
34
+ role TEXT NOT NULL DEFAULT 'contact',
35
+ contact_type TEXT NOT NULL DEFAULT 'human'
36
+ )
37
+ `);
38
+ raw.exec(/*sql*/ `
39
+ CREATE TABLE contact_channels (
40
+ id TEXT PRIMARY KEY,
41
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
42
+ type TEXT NOT NULL,
43
+ address TEXT NOT NULL,
44
+ is_primary INTEGER NOT NULL DEFAULT 0,
45
+ external_user_id TEXT,
46
+ external_chat_id TEXT,
47
+ status TEXT NOT NULL DEFAULT 'unverified',
48
+ policy TEXT NOT NULL DEFAULT 'allow',
49
+ interaction_count INTEGER NOT NULL DEFAULT 0,
50
+ updated_at INTEGER,
51
+ created_at INTEGER NOT NULL
52
+ )
53
+ `);
54
+ raw.exec(/*sql*/ `
55
+ CREATE UNIQUE INDEX idx_contact_channels_type_address
56
+ ON contact_channels(type, address)
57
+ `);
58
+ }
59
+
60
+ function insertContact(raw: Database, id: string): void {
61
+ const now = Date.now();
62
+ raw
63
+ .query(
64
+ `INSERT INTO contacts (id, display_name, created_at, updated_at) VALUES (?, ?, ?, ?)`,
65
+ )
66
+ .run(id, `Contact ${id}`, now, now);
67
+ }
68
+
69
+ function insertChannel(
70
+ raw: Database,
71
+ opts: {
72
+ id: string;
73
+ contactId: string;
74
+ type: string;
75
+ address: string;
76
+ externalUserId: string | null;
77
+ status: string;
78
+ },
79
+ ): void {
80
+ const now = Date.now();
81
+ raw
82
+ .query(
83
+ `INSERT INTO contact_channels (id, contact_id, type, address, external_user_id, status, updated_at, created_at)
84
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
85
+ )
86
+ .run(
87
+ opts.id,
88
+ opts.contactId,
89
+ opts.type,
90
+ opts.address,
91
+ opts.externalUserId,
92
+ opts.status,
93
+ now,
94
+ now,
95
+ );
96
+ }
97
+
98
+ describe("migration 291 — renormalize addresses", () => {
99
+ test("restores Slack address casing from external_user_id", () => {
100
+ const db = createTestDb();
101
+ bootstrap(db);
102
+ const raw = getSqliteFrom(db);
103
+
104
+ insertContact(raw, "c1");
105
+ insertChannel(raw, {
106
+ id: "ch1",
107
+ contactId: "c1",
108
+ type: "slack",
109
+ address: "u12345abc",
110
+ externalUserId: "U12345ABC",
111
+ status: "active",
112
+ });
113
+
114
+ migrateContactChannelsRenormalizeAddresses(db);
115
+
116
+ const rows = raw
117
+ .prepare("SELECT * FROM contact_channels")
118
+ .all() as ChannelRow[];
119
+ expect(rows).toHaveLength(1);
120
+ expect(rows[0].address).toBe("U12345ABC");
121
+ });
122
+
123
+ test("preserves lowercase email addresses (does not overwrite with mixed-case external_user_id)", () => {
124
+ const db = createTestDb();
125
+ bootstrap(db);
126
+ const raw = getSqliteFrom(db);
127
+
128
+ insertContact(raw, "c1");
129
+ insertChannel(raw, {
130
+ id: "ch1",
131
+ contactId: "c1",
132
+ type: "email",
133
+ address: "user@example.com",
134
+ // generic-examples:ignore-next-line — reason: testing mixed-case email normalization
135
+ externalUserId: "User@Example.com",
136
+ status: "active",
137
+ });
138
+
139
+ migrateContactChannelsRenormalizeAddresses(db);
140
+
141
+ const rows = raw
142
+ .prepare("SELECT * FROM contact_channels")
143
+ .all() as ChannelRow[];
144
+ expect(rows).toHaveLength(1);
145
+ expect(rows[0].address).toBe("user@example.com");
146
+ });
147
+
148
+ test("lowercases email address if it was stored incorrectly", () => {
149
+ const db = createTestDb();
150
+ bootstrap(db);
151
+ const raw = getSqliteFrom(db);
152
+
153
+ insertContact(raw, "c1");
154
+ insertChannel(raw, {
155
+ id: "ch1",
156
+ contactId: "c1",
157
+ type: "email",
158
+ // generic-examples:ignore-next-line — reason: testing mixed-case email stored incorrectly
159
+ address: "User@Example.com",
160
+ // generic-examples:ignore-next-line — reason: testing mixed-case email stored incorrectly
161
+ externalUserId: "User@Example.com",
162
+ status: "active",
163
+ });
164
+
165
+ migrateContactChannelsRenormalizeAddresses(db);
166
+
167
+ const rows = raw
168
+ .prepare("SELECT * FROM contact_channels")
169
+ .all() as ChannelRow[];
170
+ expect(rows).toHaveLength(1);
171
+ expect(rows[0].address).toBe("user@example.com");
172
+ });
173
+
174
+ test("removes cross-column blocker before normalization", () => {
175
+ const db = createTestDb();
176
+ bootstrap(db);
177
+ const raw = getSqliteFrom(db);
178
+
179
+ insertContact(raw, "c1");
180
+ insertContact(raw, "c2");
181
+ // Blocker: NULL external_user_id, address matches normalizer's external_user_id
182
+ insertChannel(raw, {
183
+ id: "blocker",
184
+ contactId: "c1",
185
+ type: "slack",
186
+ address: "u12345abc",
187
+ externalUserId: null,
188
+ status: "unverified",
189
+ });
190
+ // Normalizer: has external_user_id that will become its address
191
+ insertChannel(raw, {
192
+ id: "normalizer",
193
+ contactId: "c2",
194
+ type: "slack",
195
+ address: "old-address",
196
+ externalUserId: "U12345ABC",
197
+ status: "active",
198
+ });
199
+
200
+ migrateContactChannelsRenormalizeAddresses(db);
201
+
202
+ const rows = raw
203
+ .prepare("SELECT * FROM contact_channels ORDER BY id")
204
+ .all() as ChannelRow[];
205
+ expect(rows).toHaveLength(1);
206
+ expect(rows[0].id).toBe("normalizer");
207
+ expect(rows[0].address).toBe("U12345ABC");
208
+ });
209
+
210
+ test("idempotent — safe to run twice", () => {
211
+ const db = createTestDb();
212
+ bootstrap(db);
213
+ const raw = getSqliteFrom(db);
214
+
215
+ insertContact(raw, "c1");
216
+ insertChannel(raw, {
217
+ id: "ch1",
218
+ contactId: "c1",
219
+ type: "slack",
220
+ address: "u12345abc",
221
+ externalUserId: "U12345ABC",
222
+ status: "active",
223
+ });
224
+
225
+ migrateContactChannelsRenormalizeAddresses(db);
226
+ migrateContactChannelsRenormalizeAddresses(db);
227
+
228
+ const rows = raw
229
+ .prepare("SELECT * FROM contact_channels")
230
+ .all() as ChannelRow[];
231
+ expect(rows).toHaveLength(1);
232
+ expect(rows[0].address).toBe("U12345ABC");
233
+ });
234
+
235
+ test("does not overwrite canonical E.164 phone address with raw external_user_id", () => {
236
+ const db = createTestDb();
237
+ bootstrap(db);
238
+ const raw = getSqliteFrom(db);
239
+
240
+ insertContact(raw, "c1");
241
+ // Phone channel whose address was canonicalized to E.164 (+1 prefix)
242
+ // but external_user_id still holds the raw 10-digit number.
243
+ insertChannel(raw, {
244
+ id: "ch1",
245
+ contactId: "c1",
246
+ type: "phone",
247
+ // generic-examples:ignore-next-line — reason: testing E.164 canonicalization preservation
248
+ address: "+15550101234",
249
+ // generic-examples:ignore-next-line — reason: testing raw 10-digit phone before E.164 normalization
250
+ externalUserId: "5550101234",
251
+ status: "active",
252
+ });
253
+
254
+ migrateContactChannelsRenormalizeAddresses(db);
255
+
256
+ const rows = raw
257
+ .prepare("SELECT * FROM contact_channels")
258
+ .all() as ChannelRow[];
259
+ expect(rows).toHaveLength(1);
260
+ // generic-examples:ignore-next-line — reason: verifying E.164 address preserved
261
+ expect(rows[0].address).toBe("+15550101234");
262
+ });
263
+
264
+ test("does not overwrite canonical WhatsApp address with raw external_user_id", () => {
265
+ const db = createTestDb();
266
+ bootstrap(db);
267
+ const raw = getSqliteFrom(db);
268
+
269
+ insertContact(raw, "c1");
270
+ insertChannel(raw, {
271
+ id: "ch1",
272
+ contactId: "c1",
273
+ type: "whatsapp",
274
+ address: "+447911123456",
275
+ externalUserId: "447911123456",
276
+ status: "active",
277
+ });
278
+
279
+ migrateContactChannelsRenormalizeAddresses(db);
280
+
281
+ const rows = raw
282
+ .prepare("SELECT * FROM contact_channels")
283
+ .all() as ChannelRow[];
284
+ expect(rows).toHaveLength(1);
285
+ expect(rows[0].address).toBe("+447911123456");
286
+ });
287
+
288
+ test("no-op when address already matches external_user_id", () => {
289
+ const db = createTestDb();
290
+ bootstrap(db);
291
+ const raw = getSqliteFrom(db);
292
+
293
+ insertContact(raw, "c1");
294
+ insertChannel(raw, {
295
+ id: "ch1",
296
+ contactId: "c1",
297
+ type: "slack",
298
+ address: "U12345ABC",
299
+ externalUserId: "U12345ABC",
300
+ status: "active",
301
+ });
302
+
303
+ migrateContactChannelsRenormalizeAddresses(db);
304
+
305
+ const rows = raw
306
+ .prepare("SELECT * FROM contact_channels")
307
+ .all() as ChannelRow[];
308
+ expect(rows).toHaveLength(1);
309
+ expect(rows[0].address).toBe("U12345ABC");
310
+ });
311
+ });
@@ -0,0 +1,52 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import * as schema from "../../schema.js";
7
+ import { type MigrationStep, runMigrationSteps } from "../run-migrations.js";
8
+
9
+ function createTestDb() {
10
+ const sqlite = new Database(":memory:");
11
+ sqlite.exec("PRAGMA journal_mode=WAL");
12
+ sqlite.exec("PRAGMA foreign_keys = ON");
13
+ return drizzle(sqlite, { schema });
14
+ }
15
+
16
+ describe("runMigrationSteps — checkpointing", () => {
17
+ // Skipped: documents the post-checkpointing contract and fails against the
18
+ // current behavior, which re-runs every forward step on every boot. Promote
19
+ // to `test` when true checkpointing of initial migrations lands.
20
+ test.skip("does not re-execute a step that was already applied on a prior run", () => {
21
+ /**
22
+ * Booting an already-migrated database must not re-run forward migration
23
+ * steps. With true checkpointing each step's body executes at most once
24
+ * across boots; without it every step re-runs on every startup — the
25
+ * ~200-step re-probe that floors daemon startup at ~30s.
26
+ */
27
+
28
+ // GIVEN three dummy migration steps that count how often their body runs
29
+ const calls = { a: 0, b: 0, c: 0 };
30
+ const steps: MigrationStep[] = [
31
+ function dummyMigrationA() {
32
+ calls.a++;
33
+ },
34
+ function dummyMigrationB() {
35
+ calls.b++;
36
+ },
37
+ function dummyMigrationC() {
38
+ calls.c++;
39
+ },
40
+ ];
41
+
42
+ // AND a database that has already been migrated once
43
+ const db = createTestDb();
44
+ runMigrationSteps(db, steps);
45
+
46
+ // WHEN the same steps run again against the already-migrated database
47
+ runMigrationSteps(db, steps);
48
+
49
+ // THEN no step body executes a second time
50
+ expect(calls).toEqual({ a: 1, b: 1, c: 1 });
51
+ });
52
+ });
@@ -282,6 +282,7 @@ export { migrateConversationOriginChannelIndex } from "./287-conversation-origin
282
282
  export { migrateBackfillOriginChannelFromBindings } from "./288-backfill-origin-channel-from-bindings.js";
283
283
  export { migrateContactChannelsUniqueExtUser } from "./289-contact-channels-unique-ext-user.js";
284
284
  export { migrateScheduleCapabilities } from "./290-schedule-capabilities.js";
285
+ export { migrateContactChannelsRenormalizeAddresses } from "./291-contact-channels-renormalize-addresses.js";
285
286
  export {
286
287
  MIGRATION_REGISTRY,
287
288
  type MigrationRegistryEntry,
@@ -0,0 +1,41 @@
1
+ import { getLogger } from "../../util/logger.js";
2
+ import type { DrizzleDb } from "../db-connection.js";
3
+
4
+ const log = getLogger("db-init");
5
+
6
+ /** A single forward migration step, identified for logging by its `.name`. */
7
+ export type MigrationStep = (database: DrizzleDb) => void;
8
+
9
+ export interface MigrationRunResult {
10
+ /** Steps whose body threw. */
11
+ failed: string[];
12
+ }
13
+
14
+ /**
15
+ * Run the ordered list of forward migration steps against the database.
16
+ *
17
+ * Individual step failures are caught and logged so one broken migration does
18
+ * not prevent independent later ones from succeeding.
19
+ */
20
+ export function runMigrationSteps(
21
+ database: DrizzleDb,
22
+ steps: MigrationStep[],
23
+ ): MigrationRunResult {
24
+ const failed: string[] = [];
25
+
26
+ for (const step of steps) {
27
+ try {
28
+ log.debug({ migration: step.name }, `Starting migration: ${step.name}`);
29
+ step(database);
30
+ log.debug({ migration: step.name }, `Migration succeeded: ${step.name}`);
31
+ } catch (err) {
32
+ failed.push(step.name);
33
+ log.error(
34
+ { err, migration: step.name },
35
+ `Migration failed: ${step.name}`,
36
+ );
37
+ }
38
+ }
39
+
40
+ return { failed };
41
+ }
@@ -220,7 +220,7 @@ export function validateMigrationState(
220
220
  // registry entry — these are from a newer version of the daemon.
221
221
  //
222
222
  // The memory_checkpoints table is a general-purpose key-value store also
223
- // used by non-migration subsystems (e.g., "identity:intro:text",
223
+ // used by non-migration subsystems (e.g., "empty_state:greeting:text",
224
224
  // "conversation_starters:item_count_at_last_gen"). Filter to only keys
225
225
  // that follow migration naming conventions before comparing against the
226
226
  // registry to avoid false-positive warnings.
@@ -42,10 +42,6 @@ export const contactChannels = sqliteTable(
42
42
  createdAt: integer("created_at").notNull(),
43
43
  },
44
44
  (table) => [
45
- index("idx_contact_channels_type_ext_user").on(
46
- table.type,
47
- table.externalUserId,
48
- ),
49
45
  index("idx_contact_channels_type_ext_chat").on(
50
46
  table.type,
51
47
  table.externalChatId,
@@ -246,7 +246,7 @@ async function resolveUserInfoUncached(
246
246
  try {
247
247
  const result = findContactChannel({
248
248
  channelType: "slack",
249
- externalUserId: userId,
249
+ address: userId,
250
250
  });
251
251
  if (result) {
252
252
  contactDisplayName = result.contact.displayName;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared adapter utilities — functions used by multiple channel adapters.
3
+ */
4
+
5
+ import { isConversationSeedSane } from "../conversation-seed-composer.js";
6
+ import { nonEmpty } from "../notification-utils.js";
7
+ import type { ChannelDeliveryPayload } from "../types.js";
8
+
9
+ /**
10
+ * Resolve the primary message text for a notification delivery.
11
+ *
12
+ * Cascade: deliveryText → conversationSeedMessage → body → title → event name.
13
+ */
14
+ export function resolveMessageText(payload: ChannelDeliveryPayload): string {
15
+ const deliveryText = nonEmpty(payload.copy.deliveryText);
16
+ if (deliveryText) return deliveryText;
17
+
18
+ if (isConversationSeedSane(payload.copy.conversationSeedMessage)) {
19
+ return payload.copy.conversationSeedMessage.trim();
20
+ }
21
+
22
+ const body = nonEmpty(payload.copy.body);
23
+ if (body) return body;
24
+
25
+ const title = nonEmpty(payload.copy.title);
26
+ if (title) return title;
27
+
28
+ return payload.sourceEventName.replace(/[._]/g, " ");
29
+ }
@@ -20,14 +20,13 @@ import {
20
20
  buildAccessRequestWarnings,
21
21
  buildSlackMessagePermalink,
22
22
  isSlackDmConversation,
23
- parseAccessRequestPayload,
24
23
  type ParsedAccessRequestPayload,
25
24
  } from "../access-request-copy.js";
26
- import { isConversationSeedSane } from "../conversation-seed-composer.js";
27
25
  import {
28
26
  nonEmpty,
29
27
  sanitizeIdentityField,
30
28
  sanitizeMessagePreview,
29
+ truncate,
31
30
  } from "../notification-utils.js";
32
31
  import type {
33
32
  ChannelAdapter,
@@ -38,36 +37,10 @@ import type {
38
37
  DeliveryResult,
39
38
  NotificationChannel,
40
39
  } from "../types.js";
40
+ import { resolveMessageText } from "./shared.js";
41
41
 
42
42
  const log = getLogger("notif-adapter-slack");
43
43
 
44
- // ---------------------------------------------------------------------------
45
- // Text resolution
46
- // ---------------------------------------------------------------------------
47
-
48
- function resolveSlackMessageText(payload: ChannelDeliveryPayload): string {
49
- const deliveryText = nonEmpty(payload.copy.deliveryText);
50
- if (deliveryText) return deliveryText;
51
-
52
- if (isConversationSeedSane(payload.copy.conversationSeedMessage)) {
53
- return payload.copy.conversationSeedMessage.trim();
54
- }
55
-
56
- const body = nonEmpty(payload.copy.body);
57
- if (body) return body;
58
-
59
- const title = nonEmpty(payload.copy.title);
60
- if (title) return title;
61
-
62
- return payload.sourceEventName.replace(/[._]/g, " ");
63
- }
64
-
65
- /** Truncate to `maxLength`, appending "…" when exceeded. */
66
- function truncate(text: string, maxLength: number): string {
67
- if (text.length <= maxLength) return text;
68
- return text.slice(0, maxLength - 1) + "…";
69
- }
70
-
71
44
  // ---------------------------------------------------------------------------
72
45
  // Slack Card block builders for approval notifications
73
46
  // ---------------------------------------------------------------------------
@@ -193,7 +166,7 @@ function buildAccessRequestCardBlocks(
193
166
  payload: ChannelDeliveryPayload,
194
167
  ): unknown[] {
195
168
  const approval = payload.approvalContext!;
196
- const p = parseAccessRequestPayload(payload.contextPayload!);
169
+ const p = payload.accessRequestContext!;
197
170
  const blocks: unknown[] = [];
198
171
 
199
172
  const subtitle = buildAccessRequestSubtitle(p);
@@ -314,7 +287,7 @@ function buildApprovalNotificationBlocks(
314
287
  ): unknown[] {
315
288
  if (
316
289
  payload.sourceEventName === "ingress.access_request" &&
317
- payload.contextPayload != null
290
+ payload.accessRequestContext != null
318
291
  ) {
319
292
  return buildAccessRequestCardBlocks(payload);
320
293
  }
@@ -345,7 +318,7 @@ export class SlackAdapter implements ChannelAdapter {
345
318
  };
346
319
  }
347
320
 
348
- const messageText = resolveSlackMessageText(payload);
321
+ const messageText = resolveMessageText(payload);
349
322
 
350
323
  try {
351
324
  const result = payload.approvalContext
@@ -11,8 +11,6 @@
11
11
  import { sendTelegramReply } from "../../messaging/providers/telegram-bot/send.js";
12
12
  import { ConfigError } from "../../util/errors.js";
13
13
  import { getLogger } from "../../util/logger.js";
14
- import { isConversationSeedSane } from "../conversation-seed-composer.js";
15
- import { nonEmpty } from "../notification-utils.js";
16
14
  import type {
17
15
  ChannelAdapter,
18
16
  ChannelDeliveryPayload,
@@ -20,26 +18,10 @@ import type {
20
18
  DeliveryResult,
21
19
  NotificationChannel,
22
20
  } from "../types.js";
21
+ import { resolveMessageText } from "./shared.js";
23
22
 
24
23
  const log = getLogger("notif-adapter-telegram");
25
24
 
26
- function resolveTelegramMessageText(payload: ChannelDeliveryPayload): string {
27
- const deliveryText = nonEmpty(payload.copy.deliveryText);
28
- if (deliveryText) return deliveryText;
29
-
30
- if (isConversationSeedSane(payload.copy.conversationSeedMessage)) {
31
- return payload.copy.conversationSeedMessage.trim();
32
- }
33
-
34
- const body = nonEmpty(payload.copy.body);
35
- if (body) return body;
36
-
37
- const title = nonEmpty(payload.copy.title);
38
- if (title) return title;
39
-
40
- return payload.sourceEventName.replace(/[._]/g, " ");
41
- }
42
-
43
25
  export class TelegramAdapter implements ChannelAdapter {
44
26
  readonly channel: NotificationChannel = "telegram";
45
27
 
@@ -59,7 +41,7 @@ export class TelegramAdapter implements ChannelAdapter {
59
41
  };
60
42
  }
61
43
 
62
- const messageText = resolveTelegramMessageText(payload);
44
+ const messageText = resolveMessageText(payload);
63
45
  const approval = payload.approvalContext;
64
46
 
65
47
  try {