@vellumai/assistant 0.5.6 → 0.5.7

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 (305) hide show
  1. package/.env.example +16 -2
  2. package/ARCHITECTURE.md +6 -75
  3. package/Dockerfile +1 -1
  4. package/README.md +0 -2
  5. package/bun.lock +0 -414
  6. package/docs/architecture/keychain-broker.md +45 -240
  7. package/docs/architecture/security.md +0 -17
  8. package/docs/credential-execution-service.md +2 -2
  9. package/node_modules/@vellumai/ces-contracts/package.json +1 -0
  10. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +119 -0
  11. package/node_modules/@vellumai/credential-storage/package.json +1 -0
  12. package/node_modules/@vellumai/egress-proxy/package.json +1 -0
  13. package/package.json +2 -3
  14. package/src/__tests__/actor-token-service.test.ts +0 -114
  15. package/src/__tests__/assistant-feature-flags-integration.test.ts +30 -29
  16. package/src/__tests__/browser-skill-endstate.test.ts +6 -5
  17. package/src/__tests__/btw-routes.test.ts +0 -39
  18. package/src/__tests__/call-domain.test.ts +0 -128
  19. package/src/__tests__/ces-rpc-credential-backend.test.ts +199 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +0 -5
  21. package/src/__tests__/channel-readiness-service.test.ts +1 -60
  22. package/src/__tests__/checker.test.ts +4 -2
  23. package/src/__tests__/cli-command-risk-guard.test.ts +112 -0
  24. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  25. package/src/__tests__/config-schema.test.ts +1 -1
  26. package/src/__tests__/conversation-attention-telegram.test.ts +0 -5
  27. package/src/__tests__/conversation-init.benchmark.test.ts +0 -2
  28. package/src/__tests__/conversation-skill-tools.test.ts +0 -54
  29. package/src/__tests__/conversation-title-service.test.ts +87 -0
  30. package/src/__tests__/credential-execution-feature-gates.test.ts +28 -14
  31. package/src/__tests__/credential-execution-managed-contract.test.ts +33 -18
  32. package/src/__tests__/credential-security-e2e.test.ts +0 -66
  33. package/src/__tests__/credential-security-invariants.test.ts +4 -45
  34. package/src/__tests__/credentials-cli.test.ts +78 -0
  35. package/src/__tests__/db-migration-rollback.test.ts +2015 -1
  36. package/src/__tests__/docker-signing-key-bootstrap.test.ts +34 -143
  37. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -4
  38. package/src/__tests__/guardian-routing-state.test.ts +0 -5
  39. package/src/__tests__/host-shell-tool.test.ts +6 -7
  40. package/src/__tests__/http-user-message-parity.test.ts +3 -103
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -4
  42. package/src/__tests__/inline-skill-load-permissions.test.ts +6 -8
  43. package/src/__tests__/intent-routing.test.ts +0 -13
  44. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +178 -0
  45. package/src/__tests__/keychain-broker-client.test.ts +161 -22
  46. package/src/__tests__/memory-jobs-worker-backoff.test.ts +150 -0
  47. package/src/__tests__/migration-export-http.test.ts +2 -2
  48. package/src/__tests__/migration-import-commit-http.test.ts +2 -2
  49. package/src/__tests__/migration-import-preflight-http.test.ts +2 -2
  50. package/src/__tests__/migration-validate-http.test.ts +2 -2
  51. package/src/__tests__/non-member-access-request.test.ts +0 -5
  52. package/src/__tests__/notification-decision-fallback.test.ts +4 -0
  53. package/src/__tests__/notification-decision-identity.test.ts +4 -0
  54. package/src/__tests__/permission-types.test.ts +1 -0
  55. package/src/__tests__/provider-managed-proxy-integration.test.ts +5 -6
  56. package/src/__tests__/qdrant-manager.test.ts +28 -2
  57. package/src/__tests__/registry.test.ts +0 -6
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -4
  59. package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -4
  60. package/src/__tests__/secure-keys.test.ts +83 -263
  61. package/src/__tests__/shell-identity.test.ts +96 -6
  62. package/src/__tests__/skill-feature-flags-integration.test.ts +22 -14
  63. package/src/__tests__/skill-feature-flags.test.ts +46 -45
  64. package/src/__tests__/skill-load-feature-flag.test.ts +7 -10
  65. package/src/__tests__/skill-load-inline-command.test.ts +8 -12
  66. package/src/__tests__/skill-load-inline-includes.test.ts +6 -10
  67. package/src/__tests__/skill-load-tool.test.ts +0 -2
  68. package/src/__tests__/skill-projection-feature-flag.test.ts +33 -29
  69. package/src/__tests__/skills.test.ts +0 -2
  70. package/src/__tests__/slack-inbound-verification.test.ts +0 -4
  71. package/src/__tests__/suggestion-routes.test.ts +1 -32
  72. package/src/__tests__/system-prompt.test.ts +0 -1
  73. package/src/__tests__/tool-executor-shell-integration.test.ts +5 -3
  74. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -5
  75. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -4
  76. package/src/__tests__/update-bulletin.test.ts +0 -2
  77. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +6 -9
  78. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -6
  79. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +252 -0
  80. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +218 -0
  81. package/src/__tests__/workspace-migration-down-functions.test.ts +1009 -0
  82. package/src/__tests__/workspace-migrations-runner.test.ts +114 -0
  83. package/src/calls/audio-store.test.ts +97 -0
  84. package/src/calls/audio-store.ts +205 -0
  85. package/src/calls/call-controller.ts +85 -7
  86. package/src/calls/call-domain.ts +3 -0
  87. package/src/calls/call-store.ts +10 -3
  88. package/src/calls/fish-audio-client.ts +117 -0
  89. package/src/calls/relay-server.ts +27 -0
  90. package/src/calls/twilio-routes.ts +2 -1
  91. package/src/calls/types.ts +1 -0
  92. package/src/calls/voice-ingress-preflight.ts +0 -42
  93. package/src/calls/voice-quality.ts +26 -5
  94. package/src/calls/voice-session-bridge.ts +6 -12
  95. package/src/cli/commands/config.ts +1 -4
  96. package/src/cli/commands/credentials.ts +34 -4
  97. package/src/cli/commands/oauth/index.ts +7 -0
  98. package/src/cli/commands/oauth/platform.ts +179 -0
  99. package/src/cli/commands/platform.ts +3 -3
  100. package/src/config/assistant-feature-flags.ts +186 -5
  101. package/src/config/bundled-skills/messaging/SKILL.md +5 -5
  102. package/src/config/bundled-skills/phone-calls/TOOLS.json +4 -0
  103. package/src/config/bundled-skills/settings/TOOLS.json +2 -2
  104. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +42 -0
  105. package/src/config/bundled-tool-registry.ts +1 -11
  106. package/src/config/env-registry.ts +1 -1
  107. package/src/config/env.ts +8 -14
  108. package/src/config/feature-flag-registry.json +48 -8
  109. package/src/config/loader.ts +98 -31
  110. package/src/config/schema.ts +4 -13
  111. package/src/config/schemas/calls.ts +13 -0
  112. package/src/config/schemas/fish-audio.ts +39 -0
  113. package/src/config/schemas/security.ts +0 -4
  114. package/src/config/types.ts +0 -1
  115. package/src/contacts/contact-store.ts +39 -0
  116. package/src/contacts/types.ts +2 -0
  117. package/src/credential-execution/approval-bridge.ts +1 -0
  118. package/src/credential-execution/executable-discovery.ts +28 -4
  119. package/src/credential-execution/feature-gates.ts +16 -0
  120. package/src/credential-execution/process-manager.ts +38 -0
  121. package/src/daemon/assistant-attachments.ts +9 -0
  122. package/src/daemon/config-watcher.ts +5 -0
  123. package/src/daemon/conversation-tool-setup.ts +0 -105
  124. package/src/daemon/conversation.ts +10 -1
  125. package/src/daemon/handlers/config-vercel.ts +92 -0
  126. package/src/daemon/handlers/skills.ts +2 -15
  127. package/src/daemon/install-symlink.ts +195 -0
  128. package/src/daemon/lifecycle.ts +227 -51
  129. package/src/daemon/message-types/conversations.ts +3 -4
  130. package/src/daemon/message-types/diagnostics.ts +3 -22
  131. package/src/daemon/message-types/messages.ts +0 -2
  132. package/src/daemon/message-types/upgrades.ts +8 -0
  133. package/src/daemon/server.ts +30 -92
  134. package/src/events/domain-events.ts +2 -1
  135. package/src/inbound/platform-callback-registration.ts +3 -3
  136. package/src/instrument.ts +8 -5
  137. package/src/memory/conversation-title-service.ts +50 -1
  138. package/src/memory/db-init.ts +12 -0
  139. package/src/memory/items-extractor.ts +15 -1
  140. package/src/memory/job-handlers/conversation-starters.ts +4 -1
  141. package/src/memory/jobs-store.ts +30 -5
  142. package/src/memory/jobs-worker.ts +31 -7
  143. package/src/memory/migrations/001-job-deferrals.ts +19 -0
  144. package/src/memory/migrations/004-entity-relation-dedup.ts +10 -0
  145. package/src/memory/migrations/005-fingerprint-scope-unique.ts +76 -0
  146. package/src/memory/migrations/006-scope-salted-fingerprints.ts +50 -0
  147. package/src/memory/migrations/007-assistant-id-to-self.ts +10 -0
  148. package/src/memory/migrations/008-remove-assistant-id-columns.ts +34 -0
  149. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +26 -0
  150. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +10 -0
  151. package/src/memory/migrations/015-drop-active-search-index.ts +17 -0
  152. package/src/memory/migrations/019-notification-tables-schema-migration.ts +12 -0
  153. package/src/memory/migrations/020-rename-macos-ios-channel-to-vellum.ts +121 -0
  154. package/src/memory/migrations/024-embedding-vector-blob.ts +74 -0
  155. package/src/memory/migrations/026a-embeddings-nullable-vector-json.ts +82 -0
  156. package/src/memory/migrations/036-normalize-phone-identities.ts +11 -0
  157. package/src/memory/migrations/116-messages-fts.ts +106 -1
  158. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +52 -0
  159. package/src/memory/migrations/127-guardian-principal-id-not-null.ts +77 -0
  160. package/src/memory/migrations/134-contacts-notes-column.ts +13 -0
  161. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +20 -0
  162. package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -0
  163. package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +13 -0
  164. package/src/memory/migrations/141-rename-verification-table.ts +54 -0
  165. package/src/memory/migrations/142-rename-verification-session-id-column.ts +25 -0
  166. package/src/memory/migrations/143-rename-guardian-verification-values.ts +35 -0
  167. package/src/memory/migrations/144-rename-voice-to-phone.ts +136 -0
  168. package/src/memory/migrations/145-drop-accounts-table.ts +32 -0
  169. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +14 -1
  170. package/src/memory/migrations/148-drop-reminders-table.ts +35 -1
  171. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +69 -1
  172. package/src/memory/migrations/162-guardian-timestamps-epoch-ms.ts +290 -0
  173. package/src/memory/migrations/169-rename-gmail-provider-key-to-google.ts +51 -1
  174. package/src/memory/migrations/174-rename-thread-starters-table.ts +47 -1
  175. package/src/memory/migrations/176-drop-capability-card-state.ts +13 -0
  176. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +16 -0
  177. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +28 -1
  178. package/src/memory/migrations/190-call-session-skip-disclosure.ts +15 -0
  179. package/src/memory/migrations/191-backfill-audio-attachment-mime-types.ts +64 -0
  180. package/src/memory/migrations/192-contacts-user-file-column.ts +15 -0
  181. package/src/memory/migrations/index.ts +4 -0
  182. package/src/memory/migrations/registry.ts +90 -0
  183. package/src/memory/migrations/validate-migration-state.ts +137 -11
  184. package/src/memory/qdrant-circuit-breaker.ts +9 -0
  185. package/src/memory/qdrant-manager.ts +64 -7
  186. package/src/memory/schema/calls.ts +1 -0
  187. package/src/memory/schema/contacts.ts +1 -0
  188. package/src/notifications/decision-engine.ts +4 -1
  189. package/src/oauth/connection-resolver.ts +6 -4
  190. package/src/permissions/checker.ts +0 -38
  191. package/src/permissions/shell-identity.ts +76 -22
  192. package/src/permissions/types.ts +4 -2
  193. package/src/platform/client.ts +35 -7
  194. package/src/prompts/persona-resolver.ts +138 -0
  195. package/src/prompts/system-prompt.ts +36 -4
  196. package/src/prompts/templates/users/default.md +1 -0
  197. package/src/providers/registry.ts +27 -40
  198. package/src/runtime/auth/__tests__/credential-service.test.ts +0 -1
  199. package/src/runtime/auth/__tests__/external-assistant-id.test.ts +13 -68
  200. package/src/runtime/auth/external-assistant-id.ts +13 -59
  201. package/src/runtime/auth/route-policy.ts +15 -1
  202. package/src/runtime/auth/token-service.ts +43 -138
  203. package/src/runtime/channel-readiness-service.ts +1 -16
  204. package/src/runtime/http-server.ts +27 -2
  205. package/src/runtime/middleware/error-handler.ts +1 -9
  206. package/src/runtime/routes/audio-routes.ts +40 -0
  207. package/src/runtime/routes/btw-routes.ts +0 -17
  208. package/src/runtime/routes/conversation-query-routes.ts +63 -1
  209. package/src/runtime/routes/conversation-routes.ts +4 -44
  210. package/src/runtime/routes/diagnostics-routes.ts +1 -477
  211. package/src/runtime/routes/identity-routes.ts +18 -29
  212. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +4 -33
  213. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +1 -1
  214. package/src/runtime/routes/integrations/vercel.ts +89 -0
  215. package/src/runtime/routes/log-export-routes.ts +5 -0
  216. package/src/runtime/routes/memory-item-routes.ts +24 -6
  217. package/src/runtime/routes/migration-rollback-routes.ts +209 -0
  218. package/src/runtime/routes/migration-routes.ts +17 -1
  219. package/src/runtime/routes/notification-routes.ts +58 -0
  220. package/src/runtime/routes/schedule-routes.ts +65 -0
  221. package/src/runtime/routes/settings-routes.ts +41 -1
  222. package/src/runtime/routes/tts-routes.ts +86 -0
  223. package/src/runtime/routes/upgrade-broadcast-routes.ts +26 -2
  224. package/src/runtime/routes/workspace-commit-routes.ts +62 -0
  225. package/src/runtime/routes/workspace-routes.test.ts +22 -1
  226. package/src/runtime/routes/workspace-routes.ts +1 -1
  227. package/src/runtime/routes/workspace-utils.ts +86 -2
  228. package/src/security/ces-credential-client.ts +59 -22
  229. package/src/security/ces-rpc-credential-backend.ts +85 -0
  230. package/src/security/credential-backend.ts +12 -88
  231. package/src/security/keychain-broker-client.ts +10 -2
  232. package/src/security/secure-keys.ts +94 -113
  233. package/src/skills/catalog-install.ts +13 -7
  234. package/src/telemetry/usage-telemetry-reporter.ts +4 -2
  235. package/src/tools/calls/call-start.ts +1 -0
  236. package/src/tools/executor.ts +0 -4
  237. package/src/tools/network/script-proxy/session-manager.ts +19 -4
  238. package/src/tools/network/web-fetch.ts +3 -1
  239. package/src/tools/skills/execute.ts +1 -1
  240. package/src/tools/types.ts +0 -8
  241. package/src/util/errors.ts +0 -12
  242. package/src/util/platform.ts +3 -50
  243. package/src/workspace/git-service.ts +5 -2
  244. package/src/workspace/migrations/001-avatar-rename.ts +15 -0
  245. package/src/workspace/migrations/003-seed-device-id.ts +17 -1
  246. package/src/workspace/migrations/004-extract-collect-usage-data.ts +33 -0
  247. package/src/workspace/migrations/005-add-send-diagnostics.ts +3 -0
  248. package/src/workspace/migrations/006-services-config.ts +49 -0
  249. package/src/workspace/migrations/007-web-search-provider-rename.ts +27 -0
  250. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +3 -0
  251. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +4 -0
  252. package/src/workspace/migrations/010-app-dir-rename.ts +78 -0
  253. package/src/workspace/migrations/011-backfill-installation-id.ts +11 -0
  254. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +44 -0
  255. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +5 -0
  256. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +153 -0
  257. package/src/workspace/migrations/016-extract-feature-flags-to-protected.ts +156 -0
  258. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +150 -0
  259. package/src/workspace/migrations/017-seed-persona-dirs.ts +95 -0
  260. package/src/workspace/migrations/migrate-to-workspace-volume.ts +23 -1
  261. package/src/workspace/migrations/registry.ts +8 -0
  262. package/src/workspace/migrations/runner.ts +106 -2
  263. package/src/workspace/migrations/types.ts +4 -0
  264. package/src/__tests__/claude-code-skill-regression.test.ts +0 -206
  265. package/src/__tests__/claude-code-tool-profiles.test.ts +0 -99
  266. package/src/__tests__/diagnostics-export.test.ts +0 -288
  267. package/src/__tests__/local-gateway-health.test.ts +0 -209
  268. package/src/__tests__/secret-ingress-handler.test.ts +0 -120
  269. package/src/__tests__/swarm-conversation-integration.test.ts +0 -358
  270. package/src/__tests__/swarm-dag-pathological.test.ts +0 -547
  271. package/src/__tests__/swarm-orchestrator.test.ts +0 -463
  272. package/src/__tests__/swarm-plan-validator.test.ts +0 -384
  273. package/src/__tests__/swarm-recursion.test.ts +0 -197
  274. package/src/__tests__/swarm-router-planner.test.ts +0 -234
  275. package/src/__tests__/swarm-tool.test.ts +0 -185
  276. package/src/__tests__/swarm-worker-backend.test.ts +0 -144
  277. package/src/__tests__/swarm-worker-runner.test.ts +0 -288
  278. package/src/commands/__tests__/cc-command-registry.test.ts +0 -396
  279. package/src/commands/cc-command-registry.ts +0 -248
  280. package/src/config/bundled-skills/claude-code/SKILL.md +0 -53
  281. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -47
  282. package/src/config/bundled-skills/claude-code/tools/claude-code.ts +0 -12
  283. package/src/config/bundled-skills/orchestration/SKILL.md +0 -33
  284. package/src/config/bundled-skills/orchestration/TOOLS.json +0 -35
  285. package/src/config/bundled-skills/orchestration/tools/swarm-delegate.ts +0 -12
  286. package/src/config/schemas/swarm.ts +0 -82
  287. package/src/logfire.ts +0 -135
  288. package/src/runtime/local-gateway-health.ts +0 -275
  289. package/src/security/secret-ingress.ts +0 -68
  290. package/src/swarm/backend-claude-code.ts +0 -225
  291. package/src/swarm/checkpoint.ts +0 -137
  292. package/src/swarm/graph-utils.ts +0 -53
  293. package/src/swarm/index.ts +0 -55
  294. package/src/swarm/limits.ts +0 -66
  295. package/src/swarm/orchestrator.ts +0 -424
  296. package/src/swarm/plan-validator.ts +0 -117
  297. package/src/swarm/router-planner.ts +0 -162
  298. package/src/swarm/router-prompts.ts +0 -39
  299. package/src/swarm/synthesizer.ts +0 -81
  300. package/src/swarm/types.ts +0 -72
  301. package/src/swarm/worker-backend.ts +0 -131
  302. package/src/swarm/worker-prompts.ts +0 -80
  303. package/src/swarm/worker-runner.ts +0 -170
  304. package/src/tools/claude-code/claude-code.ts +0 -610
  305. package/src/tools/swarm/delegate.ts +0 -205
