From 5451dae867304b651919b7bc7ec5d3cac1ce28a2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 10 Mar 2026 21:27:02 -0400 Subject: [PATCH 1/7] feat(project): brand ProjectID through Drizzle and Zod schemas Introduce Effect branded ProjectID type into Drizzle table columns and Zod schemas so the brand flows through z.infer automatically. Adds ProjectID.zod helper to bridge Effect brands into Zod, eliminating manual Omit & intersect type overrides. --- packages/opencode/src/control-plane/workspace.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f3af985cac6..6b0b45956a4 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -10,6 +10,7 @@ import { ProjectID } from "@/project/schema" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" +import type { WorkspaceInfo as WorkspaceInfoType } from "./types" import { parseSSE } from "./sse" export namespace Workspace { @@ -31,7 +32,7 @@ export namespace Workspace { export const Info = WorkspaceInfo.meta({ ref: "Workspace", }) - export type Info = z.infer + export type Info = WorkspaceInfoType function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { @@ -66,7 +67,7 @@ export namespace Workspace { name: config.name ?? null, directory: config.directory ?? null, extra: config.extra ?? null, - projectID: input.projectID, + projectID: config.projectID, } Database.use((db) => { From f510d2193dc07a79854328ee4e425ff0ae3e3654 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 10 Mar 2026 21:33:22 -0400 Subject: [PATCH 2/7] fix: address PR feedback - Remove unnecessary WorkspaceInfoType alias import - Use input.projectID instead of config.projectID in workspace create - Compare against ProjectID.global instead of String coercion in test --- packages/opencode/src/control-plane/workspace.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 6b0b45956a4..c48bd0b263e 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -10,7 +10,6 @@ import { ProjectID } from "@/project/schema" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" -import type { WorkspaceInfo as WorkspaceInfoType } from "./types" import { parseSSE } from "./sse" export namespace Workspace { @@ -32,7 +31,7 @@ export namespace Workspace { export const Info = WorkspaceInfo.meta({ ref: "Workspace", }) - export type Info = WorkspaceInfoType + export type Info = z.infer function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { @@ -67,7 +66,7 @@ export namespace Workspace { name: config.name ?? null, directory: config.directory ?? null, extra: config.extra ?? null, - projectID: config.projectID, + projectID: input.projectID, } Database.use((db) => { From 62c9a277a5bbc13d0fbfdf164bc49391918f5087 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 10 Mar 2026 21:35:03 -0400 Subject: [PATCH 3/7] fix: use z.infer instead of z.infer --- packages/opencode/src/control-plane/workspace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index c48bd0b263e..f3af985cac6 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -31,7 +31,7 @@ export namespace Workspace { export const Info = WorkspaceInfo.meta({ ref: "Workspace", }) - export type Info = z.infer + export type Info = z.infer function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { From ce6a5a1f6ff32c4d202e62fef8c1b0e6d1d89c6a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 10 Mar 2026 22:37:31 -0400 Subject: [PATCH 4/7] feat(message): brand MessageID through Drizzle and Zod schemas Same pattern as ProjectID/SessionID: add branded MessageID type with MessageID.zod and MessageID.ascending() helpers, type Drizzle columns with $type(), and replace all Identifier.schema("message") and z.string() usages. Brand flows through z.infer automatically. --- packages/opencode/script/seed-e2e.ts | 3 ++- packages/opencode/src/cli/cmd/account.ts | 11 +++++++---- packages/opencode/src/cli/cmd/debug/agent.ts | 3 ++- packages/opencode/src/cli/cmd/github.ts | 5 +++-- packages/opencode/src/cli/cmd/import.ts | 13 ++++++++----- packages/opencode/src/command/index.ts | 4 ++-- packages/opencode/src/permission/index.ts | 4 ++-- packages/opencode/src/permission/next.ts | 4 ++-- packages/opencode/src/question/index.ts | 6 +++--- packages/opencode/src/server/routes/session.ts | 14 +++++++------- packages/opencode/src/session/compaction.ts | 12 ++++++------ packages/opencode/src/session/index.ts | 18 +++++++++--------- packages/opencode/src/session/message-v2.ts | 18 +++++++++--------- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/prompt.ts | 18 +++++++++--------- packages/opencode/src/session/revert.ts | 4 ++-- packages/opencode/src/session/schema.ts | 12 ++++++++++++ packages/opencode/src/session/session.sql.ts | 7 ++++--- packages/opencode/src/session/summary.ts | 6 +++--- packages/opencode/src/tool/plan.ts | 6 +++--- packages/opencode/src/tool/task.ts | 4 ++-- packages/opencode/src/tool/tool.ts | 4 ++-- packages/opencode/test/cli/account.test.ts | 18 ++++++++++++++++++ .../opencode/test/cli/github-action.test.ts | 14 +++++++------- .../opencode/test/memory/abort-leak.test.ts | 4 ++-- packages/opencode/test/session/llm.test.ts | 10 +++++----- .../opencode/test/session/message-v2.test.ts | 4 ++-- .../test/session/revert-compact.test.ts | 13 +++++++------ packages/opencode/test/session/session.test.ts | 3 ++- .../test/storage/json-migration.test.ts | 12 ++++++------ .../opencode/test/tool/apply_patch.test.ts | 4 ++-- packages/opencode/test/tool/bash.test.ts | 4 ++-- packages/opencode/test/tool/edit.test.ts | 4 ++-- .../test/tool/external-directory.test.ts | 4 ++-- packages/opencode/test/tool/grep.test.ts | 4 ++-- packages/opencode/test/tool/question.test.ts | 4 ++-- packages/opencode/test/tool/read.test.ts | 4 ++-- packages/opencode/test/tool/skill.test.ts | 4 ++-- packages/opencode/test/tool/webfetch.test.ts | 4 ++-- packages/opencode/test/tool/write.test.ts | 4 ++-- 40 files changed, 169 insertions(+), 127 deletions(-) create mode 100644 packages/opencode/test/cli/account.test.ts diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index ba2155cb692..2c6abfa0f53 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -12,6 +12,7 @@ const seed = async () => { const { InstanceBootstrap } = await import("../src/project/bootstrap") const { Session } = await import("../src/session") const { Identifier } = await import("../src/id/id") + const { MessageID } = await import("../src/session/schema") const { Project } = await import("../src/project/project") await Instance.provide({ @@ -19,7 +20,7 @@ const seed = async () => { init: InstanceBootstrap, fn: async () => { const session = await Session.create({ title }) - const messageID = Identifier.descending("message") + const messageID = MessageID.make(Identifier.descending("message")) const partID = Identifier.descending("part") const message = { id: messageID, diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index dd0834a3d80..739eeb3a6f7 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -11,6 +11,11 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => const println = (msg: string) => Effect.sync(() => UI.println(msg)) +export const isActiveOrgChoice = ( + active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>, + choice: { accountID: AccountID; orgID: OrgID }, +) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID + const loginEffect = Effect.fn("login")(function* (url: string) { const service = yield* AccountService @@ -99,11 +104,10 @@ const switchEffect = Effect.fn("switch")(function* () { if (groups.length === 0) return yield* println("Not logged in") const active = yield* service.active() - const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id)) const opts = groups.flatMap((group) => group.orgs.map((org) => { - const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id + const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) return { value: { orgID: org.id, accountID: group.account.id, label: org.name }, label: isActive @@ -132,11 +136,10 @@ const orgsEffect = Effect.fn("orgs")(function* () { if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found") const active = yield* service.active() - const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id)) for (const group of groups) { for (const org of group.orgs) { - const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id + const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " " const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index fe300348597..47e1d36373f 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -5,6 +5,7 @@ import { Provider } from "../../../provider/provider" import { Session } from "../../../session" import type { MessageV2 } from "../../../session/message-v2" import { Identifier } from "../../../id/id" +import { MessageID } from "../../../session/schema" import { ToolRegistry } from "../../../tool/registry" import { Instance } from "../../../project/instance" import { PermissionNext } from "../../../permission/next" @@ -113,7 +114,7 @@ function parseToolParams(input?: string) { async function createToolContext(agent: Agent.Info) { const session = await Session.create({ title: `Debug tool run (${agent.name})` }) - const messageID = Identifier.ascending("message") + const messageID = MessageID.ascending() const model = agent.model ?? (await Provider.defaultModel()) const now = Date.now() const message: MessageV2.Assistant = { diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index ad893ce4f3e..af9ff4a4289 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -24,6 +24,7 @@ import { bootstrap } from "../bootstrap" import { Session } from "../../session" import type { SessionID } from "../../session/schema" import { Identifier } from "../../id/id" +import { MessageID } from "../../session/schema" import { Provider } from "../../provider/provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" @@ -935,7 +936,7 @@ export const GithubRunCommand = cmd({ const result = await SessionPrompt.prompt({ sessionID: session.id, - messageID: Identifier.ascending("message"), + messageID: MessageID.ascending(), variant, model: { providerID, @@ -989,7 +990,7 @@ export const GithubRunCommand = cmd({ console.log("Requesting summary from agent...") const summary = await SessionPrompt.prompt({ sessionID: session.id, - messageID: Identifier.ascending("message"), + messageID: MessageID.ascending(), variant, model: { providerID, diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index f06b8f39fe0..e7544a761fd 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,7 +1,7 @@ import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "../../session" -import { SessionID } from "../../session/schema" +import { SessionID, MessageID } from "../../session/schema" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { Database } from "../../storage/db" @@ -158,6 +158,9 @@ export const ImportCommand = cmd({ id: SessionID.make(exportData.info.id), parentID: exportData.info.parentID ? SessionID.make(exportData.info.parentID) : undefined, projectID: Instance.project.id, + revert: exportData.info.revert + ? { ...exportData.info.revert, messageID: MessageID.make(exportData.info.revert.messageID) } + : undefined, }) Database.use((db) => db @@ -172,10 +175,10 @@ export const ImportCommand = cmd({ db .insert(MessageTable) .values({ - id: msg.info.id, + id: MessageID.make(msg.info.id), session_id: row.id, time_created: msg.info.time?.created ?? Date.now(), - data: msg.info, + data: msg.info as any, }) .onConflictDoNothing() .run(), @@ -187,9 +190,9 @@ export const ImportCommand = cmd({ .insert(PartTable) .values({ id: part.id, - message_id: msg.info.id, + message_id: MessageID.make(msg.info.id), session_id: row.id, - data: part, + data: part as any, }) .onConflictDoNothing() .run(), diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 3f9f25e1dc4..2c47984fdd8 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,5 +1,5 @@ import { BusEvent } from "@/bus/bus-event" -import { SessionID } from "@/session/schema" +import { SessionID, MessageID } from "@/session/schema" import z from "zod" import { Config } from "../config/config" import { Instance } from "../project/instance" @@ -17,7 +17,7 @@ export namespace Command { name: z.string(), sessionID: SessionID.zod, arguments: z.string(), - messageID: Identifier.schema("message"), + messageID: MessageID.zod, }), ), } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 16a94ec54d8..8a2ba64e117 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { SessionID } from "@/session/schema" +import { SessionID, MessageID } from "@/session/schema" import z from "zod" import { Log } from "../util/log" import { Identifier } from "../id/id" @@ -26,7 +26,7 @@ export namespace Permission { type: z.string(), pattern: z.union([z.string(), z.array(z.string())]).optional(), sessionID: SessionID.zod, - messageID: z.string(), + messageID: MessageID.zod, callID: z.string().optional(), message: z.string(), metadata: z.record(z.string(), z.any()), diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 6bfe0035ba8..2d665a832a4 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -2,7 +2,7 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { Config } from "@/config/config" import { Identifier } from "@/id/id" -import { SessionID } from "@/session/schema" +import { SessionID, MessageID } from "@/session/schema" import { Instance } from "@/project/instance" import { Database, eq } from "@/storage/db" import { PermissionTable } from "@/session/session.sql" @@ -77,7 +77,7 @@ export namespace PermissionNext { always: z.string().array(), tool: z .object({ - messageID: z.string(), + messageID: MessageID.zod, callID: z.string(), }) .optional(), diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index ba1fc66506b..2142fe183fc 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,7 +1,7 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { Identifier } from "@/id/id" -import { SessionID } from "@/session/schema" +import { SessionID, MessageID } from "@/session/schema" import { Instance } from "@/project/instance" import { Log } from "@/util/log" import z from "zod" @@ -39,7 +39,7 @@ export namespace Question { questions: z.array(Info).describe("Questions to ask"), tool: z .object({ - messageID: z.string(), + messageID: MessageID.zod, callID: z.string(), }) .optional(), @@ -98,7 +98,7 @@ export namespace Question { export async function ask(input: { sessionID: SessionID questions: Info[] - tool?: { messageID: string; callID: string } + tool?: { messageID: MessageID; callID: string } }): Promise { const s = await state() const id = Identifier.ascending("question") diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 8e465d7ec23..26468c52c51 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import { stream } from "hono/streaming" import { describeRoute, validator, resolver } from "hono-openapi" -import { SessionID } from "@/session/schema" +import { SessionID, MessageID } from "@/session/schema" import z from "zod" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" @@ -607,7 +607,7 @@ export const SessionRoutes = lazy(() => "param", z.object({ sessionID: SessionID.zod, - messageID: z.string().meta({ description: "Message ID" }), + messageID: MessageID.zod, }), ), async (c) => { @@ -642,7 +642,7 @@ export const SessionRoutes = lazy(() => "param", z.object({ sessionID: SessionID.zod, - messageID: z.string().meta({ description: "Message ID" }), + messageID: MessageID.zod, }), ), async (c) => { @@ -676,8 +676,8 @@ export const SessionRoutes = lazy(() => "param", z.object({ sessionID: SessionID.zod, - messageID: z.string().meta({ description: "Message ID" }), - partID: z.string().meta({ description: "Part ID" }), + messageID: MessageID.zod, + partID: z.string(), }), ), async (c) => { @@ -711,8 +711,8 @@ export const SessionRoutes = lazy(() => "param", z.object({ sessionID: SessionID.zod, - messageID: z.string().meta({ description: "Message ID" }), - partID: z.string().meta({ description: "Part ID" }), + messageID: MessageID.zod, + partID: z.string(), }), ), validator("json", MessageV2.Part), diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d26c3bb6b9f..37a7cad84da 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -2,7 +2,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Session } from "." import { Identifier } from "../id/id" -import { SessionID } from "./schema" +import { SessionID, MessageID } from "./schema" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" @@ -100,7 +100,7 @@ export namespace SessionCompaction { } export async function process(input: { - parentID: string + parentID: MessageID messages: MessageV2.WithParts[] sessionID: SessionID abort: AbortSignal @@ -134,7 +134,7 @@ export namespace SessionCompaction { ? await Provider.getModel(agent.model.providerID, agent.model.modelID) : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID) const msg = (await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), role: "assistant", parentID: input.parentID, sessionID: input.sessionID, @@ -237,7 +237,7 @@ When constructing the summary, try to stick to this template: if (replay) { const original = replay.info as MessageV2.User const replayMsg = await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), role: "user", sessionID: input.sessionID, time: { created: Date.now() }, @@ -263,7 +263,7 @@ When constructing the summary, try to stick to this template: } } else { const continueMsg = await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), role: "user", sessionID: input.sessionID, time: { created: Date.now() }, @@ -307,7 +307,7 @@ When constructing the summary, try to stick to this template: }), async (input) => { const msg = await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), role: "user", model: input.model, sessionID: input.sessionID, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 76b550d3098..2f485508241 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -24,7 +24,7 @@ import { Command } from "../command" import { Snapshot } from "@/snapshot" import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" -import { SessionID } from "./schema" +import { SessionID, MessageID } from "./schema" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" @@ -150,7 +150,7 @@ export namespace Session { permission: PermissionNext.Ruleset.optional(), revert: z .object({ - messageID: z.string(), + messageID: MessageID.zod, partID: z.string().optional(), snapshot: z.string().optional(), diff: z.string().optional(), @@ -238,7 +238,7 @@ export namespace Session { export const fork = fn( z.object({ sessionID: SessionID.zod, - messageID: Identifier.schema("message").optional(), + messageID: MessageID.zod.optional(), }), async (input) => { const original = await get(input.sessionID) @@ -250,11 +250,11 @@ export namespace Session { title, }) const msgs = await messages({ sessionID: input.sessionID }) - const idMap = new Map() + const idMap = new Map() for (const msg of msgs) { if (input.messageID && msg.info.id >= input.messageID) break - const newID = Identifier.ascending("message") + const newID = MessageID.ascending() idMap.set(msg.info.id, newID) const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined @@ -707,7 +707,7 @@ export namespace Session { export const removeMessage = fn( z.object({ sessionID: SessionID.zod, - messageID: Identifier.schema("message"), + messageID: MessageID.zod, }), async (input) => { // CASCADE delete handles parts automatically @@ -729,7 +729,7 @@ export namespace Session { export const removePart = fn( z.object({ sessionID: SessionID.zod, - messageID: Identifier.schema("message"), + messageID: MessageID.zod, partID: Identifier.schema("part"), }), async (input) => { @@ -777,7 +777,7 @@ export namespace Session { export const updatePartDelta = fn( z.object({ sessionID: SessionID.zod, - messageID: z.string(), + messageID: MessageID.zod, partID: z.string(), field: z.string(), delta: z.string(), @@ -877,7 +877,7 @@ export namespace Session { sessionID: SessionID.zod, modelID: z.string(), providerID: z.string(), - messageID: Identifier.schema("message"), + messageID: MessageID.zod, }), async (input) => { await SessionPrompt.command({ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 43d735e4bdb..98f90ba6595 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,5 +1,5 @@ import { BusEvent } from "@/bus/bus-event" -import { SessionID } from "./schema" +import { SessionID, MessageID } from "./schema" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" @@ -81,7 +81,7 @@ export namespace MessageV2 { const PartBase = z.object({ id: z.string(), sessionID: SessionID.zod, - messageID: z.string(), + messageID: MessageID.zod, }) export const SnapshotPart = PartBase.extend({ @@ -344,7 +344,7 @@ export namespace MessageV2 { export type ToolPart = z.infer const Base = z.object({ - id: z.string(), + id: MessageID.zod, sessionID: SessionID.zod, }) @@ -411,7 +411,7 @@ export namespace MessageV2 { APIError.Schema, ]) .optional(), - parentID: z.string(), + parentID: MessageID.zod, modelID: z.string(), providerID: z.string(), /** @@ -459,7 +459,7 @@ export namespace MessageV2 { "message.removed", z.object({ sessionID: SessionID.zod, - messageID: z.string(), + messageID: MessageID.zod, }), ), PartUpdated: BusEvent.define( @@ -472,7 +472,7 @@ export namespace MessageV2 { "message.part.delta", z.object({ sessionID: SessionID.zod, - messageID: z.string(), + messageID: MessageID.zod, partID: z.string(), field: z.string(), delta: z.string(), @@ -482,7 +482,7 @@ export namespace MessageV2 { "message.part.removed", z.object({ sessionID: SessionID.zod, - messageID: z.string(), + messageID: MessageID.zod, partID: z.string(), }), ), @@ -782,7 +782,7 @@ export namespace MessageV2 { } }) - export const parts = fn(Identifier.schema("message"), async (message_id) => { + export const parts = fn(MessageID.zod, async (message_id) => { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) @@ -794,7 +794,7 @@ export namespace MessageV2 { export const get = fn( z.object({ sessionID: SessionID.zod, - messageID: Identifier.schema("message"), + messageID: MessageID.zod, }), async (input): Promise => { const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get()) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 33c6c9e4270..c208663ca25 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -15,7 +15,7 @@ import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" -import type { SessionID } from "./schema" +import type { SessionID, MessageID } from "./schema" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5fe9121486a..70c620dc512 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import z from "zod" import { Filesystem } from "../util/filesystem" import { Identifier } from "../id/id" -import { SessionID } from "./schema" +import { SessionID, MessageID } from "./schema" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" import { SessionRevert } from "./revert" @@ -92,7 +92,7 @@ export namespace SessionPrompt { export const PromptInput = z.object({ sessionID: SessionID.zod, - messageID: Identifier.schema("message").optional(), + messageID: MessageID.zod.optional(), model: z .object({ providerID: z.string(), @@ -355,7 +355,7 @@ export namespace SessionPrompt { const taskTool = await TaskTool.init() const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model const assistantMessage = (await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), role: "assistant", parentID: lastUser.id, sessionID, @@ -504,7 +504,7 @@ export namespace SessionPrompt { // If we create assistant messages w/ out user ones following mid loop thinking signatures // will be missing and it can cause errors for models like gemini for example const summaryUserMsg: MessageV2.User = { - id: Identifier.ascending("message"), + id: MessageID.ascending(), sessionID, role: "user", time: { @@ -568,7 +568,7 @@ export namespace SessionPrompt { const processor = SessionProcessor.create({ assistantMessage: (await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), parentID: lastUser.id, role: "assistant", mode: agent.name, @@ -971,7 +971,7 @@ export namespace SessionPrompt { const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined) const info: MessageV2.Info = { - id: input.messageID ?? Identifier.ascending("message"), + id: input.messageID ?? MessageID.ascending(), role: "user", sessionID: input.sessionID, time: { @@ -1505,7 +1505,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const agent = await Agent.get(input.agent) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const userMsg: MessageV2.User = { - id: Identifier.ascending("message"), + id: MessageID.ascending(), sessionID: input.sessionID, time: { created: Date.now(), @@ -1529,7 +1529,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the await Session.updatePart(userPart) const msg: MessageV2.Assistant = { - id: Identifier.ascending("message"), + id: MessageID.ascending(), sessionID: input.sessionID, parentID: userMsg.id, mode: input.agent, @@ -1719,7 +1719,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } export const CommandInput = z.object({ - messageID: Identifier.schema("message").optional(), + messageID: MessageID.zod.optional(), sessionID: SessionID.zod, agent: z.string().optional(), model: z.string().optional(), diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 9b42ab350af..2f25bdfed95 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,6 +1,6 @@ import z from "zod" import { Identifier } from "../id/id" -import { SessionID } from "./schema" +import { SessionID, MessageID } from "./schema" import { Snapshot } from "../snapshot" import { MessageV2 } from "./message-v2" import { Session } from "." @@ -17,7 +17,7 @@ export namespace SessionRevert { export const RevertInput = z.object({ sessionID: SessionID.zod, - messageID: Identifier.schema("message"), + messageID: MessageID.zod, partID: Identifier.schema("part").optional(), }) export type RevertInput = z.infer diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 5277f5e0b60..78a0b35ec14 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -15,3 +15,15 @@ export const SessionID = sessionIdSchema.pipe( zod: z.string().startsWith("ses").pipe(z.custom()), })), ) + +const messageIdSchema = Schema.String.pipe(Schema.brand("MessageId")) + +export type MessageID = typeof messageIdSchema.Type + +export const MessageID = messageIdSchema.pipe( + withStatics((schema: typeof messageIdSchema) => ({ + make: (id: string) => schema.makeUnsafe(id), + ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)), + zod: z.string().startsWith("msg").pipe(z.custom()), + })), +) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 309b623b864..73e989cfbd4 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -4,7 +4,7 @@ import type { MessageV2 } from "./message-v2" import type { Snapshot } from "../snapshot" import type { PermissionNext } from "../permission/next" import type { ProjectID } from "../project/schema" -import type { SessionID } from "./schema" +import type { SessionID, MessageID } from "./schema" import { Timestamps } from "../storage/schema.sql" type PartData = Omit @@ -29,7 +29,7 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), - revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(), + revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: string; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), ...Timestamps, time_compacting: integer(), @@ -45,7 +45,7 @@ export const SessionTable = sqliteTable( export const MessageTable = sqliteTable( "message", { - id: text().primaryKey(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() @@ -61,6 +61,7 @@ export const PartTable = sqliteTable( { id: text().primaryKey(), message_id: text() + .$type() .notNull() .references(() => MessageTable.id, { onDelete: "cascade" }), session_id: text().$type().notNull(), diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f489c9a6288..678a0085181 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -4,7 +4,7 @@ import { Session } from "." import { MessageV2 } from "./message-v2" import { Identifier } from "@/id/id" -import { SessionID } from "./schema" +import { SessionID, MessageID } from "./schema" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" @@ -70,7 +70,7 @@ export namespace SessionSummary { export const summarize = fn( z.object({ sessionID: SessionID.zod, - messageID: z.string(), + messageID: MessageID.zod, }), async (input) => { const all = await Session.messages({ sessionID: input.sessionID }) @@ -115,7 +115,7 @@ export namespace SessionSummary { export const diff = fn( z.object({ sessionID: SessionID.zod, - messageID: Identifier.schema("message").optional(), + messageID: MessageID.zod.optional(), }), async (input) => { const diffs = await Storage.read(["session_diff", input.sessionID]).catch(() => []) diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 0c25cf3e95e..75ae4f5a50e 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -7,7 +7,7 @@ import { MessageV2 } from "../session/message-v2" import { Identifier } from "../id/id" import { Provider } from "../provider/provider" import { Instance } from "../project/instance" -import type { SessionID } from "../session/schema" +import { type SessionID, MessageID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" async function getLastModel(sessionID: SessionID) { @@ -45,7 +45,7 @@ export const PlanExitTool = Tool.define("plan_exit", { const model = await getLastModel(ctx.sessionID) const userMsg: MessageV2.User = { - id: Identifier.ascending("message"), + id: MessageID.ascending(), sessionID: ctx.sessionID, role: "user", time: { @@ -103,7 +103,7 @@ export const PlanEnterTool = Tool.define("plan_enter", { const model = await getLastModel(ctx.sessionID) const userMsg: MessageV2.User = { - id: Identifier.ascending("message"), + id: MessageID.ascending(), sessionID: ctx.sessionID, role: "user", time: { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 2304a2e2bf3..68e44eb97e4 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -2,7 +2,7 @@ import { Tool } from "./tool" import DESCRIPTION from "./task.txt" import z from "zod" import { Session } from "../session" -import { SessionID } from "../session/schema" +import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Identifier } from "../id/id" import { Agent } from "../agent/agent" @@ -117,7 +117,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { }, }) - const messageID = Identifier.ascending("message") + const messageID = MessageID.ascending() function cancel() { SessionPrompt.cancel(session.id) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 7f58520c56d..8cc7b57d854 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -2,7 +2,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" import type { PermissionNext } from "../permission/next" -import type { SessionID } from "../session/schema" +import type { SessionID, MessageID } from "../session/schema" import { Truncate } from "./truncation" export namespace Tool { @@ -16,7 +16,7 @@ export namespace Tool { export type Context = { sessionID: SessionID - messageID: string + messageID: MessageID agent: string abort: AbortSignal callID?: string diff --git a/packages/opencode/test/cli/account.test.ts b/packages/opencode/test/cli/account.test.ts new file mode 100644 index 00000000000..8a8f066ebce --- /dev/null +++ b/packages/opencode/test/cli/account.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test" +import { Option } from "effect" + +import { isActiveOrgChoice } from "../../src/cli/cmd/account" +import { AccountID, OrgID } from "../../src/account/schema" + +describe("isActiveOrgChoice", () => { + it("requires both account id and org id to match", () => { + const active = Option.some({ + id: AccountID.make("account-1"), + active_org_id: OrgID.make("org-1"), + }) + + expect(isActiveOrgChoice(active, { accountID: AccountID.make("account-1"), orgID: OrgID.make("org-1") })).toBe(true) + expect(isActiveOrgChoice(active, { accountID: AccountID.make("account-2"), orgID: OrgID.make("org-1") })).toBe(false) + expect(isActiveOrgChoice(active, { accountID: AccountID.make("account-1"), orgID: OrgID.make("org-2") })).toBe(false) + }) +}) diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index c2bd293d79f..48d7656ee3d 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -1,14 +1,14 @@ import { test, expect, describe } from "bun:test" import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github" import type { MessageV2 } from "../../src/session/message-v2" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" // Helper to create minimal valid parts function createTextPart(text: string): MessageV2.Part { return { id: "1", sessionID: SessionID.make("s"), - messageID: "m", + messageID: MessageID.make("m"), type: "text" as const, text, } @@ -18,7 +18,7 @@ function createReasoningPart(text: string): MessageV2.Part { return { id: "1", sessionID: SessionID.make("s"), - messageID: "m", + messageID: MessageID.make("m"), type: "reasoning" as const, text, time: { start: 0 }, @@ -30,7 +30,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn return { id: "1", sessionID: SessionID.make("s"), - messageID: "m", + messageID: MessageID.make("m"), type: "tool" as const, callID: "c1", tool, @@ -47,7 +47,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn return { id: "1", sessionID: SessionID.make("s"), - messageID: "m", + messageID: MessageID.make("m"), type: "tool" as const, callID: "c1", tool, @@ -63,7 +63,7 @@ function createStepStartPart(): MessageV2.Part { return { id: "1", sessionID: SessionID.make("s"), - messageID: "m", + messageID: MessageID.make("m"), type: "step-start" as const, } } @@ -72,7 +72,7 @@ function createStepFinishPart(): MessageV2.Part { return { id: "1", sessionID: SessionID.make("s"), - messageID: "m", + messageID: MessageID.make("m"), type: "step-finish" as const, reason: "done", cost: 0, diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts index 68770bf17ac..eebb651a53b 100644 --- a/packages/opencode/test/memory/abort-leak.test.ts +++ b/packages/opencode/test/memory/abort-leak.test.ts @@ -2,13 +2,13 @@ import { describe, test, expect } from "bun:test" import path from "path" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const projectRoot = path.join(__dirname, "../..") const ctx = { sessionID: SessionID.make("ses_test"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: new AbortController().signal, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 871fd8cb57e..df9dedb356a 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -11,7 +11,7 @@ import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" describe("session.llm.hasToolCalls", () => { test("returns false for empty messages array", () => { @@ -277,7 +277,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: "user-1", + id: MessageID.make("user-1"), sessionID, role: "user", time: { created: Date.now() }, @@ -406,7 +406,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: "user-2", + id: MessageID.make("user-2"), sessionID, role: "user", time: { created: Date.now() }, @@ -529,7 +529,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: "user-3", + id: MessageID.make("user-3"), sessionID, role: "user", time: { created: Date.now() }, @@ -630,7 +630,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: "user-4", + id: MessageID.make("user-4"), sessionID, role: "user", time: { created: Date.now() }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 158d341f9eb..af1d0018bcc 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" import type { Provider } from "../../src/provider/provider" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const sessionID = SessionID.make("session") const model: Provider.Model = { @@ -100,7 +100,7 @@ function basePart(messageID: string, id: string) { return { id, sessionID, - messageID, + messageID: MessageID.make(messageID), } } diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index de2b14573f4..015978546ee 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -7,6 +7,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { Identifier } from "../../src/id/id" +import { MessageID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") @@ -24,7 +25,7 @@ describe("revert + compact workflow", () => { // Create a user message const userMsg1 = await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.make(Identifier.ascending("message")), role: "user", sessionID, agent: "default", @@ -48,7 +49,7 @@ describe("revert + compact workflow", () => { // Create an assistant response message const assistantMsg1: MessageV2.Assistant = { - id: Identifier.ascending("message"), + id: MessageID.make(Identifier.ascending("message")), role: "assistant", sessionID, mode: "default", @@ -85,7 +86,7 @@ describe("revert + compact workflow", () => { // Create another user message const userMsg2 = await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.make(Identifier.ascending("message")), role: "user", sessionID, agent: "default", @@ -108,7 +109,7 @@ describe("revert + compact workflow", () => { // Create another assistant response const assistantMsg2: MessageV2.Assistant = { - id: Identifier.ascending("message"), + id: MessageID.make(Identifier.ascending("message")), role: "assistant", sessionID, mode: "default", @@ -200,7 +201,7 @@ describe("revert + compact workflow", () => { // Create initial messages const userMsg = await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.make(Identifier.ascending("message")), role: "user", sessionID, agent: "default", @@ -222,7 +223,7 @@ describe("revert + compact workflow", () => { }) const assistantMsg: MessageV2.Assistant = { - id: Identifier.ascending("message"), + id: MessageID.make(Identifier.ascending("message")), role: "assistant", sessionID, mode: "default", diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index aa9ca05d047..0f41f7ed3e7 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -6,6 +6,7 @@ import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" import { Identifier } from "../../src/id/id" +import { MessageID } from "../../src/session/schema" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) @@ -81,7 +82,7 @@ describe("step-finish token propagation via Bus event", () => { fn: async () => { const session = await Session.create({}) - const messageID = Identifier.ascending("message") + const messageID = MessageID.make(Identifier.ascending("message")) await Session.updateMessage({ id: messageID, sessionID: session.id, diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index f5f8cbd5cd0..f436037158b 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -11,7 +11,7 @@ import { ProjectTable } from "../../src/project/project.sql" import { ProjectID } from "../../src/project/schema" import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql" import { SessionShareTable } from "../../src/share/share.sql" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" // Test fixtures const fixtures = { @@ -255,7 +255,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const messages = db.select().from(MessageTable).all() expect(messages.length).toBe(1) - expect(messages[0].id).toBe("msg_test789ghi") + expect(messages[0].id).toBe(MessageID.make("msg_test789ghi")) const parts = db.select().from(PartTable).all() expect(parts.length).toBe(1) @@ -295,7 +295,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const messages = db.select().from(MessageTable).all() expect(messages.length).toBe(1) - expect(messages[0].id).toBe("msg_test789ghi") + expect(messages[0].id).toBe(MessageID.make("msg_test789ghi")) expect(messages[0].session_id).toBe(SessionID.make("ses_test456def")) expect(messages[0].data).not.toHaveProperty("id") expect(messages[0].data).not.toHaveProperty("sessionID") @@ -303,7 +303,7 @@ describe("JSON to SQLite migration", () => { const parts = db.select().from(PartTable).all() expect(parts.length).toBe(1) expect(parts[0].id).toBe("prt_testabc123") - expect(parts[0].message_id).toBe("msg_test789ghi") + expect(parts[0].message_id).toBe(MessageID.make("msg_test789ghi")) expect(parts[0].session_id).toBe(SessionID.make("ses_test456def")) expect(parts[0].data).not.toHaveProperty("id") expect(parts[0].data).not.toHaveProperty("messageID") @@ -336,7 +336,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const messages = db.select().from(MessageTable).all() expect(messages.length).toBe(1) - expect(messages[0].id).toBe("msg_from_filename") // Uses filename, not JSON id + expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id expect(messages[0].session_id).toBe(SessionID.make("ses_test456def")) }) @@ -375,7 +375,7 @@ describe("JSON to SQLite migration", () => { const parts = db.select().from(PartTable).all() expect(parts.length).toBe(1) expect(parts[0].id).toBe("prt_from_filename") // Uses filename, not JSON id - expect(parts[0].message_id).toBe("msg_realmsgid") // Uses parent dir, not JSON messageID + expect(parts[0].message_id).toBe(MessageID.make("msg_realmsgid")) // Uses parent dir, not JSON messageID }) test("skips orphaned sessions (no parent project)", async () => { diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 008b4fa49c6..4e276517f10 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -4,11 +4,11 @@ import * as fs from "fs/promises" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const baseCtx = { sessionID: SessionID.make("ses_test"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index d4a6c7ccdb5..f947398b37e 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -7,11 +7,11 @@ import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission/next" import { Truncate } from "../../src/tool/truncation" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 601995889ed..b0ee95ff6f7 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -5,11 +5,11 @@ import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { FileTime } from "../../src/file/time" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index fe33d69151d..58e53e58395 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -4,11 +4,11 @@ import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import type { PermissionNext } from "../../src/permission/next" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 5823553722e..e03b1752ec0 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -3,11 +3,11 @@ import path from "path" import { GrepTool } from "../../src/tool/grep" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 69e211abdd5..9157aaa9a48 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" import { z } from "zod" import { QuestionTool } from "../../src/tool/question" import * as QuestionModule from "../../src/question" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test-session"), - messageID: "test-message", + messageID: MessageID.make("test-message"), callID: "test-call", agent: "test-agent", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 45f3c1b32e8..7659d690c3a 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -6,13 +6,13 @@ import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import { PermissionNext } from "../../src/permission/next" import { Agent } from "../../src/agent/agent" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ctx = { sessionID: SessionID.make("ses_test"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index fa67c293e90..5bcdb6c2b9d 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -6,11 +6,11 @@ import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { tmpdir } from "../fixture/fixture" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 769a61e1656..088f3dd16da 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const projectRoot = path.join(import.meta.dir, "../..") const ctx = { sessionID: SessionID.make("ses_test"), - messageID: "message", + messageID: MessageID.make("message"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 62279057a99..b93ab4e853e 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -4,11 +4,11 @@ import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test-write-session"), - messageID: "", + messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), From 844e55915804d22f5709fc1f10d9868a706ee385 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 10 Mar 2026 22:45:54 -0400 Subject: [PATCH 5/7] refactor(message): use MessageID.ascending() and remove as any casts - Replace MessageID.make(Identifier.ascending("message")) with MessageID.ascending() - Remove as any casts in import.ts by destructuring out column-stored fields - Revert unrelated account.ts changes --- packages/opencode/script/seed-e2e.ts | 2 +- packages/opencode/src/cli/cmd/account.ts | 11 ++++------- packages/opencode/src/cli/cmd/import.ts | 6 ++++-- .../src/cli/cmd/tui/component/prompt/index.tsx | 3 ++- packages/opencode/src/session/message-v2.ts | 3 +-- packages/opencode/test/cli/account.test.ts | 18 ------------------ .../test/session/revert-compact.test.ts | 12 ++++++------ packages/opencode/test/session/session.test.ts | 2 +- 8 files changed, 19 insertions(+), 38 deletions(-) delete mode 100644 packages/opencode/test/cli/account.test.ts diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index 2c6abfa0f53..d2333414488 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -20,7 +20,7 @@ const seed = async () => { init: InstanceBootstrap, fn: async () => { const session = await Session.create({ title }) - const messageID = MessageID.make(Identifier.descending("message")) + const messageID = MessageID.ascending() const partID = Identifier.descending("part") const message = { id: messageID, diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 739eeb3a6f7..dd0834a3d80 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -11,11 +11,6 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => const println = (msg: string) => Effect.sync(() => UI.println(msg)) -export const isActiveOrgChoice = ( - active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>, - choice: { accountID: AccountID; orgID: OrgID }, -) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID - const loginEffect = Effect.fn("login")(function* (url: string) { const service = yield* AccountService @@ -104,10 +99,11 @@ const switchEffect = Effect.fn("switch")(function* () { if (groups.length === 0) return yield* println("Not logged in") const active = yield* service.active() + const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id)) const opts = groups.flatMap((group) => group.orgs.map((org) => { - const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) + const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id return { value: { orgID: org.id, accountID: group.account.id, label: org.name }, label: isActive @@ -136,10 +132,11 @@ const orgsEffect = Effect.fn("orgs")(function* () { if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found") const active = yield* service.active() + const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id)) for (const group of groups) { for (const org of group.orgs) { - const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) + const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " " const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index e7544a761fd..12beafef11c 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -171,6 +171,7 @@ export const ImportCommand = cmd({ ) for (const msg of exportData.messages) { + const { id: _mid, sessionID: _msid, ...msgData } = msg.info Database.use((db) => db .insert(MessageTable) @@ -178,13 +179,14 @@ export const ImportCommand = cmd({ id: MessageID.make(msg.info.id), session_id: row.id, time_created: msg.info.time?.created ?? Date.now(), - data: msg.info as any, + data: msgData, }) .onConflictDoNothing() .run(), ) for (const part of msg.parts) { + const { id: _pid, sessionID: _psid, messageID: _pmid, ...partData } = part Database.use((db) => db .insert(PartTable) @@ -192,7 +194,7 @@ export const ImportCommand = cmd({ id: part.id, message_id: MessageID.make(msg.info.id), session_id: row.id, - data: part as any, + data: partData, }) .onConflictDoNothing() .run(), diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2d99051fb97..9923cd3fabb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -10,6 +10,7 @@ import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { Identifier } from "@/id/id" +import { MessageID } from "@/session/schema" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" @@ -561,7 +562,7 @@ export function Prompt(props: PromptProps) { sessionID = res.data.id } - const messageID = Identifier.ascending("message") + const messageID = MessageID.ascending() let inputText = store.prompt.input // Expand pasted text inline before submitting diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 98f90ba6595..0fa842b2999 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -3,7 +3,6 @@ import { SessionID, MessageID } from "./schema" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" -import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" @@ -699,7 +698,7 @@ export namespace MessageV2 { // media (images, PDFs) in tool results if (media.length > 0) { result.push({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), role: "user", parts: [ { diff --git a/packages/opencode/test/cli/account.test.ts b/packages/opencode/test/cli/account.test.ts deleted file mode 100644 index 8a8f066ebce..00000000000 --- a/packages/opencode/test/cli/account.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { Option } from "effect" - -import { isActiveOrgChoice } from "../../src/cli/cmd/account" -import { AccountID, OrgID } from "../../src/account/schema" - -describe("isActiveOrgChoice", () => { - it("requires both account id and org id to match", () => { - const active = Option.some({ - id: AccountID.make("account-1"), - active_org_id: OrgID.make("org-1"), - }) - - expect(isActiveOrgChoice(active, { accountID: AccountID.make("account-1"), orgID: OrgID.make("org-1") })).toBe(true) - expect(isActiveOrgChoice(active, { accountID: AccountID.make("account-2"), orgID: OrgID.make("org-1") })).toBe(false) - expect(isActiveOrgChoice(active, { accountID: AccountID.make("account-1"), orgID: OrgID.make("org-2") })).toBe(false) - }) -}) diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 015978546ee..37b3e217717 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -25,7 +25,7 @@ describe("revert + compact workflow", () => { // Create a user message const userMsg1 = await Session.updateMessage({ - id: MessageID.make(Identifier.ascending("message")), + id: MessageID.ascending(), role: "user", sessionID, agent: "default", @@ -49,7 +49,7 @@ describe("revert + compact workflow", () => { // Create an assistant response message const assistantMsg1: MessageV2.Assistant = { - id: MessageID.make(Identifier.ascending("message")), + id: MessageID.ascending(), role: "assistant", sessionID, mode: "default", @@ -86,7 +86,7 @@ describe("revert + compact workflow", () => { // Create another user message const userMsg2 = await Session.updateMessage({ - id: MessageID.make(Identifier.ascending("message")), + id: MessageID.ascending(), role: "user", sessionID, agent: "default", @@ -109,7 +109,7 @@ describe("revert + compact workflow", () => { // Create another assistant response const assistantMsg2: MessageV2.Assistant = { - id: MessageID.make(Identifier.ascending("message")), + id: MessageID.ascending(), role: "assistant", sessionID, mode: "default", @@ -201,7 +201,7 @@ describe("revert + compact workflow", () => { // Create initial messages const userMsg = await Session.updateMessage({ - id: MessageID.make(Identifier.ascending("message")), + id: MessageID.ascending(), role: "user", sessionID, agent: "default", @@ -223,7 +223,7 @@ describe("revert + compact workflow", () => { }) const assistantMsg: MessageV2.Assistant = { - id: MessageID.make(Identifier.ascending("message")), + id: MessageID.ascending(), role: "assistant", sessionID, mode: "default", diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 0f41f7ed3e7..f5512f240ef 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -82,7 +82,7 @@ describe("step-finish token propagation via Bus event", () => { fn: async () => { const session = await Session.create({}) - const messageID = MessageID.make(Identifier.ascending("message")) + const messageID = MessageID.ascending() await Session.updateMessage({ id: messageID, sessionID: session.id, From 6be5ff7cf78806de8e06636cbac0cf73fd3515ee Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 11 Mar 2026 09:51:28 -0400 Subject: [PATCH 6/7] fix: use MessageID.ascending() in structured-output tests The rebase dropped the MessageID test fix. Tests were using plain strings ("test-id", "parent-id") which fail zod's startsWith("msg") validation. --- .../opencode/test/session/structured-output.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts index 537df07af6d..f6131b149b5 100644 --- a/packages/opencode/test/session/structured-output.test.ts +++ b/packages/opencode/test/session/structured-output.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" -import { SessionID } from "../../src/session/schema" +import { SessionID, MessageID } from "../../src/session/schema" describe("structured-output.OutputFormat", () => { test("parses text format", () => { @@ -96,7 +96,7 @@ describe("structured-output.StructuredOutputError", () => { describe("structured-output.UserMessage", () => { test("user message accepts outputFormat", () => { const result = MessageV2.User.safeParse({ - id: "test-id", + id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", time: { created: Date.now() }, @@ -112,7 +112,7 @@ describe("structured-output.UserMessage", () => { test("user message works without outputFormat (optional)", () => { const result = MessageV2.User.safeParse({ - id: "test-id", + id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", time: { created: Date.now() }, @@ -125,10 +125,10 @@ describe("structured-output.UserMessage", () => { describe("structured-output.AssistantMessage", () => { const baseAssistantMessage = { - id: "test-id", + id: MessageID.ascending(), sessionID: SessionID.descending(), role: "assistant" as const, - parentID: "parent-id", + parentID: MessageID.ascending(), modelID: "claude-3", providerID: "anthropic", mode: "default", From 0a80b9fcd426122a0213433007ddc71ac45e4e6b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 11 Mar 2026 09:29:53 -0400 Subject: [PATCH 7/7] feat(workspace): brand WorkspaceID through Drizzle and Zod schemas --- packages/opencode/src/cli/cmd/import.ts | 2 ++ packages/opencode/src/control-plane/schema.ts | 17 +++++++++++++++++ packages/opencode/src/control-plane/types.ts | 4 ++-- .../src/control-plane/workspace-context.ts | 5 +++-- .../control-plane/workspace-server/server.ts | 7 ++++--- .../opencode/src/control-plane/workspace.sql.ts | 3 ++- .../opencode/src/control-plane/workspace.ts | 10 +++++----- packages/opencode/src/server/server.ts | 5 +++-- packages/opencode/src/session/index.ts | 9 +++++---- packages/opencode/src/session/session.sql.ts | 3 ++- .../session-proxy-middleware.test.ts | 6 +++--- .../test/control-plane/workspace-sync.test.ts | 6 +++--- 12 files changed, 51 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/src/control-plane/schema.ts diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 12beafef11c..a5a21d1c3d7 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -2,6 +2,7 @@ import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "../../session" import { SessionID, MessageID } from "../../session/schema" +import { WorkspaceID } from "../../control-plane/schema" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { Database } from "../../storage/db" @@ -157,6 +158,7 @@ export const ImportCommand = cmd({ ...exportData.info, id: SessionID.make(exportData.info.id), parentID: exportData.info.parentID ? SessionID.make(exportData.info.parentID) : undefined, + workspaceID: exportData.info.workspaceID ? WorkspaceID.make(exportData.info.workspaceID) : undefined, projectID: Instance.project.id, revert: exportData.info.revert ? { ...exportData.info.revert, messageID: MessageID.make(exportData.info.revert.messageID) } diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts new file mode 100644 index 00000000000..00a9f8b56a9 --- /dev/null +++ b/packages/opencode/src/control-plane/schema.ts @@ -0,0 +1,17 @@ +import { Schema } from "effect" +import z from "zod" + +import { withStatics } from "@/util/schema" +import { Identifier } from "@/id/id" + +const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")) + +export type WorkspaceID = typeof workspaceIdSchema.Type + +export const WorkspaceID = workspaceIdSchema.pipe( + withStatics((schema: typeof workspaceIdSchema) => ({ + make: (id: string) => schema.makeUnsafe(id), + ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)), + zod: z.string().startsWith("wrk").pipe(z.custom()), + })), +) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 53a6c8a0732..ab628a69381 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,9 +1,9 @@ import z from "zod" -import { Identifier } from "@/id/id" import { ProjectID } from "@/project/schema" +import { WorkspaceID } from "./schema" export const WorkspaceInfo = z.object({ - id: Identifier.schema("workspace"), + id: WorkspaceID.zod, type: z.string(), branch: z.string().nullable(), name: z.string().nullable(), diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts index f7297b3f4b4..cdd975dc4f5 100644 --- a/packages/opencode/src/control-plane/workspace-context.ts +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -1,13 +1,14 @@ import { Context } from "../util/context" +import type { WorkspaceID } from "./schema" interface Context { - workspaceID?: string + workspaceID?: WorkspaceID } const context = Context.create("workspace") export const WorkspaceContext = { - async provide(input: { workspaceID?: string; fn: () => R }): Promise { + async provide(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise { return context.provide({ workspaceID: input.workspaceID }, async () => { return input.fn() }) diff --git a/packages/opencode/src/control-plane/workspace-server/server.ts b/packages/opencode/src/control-plane/workspace-server/server.ts index fd7fd930867..b0744fe025b 100644 --- a/packages/opencode/src/control-plane/workspace-server/server.ts +++ b/packages/opencode/src/control-plane/workspace-server/server.ts @@ -4,6 +4,7 @@ import { InstanceBootstrap } from "../../project/bootstrap" import { SessionRoutes } from "../../server/routes/session" import { WorkspaceServerRoutes } from "./routes" import { WorkspaceContext } from "../workspace-context" +import { WorkspaceID } from "../schema" export namespace WorkspaceServer { export function App() { @@ -20,9 +21,9 @@ export namespace WorkspaceServer { return new Hono() .use(async (c, next) => { - const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") + const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") - if (workspaceID == null) { + if (rawWorkspaceID == null) { throw new Error("workspaceID parameter is required") } if (raw == null) { @@ -38,7 +39,7 @@ export namespace WorkspaceServer { })() return WorkspaceContext.provide({ - workspaceID, + workspaceID: WorkspaceID.make(rawWorkspaceID), async fn() { return Instance.provide({ directory, diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index eb7d21d1e89..272907da150 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,9 +1,10 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" +import type { WorkspaceID } from "./schema" export const WorkspaceTable = sqliteTable("workspace", { - id: text().primaryKey(), + id: text().$type().primaryKey(), type: text().notNull(), branch: text(), name: text(), diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f3af985cac6..c3c28ed6057 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,5 +1,4 @@ import z from "zod" -import { Identifier } from "@/id/id" import { fn } from "@/util/fn" import { Database, eq } from "@/storage/db" import { Project } from "@/project/project" @@ -10,6 +9,7 @@ import { ProjectID } from "@/project/schema" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" +import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" export namespace Workspace { @@ -46,7 +46,7 @@ export namespace Workspace { } const CreateInput = z.object({ - id: Identifier.schema("workspace").optional(), + id: WorkspaceID.zod.optional(), type: Info.shape.type, branch: Info.shape.branch, projectID: ProjectID.zod, @@ -54,7 +54,7 @@ export namespace Workspace { }) export const create = fn(CreateInput, async (input) => { - const id = Identifier.ascending("workspace", input.id) + const id = WorkspaceID.ascending(input.id) const adaptor = await getAdaptor(input.type) const config = await adaptor.configure({ ...input, id, name: null, directory: null }) @@ -94,13 +94,13 @@ export namespace Workspace { return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) } - export const get = fn(Identifier.schema("workspace"), async (id) => { + export const get = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return return fromRow(row) }) - export const remove = fn(Identifier.schema("workspace"), async (id) => { + export const remove = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (row) { const info = fromRow(row) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 0566547b735..14c2346a611 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -22,6 +22,7 @@ import { Flag } from "../flag/flag" import { Command } from "../command" import { Global } from "../global" import { WorkspaceContext } from "../control-plane/workspace-context" +import { WorkspaceID } from "../control-plane/schema" import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware" import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" @@ -190,7 +191,7 @@ export namespace Server { ) .use(async (c, next) => { if (c.req.path === "/log") return next() - const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") + const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const directory = Filesystem.resolve( (() => { @@ -203,7 +204,7 @@ export namespace Server { ) return WorkspaceContext.provide({ - workspaceID, + workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined, async fn() { return Instance.provide({ directory, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2f485508241..63495cd00cd 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -24,6 +24,7 @@ import { Command } from "../command" import { Snapshot } from "@/snapshot" import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" +import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID } from "./schema" import type { Provider } from "@/provider/provider" @@ -123,7 +124,7 @@ export namespace Session { id: SessionID.zod, slug: z.string(), projectID: ProjectID.zod, - workspaceID: z.string().optional(), + workspaceID: WorkspaceID.zod.optional(), directory: z.string(), parentID: SessionID.zod.optional(), summary: z @@ -221,7 +222,7 @@ export namespace Session { parentID: SessionID.zod.optional(), title: z.string().optional(), permission: Info.shape.permission, - workspaceID: Identifier.schema("workspace").optional(), + workspaceID: WorkspaceID.zod.optional(), }) .optional(), async (input) => { @@ -297,7 +298,7 @@ export namespace Session { id?: SessionID title?: string parentID?: SessionID - workspaceID?: string + workspaceID?: WorkspaceID directory: string permission?: PermissionNext.Ruleset }) { @@ -538,7 +539,7 @@ export namespace Session { export function* list(input?: { directory?: string - workspaceID?: string + workspaceID?: WorkspaceID roots?: boolean start?: number search?: string diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 73e989cfbd4..7c456161e15 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -5,6 +5,7 @@ import type { Snapshot } from "../snapshot" import type { PermissionNext } from "../permission/next" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID } from "./schema" +import type { WorkspaceID } from "../control-plane/schema" import { Timestamps } from "../storage/schema.sql" type PartData = Omit @@ -18,7 +19,7 @@ export const SessionTable = sqliteTable( .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - workspace_id: text(), + workspace_id: text().$type(), parent_id: text().$type(), slug: text().notNull(), directory: text().notNull(), diff --git a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts index c674d95ec7d..d4d152a1c6e 100644 --- a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts +++ b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, test } from "bun:test" -import { Identifier } from "../../src/id/id" +import { WorkspaceID } from "../../src/control-plane/schema" import { Hono } from "hono" import { tmpdir } from "../fixture/fixture" import { Project } from "../../src/project/project" @@ -64,8 +64,8 @@ async function setup(state: State) { await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path) - const id1 = Identifier.descending("workspace") - const id2 = Identifier.descending("workspace") + const id1 = WorkspaceID.ascending() + const id2 = WorkspaceID.ascending() Database.use((db) => db diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts index 899118920fb..0f8d608fb39 100644 --- a/packages/opencode/test/control-plane/workspace-sync.test.ts +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, test } from "bun:test" -import { Identifier } from "../../src/id/id" +import { WorkspaceID } from "../../src/control-plane/schema" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" import { Project } from "../../src/project/project" @@ -52,8 +52,8 @@ describe("control-plane/workspace.startSyncing", () => { await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path) - const id1 = Identifier.descending("workspace") - const id2 = Identifier.descending("workspace") + const id1 = WorkspaceID.ascending() + const id2 = WorkspaceID.ascending() Database.use((db) => db