@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321
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/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/openapi.yaml +97 -4
- 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__/channel-readiness-discord.test.ts +8 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -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__/job-handler-registration-guard.test.ts +1 -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__/openai-responses-prompt-cache.test.ts +3 -1
- package/src/__tests__/platform-callback-registration.test.ts +46 -1
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/pricing.test.ts +33 -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__/scaffold-managed-skill-tool.test.ts +116 -66
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/skill-load-inline-command.test.ts +35 -0
- package/src/__tests__/skill-load-tool.test.ts +42 -11
- package/src/__tests__/skill-tool-factory.test.ts +50 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/__tests__/validate-input.test.ts +73 -0
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/ask-question-tool.ts +50 -0
- package/src/api/events/tool-result.ts +11 -0
- package/src/api/index.ts +7 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/api/responses/home.ts +27 -0
- package/src/avatar/notification-avatar.test.ts +42 -34
- package/src/avatar/notification-avatar.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
- package/src/calls/voice-escalation-judge.ts +6 -5
- package/src/calls/voice-screen-annotation.ts +44 -0
- package/src/calls/voice-session-bridge.ts +22 -0
- package/src/cli/commands/__tests__/plugins.test.ts +29 -1
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/commands/platform/index.help.ts +2 -1
- package/src/cli/commands/platform/index.ts +8 -2
- package/src/cli/commands/plugins.ts +16 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/cli/lib/inspect-plugin.ts +5 -0
- package/src/cli/lib/plugin-catalog-cache.ts +41 -4
- package/src/cli/lib/plugin-catalog-local.ts +9 -0
- package/src/cli/lib/plugin-catalog-platform.ts +48 -9
- package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
- package/src/cli/lib/plugin-details.ts +10 -7
- package/src/cli/lib/search-plugins.ts +2 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
- package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
- package/src/config/call-site-defaults.ts +17 -11
- package/src/config/default-profile-catalog.ts +118 -19
- package/src/config/default-profile-names.ts +69 -0
- package/src/config/feature-flag-registry.json +25 -9
- 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 +8 -1
- package/src/config/schemas/llm.ts +70 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/config/sync-gated-profiles.ts +96 -44
- package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
- package/src/daemon/auto-profile-router.ts +220 -0
- package/src/daemon/conversation-agent-loop.ts +77 -4
- 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/daemon/pkb-reminder-builder.test.ts +10 -3
- package/src/daemon/pkb-reminder-builder.ts +1 -1
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/home/feed-types.ts +2 -0
- package/src/inbound/platform-callback-registration.ts +19 -6
- 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/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
- package/src/live-voice/__tests__/protocol.test.ts +22 -0
- package/src/live-voice/continuation-judge.ts +1 -1
- package/src/live-voice/live-voice-session.ts +7 -6
- package/src/live-voice/protocol.ts +16 -5
- package/src/messaging/providers/gmail/client.ts +4 -0
- package/src/notifications/README.md +11 -3
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
- package/src/notifications/conversation-pairing.ts +23 -8
- package/src/notifications/home-feed-mirror.ts +39 -0
- package/src/notifications/home-feed-side-effect.ts +46 -19
- package/src/oauth/AGENTS.md +7 -1
- 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 +228 -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/persistence/jobs-store.ts +229 -0
- package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
- package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
- package/src/persistence/steps.ts +12 -0
- package/src/platform/sync-avatar.test.ts +29 -0
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/AGENTS.md +5 -3
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
- package/src/plugins/defaults/memory/graph/tools.ts +9 -5
- package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
- package/src/plugins/defaults/memory/job-handlers.ts +8 -2
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
- package/src/plugins/defaults/memory/tools.test.ts +17 -0
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/anthropic/client.ts +2 -2
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/inference/auth.ts +25 -0
- package/src/providers/jev/ask.test.ts +66 -0
- package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/model-catalog.ts +257 -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/openai/codex-models.ts +2 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/search-provider-catalog.ts +10 -0
- 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__/inference-provider-connection-routes.test.ts +60 -0
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
- 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/inference-provider-connection-routes.ts +25 -8
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/runtime/routes/platform-routes.ts +6 -1
- package/src/runtime/routes/plugins-routes.ts +5 -2
- 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/skills/validate-input.ts +75 -11
- package/src/telegram/__tests__/webhook-health.test.ts +8 -0
- 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/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
- package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
- package/src/tools/network/__tests__/web-search.test.ts +96 -0
- package/src/tools/network/web-fetch.ts +30 -9
- package/src/tools/network/web-search.ts +235 -4
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +15 -0
- package/src/tools/skills/load.ts +15 -43
- package/src/tools/skills/scaffold-managed.ts +86 -71
- package/src/tools/skills/skill-tool-factory.ts +2 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +146 -0
- package/src/util/worker-pipe.ts +49 -0
- package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
|
|
5
|
+
import type { WorkspaceMigration } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Repoint legacy-shape LLM fragments (`provider: "openai-compatible"` plus a
|
|
9
|
+
* `provider_connection` binding) whose bound row's endpoint host is
|
|
10
|
+
* opencode.ai to `provider: "opencode"`.
|
|
11
|
+
*
|
|
12
|
+
* DB migration 383 restamps such rows as provider "opencode", and dispatch
|
|
13
|
+
* requires a bound row's provider to equal the fragment's declared provider,
|
|
14
|
+
* so a fragment left declaring "openai-compatible" would either mismatch or
|
|
15
|
+
* auto-resolve to an unrelated generic row. Entry-shape fragments (provider
|
|
16
|
+
* holds the row name) derive their kind from the row and need no rewrite.
|
|
17
|
+
* Rows are judged by their base_url host, not their provider column, so the
|
|
18
|
+
* result is the same whichever migration runs first.
|
|
19
|
+
*
|
|
20
|
+
* Swept in `llm.default` and `llm.profiles.*`; call-site fragments carry no
|
|
21
|
+
* binding (the schema strips a raw `provider_connection`).
|
|
22
|
+
*/
|
|
23
|
+
export const repointOpencodeHostProviderFragmentsMigration: WorkspaceMigration =
|
|
24
|
+
{
|
|
25
|
+
id: "158-repoint-opencode-host-provider-fragments",
|
|
26
|
+
description:
|
|
27
|
+
"Repoint openai-compatible LLM fragments bound to opencode.ai connections to provider opencode",
|
|
28
|
+
run(workspaceDir: string): void {
|
|
29
|
+
const configPath = join(workspaceDir, "config.json");
|
|
30
|
+
if (!existsSync(configPath)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Read outside the parse catch: a transient filesystem error must reach
|
|
35
|
+
// the runner so the migration retries, while malformed JSON is a
|
|
36
|
+
// permanent state this migration cannot repair.
|
|
37
|
+
const rawText = readFileSync(configPath, "utf-8");
|
|
38
|
+
|
|
39
|
+
let config: Record<string, unknown>;
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(rawText);
|
|
42
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
config = raw as Record<string, unknown>;
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const llm = readObject(config.llm);
|
|
51
|
+
if (llm === null) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Rows load lazily: only a legacy-shape openai-compatible fragment
|
|
56
|
+
// needs them. An unreadable DB then fails the run (retried next boot)
|
|
57
|
+
// rather than checkpointing a pass that skipped bound fragments.
|
|
58
|
+
let openCodeRows: Set<string> | null | undefined;
|
|
59
|
+
const isOpenCodeRow = (name: unknown): boolean => {
|
|
60
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (openCodeRows === undefined) {
|
|
64
|
+
openCodeRows = readOpenCodeRowNames(workspaceDir);
|
|
65
|
+
}
|
|
66
|
+
if (openCodeRows === null) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"provider_connections is not readable; retrying the opencode repoint on the next run",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return openCodeRows.has(name);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const repoint = (fragment: Record<string, unknown> | null): boolean => {
|
|
75
|
+
if (
|
|
76
|
+
fragment === null ||
|
|
77
|
+
fragment.provider !== "openai-compatible" ||
|
|
78
|
+
!isOpenCodeRow(fragment.provider_connection)
|
|
79
|
+
) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
fragment.provider = "opencode";
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let changed = repoint(readObject(llm.default));
|
|
87
|
+
const profiles = readObject(llm.profiles);
|
|
88
|
+
if (profiles !== null) {
|
|
89
|
+
for (const rawProfile of Object.values(profiles)) {
|
|
90
|
+
changed = repoint(readObject(rawProfile)) || changed;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!changed) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Write-then-rename so an interrupted write cannot leave config.json
|
|
98
|
+
// truncated for the retry to misread as "nothing to do".
|
|
99
|
+
const tmpPath = `${configPath}.migration-158.tmp`;
|
|
100
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2) + "\n");
|
|
101
|
+
renameSync(tmpPath, configPath);
|
|
102
|
+
},
|
|
103
|
+
// The exact-match rewrite is idempotent, so a transient failure is safe
|
|
104
|
+
// to retry on later startups.
|
|
105
|
+
retryFailedCheckpoint: true,
|
|
106
|
+
down(_workspaceDir: string): void {
|
|
107
|
+
// Forward-only: the rows these fragments bind now carry provider
|
|
108
|
+
// "opencode", so reverting the fragments would recreate the mismatch.
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Helpers: self-contained per workspace migrations AGENTS.md
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Names of `provider_connections` rows whose base_url host is opencode.ai or
|
|
118
|
+
* a subdomain, or null when the DB or table is not readable. An absent DB
|
|
119
|
+
* file is a real state (no rows, so no fragment is bound to one).
|
|
120
|
+
*/
|
|
121
|
+
function readOpenCodeRowNames(workspaceDir: string): Set<string> | null {
|
|
122
|
+
const dbPath = join(workspaceDir, "data", "db", "assistant.db");
|
|
123
|
+
if (!existsSync(dbPath)) {
|
|
124
|
+
return new Set();
|
|
125
|
+
}
|
|
126
|
+
let db: Database;
|
|
127
|
+
try {
|
|
128
|
+
db = new Database(dbPath, { readonly: true });
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const rows = db
|
|
134
|
+
.query(
|
|
135
|
+
`SELECT name, base_url FROM provider_connections WHERE base_url IS NOT NULL`,
|
|
136
|
+
)
|
|
137
|
+
.all() as Array<{ name: string; base_url: string }>;
|
|
138
|
+
return new Set(
|
|
139
|
+
rows.filter((row) => isOpenCodeHost(row.base_url)).map((row) => row.name),
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
} finally {
|
|
144
|
+
db.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isOpenCodeHost(baseUrl: string): boolean {
|
|
149
|
+
let host: string;
|
|
150
|
+
try {
|
|
151
|
+
host = new URL(baseUrl).hostname.toLowerCase();
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return host === "opencode.ai" || host.endsWith(".opencode.ai");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readObject(value: unknown): Record<string, unknown> | null {
|
|
159
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return value as Record<string, unknown>;
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|
package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { WORKSPACE_MIGRATIONS } from "../registry.js";
|
|
14
|
+
|
|
15
|
+
const migration = WORKSPACE_MIGRATIONS.find(
|
|
16
|
+
(entry) => entry.id === "158-repoint-opencode-host-provider-fragments",
|
|
17
|
+
)!;
|
|
18
|
+
|
|
19
|
+
const workspaces: string[] = [];
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const workspace of workspaces.splice(0)) {
|
|
22
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function setup(): string {
|
|
27
|
+
const workspace = mkdtempSync(join(tmpdir(), "opencode-repoint-migration-"));
|
|
28
|
+
workspaces.push(workspace);
|
|
29
|
+
return workspace;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeConfig(workspace: string, data: Record<string, unknown>): void {
|
|
33
|
+
writeFileSync(
|
|
34
|
+
join(workspace, "config.json"),
|
|
35
|
+
JSON.stringify(data, null, 2) + "\n",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readLlm(workspace: string): Record<string, any> {
|
|
40
|
+
return JSON.parse(readFileSync(join(workspace, "config.json"), "utf-8")).llm;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function seedRows(
|
|
44
|
+
workspace: string,
|
|
45
|
+
rows: Array<{ name: string; provider: string; baseUrl: string | null }>,
|
|
46
|
+
): void {
|
|
47
|
+
mkdirSync(join(workspace, "data", "db"), { recursive: true });
|
|
48
|
+
const db = new Database(join(workspace, "data", "db", "assistant.db"));
|
|
49
|
+
db.run(`CREATE TABLE provider_connections (
|
|
50
|
+
name TEXT PRIMARY KEY,
|
|
51
|
+
provider TEXT NOT NULL,
|
|
52
|
+
auth TEXT NOT NULL,
|
|
53
|
+
base_url TEXT,
|
|
54
|
+
created_at INTEGER NOT NULL,
|
|
55
|
+
updated_at INTEGER NOT NULL
|
|
56
|
+
)`);
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
db.query(
|
|
59
|
+
`INSERT INTO provider_connections (name, provider, auth, base_url, created_at, updated_at)
|
|
60
|
+
VALUES (?, ?, '{"type":"api_key","credential":"cred"}', ?, 1, 1)`,
|
|
61
|
+
).run(row.name, row.provider, row.baseUrl);
|
|
62
|
+
}
|
|
63
|
+
db.close();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("migration 158: repoint opencode-host provider fragments", () => {
|
|
67
|
+
test("repoints legacy-shape fragments bound to an opencode.ai row, whichever provider the row stores", () => {
|
|
68
|
+
const workspace = setup();
|
|
69
|
+
seedRows(workspace, [
|
|
70
|
+
{
|
|
71
|
+
name: "go",
|
|
72
|
+
provider: "openai-compatible",
|
|
73
|
+
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "zen",
|
|
77
|
+
provider: "opencode",
|
|
78
|
+
baseUrl: "https://opencode.ai/zen/v1",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "vllm",
|
|
82
|
+
provider: "openai-compatible",
|
|
83
|
+
baseUrl: "http://localhost:8080/v1",
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
writeConfig(workspace, {
|
|
87
|
+
llm: {
|
|
88
|
+
default: {
|
|
89
|
+
provider: "openai-compatible",
|
|
90
|
+
provider_connection: "go",
|
|
91
|
+
model: "m",
|
|
92
|
+
},
|
|
93
|
+
profiles: {
|
|
94
|
+
a: {
|
|
95
|
+
provider: "openai-compatible",
|
|
96
|
+
provider_connection: "go",
|
|
97
|
+
model: "m",
|
|
98
|
+
},
|
|
99
|
+
b: {
|
|
100
|
+
provider: "openai-compatible",
|
|
101
|
+
provider_connection: "zen",
|
|
102
|
+
model: "m",
|
|
103
|
+
},
|
|
104
|
+
c: {
|
|
105
|
+
provider: "openai-compatible",
|
|
106
|
+
provider_connection: "vllm",
|
|
107
|
+
model: "m",
|
|
108
|
+
},
|
|
109
|
+
entry: { provider: "go", model: "m" },
|
|
110
|
+
unbound: { provider: "openai-compatible", model: "m" },
|
|
111
|
+
dangling: {
|
|
112
|
+
provider: "openai-compatible",
|
|
113
|
+
provider_connection: "gone",
|
|
114
|
+
model: "m",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
migration.run(workspace);
|
|
121
|
+
|
|
122
|
+
const llm = readLlm(workspace);
|
|
123
|
+
expect(llm.default.provider).toBe("opencode");
|
|
124
|
+
expect(llm.default.provider_connection).toBe("go");
|
|
125
|
+
expect(llm.profiles.a.provider).toBe("opencode");
|
|
126
|
+
expect(llm.profiles.b.provider).toBe("opencode");
|
|
127
|
+
expect(llm.profiles.c.provider).toBe("openai-compatible");
|
|
128
|
+
expect(llm.profiles.entry.provider).toBe("go");
|
|
129
|
+
expect(llm.profiles.unbound.provider).toBe("openai-compatible");
|
|
130
|
+
expect(llm.profiles.dangling.provider).toBe("openai-compatible");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("no-ops without config, without a db, or with nothing bound", () => {
|
|
134
|
+
const noConfig = setup();
|
|
135
|
+
expect(() => migration.run(noConfig)).not.toThrow();
|
|
136
|
+
|
|
137
|
+
const noDb = setup();
|
|
138
|
+
writeConfig(noDb, {
|
|
139
|
+
llm: {
|
|
140
|
+
profiles: {
|
|
141
|
+
a: { provider: "openai-compatible", provider_connection: "go" },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
migration.run(noDb);
|
|
146
|
+
expect(readLlm(noDb).profiles.a.provider).toBe("openai-compatible");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("fails the run instead of checkpointing when the db is unreadable", () => {
|
|
150
|
+
const workspace = setup();
|
|
151
|
+
seedRows(workspace, []);
|
|
152
|
+
writeFileSync(
|
|
153
|
+
join(workspace, "data", "db", "assistant.db"),
|
|
154
|
+
"not a database",
|
|
155
|
+
);
|
|
156
|
+
writeConfig(workspace, {
|
|
157
|
+
llm: {
|
|
158
|
+
profiles: {
|
|
159
|
+
a: { provider: "openai-compatible", provider_connection: "go" },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
expect(() => migration.run(workspace)).toThrow(/not readable/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("is idempotent", () => {
|
|
167
|
+
const workspace = setup();
|
|
168
|
+
seedRows(workspace, [
|
|
169
|
+
{
|
|
170
|
+
name: "go",
|
|
171
|
+
provider: "openai-compatible",
|
|
172
|
+
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
writeConfig(workspace, {
|
|
176
|
+
llm: {
|
|
177
|
+
profiles: {
|
|
178
|
+
a: { provider: "openai-compatible", provider_connection: "go" },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
migration.run(workspace);
|
|
183
|
+
const once = readFileSync(join(workspace, "config.json"), "utf-8");
|
|
184
|
+
migration.run(workspace);
|
|
185
|
+
expect(readFileSync(join(workspace, "config.json"), "utf-8")).toBe(once);
|
|
186
|
+
expect(readLlm(workspace).profiles.a.provider).toBe("opencode");
|
|
187
|
+
});
|
|
188
|
+
});
|