@@ -0,0 +1,1009 @@
1
+ /**
2
+ * Tests for workspace migration down() rollback functions.
3
+ *
4
+ * Each migration with a meaningful reverse operation is tested for:
5
+ * 1. Correctness: down() after run() restores pre-migration state
6
+ * 2. Idempotency: calling down() twice produces the same result
7
+ * 3. No-op safety: down() on a workspace where run() never executed
8
+ */
9
+
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Mocks — must precede all migration imports
23
+ // ---------------------------------------------------------------------------
24
+
25
+ // Mock secure-keys (used by 006-services-config)
26
+ mock.module("../security/secure-keys.js", () => ({
27
+ getProviderKeyAsync: async () => null,
28
+ getSecureKeyAsync: async () => null,
29
+ }));
30
+
31
+ mock.module("../security/credential-key.js", () => ({
32
+ credentialKey: (...args: string[]) => args.join(":"),
33
+ }));
34
+
35
+ // Mock getRootDir for 016-extract-feature-flags-to-protected
36
+ let mockRootDir: string = "/tmp/mock-root";
37
+ mock.module("../util/platform.js", () => ({
38
+ getRootDir: () => mockRootDir,
39
+ getDataDir: () => join(mockRootDir, "workspace", "data"),
40
+ getWorkspaceDir: () => join(mockRootDir, "workspace"),
41
+ }));
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Imports — after mocking
45
+ // ---------------------------------------------------------------------------
46
+
47
+ import { avatarRenameMigration } from "../workspace/migrations/001-avatar-rename.js";
48
+ import { extractCollectUsageDataMigration } from "../workspace/migrations/004-extract-collect-usage-data.js";
49
+ import { servicesConfigMigration } from "../workspace/migrations/006-services-config.js";
50
+ import { webSearchProviderRenameMigration } from "../workspace/migrations/007-web-search-provider-rename.js";
51
+ import { appDirRenameMigration } from "../workspace/migrations/010-app-dir-rename.js";
52
+ import { renameConversationDiskViewDirsMigration } from "../workspace/migrations/012-rename-conversation-disk-view-dirs.js";
53
+ import { extractFeatureFlagsToProtectedMigration } from "../workspace/migrations/016-extract-feature-flags-to-protected.js";
54
+ import { seedPersonaDirsMigration } from "../workspace/migrations/017-seed-persona-dirs.js";
55
+ import { migrateToWorkspaceVolumeMigration } from "../workspace/migrations/migrate-to-workspace-volume.js";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ let workspaceDir: string;
62
+
63
+ function freshWorkspace(): string {
64
+ const dir = join(
65
+ tmpdir(),
66
+ `vellum-migration-down-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
67
+ );
68
+ mkdirSync(dir, { recursive: true });
69
+ return dir;
70
+ }
71
+
72
+ function writeConfig(data: Record<string, unknown>): void {
73
+ writeFileSync(
74
+ join(workspaceDir, "config.json"),
75
+ JSON.stringify(data, null, 2) + "\n",
76
+ );
77
+ }
78
+
79
+ function readConfig(): Record<string, unknown> {
80
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Setup / teardown
85
+ // ---------------------------------------------------------------------------
86
+
87
+ beforeEach(() => {
88
+ workspaceDir = freshWorkspace();
89
+ });
90
+
91
+ afterEach(() => {
92
+ if (existsSync(workspaceDir)) {
93
+ rmSync(workspaceDir, { recursive: true, force: true });
94
+ }
95
+ // Clean up any mock root dir created for feature-flags tests
96
+ if (existsSync(mockRootDir)) {
97
+ rmSync(mockRootDir, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // 001-avatar-rename down()
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("001-avatar-rename down()", () => {
106
+ test("renames files back to old names after run()", () => {
107
+ const avatarDir = join(workspaceDir, "data", "avatar");
108
+ mkdirSync(avatarDir, { recursive: true });
109
+
110
+ // Set up pre-migration state: old file names
111
+ writeFileSync(join(avatarDir, "custom-avatar.png"), "image-data");
112
+ writeFileSync(
113
+ join(avatarDir, "avatar-components.json"),
114
+ '{"traits": true}',
115
+ );
116
+
117
+ // Run forward migration
118
+ avatarRenameMigration.run(workspaceDir);
119
+
120
+ // Verify forward migration worked
121
+ expect(existsSync(join(avatarDir, "avatar-image.png"))).toBe(true);
122
+ expect(existsSync(join(avatarDir, "character-traits.json"))).toBe(true);
123
+ expect(existsSync(join(avatarDir, "custom-avatar.png"))).toBe(false);
124
+ expect(existsSync(join(avatarDir, "avatar-components.json"))).toBe(false);
125
+
126
+ // Run down() to reverse
127
+ avatarRenameMigration.down!(workspaceDir);
128
+
129
+ // Verify reversal
130
+ expect(existsSync(join(avatarDir, "custom-avatar.png"))).toBe(true);
131
+ expect(existsSync(join(avatarDir, "avatar-components.json"))).toBe(true);
132
+ expect(existsSync(join(avatarDir, "avatar-image.png"))).toBe(false);
133
+ expect(existsSync(join(avatarDir, "character-traits.json"))).toBe(false);
134
+
135
+ // Verify content preserved
136
+ expect(readFileSync(join(avatarDir, "custom-avatar.png"), "utf-8")).toBe(
137
+ "image-data",
138
+ );
139
+ expect(
140
+ readFileSync(join(avatarDir, "avatar-components.json"), "utf-8"),
141
+ ).toBe('{"traits": true}');
142
+ });
143
+
144
+ test("idempotent: calling down() twice produces same result", () => {
145
+ const avatarDir = join(workspaceDir, "data", "avatar");
146
+ mkdirSync(avatarDir, { recursive: true });
147
+
148
+ writeFileSync(join(avatarDir, "avatar-image.png"), "image-data");
149
+ writeFileSync(join(avatarDir, "character-traits.json"), '{"traits": true}');
150
+
151
+ avatarRenameMigration.down!(workspaceDir);
152
+ avatarRenameMigration.down!(workspaceDir);
153
+
154
+ expect(existsSync(join(avatarDir, "custom-avatar.png"))).toBe(true);
155
+ expect(existsSync(join(avatarDir, "avatar-components.json"))).toBe(true);
156
+ expect(existsSync(join(avatarDir, "avatar-image.png"))).toBe(false);
157
+ expect(existsSync(join(avatarDir, "character-traits.json"))).toBe(false);
158
+ });
159
+
160
+ test("no-op when forward migration never ran (no files)", () => {
161
+ const avatarDir = join(workspaceDir, "data", "avatar");
162
+ mkdirSync(avatarDir, { recursive: true });
163
+
164
+ // No files exist — down() should be a no-op
165
+ avatarRenameMigration.down!(workspaceDir);
166
+
167
+ expect(existsSync(join(avatarDir, "custom-avatar.png"))).toBe(false);
168
+ expect(existsSync(join(avatarDir, "avatar-image.png"))).toBe(false);
169
+ });
170
+
171
+ test("no-op when avatar directory does not exist", () => {
172
+ // No avatar dir at all — should not throw
173
+ avatarRenameMigration.down!(workspaceDir);
174
+ });
175
+
176
+ test("partial: only image exists in new name", () => {
177
+ const avatarDir = join(workspaceDir, "data", "avatar");
178
+ mkdirSync(avatarDir, { recursive: true });
179
+
180
+ writeFileSync(join(avatarDir, "avatar-image.png"), "image-data");
181
+
182
+ avatarRenameMigration.down!(workspaceDir);
183
+
184
+ expect(existsSync(join(avatarDir, "custom-avatar.png"))).toBe(true);
185
+ expect(existsSync(join(avatarDir, "avatar-image.png"))).toBe(false);
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // 004-extract-collect-usage-data down()
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe("004-extract-collect-usage-data down()", () => {
194
+ test("restores collectUsageData=false back to feature flag", () => {
195
+ writeConfig({
196
+ collectUsageData: false,
197
+ otherSetting: true,
198
+ });
199
+
200
+ extractCollectUsageDataMigration.down!(workspaceDir);
201
+
202
+ const config = readConfig();
203
+ expect(config.collectUsageData).toBeUndefined();
204
+ expect(config.otherSetting).toBe(true);
205
+ const flagValues = config.assistantFeatureFlagValues as Record<
206
+ string,
207
+ unknown
208
+ >;
209
+ expect(flagValues["feature_flags.collect-usage-data.enabled"]).toBe(false);
210
+ });
211
+
212
+ test("round-trip: run() then down() restores original state", () => {
213
+ const original = {
214
+ assistantFeatureFlagValues: {
215
+ "feature_flags.collect-usage-data.enabled": false,
216
+ },
217
+ otherSetting: "hello",
218
+ };
219
+ writeConfig(original);
220
+
221
+ extractCollectUsageDataMigration.run(workspaceDir);
222
+
223
+ // After run, collectUsageData should be extracted
224
+ const afterRun = readConfig();
225
+ expect(afterRun.collectUsageData).toBe(false);
226
+ expect(afterRun.assistantFeatureFlagValues).toBeUndefined();
227
+
228
+ extractCollectUsageDataMigration.down!(workspaceDir);
229
+
230
+ const afterDown = readConfig();
231
+ expect(afterDown.collectUsageData).toBeUndefined();
232
+ const flagValues = afterDown.assistantFeatureFlagValues as Record<
233
+ string,
234
+ unknown
235
+ >;
236
+ expect(flagValues["feature_flags.collect-usage-data.enabled"]).toBe(false);
237
+ expect(afterDown.otherSetting).toBe("hello");
238
+ });
239
+
240
+ test("idempotent: calling down() twice produces same result", () => {
241
+ writeConfig({ collectUsageData: false });
242
+
243
+ extractCollectUsageDataMigration.down!(workspaceDir);
244
+ const afterFirst = readConfig();
245
+
246
+ extractCollectUsageDataMigration.down!(workspaceDir);
247
+ const afterSecond = readConfig();
248
+
249
+ expect(afterSecond).toEqual(afterFirst);
250
+ });
251
+
252
+ test("no-op when collectUsageData not present", () => {
253
+ const original = { otherSetting: true };
254
+ writeConfig(original);
255
+
256
+ extractCollectUsageDataMigration.down!(workspaceDir);
257
+
258
+ expect(readConfig()).toEqual(original);
259
+ });
260
+
261
+ test("no-op when config.json does not exist", () => {
262
+ extractCollectUsageDataMigration.down!(workspaceDir);
263
+ expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
264
+ });
265
+
266
+ test("merges into existing assistantFeatureFlagValues", () => {
267
+ writeConfig({
268
+ collectUsageData: false,
269
+ assistantFeatureFlagValues: {
270
+ "feature_flags.other-flag.enabled": true,
271
+ },
272
+ });
273
+
274
+ extractCollectUsageDataMigration.down!(workspaceDir);
275
+
276
+ const config = readConfig();
277
+ const flagValues = config.assistantFeatureFlagValues as Record<
278
+ string,
279
+ unknown
280
+ >;
281
+ expect(flagValues["feature_flags.collect-usage-data.enabled"]).toBe(false);
282
+ expect(flagValues["feature_flags.other-flag.enabled"]).toBe(true);
283
+ });
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // 006-services-config down()
288
+ // ---------------------------------------------------------------------------
289
+
290
+ describe("006-services-config down()", () => {
291
+ test("extracts services back to top-level fields", () => {
292
+ writeConfig({
293
+ services: {
294
+ inference: { mode: "your-own", provider: "openai", model: "gpt-4o" },
295
+ "image-generation": {
296
+ mode: "your-own",
297
+ provider: "openai",
298
+ model: "dall-e-3",
299
+ },
300
+ "web-search": { mode: "your-own", provider: "brave" },
301
+ },
302
+ otherSetting: true,
303
+ });
304
+
305
+ servicesConfigMigration.down!(workspaceDir);
306
+
307
+ const config = readConfig();
308
+ expect(config.provider).toBe("openai");
309
+ expect(config.model).toBe("gpt-4o");
310
+ expect(config.imageGenModel).toBe("dall-e-3");
311
+ expect(config.webSearchProvider).toBe("brave");
312
+ expect(config.services).toBeUndefined();
313
+ expect(config.otherSetting).toBe(true);
314
+ });
315
+
316
+ test("round-trip: run() then down() restores top-level fields", async () => {
317
+ writeConfig({
318
+ provider: "openai",
319
+ model: "gpt-4o",
320
+ imageGenModel: "dall-e-3",
321
+ webSearchProvider: "brave",
322
+ otherSetting: true,
323
+ });
324
+
325
+ await servicesConfigMigration.run(workspaceDir);
326
+
327
+ const afterRun = readConfig();
328
+ expect(afterRun.provider).toBeUndefined();
329
+ expect(afterRun.services).toBeDefined();
330
+
331
+ servicesConfigMigration.down!(workspaceDir);
332
+
333
+ const afterDown = readConfig();
334
+ expect(afterDown.provider).toBe("openai");
335
+ expect(afterDown.model).toBe("gpt-4o");
336
+ expect(afterDown.imageGenModel).toBe("dall-e-3");
337
+ expect(afterDown.webSearchProvider).toBe("brave");
338
+ expect(afterDown.services).toBeUndefined();
339
+ expect(afterDown.otherSetting).toBe(true);
340
+ });
341
+
342
+ test("idempotent: calling down() twice produces same result", () => {
343
+ writeConfig({
344
+ services: {
345
+ inference: {
346
+ mode: "your-own",
347
+ provider: "anthropic",
348
+ model: "claude-opus-4-6",
349
+ },
350
+ "image-generation": {
351
+ mode: "your-own",
352
+ provider: "gemini",
353
+ model: "gemini-2.5-flash-image",
354
+ },
355
+ "web-search": {
356
+ mode: "your-own",
357
+ provider: "inference-provider-native",
358
+ },
359
+ },
360
+ });
361
+
362
+ servicesConfigMigration.down!(workspaceDir);
363
+ const afterFirst = readConfig();
364
+
365
+ // Second call: services was already removed, so down() is a no-op
366
+ servicesConfigMigration.down!(workspaceDir);
367
+ const afterSecond = readConfig();
368
+
369
+ expect(afterSecond).toEqual(afterFirst);
370
+ });
371
+
372
+ test("no-op when no services object present", () => {
373
+ const original = { provider: "openai", model: "gpt-4o" };
374
+ writeConfig(original);
375
+
376
+ servicesConfigMigration.down!(workspaceDir);
377
+
378
+ expect(readConfig()).toEqual(original);
379
+ });
380
+
381
+ test("no-op when config.json does not exist", () => {
382
+ servicesConfigMigration.down!(workspaceDir);
383
+ expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
384
+ });
385
+
386
+ test("gracefully handles invalid JSON", () => {
387
+ writeFileSync(join(workspaceDir, "config.json"), "not-valid-json");
388
+
389
+ servicesConfigMigration.down!(workspaceDir);
390
+
391
+ expect(readFileSync(join(workspaceDir, "config.json"), "utf-8")).toBe(
392
+ "not-valid-json",
393
+ );
394
+ });
395
+
396
+ test("handles partial services object (only inference present)", () => {
397
+ writeConfig({
398
+ services: {
399
+ inference: { mode: "your-own", provider: "openai", model: "gpt-4o" },
400
+ },
401
+ });
402
+
403
+ servicesConfigMigration.down!(workspaceDir);
404
+
405
+ const config = readConfig();
406
+ expect(config.provider).toBe("openai");
407
+ expect(config.model).toBe("gpt-4o");
408
+ expect(config.imageGenModel).toBeUndefined();
409
+ expect(config.webSearchProvider).toBeUndefined();
410
+ expect(config.services).toBeUndefined();
411
+ });
412
+ });
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // 007-web-search-provider-rename down()
416
+ // ---------------------------------------------------------------------------
417
+
418
+ describe("007-web-search-provider-rename down()", () => {
419
+ test("renames inference-provider-native back to anthropic-native", () => {
420
+ writeConfig({
421
+ services: {
422
+ inference: {
423
+ mode: "your-own",
424
+ provider: "anthropic",
425
+ model: "claude-opus-4-6",
426
+ },
427
+ "web-search": {
428
+ mode: "your-own",
429
+ provider: "inference-provider-native",
430
+ },
431
+ },
432
+ });
433
+
434
+ webSearchProviderRenameMigration.down!(workspaceDir);
435
+
436
+ const config = readConfig();
437
+ const services = config.services as Record<string, Record<string, unknown>>;
438
+ expect(services["web-search"].provider).toBe("anthropic-native");
439
+ });
440
+
441
+ test("round-trip: run() then down() restores original provider name", () => {
442
+ writeConfig({
443
+ services: {
444
+ "web-search": { mode: "your-own", provider: "anthropic-native" },
445
+ },
446
+ });
447
+
448
+ webSearchProviderRenameMigration.run(workspaceDir);
449
+
450
+ const afterRun = readConfig();
451
+ const svcAfterRun = afterRun.services as Record<
452
+ string,
453
+ Record<string, unknown>
454
+ >;
455
+ expect(svcAfterRun["web-search"].provider).toBe(
456
+ "inference-provider-native",
457
+ );
458
+
459
+ webSearchProviderRenameMigration.down!(workspaceDir);
460
+
461
+ const afterDown = readConfig();
462
+ const svcAfterDown = afterDown.services as Record<
463
+ string,
464
+ Record<string, unknown>
465
+ >;
466
+ expect(svcAfterDown["web-search"].provider).toBe("anthropic-native");
467
+ });
468
+
469
+ test("idempotent: calling down() twice produces same result", () => {
470
+ writeConfig({
471
+ services: {
472
+ "web-search": {
473
+ mode: "your-own",
474
+ provider: "inference-provider-native",
475
+ },
476
+ },
477
+ });
478
+
479
+ webSearchProviderRenameMigration.down!(workspaceDir);
480
+ const afterFirst = readConfig();
481
+
482
+ webSearchProviderRenameMigration.down!(workspaceDir);
483
+ const afterSecond = readConfig();
484
+
485
+ expect(afterSecond).toEqual(afterFirst);
486
+ });
487
+
488
+ test("no-op when provider is not inference-provider-native", () => {
489
+ const original = {
490
+ services: {
491
+ "web-search": { mode: "your-own", provider: "brave" },
492
+ },
493
+ };
494
+ writeConfig(original);
495
+
496
+ webSearchProviderRenameMigration.down!(workspaceDir);
497
+
498
+ expect(readConfig()).toEqual(original);
499
+ });
500
+
501
+ test("no-op when config.json does not exist", () => {
502
+ webSearchProviderRenameMigration.down!(workspaceDir);
503
+ expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
504
+ });
505
+
506
+ test("no-op when services or web-search is missing", () => {
507
+ const original = { otherSetting: true };
508
+ writeConfig(original);
509
+
510
+ webSearchProviderRenameMigration.down!(workspaceDir);
511
+
512
+ expect(readConfig()).toEqual(original);
513
+ });
514
+ });
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // 010-app-dir-rename down()
518
+ // ---------------------------------------------------------------------------
519
+
520
+ describe("010-app-dir-rename down()", () => {
521
+ test("renames slugified app dirs back to UUID-based names", () => {
522
+ const appsDir = join(workspaceDir, "data", "apps");
523
+ mkdirSync(appsDir, { recursive: true });
524
+
525
+ const appId = "a1b2c3d4-5678-9abc-def0-123456789abc";
526
+ const dirName = "my-cool-app";
527
+
528
+ // Create migrated state: slugified dir, json with dirName
529
+ mkdirSync(join(appsDir, dirName), { recursive: true });
530
+ writeFileSync(join(appsDir, dirName, "index.html"), "<html>app</html>");
531
+ writeFileSync(
532
+ join(appsDir, `${dirName}.json`),
533
+ JSON.stringify({ id: appId, name: "My Cool App", dirName }),
534
+ );
535
+ writeFileSync(join(appsDir, `${dirName}.preview`), "preview-data");
536
+
537
+ appDirRenameMigration.down!(workspaceDir);
538
+
539
+ // UUID-based files should now exist
540
+ expect(existsSync(join(appsDir, appId))).toBe(true);
541
+ expect(existsSync(join(appsDir, `${appId}.json`))).toBe(true);
542
+ expect(existsSync(join(appsDir, `${appId}.preview`))).toBe(true);
543
+
544
+ // Slugified files should be gone
545
+ expect(existsSync(join(appsDir, dirName))).toBe(false);
546
+ expect(existsSync(join(appsDir, `${dirName}.json`))).toBe(false);
547
+ expect(existsSync(join(appsDir, `${dirName}.preview`))).toBe(false);
548
+
549
+ // JSON content should have dirName removed
550
+ const json = JSON.parse(
551
+ readFileSync(join(appsDir, `${appId}.json`), "utf-8"),
552
+ );
553
+ expect(json.id).toBe(appId);
554
+ expect(json.name).toBe("My Cool App");
555
+ expect(json.dirName).toBeUndefined();
556
+
557
+ // App files should be preserved
558
+ expect(readFileSync(join(appsDir, appId, "index.html"), "utf-8")).toBe(
559
+ "<html>app</html>",
560
+ );
561
+ });
562
+
563
+ test("idempotent: calling down() twice produces same result", () => {
564
+ const appsDir = join(workspaceDir, "data", "apps");
565
+ mkdirSync(appsDir, { recursive: true });
566
+
567
+ const appId = "b2c3d4e5-6789-abcd-ef01-234567890abc";
568
+ const dirName = "test-app";
569
+
570
+ mkdirSync(join(appsDir, dirName), { recursive: true });
571
+ writeFileSync(
572
+ join(appsDir, `${dirName}.json`),
573
+ JSON.stringify({ id: appId, name: "Test App", dirName }),
574
+ );
575
+
576
+ appDirRenameMigration.down!(workspaceDir);
577
+ appDirRenameMigration.down!(workspaceDir);
578
+
579
+ expect(existsSync(join(appsDir, appId))).toBe(true);
580
+ expect(existsSync(join(appsDir, `${appId}.json`))).toBe(true);
581
+ expect(existsSync(join(appsDir, dirName))).toBe(false);
582
+ });
583
+
584
+ test("no-op when apps directory does not exist", () => {
585
+ appDirRenameMigration.down!(workspaceDir);
586
+ // Should not throw
587
+ });
588
+
589
+ test("no-op when no JSON files exist", () => {
590
+ const appsDir = join(workspaceDir, "data", "apps");
591
+ mkdirSync(appsDir, { recursive: true });
592
+
593
+ appDirRenameMigration.down!(workspaceDir);
594
+ // Should not throw
595
+ });
596
+
597
+ test("handles multiple apps", () => {
598
+ const appsDir = join(workspaceDir, "data", "apps");
599
+ mkdirSync(appsDir, { recursive: true });
600
+
601
+ const apps = [
602
+ { id: "aaa-111", dirName: "first-app", name: "First App" },
603
+ { id: "bbb-222", dirName: "second-app", name: "Second App" },
604
+ ];
605
+
606
+ for (const app of apps) {
607
+ mkdirSync(join(appsDir, app.dirName), { recursive: true });
608
+ writeFileSync(
609
+ join(appsDir, `${app.dirName}.json`),
610
+ JSON.stringify({ id: app.id, name: app.name, dirName: app.dirName }),
611
+ );
612
+ }
613
+
614
+ appDirRenameMigration.down!(workspaceDir);
615
+
616
+ for (const app of apps) {
617
+ expect(existsSync(join(appsDir, app.id))).toBe(true);
618
+ expect(existsSync(join(appsDir, `${app.id}.json`))).toBe(true);
619
+ expect(existsSync(join(appsDir, app.dirName))).toBe(false);
620
+ }
621
+ });
622
+ });
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // 012-rename-conversation-disk-view-dirs down()
626
+ // ---------------------------------------------------------------------------
627
+
628
+ describe("012-rename-conversation-disk-view-dirs down()", () => {
629
+ test("renames timestamp-first dirs back to legacy id-first format", () => {
630
+ const conversationsDir = join(workspaceDir, "conversations");
631
+ mkdirSync(conversationsDir, { recursive: true });
632
+
633
+ // Create new-format dir: {timestamp}_{conversationId}
634
+ const timestamp = "2025-06-15T10-30-00.000Z";
635
+ const convId = "conv-abc-123";
636
+ const newName = `${timestamp}_${convId}`;
637
+ mkdirSync(join(conversationsDir, newName));
638
+ writeFileSync(join(conversationsDir, newName, "messages.json"), "[]");
639
+
640
+ renameConversationDiskViewDirsMigration.down!(workspaceDir);
641
+
642
+ const legacyName = `${convId}_${timestamp}`;
643
+ expect(existsSync(join(conversationsDir, legacyName))).toBe(true);
644
+ expect(existsSync(join(conversationsDir, newName))).toBe(false);
645
+
646
+ // Content preserved
647
+ expect(
648
+ readFileSync(
649
+ join(conversationsDir, legacyName, "messages.json"),
650
+ "utf-8",
651
+ ),
652
+ ).toBe("[]");
653
+ });
654
+
655
+ test("round-trip: run() then down() restores legacy format", () => {
656
+ const conversationsDir = join(workspaceDir, "conversations");
657
+ mkdirSync(conversationsDir, { recursive: true });
658
+
659
+ const timestamp = "2025-06-15T10-30-00.000Z";
660
+ const convId = "my-conversation";
661
+ const legacyName = `${convId}_${timestamp}`;
662
+ mkdirSync(join(conversationsDir, legacyName));
663
+
664
+ renameConversationDiskViewDirsMigration.run(workspaceDir);
665
+
666
+ const newName = `${timestamp}_${convId}`;
667
+ expect(existsSync(join(conversationsDir, newName))).toBe(true);
668
+ expect(existsSync(join(conversationsDir, legacyName))).toBe(false);
669
+
670
+ renameConversationDiskViewDirsMigration.down!(workspaceDir);
671
+
672
+ expect(existsSync(join(conversationsDir, legacyName))).toBe(true);
673
+ expect(existsSync(join(conversationsDir, newName))).toBe(false);
674
+ });
675
+
676
+ test("idempotent: calling down() twice produces same result", () => {
677
+ const conversationsDir = join(workspaceDir, "conversations");
678
+ mkdirSync(conversationsDir, { recursive: true });
679
+
680
+ const timestamp = "2025-01-01T00-00-00.000Z";
681
+ const convId = "test-conv";
682
+ mkdirSync(join(conversationsDir, `${timestamp}_${convId}`));
683
+
684
+ renameConversationDiskViewDirsMigration.down!(workspaceDir);
685
+ renameConversationDiskViewDirsMigration.down!(workspaceDir);
686
+
687
+ expect(existsSync(join(conversationsDir, `${convId}_${timestamp}`))).toBe(
688
+ true,
689
+ );
690
+ expect(existsSync(join(conversationsDir, `${timestamp}_${convId}`))).toBe(
691
+ false,
692
+ );
693
+ });
694
+
695
+ test("no-op when conversations directory does not exist", () => {
696
+ renameConversationDiskViewDirsMigration.down!(workspaceDir);
697
+ // Should not throw
698
+ });
699
+
700
+ test("no-op when no directories match new format", () => {
701
+ const conversationsDir = join(workspaceDir, "conversations");
702
+ mkdirSync(conversationsDir, { recursive: true });
703
+ mkdirSync(join(conversationsDir, "some-random-dir"));
704
+
705
+ renameConversationDiskViewDirsMigration.down!(workspaceDir);
706
+
707
+ expect(existsSync(join(conversationsDir, "some-random-dir"))).toBe(true);
708
+ });
709
+
710
+ test("handles multiple conversation directories", () => {
711
+ const conversationsDir = join(workspaceDir, "conversations");
712
+ mkdirSync(conversationsDir, { recursive: true });
713
+
714
+ const entries = [
715
+ { ts: "2025-01-01T00-00-00.000Z", id: "conv-a" },
716
+ { ts: "2025-02-15T12-30-00.000Z", id: "conv-b" },
717
+ ];
718
+
719
+ for (const { ts, id } of entries) {
720
+ mkdirSync(join(conversationsDir, `${ts}_${id}`));
721
+ }
722
+
723
+ renameConversationDiskViewDirsMigration.down!(workspaceDir);
724
+
725
+ for (const { ts, id } of entries) {
726
+ expect(existsSync(join(conversationsDir, `${id}_${ts}`))).toBe(true);
727
+ expect(existsSync(join(conversationsDir, `${ts}_${id}`))).toBe(false);
728
+ }
729
+ });
730
+ });
731
+
732
+ // ---------------------------------------------------------------------------
733
+ // 014-migrate-to-workspace-volume down()
734
+ // ---------------------------------------------------------------------------
735
+
736
+ describe("014-migrate-to-workspace-volume down()", () => {
737
+ test("removes sentinel file", () => {
738
+ const sentinelPath = join(workspaceDir, ".workspace-volume-migrated");
739
+ writeFileSync(sentinelPath, new Date().toISOString());
740
+
741
+ expect(existsSync(sentinelPath)).toBe(true);
742
+
743
+ migrateToWorkspaceVolumeMigration.down!(workspaceDir);
744
+
745
+ expect(existsSync(sentinelPath)).toBe(false);
746
+ });
747
+
748
+ test("idempotent: calling down() twice does not error", () => {
749
+ const sentinelPath = join(workspaceDir, ".workspace-volume-migrated");
750
+ writeFileSync(sentinelPath, new Date().toISOString());
751
+
752
+ migrateToWorkspaceVolumeMigration.down!(workspaceDir);
753
+ migrateToWorkspaceVolumeMigration.down!(workspaceDir);
754
+
755
+ expect(existsSync(sentinelPath)).toBe(false);
756
+ });
757
+
758
+ test("no-op when sentinel file does not exist", () => {
759
+ migrateToWorkspaceVolumeMigration.down!(workspaceDir);
760
+ // Should not throw
761
+ expect(existsSync(join(workspaceDir, ".workspace-volume-migrated"))).toBe(
762
+ false,
763
+ );
764
+ });
765
+ });
766
+
767
+ // ---------------------------------------------------------------------------
768
+ // 016-extract-feature-flags-to-protected down()
769
+ // ---------------------------------------------------------------------------
770
+
771
+ describe("016-extract-feature-flags-to-protected down()", () => {
772
+ beforeEach(() => {
773
+ // Set up mock root dir for getRootDir() to point to our temp dir
774
+ mockRootDir = freshWorkspace();
775
+ });
776
+
777
+ test("moves feature flags from protected dir back to config.json", () => {
778
+ const protectedDir = join(mockRootDir, "protected");
779
+ mkdirSync(protectedDir, { recursive: true });
780
+
781
+ // Write feature flags to protected dir (post-run() state)
782
+ writeFileSync(
783
+ join(protectedDir, "feature-flags.json"),
784
+ JSON.stringify(
785
+ {
786
+ version: 1,
787
+ values: {
788
+ "feature_flags.my-flag.enabled": true,
789
+ "feature_flags.other-flag.enabled": false,
790
+ },
791
+ },
792
+ null,
793
+ 2,
794
+ ) + "\n",
795
+ );
796
+
797
+ // Write config without feature flags
798
+ writeConfig({ otherSetting: true });
799
+
800
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
801
+
802
+ const config = readConfig();
803
+ const flagValues = config.assistantFeatureFlagValues as Record<
804
+ string,
805
+ boolean
806
+ >;
807
+ expect(flagValues["feature_flags.my-flag.enabled"]).toBe(true);
808
+ expect(flagValues["feature_flags.other-flag.enabled"]).toBe(false);
809
+ expect(config.otherSetting).toBe(true);
810
+
811
+ // Protected file should be cleaned up
812
+ expect(existsSync(join(protectedDir, "feature-flags.json"))).toBe(false);
813
+ });
814
+
815
+ test("round-trip: run() then down() restores config.json", () => {
816
+ const protectedDir = join(mockRootDir, "protected");
817
+
818
+ writeConfig({
819
+ assistantFeatureFlagValues: {
820
+ "feature_flags.test-flag.enabled": false,
821
+ },
822
+ otherSetting: "hello",
823
+ });
824
+
825
+ extractFeatureFlagsToProtectedMigration.run(workspaceDir);
826
+
827
+ // After run: feature flags should be in protected dir
828
+ expect(existsSync(join(protectedDir, "feature-flags.json"))).toBe(true);
829
+ const configAfterRun = readConfig();
830
+ expect(configAfterRun.assistantFeatureFlagValues).toBeUndefined();
831
+
832
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
833
+
834
+ const configAfterDown = readConfig();
835
+ const flagValues = configAfterDown.assistantFeatureFlagValues as Record<
836
+ string,
837
+ boolean
838
+ >;
839
+ expect(flagValues["feature_flags.test-flag.enabled"]).toBe(false);
840
+ expect(configAfterDown.otherSetting).toBe("hello");
841
+ expect(existsSync(join(protectedDir, "feature-flags.json"))).toBe(false);
842
+ });
843
+
844
+ test("idempotent: calling down() twice produces same result", () => {
845
+ const protectedDir = join(mockRootDir, "protected");
846
+ mkdirSync(protectedDir, { recursive: true });
847
+
848
+ writeFileSync(
849
+ join(protectedDir, "feature-flags.json"),
850
+ JSON.stringify({
851
+ version: 1,
852
+ values: { "feature_flags.flag.enabled": true },
853
+ }) + "\n",
854
+ );
855
+
856
+ writeConfig({});
857
+
858
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
859
+ const afterFirst = readConfig();
860
+
861
+ // Second call: feature-flags.json was already deleted, so this is a no-op
862
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
863
+ const afterSecond = readConfig();
864
+
865
+ expect(afterSecond).toEqual(afterFirst);
866
+ });
867
+
868
+ test("no-op when feature-flags.json does not exist in protected dir", () => {
869
+ const original = { otherSetting: true };
870
+ writeConfig(original);
871
+
872
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
873
+
874
+ expect(readConfig()).toEqual(original);
875
+ });
876
+
877
+ test("no-op when feature-flags.json has no values", () => {
878
+ const protectedDir = join(mockRootDir, "protected");
879
+ mkdirSync(protectedDir, { recursive: true });
880
+
881
+ writeFileSync(
882
+ join(protectedDir, "feature-flags.json"),
883
+ JSON.stringify({ version: 1, values: {} }) + "\n",
884
+ );
885
+
886
+ const original = { otherSetting: true };
887
+ writeConfig(original);
888
+
889
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
890
+
891
+ expect(readConfig()).toEqual(original);
892
+ });
893
+
894
+ test("merges into existing assistantFeatureFlagValues", () => {
895
+ const protectedDir = join(mockRootDir, "protected");
896
+ mkdirSync(protectedDir, { recursive: true });
897
+
898
+ writeFileSync(
899
+ join(protectedDir, "feature-flags.json"),
900
+ JSON.stringify({
901
+ version: 1,
902
+ values: { "feature_flags.new-flag.enabled": true },
903
+ }) + "\n",
904
+ );
905
+
906
+ writeConfig({
907
+ assistantFeatureFlagValues: {
908
+ "feature_flags.existing-flag.enabled": false,
909
+ },
910
+ });
911
+
912
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
913
+
914
+ const config = readConfig();
915
+ const flagValues = config.assistantFeatureFlagValues as Record<
916
+ string,
917
+ boolean
918
+ >;
919
+ expect(flagValues["feature_flags.existing-flag.enabled"]).toBe(false);
920
+ expect(flagValues["feature_flags.new-flag.enabled"]).toBe(true);
921
+ });
922
+
923
+ test("creates config.json if it does not exist", () => {
924
+ const protectedDir = join(mockRootDir, "protected");
925
+ mkdirSync(protectedDir, { recursive: true });
926
+
927
+ writeFileSync(
928
+ join(protectedDir, "feature-flags.json"),
929
+ JSON.stringify({
930
+ version: 1,
931
+ values: { "feature_flags.flag.enabled": true },
932
+ }) + "\n",
933
+ );
934
+
935
+ // No config.json exists
936
+
937
+ extractFeatureFlagsToProtectedMigration.down!(workspaceDir);
938
+
939
+ const config = readConfig();
940
+ const flagValues = config.assistantFeatureFlagValues as Record<
941
+ string,
942
+ boolean
943
+ >;
944
+ expect(flagValues["feature_flags.flag.enabled"]).toBe(true);
945
+ });
946
+ });
947
+
948
+ // ---------------------------------------------------------------------------
949
+ // 017-seed-persona-dirs down()
950
+ // ---------------------------------------------------------------------------
951
+
952
+ describe("017-seed-persona-dirs down()", () => {
953
+ test("removes empty users/ and channels/ directories", () => {
954
+ const usersDir = join(workspaceDir, "users");
955
+ const channelsDir = join(workspaceDir, "channels");
956
+ mkdirSync(usersDir, { recursive: true });
957
+ mkdirSync(channelsDir, { recursive: true });
958
+
959
+ seedPersonaDirsMigration.down!(workspaceDir);
960
+
961
+ expect(existsSync(usersDir)).toBe(false);
962
+ expect(existsSync(channelsDir)).toBe(false);
963
+ });
964
+
965
+ test("leaves non-empty directories in place", () => {
966
+ const usersDir = join(workspaceDir, "users");
967
+ const channelsDir = join(workspaceDir, "channels");
968
+ mkdirSync(usersDir, { recursive: true });
969
+ mkdirSync(channelsDir, { recursive: true });
970
+
971
+ // Add content to users/ so it should not be removed
972
+ writeFileSync(join(usersDir, "guardian.md"), "# Guardian");
973
+
974
+ seedPersonaDirsMigration.down!(workspaceDir);
975
+
976
+ expect(existsSync(usersDir)).toBe(true);
977
+ expect(existsSync(channelsDir)).toBe(false);
978
+ });
979
+
980
+ test("idempotent: calling down() twice does not error", () => {
981
+ const usersDir = join(workspaceDir, "users");
982
+ const channelsDir = join(workspaceDir, "channels");
983
+ mkdirSync(usersDir, { recursive: true });
984
+ mkdirSync(channelsDir, { recursive: true });
985
+
986
+ seedPersonaDirsMigration.down!(workspaceDir);
987
+ seedPersonaDirsMigration.down!(workspaceDir);
988
+
989
+ expect(existsSync(usersDir)).toBe(false);
990
+ expect(existsSync(channelsDir)).toBe(false);
991
+ });
992
+
993
+ test("no-op when directories do not exist", () => {
994
+ seedPersonaDirsMigration.down!(workspaceDir);
995
+ // Should not throw
996
+ expect(existsSync(join(workspaceDir, "users"))).toBe(false);
997
+ expect(existsSync(join(workspaceDir, "channels"))).toBe(false);
998
+ });
999
+
1000
+ test("handles case where only one directory exists", () => {
1001
+ const usersDir = join(workspaceDir, "users");
1002
+ mkdirSync(usersDir, { recursive: true });
1003
+
1004
+ seedPersonaDirsMigration.down!(workspaceDir);
1005
+
1006
+ expect(existsSync(usersDir)).toBe(false);
1007
+ expect(existsSync(join(workspaceDir, "channels"))).toBe(false);
1008
+ });
1009
+ });