Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 23e4e35ae8 fix(core): tolerate older migration schemas 2026-08-11 12:16:59 -04:00
5 changed files with 202 additions and 86 deletions
+81 -5
View File
@@ -159,6 +159,58 @@ type NextMessage = {
readonly data: string
}
type SourceColumns<Row> = Readonly<Record<keyof Row, true | null>>
const nextProjectColumns = {
id: true,
worktree: true,
vcs: null,
name: null,
icon_url: null,
icon_url_override: null,
icon_color: null,
time_created: true,
time_updated: true,
time_initialized: null,
sandboxes: true,
commands: null,
} satisfies SourceColumns<NextProject>
const nextSessionColumns = {
id: true,
project_id: true,
workspace_id: null,
parent_id: null,
fork_session_id: null,
fork_boundary: null,
slug: true,
directory: true,
path: null,
title: null,
version: true,
share_url: null,
summary_additions: null,
summary_deletions: null,
summary_files: null,
summary_diffs: null,
metadata: null,
cost: true,
tokens_input: true,
tokens_output: true,
tokens_reasoning: true,
tokens_cache_read: true,
tokens_cache_write: true,
revert: null,
permission: null,
agent: null,
model: null,
time_created: true,
time_updated: true,
time_compacting: null,
time_archived: null,
time_suspended: null,
} satisfies SourceColumns<NextSession>
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
@@ -678,12 +730,9 @@ function importNextDatabase(
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
selectSourceRows<NextProject>(source, "project", nextProjectColumns).map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
const sessions = selectSourceRows<NextSession>(source, "session", nextSessionColumns, "id")
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
@@ -776,6 +825,33 @@ function isNextDatabase(source: SQLiteDatabase) {
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function selectSourceRows<Row>(
source: SQLiteDatabase,
table: "project" | "session",
columns: SourceColumns<Row>,
orderBy?: keyof Row,
) {
const available = new Set(
source
.query<{ name: string }, []>(`PRAGMA table_info("${table}")`)
.all()
.map((column) => column.name),
)
const selection = Object.entries(columns)
.map(([name, required]) => {
if (available.has(name)) return `"${name}"`
if (!required) return `NULL AS "${name}"`
throw new Error(`Previous V2 database ${table} table is missing required column ${name}`)
})
.join(", ")
return source
.query<
Row,
[]
>(`SELECT ${selection} FROM "${table}"${orderBy === undefined ? "" : ` ORDER BY "${String(orderBy)}" DESC`}`)
.all()
}
function row(
source: SourceMessage,
message: {
@@ -1,6 +1,5 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import type { Model } from "../../model"
import { SessionMessage } from "../message"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
@@ -15,13 +14,6 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const attachmentLocation = (file: FileAttachment) => {
if (file.source.type !== "uri") return undefined
const url = URL.parse(file.source.uri)
if (url?.protocol !== "file:") return undefined
return fileURLToPath(url, { windows: url.hostname !== "" || /^\/[a-zA-Z]:\//.test(url.pathname) })
}
const textAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
@@ -44,7 +36,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
const directoryAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
@@ -63,10 +55,7 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime)) {
const location = attachmentLocation(file)
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
}
if (imageMimes.has(file.mime)) return [media(file)]
return []
}
@@ -0,0 +1,74 @@
CREATE TABLE project (
id text PRIMARY KEY,
worktree text NOT NULL,
vcs text,
name text,
icon_url text,
time_created integer NOT NULL,
time_updated integer NOT NULL,
time_initialized integer,
sandboxes text NOT NULL
);
CREATE TABLE session (
id text PRIMARY KEY,
project_id text NOT NULL,
workspace_id text,
parent_id text,
fork_session_id text,
fork_message_id text,
fork_seq integer,
slug text NOT NULL,
directory text NOT NULL,
path text,
title text,
version text NOT NULL,
share_url text,
summary_additions integer,
summary_deletions integer,
summary_files integer,
summary_diffs text,
metadata text,
cost real DEFAULT 0 NOT NULL,
tokens_input integer DEFAULT 0 NOT NULL,
tokens_output integer DEFAULT 0 NOT NULL,
tokens_reasoning integer DEFAULT 0 NOT NULL,
tokens_cache_read integer DEFAULT 0 NOT NULL,
tokens_cache_write integer DEFAULT 0 NOT NULL,
revert text,
permission text,
agent text,
model text,
time_created integer NOT NULL,
time_updated integer NOT NULL,
time_compacting integer,
time_archived integer
);
CREATE TABLE session_message (
id text PRIMARY KEY,
session_id text NOT NULL,
type text NOT NULL,
seq integer NOT NULL,
time_created integer NOT NULL,
time_updated integer NOT NULL,
data text NOT NULL
);
INSERT INTO project (
id, worktree, vcs, name, icon_url, time_created, time_updated, time_initialized, sandboxes
) VALUES (
'old-project', '/tmp/old-next', 'git', 'Old project', 'https://example.test/icon.png', 1, 2, 3, '[]'
);
INSERT INTO session (
id, project_id, fork_session_id, fork_message_id, fork_seq, slug, directory, title, version,
time_created, time_updated
) VALUES (
'ses_old_next', 'old-project', 'ses_parent', 'msg_parent', 4, 'old-next', '/tmp/old-next',
'Old imported session', '2', 10, 20
);
INSERT INTO session_message VALUES (
'msg_old_next', 'ses_old_next', 'user', 0, 12, 13, '{"text":"from old next","time":{"created":12}}'
);
@@ -249,14 +249,13 @@ Recent work
])
})
test("exposes admitted reference directory source paths in model context", () => {
test("lowers directory attachments as directory context", () => {
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///references/harness-engineering" },
name: "harness-engineering",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
})
expect(directory.source).toEqual({ type: "uri", uri: "file:///references/harness-engineering" })
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -278,8 +277,8 @@ Recent work
{ type: "text", text: "Review this directory" },
{
type: "text",
text: "\n\nAttached directory: /references/harness-engineering\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "src/" } },
},
],
})
@@ -315,7 +314,7 @@ Recent work
expect(messages).toHaveLength(1)
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
"Review these attachments",
"\n\nAttached directory: /project/src\n\nindex.ts",
"\n\nAttached directory: src/\n\nindex.ts",
"\n\nAttached file: main.ts\n\nexport const value = 1",
])
})
@@ -342,9 +341,7 @@ Recent work
)
expect(messages).toHaveLength(1)
expect(messages[0]?.content).toMatchObject([
{ type: "text", text: "\n\nAttached directory: /project/src\n\nindex.ts" },
])
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
@@ -376,64 +373,6 @@ Recent work
])
})
test("exposes admitted local image source paths before provider media", () => {
const data = Base64.make("AAECAw==")
const image = FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/IMG_3480.JPG" },
name: "IMG_3480.JPG",
})
expect(image.source).toEqual({ type: "uri", uri: "file:///project/IMG_3480.JPG" })
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-local-image-path"),
type: "user",
text: "Inspect this image",
files: [image],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "text", text: "Attached file: /project/IMG_3480.JPG" },
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
])
})
test("does not add attachment location text for non-local provider media", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-remote-image"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "https://example.com/image.png" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
+38
View File
@@ -945,6 +945,44 @@ describe("V1Migration database workflow", () => {
)
})
test("imports previous V2 sessions from an older source schema", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "opencode-next.db")
const sqlite = await import("bun:sqlite")
const source = new sqlite.Database(filename)
source.exec(await Bun.file(path.join(import.meta.dir, "fixture/v1-migration-old-next.sql")).text())
source.close()
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
expect(
yield* db.get(
sql`SELECT fork_session_id, fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_old_next'`,
),
).toEqual({ fork_session_id: "ses_parent", fork_boundary: null, time_suspended: null })
expect(
yield* db.get(
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'old-project'`,
),
).toEqual({
icon_url: "https://example.test/icon.png",
icon_url_override: null,
icon_color: null,
commands: null,
})
expect(yield* db.all(sql`SELECT id, seq FROM session_message WHERE session_id = 'ses_old_next'`)).toEqual([
{ id: "msg_old_next", seq: 0 },
])
expect(yield* db.get(sql`SELECT seq FROM event_sequence WHERE aggregate_id = 'ses_old_next'`)).toEqual({
seq: 0,
})
}),
)
})
test("derives required status from the durable cursor", async () => {
await database(
Effect.gen(function* () {