@vellumai/assistant 0.12.3 → 0.12.4-staging.2
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.
- package/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/openapi.yaml +9 -3
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
- package/src/calls/typesafe-noul.ts +8 -2
- package/src/calls/voice-escalation-judge.ts +5 -4
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/call-site-defaults.ts +11 -11
- package/src/config/default-profile-catalog.ts +88 -18
- package/src/config/default-profile-names.ts +35 -0
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +1 -1
- package/src/config/schemas/llm.ts +54 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/dictation-text-processing.ts +4 -4
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/model-catalog.ts +23 -4
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +100 -0
- package/src/util/worker-pipe.ts +44 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -31,6 +31,14 @@ export const NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD =
|
|
|
31
31
|
export const NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD =
|
|
32
32
|
"notify_documents_changed_externally";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* IPC method the daemon exposes for the conversation-list hand-off. Shared by
|
|
36
|
+
* the worker caller and the daemon route registration so the wire name cannot
|
|
37
|
+
* drift.
|
|
38
|
+
*/
|
|
39
|
+
export const NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD =
|
|
40
|
+
"notify_conversation_list_changed_externally";
|
|
41
|
+
|
|
34
42
|
/**
|
|
35
43
|
* IPC method the daemon exposes for the activation-progress hand-off. Shared
|
|
36
44
|
* by the worker caller and the daemon route registration so the wire name
|
|
@@ -79,6 +87,42 @@ export async function notifyDaemonConversationPersisted(
|
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Ask the daemon to republish a conversation-list-and-metadata invalidation.
|
|
92
|
+
*
|
|
93
|
+
* A worker turn can change which rows the sidebar should hold, not just what
|
|
94
|
+
* one row contains: clearing `archived_at` on a Done conversation the turn
|
|
95
|
+
* woke is the case that matters. The list tag has to reach real subscribers
|
|
96
|
+
* for the conversation to reappear, and the worker's own hub has none.
|
|
97
|
+
*
|
|
98
|
+
* Best-effort by design, like the other hand-offs: an unreachable daemon is
|
|
99
|
+
* logged at debug and swallowed, and the client picks the change up on its
|
|
100
|
+
* next list fetch.
|
|
101
|
+
*/
|
|
102
|
+
export async function notifyDaemonConversationListChanged(
|
|
103
|
+
reason: string,
|
|
104
|
+
conversationIds: string[],
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
const result = await cliIpcCall(
|
|
108
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
|
|
109
|
+
{ body: { reason, conversationIds } },
|
|
110
|
+
{ timeoutMs: NOTIFY_TIMEOUT_MS },
|
|
111
|
+
);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
log.debug(
|
|
114
|
+
{ reason, conversationIds, error: result.error },
|
|
115
|
+
"daemon conversation-list-changed notify was not acknowledged",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
log.debug(
|
|
120
|
+
{ err, reason, conversationIds },
|
|
121
|
+
"daemon conversation-list-changed notify failed",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
/**
|
|
83
127
|
* Ask the daemon to republish the documents-list invalidation.
|
|
84
128
|
*
|
|
@@ -278,7 +278,7 @@ async function startOutboundTelegram(
|
|
|
278
278
|
success: false,
|
|
279
279
|
error: "already_bound",
|
|
280
280
|
message:
|
|
281
|
-
"A guardian is already bound for this channel. Set rebind: true to
|
|
281
|
+
"A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
|
|
282
282
|
channel,
|
|
283
283
|
};
|
|
284
284
|
}
|
|
@@ -820,7 +820,7 @@ async function startOutboundTextChannel(
|
|
|
820
820
|
success: false,
|
|
821
821
|
error: "already_bound",
|
|
822
822
|
message:
|
|
823
|
-
"A guardian is already bound for this channel. Set rebind: true to
|
|
823
|
+
"A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
|
|
824
824
|
channel,
|
|
825
825
|
};
|
|
826
826
|
}
|
|
@@ -200,6 +200,44 @@ describe("AskQuestionTool.execute", () => {
|
|
|
200
200
|
expect(result.isError).toBe(false);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test("says so when the user answered in the chat instead of the card", async () => {
|
|
204
|
+
const q2 = {
|
|
205
|
+
question: "Preferred time?",
|
|
206
|
+
options: [
|
|
207
|
+
{ id: "morning", label: "Morning" },
|
|
208
|
+
{ id: "afternoon", label: "Afternoon" },
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
setNextResult({
|
|
212
|
+
entries: [
|
|
213
|
+
{
|
|
214
|
+
questionId: "q1",
|
|
215
|
+
decision: "free_text",
|
|
216
|
+
text: "banana, after lunch",
|
|
217
|
+
},
|
|
218
|
+
{ questionId: "q2", decision: "skipped" },
|
|
219
|
+
],
|
|
220
|
+
overall: "completed",
|
|
221
|
+
answeredInChat: true,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await askQuestionTool.execute(
|
|
225
|
+
{ questions: [singleQ, q2] },
|
|
226
|
+
makeContext(),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(result.isError).toBe(false);
|
|
230
|
+
const lines = String(result.content).split("\n");
|
|
231
|
+
// The model is told where the answer came from, then reads the batch: the
|
|
232
|
+
// typed text on the first question, the rest unanswered, so it can map the
|
|
233
|
+
// reply onto them itself.
|
|
234
|
+
expect(lines[0]).toContain("replied in the chat");
|
|
235
|
+
expect(lines.slice(1)).toEqual([
|
|
236
|
+
`Question "${singleQ.question}" → Free text: banana, after lunch`,
|
|
237
|
+
`Question "${q2.question}" → Skipped`,
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
203
241
|
test("formats skipped result", async () => {
|
|
204
242
|
setNextResult(singleCompleted({ decision: "skipped" }));
|
|
205
243
|
const result = await askQuestionTool.execute(validInput, makeContext());
|
|
@@ -243,6 +243,15 @@ export function toAnsweredQuestion(
|
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Model-facing note prepended when the user answered by typing in the chat
|
|
248
|
+
* instead of using the card. The text lands as free text on the first
|
|
249
|
+
* question and every other question reads as skipped, so the model is told
|
|
250
|
+
* plainly what happened and left to map the reply onto the batch itself.
|
|
251
|
+
*/
|
|
252
|
+
const CHAT_REPLY_PREAMBLE =
|
|
253
|
+
"The user replied in the chat instead of using the question card, so their message is recorded as free text on the first question below and the remaining questions are unanswered. Read their reply as the answer to whichever questions it covers, and ask again only for what it genuinely leaves open.";
|
|
254
|
+
|
|
246
255
|
// ── Tool ────────────────────────────────────────────────────────────
|
|
247
256
|
|
|
248
257
|
/**
|
|
@@ -389,7 +398,9 @@ export const askQuestionTool = {
|
|
|
389
398
|
|
|
390
399
|
switch (result.overall) {
|
|
391
400
|
case "completed": {
|
|
392
|
-
let content =
|
|
401
|
+
let content = result.answeredInChat
|
|
402
|
+
? [CHAT_REPLY_PREAMBLE, ...lines].join("\n")
|
|
403
|
+
: lines.join("\n");
|
|
393
404
|
if (desktopHelp) {
|
|
394
405
|
const entry = result.entries[0];
|
|
395
406
|
if (entry?.decision === "free_text") {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { getConversationProfilesForProvider } from "../../config/default-profile-catalog.js";
|
|
19
19
|
import { loadConfig } from "../../config/loader.js";
|
|
20
20
|
import { callerOwnsWorkflowRun } from "../../workflows/capabilities.js";
|
|
21
21
|
import type { WorkflowRun } from "../../workflows/journal-store.js";
|
|
@@ -199,7 +199,9 @@ export async function executeManageWorkflows(
|
|
|
199
199
|
// workspace-wide active profile. Read-only — leaves use this to pick a
|
|
200
200
|
// valid `profile` for `run_workflow` (an unknown profile throws).
|
|
201
201
|
const { llm } = loadConfig();
|
|
202
|
-
|
|
202
|
+
// A workflow leaf runs chat turns, so this is the conversation view,
|
|
203
|
+
// which leaves out the managed Jev profile.
|
|
204
|
+
const profiles = getConversationProfilesForProvider(
|
|
203
205
|
llm?.profiles,
|
|
204
206
|
llm?.defaultProvider ?? null,
|
|
205
207
|
);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { writeWorkerLine } from "./worker-pipe.js";
|
|
4
|
+
|
|
5
|
+
/** Comfortably larger than any OS pipe buffer, so the write cannot complete. */
|
|
6
|
+
const OVERSIZED_LINE = "x".repeat(4 * 1024 * 1024);
|
|
7
|
+
|
|
8
|
+
const spawned: { kill(signal?: NodeJS.Signals): void }[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const proc of spawned.splice(0)) {
|
|
12
|
+
try {
|
|
13
|
+
proc.kill("SIGKILL");
|
|
14
|
+
} catch {
|
|
15
|
+
/* already gone */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** A live child that never reads stdin, the way a worker busy in ONNX does not. */
|
|
21
|
+
function spawnBusyWorker() {
|
|
22
|
+
const proc = Bun.spawn({
|
|
23
|
+
cmd: [process.execPath, "-e", "setTimeout(() => {}, 60_000)"],
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
stdin: "pipe",
|
|
26
|
+
stdout: "ignore",
|
|
27
|
+
stderr: "ignore",
|
|
28
|
+
});
|
|
29
|
+
spawned.push(proc);
|
|
30
|
+
return proc;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("writeWorkerLine", () => {
|
|
34
|
+
/**
|
|
35
|
+
* The production failure: the payload outruns the pipe buffer, Bun returns a
|
|
36
|
+
* pending promise, and the worker dies before draining it. Unobserved, that
|
|
37
|
+
* rejection is an `unhandledRejection`, which `bun test` reports as a failure
|
|
38
|
+
* of this test.
|
|
39
|
+
*/
|
|
40
|
+
test("reports a write still pending when the worker dies", async () => {
|
|
41
|
+
const proc = spawnBusyWorker();
|
|
42
|
+
const failure = Promise.withResolvers<unknown>();
|
|
43
|
+
|
|
44
|
+
writeWorkerLine(proc.stdin, OVERSIZED_LINE, failure.resolve);
|
|
45
|
+
proc.kill("SIGKILL");
|
|
46
|
+
|
|
47
|
+
const err = await failure.promise;
|
|
48
|
+
expect(err).toBeInstanceOf(Error);
|
|
49
|
+
expect((err as NodeJS.ErrnoException).code).toBe("EPIPE");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reports a synchronous throw", () => {
|
|
53
|
+
const failures: unknown[] = [];
|
|
54
|
+
const epipe = Object.assign(new Error("EPIPE: broken pipe, write"), {
|
|
55
|
+
code: "EPIPE",
|
|
56
|
+
});
|
|
57
|
+
const stdin = {
|
|
58
|
+
write() {
|
|
59
|
+
throw epipe;
|
|
60
|
+
},
|
|
61
|
+
flush: () => 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
writeWorkerLine(stdin, "line", (err) => failures.push(err));
|
|
65
|
+
|
|
66
|
+
expect(failures).toEqual([epipe]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** `write` and `flush` both report the same broken pipe. */
|
|
70
|
+
test("reports one failure when write and flush both reject", async () => {
|
|
71
|
+
const failures: unknown[] = [];
|
|
72
|
+
const epipe = new Error("EPIPE: broken pipe, send");
|
|
73
|
+
const stdin = {
|
|
74
|
+
write: () => Promise.reject(epipe),
|
|
75
|
+
flush: () => Promise.reject(epipe),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
writeWorkerLine(stdin, "line", (err) => failures.push(err));
|
|
79
|
+
await Bun.sleep(0);
|
|
80
|
+
|
|
81
|
+
expect(failures).toEqual([epipe]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("stays silent when the line is delivered", async () => {
|
|
85
|
+
const failures: unknown[] = [];
|
|
86
|
+
const proc = Bun.spawn({
|
|
87
|
+
cmd: [process.execPath, "-e", "process.stdin.on('data', () => {})"],
|
|
88
|
+
windowsHide: true,
|
|
89
|
+
stdin: "pipe",
|
|
90
|
+
stdout: "ignore",
|
|
91
|
+
stderr: "ignore",
|
|
92
|
+
});
|
|
93
|
+
spawned.push(proc);
|
|
94
|
+
|
|
95
|
+
writeWorkerLine(proc.stdin, "hello", (err) => failures.push(err));
|
|
96
|
+
await Bun.sleep(50);
|
|
97
|
+
|
|
98
|
+
expect(failures).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { FileSink } from "bun";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write one line to a worker subprocess's stdin, reporting a broken pipe to
|
|
5
|
+
* `onFailure` however Bun surfaces it.
|
|
6
|
+
*
|
|
7
|
+
* `FileSink.write` and `FileSink.flush` return `number | Promise<number>`.
|
|
8
|
+
* While the pipe has room they complete synchronously. Once a payload outruns
|
|
9
|
+
* the pipe buffer, which is what happens when the worker is busy and not
|
|
10
|
+
* draining stdin, they return a pending promise instead, and that promise
|
|
11
|
+
* rejects with EPIPE if the worker dies before the write completes. A
|
|
12
|
+
* `try/catch` sees only the synchronous half: the rejection escapes it, reaches
|
|
13
|
+
* the process's `unhandledRejection` handler, and in the daemon that handler
|
|
14
|
+
* shuts the whole process down.
|
|
15
|
+
*
|
|
16
|
+
* `onFailure` runs at most once per call, even though `write` and `flush` can
|
|
17
|
+
* each report the same broken pipe.
|
|
18
|
+
*/
|
|
19
|
+
export function writeWorkerLine(
|
|
20
|
+
stdin: Pick<FileSink, "write" | "flush">,
|
|
21
|
+
line: string,
|
|
22
|
+
onFailure: (err: unknown) => void,
|
|
23
|
+
): void {
|
|
24
|
+
let failed = false;
|
|
25
|
+
const fail = (err: unknown): void => {
|
|
26
|
+
if (failed) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
failed = true;
|
|
30
|
+
onFailure(err);
|
|
31
|
+
};
|
|
32
|
+
const observe = (result: number | Promise<number>): void => {
|
|
33
|
+
if (result instanceof Promise) {
|
|
34
|
+
result.catch(fail);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
observe(stdin.write(line + "\n"));
|
|
40
|
+
observe(stdin.flush());
|
|
41
|
+
} catch (err) {
|
|
42
|
+
fail(err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace migration `159-rename-colliding-jev-profile-name`.
|
|
3
|
+
*
|
|
4
|
+
* `jev-managed` became a reserved code-owned profile name (the managed Jev
|
|
5
|
+
* profile). From this release on, that name resolves to the code body no
|
|
6
|
+
* matter what `llm.profiles` holds, and the schema rejects it as
|
|
7
|
+
* `activeProfile` or `advisorProfile`, so a user-created profile that
|
|
8
|
+
* happens to carry the name would be silently replaced in the effective
|
|
9
|
+
* catalog and its selections stranded.
|
|
10
|
+
*
|
|
11
|
+
* This migration runs before the reservation can take effect (workspace
|
|
12
|
+
* migrations run ahead of the first config load and the profile seeder) and
|
|
13
|
+
* gives such a profile a fresh key, rewriting every reference to it so the
|
|
14
|
+
* user keeps both the profile and its wiring:
|
|
15
|
+
*
|
|
16
|
+
* - `llm.profiles` key (insertion order preserved)
|
|
17
|
+
* - `llm.activeProfile`, `llm.advisorProfile`
|
|
18
|
+
* - `llm.profileOrder` entries
|
|
19
|
+
* - `llm.callSites.<site>.profile` pins
|
|
20
|
+
* - `llm.profiles.<key>.mix[].profile` arms
|
|
21
|
+
* - `llm.profiles.<key>.fallbackProfile` targets
|
|
22
|
+
* - `conversations.inference_profile` and `cron_jobs.inference_profile`
|
|
23
|
+
* rows
|
|
24
|
+
*
|
|
25
|
+
* The new key is `jev-managed-custom`, suffixed `-custom-2`, `-custom-3`,
|
|
26
|
+
* ... when that is taken. Candidates are checked against every existing
|
|
27
|
+
* profile key AND every reserved code-defined name, so a rename never lands
|
|
28
|
+
* on another name the catalog owns.
|
|
29
|
+
*
|
|
30
|
+
* Only a workspace-owned profile is renamed. An entry whose `source` is
|
|
31
|
+
* `managed` is a thin stub for the code-owned profile, and a non-object
|
|
32
|
+
* value under the key is not a profile at all; both are left alone.
|
|
33
|
+
*
|
|
34
|
+
* The DB rewrite is not best effort: a pin the rewrite abandons would route
|
|
35
|
+
* through the managed Jev profile instead of the user's own, so a database
|
|
36
|
+
* that exists but cannot be written is retried with a short backoff and a
|
|
37
|
+
* failure that outlives the retries throws; `retryFailedCheckpoint` re-runs
|
|
38
|
+
* the whole migration on the next boot. The DB rewrite runs before the
|
|
39
|
+
* config write, so a throw leaves config.json holding the old key.
|
|
40
|
+
*
|
|
41
|
+
* Idempotent: after a successful run no workspace-owned profile carries the
|
|
42
|
+
* reserved name, so a re-run finds nothing to rename and writes nothing.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
import { Database } from "bun:sqlite";
|
|
48
|
+
|
|
49
|
+
import { getLogger } from "../../util/logger.js";
|
|
50
|
+
import type { WorkspaceMigration } from "./types.js";
|
|
51
|
+
|
|
52
|
+
const log = getLogger(
|
|
53
|
+
"workspace-migration-159-rename-colliding-jev-profile-name",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The newly reserved key this migration clears. Frozen snapshot as of
|
|
58
|
+
* 2026-09-22 (migrations are self-contained).
|
|
59
|
+
*/
|
|
60
|
+
const RESERVED_KEYS = ["jev-managed"];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every code-defined profile name as of 2026-09-22: the default keys, the
|
|
64
|
+
* backups, the flag-gated `os-beta`, and the Jev profile. A renamed profile
|
|
65
|
+
* must not land on any of them, or the rename would just move the collision.
|
|
66
|
+
*/
|
|
67
|
+
const RESERVED_PROFILE_NAMES = new Set<string>([
|
|
68
|
+
"balanced",
|
|
69
|
+
"quality-optimized",
|
|
70
|
+
"cost-optimized",
|
|
71
|
+
"latency-optimized",
|
|
72
|
+
"balanced-backup",
|
|
73
|
+
"quality-optimized-backup",
|
|
74
|
+
"cost-optimized-backup",
|
|
75
|
+
"latency-optimized-backup",
|
|
76
|
+
"os-beta",
|
|
77
|
+
...RESERVED_KEYS,
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const RENAME_SUFFIX = "-custom";
|
|
81
|
+
|
|
82
|
+
/** Total attempts at the pin rewrite, the first one included. */
|
|
83
|
+
const DB_ATTEMPTS = 4;
|
|
84
|
+
|
|
85
|
+
/** First backoff step; each further wait doubles it (50, 100, 200 ms). */
|
|
86
|
+
const DB_RETRY_BASE_DELAY_MS = 50;
|
|
87
|
+
|
|
88
|
+
export const renameCollidingJevProfileNameMigration: WorkspaceMigration = {
|
|
89
|
+
id: "159-rename-colliding-jev-profile-name",
|
|
90
|
+
description:
|
|
91
|
+
"Rename a user profile colliding with the reserved managed Jev profile key and rewrite its references",
|
|
92
|
+
// The failure this migration can hit is a database it cannot write, and
|
|
93
|
+
// both halves of the run are idempotent, so a failed checkpoint is worth
|
|
94
|
+
// re-running: the config still holds the old keys, the mapping rebuilds
|
|
95
|
+
// from them, and the pin rewrite matches on the old name.
|
|
96
|
+
retryFailedCheckpoint: true,
|
|
97
|
+
|
|
98
|
+
async run(workspaceDir: string): Promise<void> {
|
|
99
|
+
const configPath = join(workspaceDir, "config.json");
|
|
100
|
+
if (!existsSync(configPath)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let config: Record<string, unknown>;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
107
|
+
if (!isPlainObject(parsed)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
config = parsed;
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const llm = asObject(config.llm);
|
|
116
|
+
const profiles = llm === null ? null : asObject(llm.profiles);
|
|
117
|
+
if (llm === null || profiles === null) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const renames = planRenames(profiles);
|
|
122
|
+
if (renames.size === 0) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The DB rewrite runs first so an interrupted or failed migration re-runs
|
|
127
|
+
// with the config still holding the old keys, i.e. with the mapping
|
|
128
|
+
// intact. A rewrite that cannot be completed throws out of here, which
|
|
129
|
+
// stops the config rename below rather than stranding the pins.
|
|
130
|
+
await rewriteDbReferences(workspaceDir, renames);
|
|
131
|
+
|
|
132
|
+
llm.profiles = renameProfileKeys(profiles, renames);
|
|
133
|
+
rewriteConfigReferences(llm, renames);
|
|
134
|
+
|
|
135
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
136
|
+
log.info(
|
|
137
|
+
{ renames: Object.fromEntries(renames) },
|
|
138
|
+
"Renamed a user profile colliding with the reserved managed Jev profile key",
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
down(_workspaceDir: string): void {
|
|
143
|
+
// Forward-only: the reserved names cannot be handed back.
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Old key -> new key for a workspace-owned profile sitting on the reserved
|
|
149
|
+
* name. Deterministic: each assigned name is reserved against later
|
|
150
|
+
* candidates, so the same config always produces the same mapping.
|
|
151
|
+
*/
|
|
152
|
+
function planRenames(profiles: Record<string, unknown>): Map<string, string> {
|
|
153
|
+
const renames = new Map<string, string>();
|
|
154
|
+
const taken = new Set<string>([
|
|
155
|
+
...Object.keys(profiles),
|
|
156
|
+
...RESERVED_PROFILE_NAMES,
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
for (const key of RESERVED_KEYS) {
|
|
160
|
+
if (!(key in profiles)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const entry = asObject(profiles[key]);
|
|
164
|
+
if (entry === null) {
|
|
165
|
+
log.warn(
|
|
166
|
+
{ profile: key },
|
|
167
|
+
"Left a non-object value under the reserved Jev profile key; it is not a profile to preserve",
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (entry.source === "managed") {
|
|
172
|
+
// A managed stub is the workspace's overlay slot for the code-owned
|
|
173
|
+
// profile, not a user profile; renaming it would manufacture a phantom
|
|
174
|
+
// user profile.
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
let candidate = `${key}${RENAME_SUFFIX}`;
|
|
178
|
+
let counter = 2;
|
|
179
|
+
while (taken.has(candidate)) {
|
|
180
|
+
candidate = `${key}${RENAME_SUFFIX}-${counter}`;
|
|
181
|
+
counter += 1;
|
|
182
|
+
}
|
|
183
|
+
taken.add(candidate);
|
|
184
|
+
renames.set(key, candidate);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return renames;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Rebuild the profiles record with the renamed keys, preserving order. */
|
|
191
|
+
function renameProfileKeys(
|
|
192
|
+
profiles: Record<string, unknown>,
|
|
193
|
+
renames: Map<string, string>,
|
|
194
|
+
): Record<string, unknown> {
|
|
195
|
+
const renamed: Record<string, unknown> = {};
|
|
196
|
+
for (const [name, value] of Object.entries(profiles)) {
|
|
197
|
+
renamed[renames.get(name) ?? name] = value;
|
|
198
|
+
}
|
|
199
|
+
return renamed;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Point every config reference at the renamed key. */
|
|
203
|
+
function rewriteConfigReferences(
|
|
204
|
+
llm: Record<string, unknown>,
|
|
205
|
+
renames: Map<string, string>,
|
|
206
|
+
): void {
|
|
207
|
+
for (const field of ["activeProfile", "advisorProfile"]) {
|
|
208
|
+
const current = llm[field];
|
|
209
|
+
if (typeof current === "string" && renames.has(current)) {
|
|
210
|
+
llm[field] = renames.get(current);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (Array.isArray(llm.profileOrder)) {
|
|
215
|
+
llm.profileOrder = (llm.profileOrder as unknown[]).map((name) =>
|
|
216
|
+
typeof name === "string" ? (renames.get(name) ?? name) : name,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const callSites = asObject(llm.callSites);
|
|
221
|
+
if (callSites !== null) {
|
|
222
|
+
for (const value of Object.values(callSites)) {
|
|
223
|
+
const site = asObject(value);
|
|
224
|
+
if (
|
|
225
|
+
site !== null &&
|
|
226
|
+
typeof site.profile === "string" &&
|
|
227
|
+
renames.has(site.profile)
|
|
228
|
+
) {
|
|
229
|
+
site.profile = renames.get(site.profile);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const profiles = asObject(llm.profiles);
|
|
235
|
+
if (profiles === null) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
for (const value of Object.values(profiles)) {
|
|
239
|
+
const entry = asObject(value);
|
|
240
|
+
if (entry === null) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (
|
|
244
|
+
typeof entry.fallbackProfile === "string" &&
|
|
245
|
+
renames.has(entry.fallbackProfile)
|
|
246
|
+
) {
|
|
247
|
+
entry.fallbackProfile = renames.get(entry.fallbackProfile);
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(entry.mix)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
for (const armValue of entry.mix as unknown[]) {
|
|
253
|
+
const arm = asObject(armValue);
|
|
254
|
+
if (
|
|
255
|
+
arm !== null &&
|
|
256
|
+
typeof arm.profile === "string" &&
|
|
257
|
+
renames.has(arm.profile)
|
|
258
|
+
) {
|
|
259
|
+
arm.profile = renames.get(arm.profile);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Repoint the profile pins stored outside config.json: the sticky
|
|
267
|
+
* per-conversation profile and the per-schedule profile.
|
|
268
|
+
*
|
|
269
|
+
* Retried as a whole rather than per statement: opening the database, reading
|
|
270
|
+
* `sqlite_master` and running the updates all fail the same way under write
|
|
271
|
+
* contention (`SQLITE_BUSY` from a second connection), and every step is
|
|
272
|
+
* idempotent, so re-running the block is the simplest recovery that cannot
|
|
273
|
+
* double-apply. A failure that outlives the attempts throws, which fails the
|
|
274
|
+
* migration for `retryFailedCheckpoint` to pick up on the next boot.
|
|
275
|
+
*/
|
|
276
|
+
async function rewriteDbReferences(
|
|
277
|
+
workspaceDir: string,
|
|
278
|
+
renames: Map<string, string>,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
const dbPath = join(workspaceDir, "data", "db", "assistant.db");
|
|
281
|
+
if (!existsSync(dbPath)) {
|
|
282
|
+
// No database yet, so there are no pins that could be stranded.
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await withDbRetry(() => {
|
|
286
|
+
const db = new Database(dbPath);
|
|
287
|
+
try {
|
|
288
|
+
for (const table of ["conversations", "cron_jobs"]) {
|
|
289
|
+
const present = db
|
|
290
|
+
.query(
|
|
291
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
292
|
+
)
|
|
293
|
+
.get(table);
|
|
294
|
+
if (!present) {
|
|
295
|
+
// A workspace whose schema does not carry this table holds no pins
|
|
296
|
+
// in it. Nothing to rewrite, and nothing to fail over.
|
|
297
|
+
log.info({ table }, "No profile pin table to repoint");
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
for (const [from, to] of renames) {
|
|
301
|
+
db.query(
|
|
302
|
+
`UPDATE ${table} SET inference_profile = ? WHERE inference_profile = ?`,
|
|
303
|
+
).run(to, from);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
db.close();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Run the pin rewrite, retrying a transient database failure with a short
|
|
314
|
+
* doubling backoff. Rethrows once the attempts are spent so the caller stops
|
|
315
|
+
* before the config rename.
|
|
316
|
+
*/
|
|
317
|
+
async function withDbRetry(attempt: () => void): Promise<void> {
|
|
318
|
+
for (let index = 0; ; index += 1) {
|
|
319
|
+
try {
|
|
320
|
+
attempt();
|
|
321
|
+
return;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (index >= DB_ATTEMPTS - 1) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Could not repoint conversation and schedule profile pins after ${DB_ATTEMPTS} attempts; leaving the config unrenamed so the next boot retries`,
|
|
326
|
+
{ cause: err },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
log.warn(
|
|
330
|
+
{ err, attempt: index },
|
|
331
|
+
"Could not repoint profile pins; retrying",
|
|
332
|
+
);
|
|
333
|
+
await Bun.sleep(DB_RETRY_BASE_DELAY_MS * 2 ** index);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
339
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function asObject(value: unknown): Record<string, unknown> | null {
|
|
343
|
+
return isPlainObject(value) ? value : null;
|
|
344
|
+
}
|
|
@@ -155,6 +155,7 @@ import { repairRetiredCodexGpt54ModelIdsMigration } from "./154-repair-retired-c
|
|
|
155
155
|
import { moveFrontModelConfigToVoiceMigration } from "./155-move-front-model-config-to-voice.js";
|
|
156
156
|
import { extractWorkspaceMcpJsonMigration } from "./156-extract-workspace-mcp-json.js";
|
|
157
157
|
import { addDesktopFilesLauncherMigration } from "./157-add-desktop-files-launcher.js";
|
|
158
|
+
import { renameCollidingJevProfileNameMigration } from "./159-rename-colliding-jev-profile-name.js";
|
|
158
159
|
import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
|
|
159
160
|
import type { WorkspaceMigration } from "./types.js";
|
|
160
161
|
|
|
@@ -325,4 +326,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
|
|
|
325
326
|
moveFrontModelConfigToVoiceMigration,
|
|
326
327
|
extractWorkspaceMcpJsonMigration,
|
|
327
328
|
addDesktopFilesLauncherMigration,
|
|
329
|
+
renameCollidingJevProfileNameMigration,
|
|
328
330
|
];
|