@vellumai/assistant 0.5.8 → 0.5.10

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 (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +8 -8
  3. package/README.md +1 -1
  4. package/docs/architecture/integrations.md +4 -4
  5. package/docs/architecture/keychain-broker.md +17 -18
  6. package/docs/architecture/security.md +5 -5
  7. package/eslint.config.mjs +0 -31
  8. package/package.json +1 -1
  9. package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
  10. package/src/__tests__/credentials-cli.test.ts +3 -3
  11. package/src/__tests__/stt-hints.test.ts +22 -22
  12. package/src/__tests__/voice-quality.test.ts +2 -2
  13. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
  14. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
  15. package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
  16. package/src/cli/commands/credentials.ts +4 -4
  17. package/src/cli/commands/oauth/apps.ts +3 -3
  18. package/src/daemon/conversation-agent-loop.ts +6 -0
  19. package/src/daemon/conversation-runtime-assembly.ts +61 -1
  20. package/src/daemon/lifecycle.ts +2 -3
  21. package/src/memory/migrations/validate-migration-state.ts +14 -1
  22. package/src/prompts/system-prompt.ts +22 -0
  23. package/src/prompts/templates/NOW.md +26 -0
  24. package/src/prompts/templates/SOUL.md +10 -0
  25. package/src/prompts/update-bulletin-format.ts +0 -2
  26. package/src/runtime/routes/settings-routes.ts +1 -1
  27. package/src/skills/inline-command-expansions.ts +7 -7
  28. package/src/tools/sensitive-output-placeholders.ts +2 -2
  29. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
  30. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
  31. package/src/workspace/migrations/AGENTS.md +11 -0
  32. package/src/workspace/migrations/runner.ts +16 -6
  33. package/src/workspace/migrations/types.ts +7 -0
  34. package/src/__tests__/keychain-broker-client.test.ts +0 -800
  35. package/src/security/keychain-broker-client.ts +0 -446
@@ -6,8 +6,8 @@ import type { ContactWithChannels } from "../contacts/types.js";
6
6
  // Mock state for resolveCallHints tests
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- let mockAssistantName: string | null = "Velissa";
10
- let mockGuardianName: string = "Sidd";
9
+ let mockAssistantName: string | null = "Nova";
10
+ let mockGuardianName: string = "Alex";
11
11
  let mockTargetContact: ContactWithChannels | null = null;
12
12
  let mockRecentContacts: ContactWithChannels[] = [];
13
13
  let mockFindContactByAddressThrows = false;
@@ -93,14 +93,14 @@ describe("buildSttHints", () => {
93
93
 
94
94
  test("assistant name included", () => {
95
95
  const input = emptyInput();
96
- input.assistantName = "Velissa";
97
- expect(buildSttHints(input)).toBe("Velissa");
96
+ input.assistantName = "Nova";
97
+ expect(buildSttHints(input)).toBe("Nova");
98
98
  });
99
99
 
100
100
  test("guardian name included", () => {
101
101
  const input = emptyInput();
102
- input.guardianName = "Sidd";
103
- expect(buildSttHints(input)).toBe("Sidd");
102
+ input.guardianName = "Alex";
103
+ expect(buildSttHints(input)).toBe("Alex");
104
104
  });
105
105
 
106
106
  test('default guardian name "my human" excluded', () => {
@@ -214,8 +214,8 @@ describe("buildSttHints", () => {
214
214
  test("all sources combined in correct order", () => {
215
215
  const input: SttHintsInput = {
216
216
  staticHints: ["StaticOne"],
217
- assistantName: "Velissa",
218
- guardianName: "Sidd",
217
+ assistantName: "Nova",
218
+ guardianName: "Alex",
219
219
  taskDescription: "Call John at Acme",
220
220
  targetContactName: "Target",
221
221
  callerContactName: "Caller",
@@ -227,8 +227,8 @@ describe("buildSttHints", () => {
227
227
  const parts = result.split(",");
228
228
  // Verify all expected hints are present
229
229
  expect(parts).toContain("StaticOne");
230
- expect(parts).toContain("Velissa");
231
- expect(parts).toContain("Sidd");
230
+ expect(parts).toContain("Nova");
231
+ expect(parts).toContain("Alex");
232
232
  expect(parts).toContain("John");
233
233
  expect(parts).toContain("Acme");
234
234
  expect(parts).toContain("Target");
@@ -308,8 +308,8 @@ function makeContact(displayName: string): ContactWithChannels {
308
308
 
309
309
  describe("resolveCallHints", () => {
310
310
  beforeEach(() => {
311
- mockAssistantName = "Velissa";
312
- mockGuardianName = "Sidd";
311
+ mockAssistantName = "Nova";
312
+ mockGuardianName = "Alex";
313
313
  mockTargetContact = null;
314
314
  mockRecentContacts = [];
315
315
  mockFindContactByAddressThrows = false;
@@ -334,8 +334,8 @@ describe("resolveCallHints", () => {
334
334
  const parts = result.split(",");
335
335
 
336
336
  expect(parts).toContain("StaticHint");
337
- expect(parts).toContain("Velissa");
338
- expect(parts).toContain("Sidd");
337
+ expect(parts).toContain("Nova");
338
+ expect(parts).toContain("Alex");
339
339
  expect(parts).toContain("Alice");
340
340
  expect(parts).toContain("Dave");
341
341
  expect(parts).toContain("Eve");
@@ -365,8 +365,8 @@ describe("resolveCallHints", () => {
365
365
 
366
366
  // Target contact should be absent (lookup failed)
367
367
  // But other sources should still work
368
- expect(parts).toContain("Velissa");
369
- expect(parts).toContain("Sidd");
368
+ expect(parts).toContain("Nova");
369
+ expect(parts).toContain("Alex");
370
370
  expect(parts).toContain("Bob");
371
371
  expect(logWarnFn).toHaveBeenCalled();
372
372
  });
@@ -390,8 +390,8 @@ describe("resolveCallHints", () => {
390
390
 
391
391
  // Recent contacts should be absent (listing failed)
392
392
  // But other sources should still work
393
- expect(parts).toContain("Velissa");
394
- expect(parts).toContain("Sidd");
393
+ expect(parts).toContain("Nova");
394
+ expect(parts).toContain("Alex");
395
395
  expect(parts).toContain("Alice");
396
396
  expect(logWarnFn).toHaveBeenCalled();
397
397
  });
@@ -414,8 +414,8 @@ describe("resolveCallHints", () => {
414
414
 
415
415
  // For inbound, the contact found via fromNumber should appear as caller, not target
416
416
  expect(parts).toContain("Alice");
417
- expect(parts).toContain("Velissa");
418
- expect(parts).toContain("Sidd");
417
+ expect(parts).toContain("Nova");
418
+ expect(parts).toContain("Alex");
419
419
  expect(parts).toContain("Bob");
420
420
  expect(logWarnFn).not.toHaveBeenCalled();
421
421
  });
@@ -427,8 +427,8 @@ describe("resolveCallHints", () => {
427
427
  const parts = result.split(",");
428
428
 
429
429
  expect(parts).toContain("Static");
430
- expect(parts).toContain("Velissa");
431
- expect(parts).toContain("Sidd");
430
+ expect(parts).toContain("Nova");
431
+ expect(parts).toContain("Alex");
432
432
  expect(parts).toContain("RecentOne");
433
433
  expect(parts).toContain("RecentTwo");
434
434
  // No target contact lookup should have been attempted (no session)
@@ -175,11 +175,11 @@ describe("resolveVoiceQualityProfile", () => {
175
175
  voice: {
176
176
  language: "en-US",
177
177
  transcriptionProvider: "Deepgram",
178
- hints: ["Vellum", "Velissa", "AI assistant"],
178
+ hints: ["Vellum", "Nova", "AI assistant"],
179
179
  },
180
180
  },
181
181
  };
182
182
  const profile = resolveVoiceQualityProfile();
183
- expect(profile.hints).toEqual(["Vellum", "Velissa", "AI assistant"]);
183
+ expect(profile.hints).toEqual(["Vellum", "Nova", "AI assistant"]);
184
184
  });
185
185
  });
@@ -1,252 +1,19 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, test } from "bun:test";
2
2
 
3
- // ---------------------------------------------------------------------------
4
- // Mock state
5
- // ---------------------------------------------------------------------------
6
-
7
- const isAvailableFn = mock((): boolean => true);
8
- const brokerSetFn = mock(
9
- async (
10
- _account: string,
11
- _value: string,
12
- ): Promise<{ status: string; code?: string; message?: string }> => ({
13
- status: "ok",
14
- }),
15
- );
16
- const createBrokerClientFn = mock(() => ({
17
- isAvailable: isAvailableFn,
18
- set: brokerSetFn,
19
- }));
20
-
21
- const listKeysFn = mock((): string[] => []);
22
- const getKeyFn = mock((_account: string): string | undefined => undefined);
23
- const deleteKeyFn = mock(
24
- (_account: string): "deleted" | "not-found" | "error" => "deleted",
25
- );
26
-
27
- // ---------------------------------------------------------------------------
28
- // Mock modules — before importing module under test
29
- //
30
- // The logger is mocked with a silent Proxy to suppress pino output in tests.
31
- // The broker client and encrypted store are mocked to control migration
32
- // behavior without touching real keychain or filesystem state.
33
- // ---------------------------------------------------------------------------
34
-
35
- mock.module("../util/logger.js", () => ({
36
- getLogger: () =>
37
- new Proxy({} as Record<string, unknown>, {
38
- get: () => () => {},
39
- }),
40
- }));
41
-
42
- mock.module("../security/keychain-broker-client.js", () => ({
43
- createBrokerClient: createBrokerClientFn,
44
- }));
45
-
46
- mock.module("../security/encrypted-store.js", () => ({
47
- listKeys: listKeysFn,
48
- getKey: getKeyFn,
49
- deleteKey: deleteKeyFn,
50
- }));
51
-
52
- // Import after mocking
53
3
  import { migrateCredentialsToKeychainMigration } from "../workspace/migrations/015-migrate-credentials-to-keychain.js";
54
4
 
55
- // ---------------------------------------------------------------------------
56
- // Helpers
57
- // ---------------------------------------------------------------------------
58
-
59
- const WORKSPACE_DIR = "/mock-home/.vellum/workspace";
60
-
61
- // ---------------------------------------------------------------------------
62
- // Tests
63
- // ---------------------------------------------------------------------------
64
-
65
5
  describe("015-migrate-credentials-to-keychain migration", () => {
66
- beforeEach(() => {
67
- isAvailableFn.mockClear();
68
- brokerSetFn.mockClear();
69
- createBrokerClientFn.mockClear();
70
- listKeysFn.mockClear();
71
- getKeyFn.mockClear();
72
- deleteKeyFn.mockClear();
73
-
74
- // Defaults: mac production build
75
- process.env.VELLUM_DESKTOP_APP = "1";
76
- delete process.env.VELLUM_DEV;
77
-
78
- isAvailableFn.mockReturnValue(true);
79
- brokerSetFn.mockResolvedValue({ status: "ok" });
80
- listKeysFn.mockReturnValue([]);
81
- getKeyFn.mockReturnValue(undefined);
82
- deleteKeyFn.mockReturnValue("deleted");
83
- });
84
-
85
6
  test("has correct migration id", () => {
86
7
  expect(migrateCredentialsToKeychainMigration.id).toBe(
87
8
  "015-migrate-credentials-to-keychain",
88
9
  );
89
10
  });
90
11
 
91
- test("skips when VELLUM_DESKTOP_APP is not set", async () => {
92
- delete process.env.VELLUM_DESKTOP_APP;
93
-
94
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
95
-
96
- expect(createBrokerClientFn).not.toHaveBeenCalled();
97
- expect(listKeysFn).not.toHaveBeenCalled();
98
- });
99
-
100
- test("skips when VELLUM_DESKTOP_APP is not '1'", async () => {
101
- process.env.VELLUM_DESKTOP_APP = "0";
102
-
103
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
104
-
105
- expect(createBrokerClientFn).not.toHaveBeenCalled();
106
- });
107
-
108
- test("skips when VELLUM_DEV=1", async () => {
109
- process.env.VELLUM_DEV = "1";
110
-
111
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
112
-
113
- expect(createBrokerClientFn).not.toHaveBeenCalled();
114
- expect(listKeysFn).not.toHaveBeenCalled();
12
+ test("run is a no-op", async () => {
13
+ await migrateCredentialsToKeychainMigration.run("/fake");
115
14
  });
116
15
 
117
- test(
118
- "throws when broker is not available after max retry attempts",
119
- async () => {
120
- isAvailableFn.mockReturnValue(false);
121
-
122
- await expect(
123
- migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR),
124
- ).rejects.toThrow(
125
- "Keychain broker not available after waiting — credential migration will be retried on next startup",
126
- );
127
-
128
- // Should have retried isAvailable multiple times
129
- expect(isAvailableFn.mock.calls.length).toBeGreaterThan(1);
130
-
131
- // Should not proceed to list or migrate keys
132
- expect(listKeysFn).not.toHaveBeenCalled();
133
- expect(brokerSetFn).not.toHaveBeenCalled();
134
- },
135
- { timeout: 10_000 },
136
- );
137
-
138
- test("succeeds when broker becomes available after retry", async () => {
139
- // Broker unavailable for first 3 calls, then available
140
- let callCount = 0;
141
- isAvailableFn.mockImplementation(() => {
142
- callCount++;
143
- return callCount > 3;
144
- });
145
- listKeysFn.mockReturnValue(["retry-key"]);
146
- getKeyFn.mockReturnValue("retry-secret");
147
- brokerSetFn.mockResolvedValue({ status: "ok" });
148
-
149
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
150
-
151
- // Should have called isAvailable 4 times (3 false + 1 true)
152
- expect(isAvailableFn).toHaveBeenCalledTimes(4);
153
-
154
- // Should have proceeded with migration
155
- expect(brokerSetFn).toHaveBeenCalledWith("retry-key", "retry-secret");
156
- expect(deleteKeyFn).toHaveBeenCalledWith("retry-key");
157
- });
158
-
159
- test("no-ops when encrypted store has no keys", async () => {
160
- listKeysFn.mockReturnValue([]);
161
-
162
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
163
-
164
- expect(brokerSetFn).not.toHaveBeenCalled();
165
- expect(deleteKeyFn).not.toHaveBeenCalled();
166
- });
167
-
168
- test("successfully migrates keys from encrypted store to keychain", async () => {
169
- listKeysFn.mockReturnValue(["account-a", "account-b"]);
170
- getKeyFn.mockImplementation((account: string) => {
171
- if (account === "account-a") return "secret-a";
172
- if (account === "account-b") return "secret-b";
173
- return undefined;
174
- });
175
- brokerSetFn.mockResolvedValue({ status: "ok" });
176
-
177
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
178
-
179
- // Should have called broker.set for each key
180
- expect(brokerSetFn).toHaveBeenCalledTimes(2);
181
- expect(brokerSetFn).toHaveBeenCalledWith("account-a", "secret-a");
182
- expect(brokerSetFn).toHaveBeenCalledWith("account-b", "secret-b");
183
-
184
- // Should have deleted each key from encrypted store after successful migration
185
- expect(deleteKeyFn).toHaveBeenCalledTimes(2);
186
- expect(deleteKeyFn).toHaveBeenCalledWith("account-a");
187
- expect(deleteKeyFn).toHaveBeenCalledWith("account-b");
188
- });
189
-
190
- test("continues on individual key failure and migrates others", async () => {
191
- listKeysFn.mockReturnValue(["fail-key", "ok-key"]);
192
- getKeyFn.mockImplementation((account: string) => {
193
- if (account === "fail-key") return "fail-secret";
194
- if (account === "ok-key") return "ok-secret";
195
- return undefined;
196
- });
197
- brokerSetFn.mockImplementation(async (account: string) => {
198
- if (account === "fail-key") {
199
- return {
200
- status: "rejected" as const,
201
- code: "UNKNOWN",
202
- message: "broker rejected",
203
- };
204
- }
205
- return { status: "ok" as const };
206
- });
207
-
208
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
209
-
210
- // fail-key should NOT have been deleted (broker rejected it)
211
- expect(deleteKeyFn).not.toHaveBeenCalledWith("fail-key");
212
-
213
- // ok-key should have been migrated and deleted
214
- expect(brokerSetFn).toHaveBeenCalledWith("ok-key", "ok-secret");
215
- expect(deleteKeyFn).toHaveBeenCalledWith("ok-key");
216
- expect(deleteKeyFn).toHaveBeenCalledTimes(1);
217
- });
218
-
219
- test("handles getKey returning undefined for a listed key", async () => {
220
- listKeysFn.mockReturnValue(["ghost-key", "real-key"]);
221
- getKeyFn.mockImplementation((account: string) => {
222
- if (account === "ghost-key") return undefined;
223
- if (account === "real-key") return "real-secret";
224
- return undefined;
225
- });
226
- brokerSetFn.mockResolvedValue({ status: "ok" });
227
-
228
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
229
-
230
- // ghost-key should not be sent to broker or deleted
231
- expect(brokerSetFn).not.toHaveBeenCalledWith(
232
- "ghost-key",
233
- expect.anything(),
234
- );
235
- expect(deleteKeyFn).not.toHaveBeenCalledWith("ghost-key");
236
-
237
- // real-key should be migrated
238
- expect(brokerSetFn).toHaveBeenCalledWith("real-key", "real-secret");
239
- expect(deleteKeyFn).toHaveBeenCalledWith("real-key");
240
- });
241
-
242
- test("handles broker unreachable status for individual keys", async () => {
243
- listKeysFn.mockReturnValue(["key-1"]);
244
- getKeyFn.mockReturnValue("secret-1");
245
- brokerSetFn.mockResolvedValue({ status: "unreachable" });
246
-
247
- await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
248
-
249
- // Should not delete when broker is unreachable
250
- expect(deleteKeyFn).not.toHaveBeenCalled();
16
+ test("down is a no-op", async () => {
17
+ await migrateCredentialsToKeychainMigration.down("/fake");
251
18
  });
252
19
  });
@@ -1,220 +1,19 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, test } from "bun:test";
2
2
 
3
- // ---------------------------------------------------------------------------
4
- // Mock state
5
- // ---------------------------------------------------------------------------
6
-
7
- const isAvailableFn = mock((): boolean => true);
8
- const brokerGetFn = mock(
9
- async (
10
- _account: string,
11
- ): Promise<{ found: boolean; value?: string } | null> => ({
12
- found: true,
13
- value: "secret",
14
- }),
15
- );
16
- const brokerDelFn = mock(async (_account: string): Promise<boolean> => true);
17
- const brokerListFn = mock(async (): Promise<string[]> => []);
18
- const createBrokerClientFn = mock(() => ({
19
- isAvailable: isAvailableFn,
20
- get: brokerGetFn,
21
- del: brokerDelFn,
22
- list: brokerListFn,
23
- }));
24
-
25
- const setKeyFn = mock(
26
- (_account: string, _value: string): boolean => true,
27
- );
28
-
29
- // ---------------------------------------------------------------------------
30
- // Mock modules — before importing module under test
31
- //
32
- // The logger is mocked with a silent Proxy to suppress pino output in tests.
33
- // The broker client and encrypted store are mocked to control migration
34
- // behavior without touching real keychain or filesystem state.
35
- // ---------------------------------------------------------------------------
36
-
37
- mock.module("../util/logger.js", () => ({
38
- getLogger: () =>
39
- new Proxy({} as Record<string, unknown>, {
40
- get: () => () => {},
41
- }),
42
- }));
43
-
44
- mock.module("../security/keychain-broker-client.js", () => ({
45
- createBrokerClient: createBrokerClientFn,
46
- }));
47
-
48
- mock.module("../security/encrypted-store.js", () => ({
49
- setKey: setKeyFn,
50
- }));
51
-
52
- // Import after mocking
53
3
  import { migrateCredentialsFromKeychainMigration } from "../workspace/migrations/016-migrate-credentials-from-keychain.js";
54
4
 
55
- // ---------------------------------------------------------------------------
56
- // Helpers
57
- // ---------------------------------------------------------------------------
58
-
59
- const WORKSPACE_DIR = "/mock-home/.vellum/workspace";
60
-
61
- // ---------------------------------------------------------------------------
62
- // Tests
63
- // ---------------------------------------------------------------------------
64
-
65
5
  describe("016-migrate-credentials-from-keychain migration", () => {
66
- beforeEach(() => {
67
- isAvailableFn.mockClear();
68
- brokerGetFn.mockClear();
69
- brokerDelFn.mockClear();
70
- brokerListFn.mockClear();
71
- createBrokerClientFn.mockClear();
72
- setKeyFn.mockClear();
73
-
74
- // Defaults: mac production build
75
- process.env.VELLUM_DESKTOP_APP = "1";
76
- delete process.env.VELLUM_DEV;
77
-
78
- isAvailableFn.mockReturnValue(true);
79
- brokerGetFn.mockResolvedValue({ found: true, value: "secret" });
80
- brokerDelFn.mockResolvedValue(true);
81
- brokerListFn.mockResolvedValue([]);
82
- setKeyFn.mockReturnValue(true);
83
- });
84
-
85
6
  test("has correct migration id", () => {
86
7
  expect(migrateCredentialsFromKeychainMigration.id).toBe(
87
8
  "016-migrate-credentials-from-keychain",
88
9
  );
89
10
  });
90
11
 
91
- test("skips when VELLUM_DESKTOP_APP is not set", async () => {
92
- delete process.env.VELLUM_DESKTOP_APP;
93
-
94
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
95
-
96
- expect(createBrokerClientFn).not.toHaveBeenCalled();
97
- expect(brokerListFn).not.toHaveBeenCalled();
98
- });
99
-
100
- test("skips when VELLUM_DESKTOP_APP is not '1'", async () => {
101
- process.env.VELLUM_DESKTOP_APP = "0";
102
-
103
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
104
-
105
- expect(createBrokerClientFn).not.toHaveBeenCalled();
106
- });
107
-
108
- test("skips when VELLUM_DEV=1", async () => {
109
- process.env.VELLUM_DEV = "1";
110
-
111
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
112
-
113
- expect(createBrokerClientFn).not.toHaveBeenCalled();
114
- expect(brokerListFn).not.toHaveBeenCalled();
12
+ test("run is a no-op", async () => {
13
+ await migrateCredentialsFromKeychainMigration.run("/fake");
115
14
  });
116
15
 
117
- test(
118
- "throws when broker is not available (skips checkpoint for retry)",
119
- async () => {
120
- isAvailableFn.mockReturnValue(false);
121
-
122
- // Throwing skips the checkpoint so the migration retries on next startup
123
- await expect(
124
- migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR),
125
- ).rejects.toThrow("Keychain broker not available after waiting");
126
-
127
- // Should not proceed to list or migrate keys
128
- expect(brokerListFn).not.toHaveBeenCalled();
129
- expect(setKeyFn).not.toHaveBeenCalled();
130
- },
131
- { timeout: 10_000 },
132
- );
133
-
134
- test("no-ops when keychain has no accounts", async () => {
135
- brokerListFn.mockResolvedValue([]);
136
-
137
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
138
-
139
- expect(setKeyFn).not.toHaveBeenCalled();
140
- expect(brokerDelFn).not.toHaveBeenCalled();
141
- });
142
-
143
- test("copies credentials from keychain to encrypted store and deletes from keychain", async () => {
144
- brokerListFn.mockResolvedValue(["account-a", "account-b"]);
145
- brokerGetFn.mockImplementation(async (account: string) => {
146
- if (account === "account-a") return { found: true, value: "secret-a" };
147
- if (account === "account-b") return { found: true, value: "secret-b" };
148
- return null;
149
- });
150
- setKeyFn.mockReturnValue(true);
151
-
152
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
153
-
154
- // Should have written each key to encrypted store
155
- expect(setKeyFn).toHaveBeenCalledTimes(2);
156
- expect(setKeyFn).toHaveBeenCalledWith("account-a", "secret-a");
157
- expect(setKeyFn).toHaveBeenCalledWith("account-b", "secret-b");
158
-
159
- // Should have deleted each key from keychain after successful migration
160
- expect(brokerDelFn).toHaveBeenCalledTimes(2);
161
- expect(brokerDelFn).toHaveBeenCalledWith("account-a");
162
- expect(brokerDelFn).toHaveBeenCalledWith("account-b");
163
- });
164
-
165
- test("skips key when broker.get returns null", async () => {
166
- brokerListFn.mockResolvedValue(["ghost-key", "real-key"]);
167
- brokerGetFn.mockImplementation(async (account: string) => {
168
- if (account === "ghost-key") return null;
169
- if (account === "real-key") return { found: true, value: "real-secret" };
170
- return null;
171
- });
172
-
173
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
174
-
175
- // ghost-key should not be written or deleted
176
- expect(setKeyFn).not.toHaveBeenCalledWith(
177
- "ghost-key",
178
- expect.anything(),
179
- );
180
- expect(brokerDelFn).not.toHaveBeenCalledWith("ghost-key");
181
-
182
- // real-key should be migrated
183
- expect(setKeyFn).toHaveBeenCalledWith("real-key", "real-secret");
184
- expect(brokerDelFn).toHaveBeenCalledWith("real-key");
185
- });
186
-
187
- test("skips key when broker.get returns not found", async () => {
188
- brokerListFn.mockResolvedValue(["missing-key"]);
189
- brokerGetFn.mockResolvedValue({ found: false });
190
-
191
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
192
-
193
- expect(setKeyFn).not.toHaveBeenCalled();
194
- expect(brokerDelFn).not.toHaveBeenCalled();
195
- });
196
-
197
- test("skips key when setKey fails and does not delete from keychain", async () => {
198
- brokerListFn.mockResolvedValue(["fail-key", "ok-key"]);
199
- brokerGetFn.mockImplementation(async (account: string) => {
200
- if (account === "fail-key")
201
- return { found: true, value: "fail-secret" };
202
- if (account === "ok-key") return { found: true, value: "ok-secret" };
203
- return null;
204
- });
205
- setKeyFn.mockImplementation((account: string) => {
206
- if (account === "fail-key") return false;
207
- return true;
208
- });
209
-
210
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
211
-
212
- // fail-key should NOT have been deleted from keychain (setKey failed)
213
- expect(brokerDelFn).not.toHaveBeenCalledWith("fail-key");
214
-
215
- // ok-key should have been migrated and deleted
216
- expect(setKeyFn).toHaveBeenCalledWith("ok-key", "ok-secret");
217
- expect(brokerDelFn).toHaveBeenCalledWith("ok-key");
218
- expect(brokerDelFn).toHaveBeenCalledTimes(1);
16
+ test("down is a no-op", async () => {
17
+ await migrateCredentialsFromKeychainMigration.down("/fake");
219
18
  });
220
19
  });
@@ -130,16 +130,16 @@ describe("runWorkspaceMigrations", () => {
130
130
  throw new Error("migration 002 failed");
131
131
  });
132
132
 
133
- await expect(
134
- runWorkspaceMigrations(WORKSPACE_DIR, [m1, m2]),
135
- ).rejects.toThrow("migration 002 failed");
133
+ // Runner no longer throws — it marks failed migrations and continues
134
+ await runWorkspaceMigrations(WORKSPACE_DIR, [m1, m2]);
136
135
 
137
- // m1 ran successfully before the error
136
+ // m1 ran successfully, m2 was attempted
138
137
  expect(m1.run).toHaveBeenCalledTimes(1);
138
+ expect(m2.run).toHaveBeenCalledTimes(1);
139
139
 
140
- // Checkpoints saved: started m1, completed m1, started m2 = 3 writes
141
- expect(writeFileSyncFn).toHaveBeenCalledTimes(3);
142
- expect(renameSyncFn).toHaveBeenCalledTimes(3);
140
+ // Checkpoints saved: started m1, completed m1, started m2, failed m2 = 4 writes
141
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(4);
142
+ expect(renameSyncFn).toHaveBeenCalledTimes(4);
143
143
 
144
144
  // Verify the completed checkpoint contains m1
145
145
  // The second write is the "completed" marker for m1
@@ -149,6 +149,14 @@ describe("runWorkspaceMigrations", () => {
149
149
  const parsed = JSON.parse(completedWrite);
150
150
  expect(parsed.applied["001"]).toBeDefined();
151
151
  expect(parsed.applied["001"].status).toBe("completed");
152
+
153
+ // Verify m2 is marked as failed
154
+ const failedWrite = (
155
+ writeFileSyncFn.mock.calls[3] as unknown[]
156
+ )[1] as string;
157
+ const failedParsed = JSON.parse(failedWrite);
158
+ expect(failedParsed.applied["002"]).toBeDefined();
159
+ expect(failedParsed.applied["002"].status).toBe("failed");
152
160
  });
153
161
 
154
162
  test("idempotent on re-run", async () => {