Skip to content

Commit 8dff51f

Browse files
committed
fix: harden okcode release migrations
1 parent e948609 commit 8dff51f

3 files changed

Lines changed: 130 additions & 1 deletion

File tree

apps/server/src/persistence/Migrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import Migration0023 from "./Migrations/023_ProjectionPendingUserInputsBackfill.
3838
import Migration0024 from "./Migrations/024_OpenclawGatewayConfig.ts";
3939
import Migration0025 from "./Migrations/025_CanonicalizeModelSelections.ts";
4040
import Migration0026 from "./Migrations/025_ProjectionProjectIconPath.ts";
41+
import Migration0027 from "./Migrations/027_CanonicalizeModelSelectionsBackfill.ts";
4142
import { Effect } from "effect";
4243

4344
/**
@@ -77,6 +78,7 @@ const loader = Migrator.fromRecord({
7778
"24_OpenclawGatewayConfig": Migration0024,
7879
"25_CanonicalizeModelSelections": Migration0025,
7980
"26_ProjectionProjectIconPath": Migration0026,
81+
"27_CanonicalizeModelSelectionsBackfill": Migration0027,
8082
});
8183

8284
/**

apps/server/src/persistence/Migrations/025_ProjectionProjectIconPath.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ export default Effect.gen(function* () {
77
yield* sql`
88
ALTER TABLE projection_projects
99
ADD COLUMN icon_path TEXT
10-
`;
10+
`.pipe(Effect.catch(() => Effect.void));
1111
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { ModelSelection, ProviderKind, ProviderModelOptions } from "@okcode/contracts";
2+
import { toCanonicalModelSelection } from "@okcode/shared/modelSelection";
3+
import { Effect } from "effect";
4+
import * as SqlClient from "effect/unstable/sql/SqlClient";
5+
6+
type ProjectRow = {
7+
readonly projectId: string;
8+
readonly defaultModel: string | null;
9+
readonly defaultModelSelection: string | null;
10+
};
11+
12+
type ThreadRow = {
13+
readonly threadId: string;
14+
readonly model: string | null;
15+
readonly modelSelection: string | null;
16+
};
17+
18+
function inferProviderKind(model: string): ProviderKind {
19+
if (model.startsWith("claude-")) return "claudeAgent";
20+
if (model.startsWith("openclaw/")) return "openclaw";
21+
if (model.startsWith("copilot/")) return "copilot";
22+
if (model.startsWith("gemini-") || model.startsWith("auto-gemini-")) return "gemini";
23+
return "codex";
24+
}
25+
26+
function parseStoredSelection(raw: string | null | undefined): ModelSelection | string | null {
27+
const trimmed = typeof raw === "string" ? raw.trim() : "";
28+
if (!trimmed) return null;
29+
30+
try {
31+
const parsed = JSON.parse(trimmed) as unknown;
32+
if (typeof parsed === "string") {
33+
return parseStoredSelection(parsed);
34+
}
35+
if (
36+
parsed &&
37+
typeof parsed === "object" &&
38+
"provider" in parsed &&
39+
typeof parsed.provider === "string" &&
40+
"model" in parsed &&
41+
typeof parsed.model === "string"
42+
) {
43+
return parsed as ModelSelection;
44+
}
45+
} catch {
46+
// Fall through and treat legacy plain strings as model slugs.
47+
}
48+
49+
return trimmed;
50+
}
51+
52+
function toCanonicalSelectionJson(
53+
rawSelection: string | null | undefined,
54+
fallbackModel: string | null | undefined,
55+
): string | null {
56+
const parsed = parseStoredSelection(rawSelection);
57+
58+
if (parsed && typeof parsed === "object") {
59+
const providerOptions = parsed.options
60+
? ({ [parsed.provider]: parsed.options } as ProviderModelOptions)
61+
: undefined;
62+
return JSON.stringify(
63+
toCanonicalModelSelection(parsed.provider, parsed.model, providerOptions),
64+
);
65+
}
66+
67+
const model =
68+
typeof parsed === "string" && parsed.length > 0
69+
? parsed
70+
: typeof fallbackModel === "string" && fallbackModel.trim().length > 0
71+
? fallbackModel.trim()
72+
: null;
73+
74+
if (!model) return null;
75+
return JSON.stringify(toCanonicalModelSelection(inferProviderKind(model), model, undefined));
76+
}
77+
78+
export default Effect.gen(function* () {
79+
const sql = yield* SqlClient.SqlClient;
80+
81+
yield* sql`
82+
ALTER TABLE projection_projects
83+
ADD COLUMN default_model_selection TEXT
84+
`.pipe(Effect.catch(() => Effect.void));
85+
86+
yield* sql`
87+
ALTER TABLE projection_threads
88+
ADD COLUMN model_selection TEXT
89+
`.pipe(Effect.catch(() => Effect.void));
90+
91+
const projectRows = yield* sql<ProjectRow>`
92+
SELECT
93+
project_id AS projectId,
94+
default_model AS defaultModel,
95+
default_model_selection AS defaultModelSelection
96+
FROM projection_projects
97+
`;
98+
99+
for (const row of projectRows) {
100+
const nextSelection = toCanonicalSelectionJson(
101+
row.defaultModelSelection,
102+
row.defaultModel,
103+
);
104+
yield* sql`
105+
UPDATE projection_projects
106+
SET default_model_selection = ${nextSelection}
107+
WHERE project_id = ${row.projectId}
108+
`;
109+
}
110+
111+
const threadRows = yield* sql<ThreadRow>`
112+
SELECT
113+
thread_id AS threadId,
114+
model,
115+
model_selection AS modelSelection
116+
FROM projection_threads
117+
`;
118+
119+
for (const row of threadRows) {
120+
const nextSelection = toCanonicalSelectionJson(row.modelSelection, row.model);
121+
yield* sql`
122+
UPDATE projection_threads
123+
SET model_selection = ${nextSelection}
124+
WHERE thread_id = ${row.threadId}
125+
`;
126+
}
127+
});

0 commit comments

Comments
 (0)