refactor(core): centralize project and workspace schemas

This commit is contained in:
Dax Raad
2026-05-24 14:55:56 -04:00
parent 077c91b95d
commit 46953462d9
48 changed files with 309 additions and 371 deletions
@@ -1,6 +1,6 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/sql"
import { Project } from "../project"
import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
@@ -11,7 +11,7 @@ export const WorkspaceTable = sqliteTable("workspace", {
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<Project.ID>()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
+27 -10
View File
@@ -5,8 +5,9 @@ import { layer as sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import path from "path"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
@@ -24,24 +25,40 @@ const layer = Layer.effect(
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* Effect.log("Applying database migrations")
yield* DatabaseMigration.apply(db)
return db
}),
}).pipe(Effect.orDie),
)
export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
return layer.pipe(Layer.provide(sqliteLayer({ filename })), Layer.orDie)
}
export const memoryLayer = layerFromPath(":memory:")
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
return layerFromPath(
!Flag.OPENCODE_DB
? path.join(Global.Path.data, "opencode.db")
: Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)
? Flag.OPENCODE_DB
: path.join(Global.Path.data, Flag.OPENCODE_DB),
)
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))
export function init(options: { path?: string } = {}) {
const filename = options.path ?? path()
return Effect.runSync(Service.use(() => Effect.void).pipe(Effect.provide(layerFromPath(filename))))
}
+15 -5
View File
@@ -19,19 +19,27 @@ export function apply(db: Database) {
export function applyOnly(db: Database, input: Migration[]) {
return Effect.gen(function* () {
yield* db.run(sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
let completed = new Set((yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id))
yield* db.run(
sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
let completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
if (completed.size === 0) {
// Existing installs used Drizzle's migration journal. Seed the new
// journal once so TypeScript migrations don't replay old SQL.
if (yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)) {
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
completed = new Set((yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id))
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
}
}
@@ -40,7 +48,9 @@ export function applyOnly(db: Database, input: Migration[]) {
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* migration.up(tx)
yield* tx.run(sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`)
yield* tx.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
)
}),
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
export * as Project from "./project"
export * as ProjectV2 from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
+2 -2
View File
@@ -1,9 +1,9 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import { Project } from "../project"
import { ProjectV2 } from "../project"
export const ProjectTable = sqliteTable("project", {
id: text().$type<Project.ID>().primaryKey(),
id: text().$type<ProjectV2.ID>().primaryKey(),
worktree: text().notNull(),
vcs: text(),
name: text(),
+3 -3
View File
@@ -3,7 +3,7 @@ export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context } from "effect"
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
import { ModelV2 } from "./model"
import { Location } from "./location"
@@ -32,7 +32,7 @@ type Cursor = {
type ListInput = {
workspaceID?: WorkspaceV2.ID
projectID?: Project.ID
projectID?: ProjectV2.ID
path?: string
roots?: boolean
start?: number
@@ -137,7 +137,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return new SessionSchema.Info({
id: SessionSchema.ID.make(row.id),
projectID: Project.ID.make(row.project_id),
projectID: ProjectV2.ID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
+3 -3
View File
@@ -3,7 +3,7 @@ export * as SessionSchema from "./schema"
import { Schema } from "effect"
import { Location } from "../location"
import { ModelV2 } from "../model"
import { Project } from "../project"
import { ProjectV2 } from "../project"
import { RelativePath, optionalOmitUndefined, withStatics } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { Identifier } from "../util/identifier"
@@ -28,14 +28,14 @@ export const LegacyInfo = Schema.Struct({
id: ID,
location: Location.Ref,
subpath: RelativePath, // derived from location
project: Project.ID, // derived from location
project: ProjectV2.ID, // derived from location
})
export type LegacyInfo = typeof LegacyInfo.Type
export class Info extends Schema.Class<Info>("Session.Info")({
id: ID,
parentID: optionalOmitUndefined(ID),
projectID: Project.ID,
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
+2 -2
View File
@@ -3,7 +3,7 @@ import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Snapshot } from "../snapshot"
import { PermissionV2 } from "../permission"
import { Project } from "../project"
import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema"
import type { MessageID, PartID } from "./legacy"
import { WorkspaceV2 } from "../workspace"
@@ -16,7 +16,7 @@ export const SessionTable = sqliteTable(
{
id: text().$type<SessionSchema.ID>().primaryKey(),
project_id: text()
.$type<Project.ID>()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
workspace_id: text().$type<WorkspaceV2.ID>(),
+8 -1
View File
@@ -6,6 +6,13 @@ import { Identifier } from "./util/identifier"
export const ID = Schema.String.pipe(
Schema.brand("WorkspaceV2.ID"),
withStatics((schema) => ({ create: () => schema.make("wrk_" + Identifier.ascending()) })),
withStatics((schema) => ({
ascending: (id?: string) => {
if (!id) return schema.make("wrk_" + Identifier.ascending())
if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`)
return schema.make(id)
},
create: () => schema.make("wrk_" + Identifier.ascending()),
})),
)
export type ID = typeof ID.Type
+20 -20
View File
@@ -3,16 +3,16 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Project } from "@opencode-ai/core/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Project.defaultLayer)
const it = testEffect(ProjectV2.defaultLayer)
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
}
function abs(value: string) {
@@ -44,11 +44,11 @@ describe("ProjectV2.resolve", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(result.id).toBe(ProjectV2.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
@@ -62,11 +62,11 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(result.id).toBe(ProjectV2.ID.make("global"))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
@@ -80,11 +80,11 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
@@ -98,12 +98,12 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(remoteID("github.com/Acme/App"))
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).not.toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
@@ -121,7 +121,7 @@ describe("ProjectV2.resolve", () => {
)
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
@@ -138,11 +138,11 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
}),
)
@@ -154,11 +154,11 @@ describe("ProjectV2.resolve", () => {
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
}),
)
@@ -170,7 +170,7 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
yield* project.resolve(abs(tmp.path))
@@ -186,7 +186,7 @@ describe("ProjectV2.resolve", () => {
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
@@ -207,12 +207,12 @@ describe("ProjectV2.resolve", () => {
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(worktree))
expect(result.directory).toBe(yield* real(worktree))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.vcs?.type).toBe("git")
}),
@@ -1,4 +1,4 @@
import type { ProjectID } from "@/project/schema"
import type { ProjectV2 } from "@opencode-ai/core/project"
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
import { WorktreeAdapter } from "./worktree"
@@ -6,9 +6,9 @@ const BUILTIN: Record<string, WorkspaceAdapter> = {
worktree: WorktreeAdapter,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
const state = new Map<ProjectV2.ID, Map<string, WorkspaceAdapter>>()
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
export function getAdapter(projectID: ProjectV2.ID, type: string): WorkspaceAdapter {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
@@ -18,7 +18,7 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter
throw new Error(`Unknown workspace adapter: ${type}`)
}
export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
export function listAdapters(projectID: ProjectV2.ID): WorkspaceAdapterEntry[] {
return registeredAdapters(projectID).map(([type, adapter]) => ({
type,
name: adapter.name,
@@ -26,15 +26,15 @@ export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
}))
}
export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] {
export function registeredAdapters(projectID: ProjectV2.ID): [string, WorkspaceAdapter][] {
const adapters = new Map(Object.entries(BUILTIN))
for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter)
return [...adapters.entries()]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
// want to install a global one pass `ProjectV2.ID.global`
export function registerAdapter(projectID: ProjectV2.ID, type: string, adapter: WorkspaceAdapter) {
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
adapters.set(type, adapter)
state.set(projectID, adapters)
@@ -1,12 +0,0 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { withStatics } from "@opencode-ai/core/schema"
export const WorkspaceID = WorkspaceV2.ID.pipe(
withStatics((schema: typeof WorkspaceV2.ID) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
})),
)
export type WorkspaceID = typeof WorkspaceID.Type
+5 -5
View File
@@ -1,17 +1,17 @@
import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import type { InstanceContext } from "@/project/instance-context"
import { WorkspaceID } from "./schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { DeepMutable } from "@opencode-ai/core/schema"
export const WorkspaceInfo = Schema.Struct({
id: WorkspaceID,
id: WorkspaceV2.ID,
type: Schema.String,
name: Schema.String,
branch: Schema.optional(Schema.NullOr(Schema.String)),
directory: Schema.optional(Schema.NullOr(Schema.String)),
extra: Schema.optional(Schema.NullOr(Schema.Unknown)),
projectID: ProjectID,
projectID: ProjectV2.ID,
}).annotate({ identifier: "Workspace" })
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
@@ -40,7 +40,7 @@ export type Target =
export type WorkspaceAdapterContext = {
readonly instance?: InstanceContext
readonly workspaceID?: WorkspaceID
readonly workspaceID?: WorkspaceV2.ID
}
export type WorkspaceAdapter = {
@@ -1,18 +1,18 @@
import { LocalContext } from "@/util/local-context"
import type { WorkspaceID } from "../control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
export interface WorkspaceContext {
workspaceID: WorkspaceID | undefined
workspaceID: WorkspaceV2.ID | undefined
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
async provide<R>(input: { workspaceID?: WorkspaceV2.ID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
},
restore<R>(workspaceID: WorkspaceID, fn: () => R): R {
restore<R>(workspaceID: WorkspaceV2.ID, fn: () => R): R {
return context.provide({ workspaceID }, fn)
},
@@ -14,12 +14,12 @@ import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { getAdapter, registeredAdapters } from "./adapters"
import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Session } from "@/session/session"
import { SessionPrompt } from "@/session/prompt"
import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -40,7 +40,7 @@ export const Info = Schema.Struct({
export type Info = WorkspaceInfo & { timeUsed: number }
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
})
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
@@ -80,16 +80,16 @@ const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D)
const log = Log.create({ service: "workspace-sync" })
export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceID),
id: Schema.optional(WorkspaceV2.ID),
type: Info.fields.type,
branch: Info.fields.branch,
projectID: ProjectID,
projectID: ProjectV2.ID,
extra: Schema.optional(Info.fields.extra),
})
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const SessionWarpInput = Schema.Struct({
workspaceID: Schema.NullOr(WorkspaceID),
workspaceID: Schema.NullOr(WorkspaceV2.ID),
sessionID: SessionID,
copyChanges: Schema.optional(Schema.Boolean),
})
@@ -105,7 +105,7 @@ export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNot
"WorkspaceNotFoundError",
{
message: Schema.String,
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
},
) {}
@@ -121,7 +121,7 @@ export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHtt
"WorkspaceSessionWarpHttpError",
{
message: Schema.String,
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
sessionID: SessionID,
status: Schema.Number,
body: Schema.String,
@@ -153,17 +153,17 @@ export interface Interface {
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly syncList: (project: Project.Info) => Effect.Effect<void>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly get: (id: WorkspaceV2.ID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceV2.ID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect<boolean>
readonly isSyncing: (workspaceID: WorkspaceV2.ID) => Effect.Effect<boolean>
readonly waitForSync: (
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
state: Record<string, number>,
signal?: AbortSignal,
timeout?: number,
) => Effect.Effect<void, WaitForSyncError>
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
readonly startWorkspaceSyncing: (projectID: ProjectV2.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
@@ -181,10 +181,10 @@ export const layer = Layer.effect(
const vcs = yield* Vcs.Service
const flags = yield* RuntimeFlags.Service
const fs = yield* AppFileSystem.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => {
const setStatus = (id: WorkspaceV2.ID, status: ConnectionStatus["status"]) => {
const prev = connections.get(id)
if (prev?.status === status) return
const next = { workspaceID: id, status }
@@ -270,7 +270,7 @@ export const layer = Layer.effect(
})
const runInWorkspace = <A, E, R>(input: {
workspaceID?: WorkspaceID
workspaceID?: WorkspaceV2.ID
local: () => Effect.Effect<A, E, R>
remote: (input: {
workspace: Info
@@ -524,13 +524,13 @@ export const layer = Layer.effect(
)
})
const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceID) {
const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceV2.ID) {
yield* FiberMap.remove(syncFibers, id)
connections.delete(id)
})
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const id = WorkspaceV2.ID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* WorkspaceAdapterRuntime.configure(adapter, {
...input,
@@ -864,7 +864,7 @@ export const layer = Layer.effect(
names.add(item.name)
const info: Info = {
id: WorkspaceID.ascending(),
id: WorkspaceV2.ID.ascending(),
type: item.type,
branch: item.branch,
name: item.name,
@@ -895,13 +895,13 @@ export const layer = Layer.effect(
)
})
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceV2.ID) {
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) {
const sessions = yield* db((db) =>
db
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
@@ -941,13 +941,13 @@ export const layer = Layer.effect(
return [...connections.values()]
})
const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceID) {
const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceV2.ID) {
const exists = yield* FiberMap.has(syncFibers, workspaceID)
return exists && connections.get(workspaceID)?.status !== "error"
})
const waitForSync = Effect.fn("Workspace.waitForSync")(function* (
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
state: Record<string, number>,
signal?: AbortSignal,
timeout = TIMEOUT,
@@ -982,7 +982,7 @@ export const layer = Layer.effect(
)
})
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectV2.ID) {
const rows = yield* db((db) =>
db
.selectDistinct({ workspace: WorkspaceTable })
+2 -2
View File
@@ -1,6 +1,6 @@
import { Context, Effect, Exit, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service"
@@ -11,7 +11,7 @@ export interface Shape {
readonly bind: <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => (...args: Args) => Result
}
function restoreWorkspace<R>(workspace: WorkspaceID | undefined, fn: () => R): R {
function restoreWorkspace<R>(workspace: WorkspaceV2.ID | undefined, fn: () => R): R {
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn()
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { Context } from "effect"
import type { InstanceContext } from "@/project/instance-context"
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
export const WorkspaceRef = Context.Reference<WorkspaceID | undefined>("~opencode/WorkspaceRef", {
export const WorkspaceRef = Context.Reference<WorkspaceV2.ID | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined,
})
@@ -15,11 +15,9 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
autoShare: bool("OPENCODE_AUTO_SHARE"),
pure: bool("OPENCODE_PURE"),
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"),
skipMigrations: bool("OPENCODE_SKIP_MIGRATIONS"),
disableClaudeCodePrompt: Config.all({
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
+1 -1
View File
@@ -116,7 +116,7 @@ const cli = yargs(args)
run_id: processMetadata.runID,
})
const marker = path.join(Global.Path.data, "opencode.db")
const marker = Database.getPath()
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
+2 -2
View File
@@ -2,7 +2,7 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect/instance-state"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@/storage/db"
@@ -61,7 +61,7 @@ export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "Permission
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export const Approval = Schema.Struct({
projectID: ProjectID,
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = Schema.Schema.Type<typeof Approval>
+26 -27
View File
@@ -9,7 +9,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { InstanceState } from "@/effect/instance-state"
@@ -17,7 +16,7 @@ import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -46,7 +45,7 @@ const ProjectTime = Schema.Struct({
})
export const Info = Schema.Struct({
id: ProjectID,
id: ProjectV2.ID,
worktree: Schema.String,
vcs: optionalOmitUndefined(ProjectVcs),
name: optionalOmitUndefined(Schema.String),
@@ -93,7 +92,7 @@ function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRule
}
export const UpdateInput = Schema.Struct({
projectID: ProjectID,
projectID: ProjectV2.ID,
name: Schema.optional(Schema.String),
icon: Schema.optional(ProjectIcon),
commands: Schema.optional(ProjectCommands),
@@ -108,7 +107,7 @@ export const UpdatePayload = Schema.Struct({
export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
projectID: ProjectID,
projectID: ProjectV2.ID,
}) {}
// ---------------------------------------------------------------------------
@@ -125,13 +124,13 @@ export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly get: (id: ProjectV2.ID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly setInitialized: (id: ProjectV2.ID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectV2.ID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -181,11 +180,11 @@ export const layer = Layer.effect(
const scope = yield* Scope.Scope
const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (
oldID: ProjectID | undefined,
newID: ProjectID,
oldID: ProjectV2.ID | undefined,
newID: ProjectV2.ID,
) {
if (!oldID) return
if (oldID === ProjectID.global) return
if (oldID === ProjectV2.ID.global) return
if (oldID === newID) return
yield* Effect.sync(() =>
@@ -237,8 +236,8 @@ export const layer = Layer.effect(
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory
// Phase 2: upsert
const projectID = ProjectID.make(data.id)
yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID)
const projectID = ProjectV2.ID.make(data.id)
yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID)
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
const existing = row
? fromRow(row)
@@ -254,12 +253,12 @@ export const layer = Layer.effect(
const result: Info = {
...existing,
worktree: projectID === ProjectID.global ? worktree : existing.worktree,
worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree,
vcs: data.vcs?.type ?? fakeVcs,
time: { ...existing.time, updated: Date.now() },
}
if (
projectID !== ProjectID.global &&
projectID !== ProjectV2.ID.global &&
data.directory !== result.worktree &&
!result.sandboxes.includes(data.directory)
)
@@ -309,18 +308,18 @@ export const layer = Layer.effect(
.run(),
)
if (projectID !== ProjectID.global) {
if (projectID !== ProjectV2.ID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: projectID })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory)))
.where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory)))
.run(),
)
}
yield* emitUpdated(result)
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
}
return { project: result, sandbox: data.vcs ? data.directory : worktree }
@@ -354,7 +353,7 @@ export const layer = Layer.effect(
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
})
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
return row ? fromRow(row) : undefined
})
@@ -392,7 +391,7 @@ export const layer = Layer.effect(
return project
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
@@ -413,7 +412,7 @@ export const layer = Layer.effect(
yield* InstanceState.get(initState)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectV2.ID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
@@ -428,7 +427,7 @@ export const layer = Layer.effect(
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
@@ -445,7 +444,7 @@ export const layer = Layer.effect(
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
@@ -498,13 +497,13 @@ export function list() {
)
}
export function get(id: ProjectID): Info | undefined {
export function get(id: ProjectV2.ID): Info | undefined {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return undefined
return fromRow(row)
}
export function setInitialized(id: ProjectID) {
export function setInitialized(id: ProjectV2.ID) {
Database.use((db) =>
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
-6
View File
@@ -1,6 +0,0 @@
import { Schema } from "effect"
import { Project } from "@opencode-ai/core/project"
export const ProjectID = Project.ID
export type ProjectID = typeof ProjectID.Type
+2 -2
View File
@@ -1,6 +1,6 @@
export * as PtyTicket from "./ticket"
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { PtyID } from "@/pty/schema"
import { PositiveInt } from "@opencode-ai/core/schema"
@@ -17,7 +17,7 @@ export const ConnectToken = Schema.Struct({
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: WorkspaceID
readonly workspaceID?: WorkspaceV2.ID
}
export interface Interface {
@@ -1,5 +1,5 @@
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProjectNotFoundError } from "../errors"
@@ -50,7 +50,7 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID },
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Project.Info, "Updated project information"),
@@ -1,6 +1,6 @@
import * as InstanceState from "@/effect/instance-state"
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@@ -33,7 +33,7 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
params: { projectID: ProjectV2.ID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
@@ -1,4 +1,4 @@
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { SessionV2 } from "@opencode-ai/core/session"
import { DateTime, Effect, Option, Schema } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
@@ -20,7 +20,7 @@ const SessionCursor = Schema.Struct({
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
directory: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
workspaceID: WorkspaceID.pipe(Schema.optional),
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
roots: Schema.Boolean.pipe(Schema.optional),
start: Schema.Finite.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
@@ -78,7 +78,7 @@ const sessionCursor = {
function decodeWorkspaceID(input: string | undefined) {
if (input === undefined) return Effect.succeed(undefined)
const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(input)
const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(input)
if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value)
return Effect.fail(
new InvalidRequestError({
@@ -1,4 +1,4 @@
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime"
@@ -30,8 +30,8 @@ type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{
InvalidWorkspace: {}
MissingWorkspace: { readonly workspaceID: WorkspaceID }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceID }
MissingWorkspace: { readonly workspaceID: WorkspaceV2.ID }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceV2.ID }
Remote: {
readonly request: HttpServerRequest.HttpServerRequest
readonly workspace: Workspace.Info
@@ -46,7 +46,7 @@ export class WorkspaceRouteContext extends Context.Service<
WorkspaceRouteContext,
{
readonly directory: string
readonly workspaceID?: WorkspaceID
readonly workspaceID?: WorkspaceV2.ID
}
>()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {}
@@ -62,23 +62,23 @@ function requestURL(request: HttpServerRequest.HttpServerRequest): URL {
return new URL(request.url, "http://localhost")
}
function configuredWorkspaceID(): WorkspaceID | undefined {
return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
function configuredWorkspaceID(): WorkspaceV2.ID | undefined {
return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceV2.ID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
}
function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | undefined {
function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceV2.ID): WorkspaceV2.ID | undefined {
const workspaceParam = url.searchParams.get("workspace")
return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
return sessionWorkspaceID ?? (workspaceParam ? WorkspaceV2.ID.make(workspaceParam) : undefined)
}
function selectedV2WorkspaceID(
url: URL,
sessionWorkspaceID?: WorkspaceID,
): WorkspaceID | typeof InvalidWorkspaceID | undefined {
sessionWorkspaceID?: WorkspaceV2.ID,
): WorkspaceV2.ID | typeof InvalidWorkspaceID | undefined {
if (sessionWorkspaceID) return sessionWorkspaceID
const workspaceParam = url.searchParams.get("workspace")
if (!workspaceParam) return undefined
const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(workspaceParam)
const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(workspaceParam)
if (Option.isNone(workspaceID)) return InvalidWorkspaceID
return workspaceID.value
}
@@ -92,14 +92,14 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest,
}
function resolveWorkspace(
id: WorkspaceID | undefined,
envWorkspaceID: WorkspaceID | undefined,
id: WorkspaceV2.ID | undefined,
envWorkspaceID: WorkspaceV2.ID | undefined,
): Effect.Effect<Workspace.Info | void, never, Workspace.Service> {
if (!id || envWorkspaceID) return Effect.void
return Workspace.Service.use((workspace) => workspace.get(id))
}
function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse {
function missingWorkspaceResponse(id: WorkspaceV2.ID): HttpServerResponse.HttpServerResponse {
return HttpServerResponse.text(`Workspace not found: ${id}`, {
status: 500,
contentType: "text/plain; charset=utf-8",
@@ -159,7 +159,7 @@ function planWorkspaceRequest(
function planRequest(
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
sessionWorkspaceID?: WorkspaceV2.ID,
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const url = requestURL(request)
+2 -2
View File
@@ -2,7 +2,7 @@ import { Database } from "@/storage/db"
import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@opencode-ai/core/event/sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
@@ -53,7 +53,7 @@ export function parse(headers: Headers): State | undefined {
)
}
export function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
export function wait(workspaceID: WorkspaceV2.ID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
+13 -13
View File
@@ -29,8 +29,8 @@ import { MessageV2 } from "./message-v2"
import type { InstanceContext } from "../project/instance-context"
import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { SessionID, MessageID, PartID } from "./schema"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -208,8 +208,8 @@ const Model = Schema.Struct({
export const Info = Schema.Struct({
id: SessionID,
slug: Schema.String,
projectID: ProjectID,
workspaceID: optionalOmitUndefined(WorkspaceID),
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
directory: Schema.String,
path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionID),
@@ -228,7 +228,7 @@ export const Info = Schema.Struct({
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const ProjectInfo = Schema.Struct({
id: ProjectID,
id: ProjectV2.ID,
name: optionalOmitUndefined(Schema.String),
worktree: Schema.String,
}).annotate({ identifier: "ProjectSummary" })
@@ -247,7 +247,7 @@ export const CreateInput = Schema.optional(
agent: Schema.optional(Schema.String),
model: Schema.optional(Model),
permission: Schema.optional(Permission.Ruleset),
workspaceID: Schema.optional(WorkspaceID),
workspaceID: Schema.optional(WorkspaceV2.ID),
}),
)
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
@@ -281,7 +281,7 @@ export type ListInput = {
directory?: string
scope?: "project"
path?: string
workspaceID?: WorkspaceID
workspaceID?: WorkspaceV2.ID
roots?: boolean
start?: number
search?: string
@@ -307,8 +307,8 @@ const UpdatedTime = Schema.Struct({
const UpdatedInfo = Schema.Struct({
id: Schema.optional(Schema.NullOr(SessionID)),
slug: Schema.optional(Schema.NullOr(Schema.String)),
projectID: Schema.optional(Schema.NullOr(ProjectID)),
workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)),
projectID: Schema.optional(Schema.NullOr(ProjectV2.ID)),
workspaceID: Schema.optional(Schema.NullOr(WorkspaceV2.ID)),
directory: Schema.optional(Schema.NullOr(Schema.String)),
path: Schema.optional(Schema.NullOr(Schema.String)),
parentID: Schema.optional(Schema.NullOr(SessionID)),
@@ -456,7 +456,7 @@ export interface Interface {
agent?: string
model?: Schema.Schema.Type<typeof Model>
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
workspaceID?: WorkspaceV2.ID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
@@ -526,7 +526,7 @@ export const layer: Layer.Layer<
agent?: string
model?: Schema.Schema.Type<typeof Model>
parentID?: SessionID
workspaceID?: WorkspaceID
workspaceID?: WorkspaceV2.ID
directory: string
path?: string
permission?: Permission.Ruleset
@@ -660,7 +660,7 @@ export const layer: Layer.Layer<
agent?: string
model?: Schema.Schema.Type<typeof Model>
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
workspaceID?: WorkspaceV2.ID
}) {
const ctx = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
@@ -890,7 +890,7 @@ const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function*
function* listByProject(
input: ListInput & {
projectID: ProjectID
projectID: ProjectV2.ID
experimentalWorkspaces: boolean
},
) {
+6 -26
View File
@@ -1,16 +1,12 @@
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { LocalContext } from "@/util/local-context"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { EffectBridge } from "@/effect/bridge"
import { init } from "#db"
import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
export const NotFoundError = NamedError.create("NotFoundError", {
message: Schema.String,
@@ -18,25 +14,7 @@ export const NotFoundError = NamedError.create("NotFoundError", {
const log = Log.create({ service: "db" })
type DatabaseFlags = Pick<RuntimeFlags.Info, "disableChannelDb" | "skipMigrations">
const readRuntimeFlags = () =>
Effect.runSync(RuntimeFlags.Service.useSync((flags) => flags).pipe(Effect.provide(RuntimeFlags.defaultLayer)))
export function getChannelPath(flags: Pick<DatabaseFlags, "disableChannelDb"> = readRuntimeFlags()) {
if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb)
return path.join(Global.Path.data, "opencode.db")
const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
}
export const getPath = (flags?: Pick<DatabaseFlags, "disableChannelDb">) => {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return path.join(Global.Path.data, Flag.OPENCODE_DB)
}
return getChannelPath(flags)
}
export const getPath = () => Database.path()
export type Transaction = SQLiteTransaction<"sync", void>
@@ -46,12 +24,14 @@ let client: Client | undefined
let loaded = false
export const Client = Object.assign(
(flags: DatabaseFlags = readRuntimeFlags()): Client => {
(): Client => {
if (loaded) return client as Client
const dbPath = getPath(flags)
const dbPath = getPath()
log.info("opening database", { path: dbPath })
Database.init({ path: dbPath })
const db = init(dbPath)
db.run("PRAGMA journal_mode = WAL")
+2 -2
View File
@@ -5,7 +5,7 @@ import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import type { ProjectID } from "../project/schema"
import type { ProjectV2 } from "@opencode-ai/core/project"
import * as Log from "@opencode-ai/core/util/log"
import { Slug } from "@opencode-ai/core/util/slug"
import { errorMessage } from "../util/error"
@@ -476,7 +476,7 @@ export const layer: Layer.Layer<
const runStartScripts = Effect.fnUntraced(function* (
directory: string,
input: { projectID: ProjectID; extra?: string },
input: { projectID: ProjectV2.ID; extra?: string },
) {
const row = yield* Effect.sync(() =>
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
+2 -2
View File
@@ -30,7 +30,7 @@ import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { Global } from "@opencode-ai/core/global"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Filesystem } from "@/util/filesystem"
import { ConfigPlugin } from "@/config/plugin"
import { AccountTest } from "../fake/account"
@@ -274,7 +274,7 @@ async function check(map: (dir: string) => string) {
const cfg = await load(ctx)
expect(cfg.snapshot).toBe(true)
expect(ctx.directory).toBe(Filesystem.resolve(tmp.path))
expect(ctx.project.id).not.toBe(ProjectID.global)
expect(ctx.project.id).not.toBe(ProjectV2.ID.global)
},
})
} finally {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import type { WorkspaceInfo } from "../../src/control-plane/types"
function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo {
@@ -36,8 +36,8 @@ function adapter(dir: string) {
describe("control-plane/adapters", () => {
test("isolates custom adapters by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const one = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(one, type, adapter("/one"))
registerAdapter(two, type, adapter("/two"))
@@ -53,7 +53,7 @@ describe("control-plane/adapters", () => {
test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const id = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(id, type, adapter("/one"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
@@ -11,7 +11,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Database } from "@/storage/db"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Session as SessionNs } from "@/session/session"
import { SessionID } from "@/session/schema"
@@ -22,7 +22,7 @@ import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import * as Workspace from "../../src/control-plane/workspace"
@@ -129,7 +129,7 @@ async function initGitRepo(dir: string) {
await $`git commit -m "base"`.cwd(dir).quiet()
}
const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) =>
const startWorkspaceSyncingWithFlag = (projectID: ProjectV2.ID, experimentalWorkspaces: boolean) =>
Effect.runPromise(
Workspace.use.startWorkspaceSyncing(projectID).pipe(Effect.provide(workspaceLayer(experimentalWorkspaces))),
)
@@ -265,9 +265,9 @@ function serverUrl() {
})
}
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
function workspaceInfo(projectID: ProjectV2.ID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
return {
id: input?.id ?? WorkspaceID.ascending(),
id: input?.id ?? WorkspaceV2.ID.ascending(),
type,
name: input?.name ?? unique("workspace"),
branch: input?.branch ?? null,
@@ -296,7 +296,7 @@ function insertWorkspace(info: Workspace.Info) {
)
}
function insertProject(id: ProjectID, worktree: string) {
function insertProject(id: ProjectV2.ID, worktree: string) {
Database.use((db) =>
db
.insert(ProjectTable)
@@ -313,7 +313,7 @@ function insertProject(id: ProjectID, worktree: string) {
)
}
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) {
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceV2.ID) {
Database.use((db) =>
db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(),
)
@@ -352,10 +352,10 @@ describe("workspace schemas and exports", () => {
test("validates create input with workspace id, project id, branch, type, and extra", () => {
const input = {
id: WorkspaceID.ascending("wrk_schema_create"),
id: WorkspaceV2.ID.ascending("wrk_schema_create"),
type: "worktree",
branch: "feature/schema",
projectID: ProjectID.make("project-schema"),
projectID: ProjectV2.ID.make("project-schema"),
extra: { nested: true },
}
@@ -372,7 +372,7 @@ describe("workspace CRUD", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
expect(yield* workspace.get(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
expect(yield* workspace.get(WorkspaceV2.ID.ascending("wrk_missing_get"))).toBeUndefined()
}),
{ git: true },
)
@@ -383,21 +383,21 @@ describe("workspace CRUD", () => {
Effect.gen(function* () {
const instance = yield* requireInstance
const workspace = yield* Workspace.Service
const otherProjectID = ProjectID.make("project-other")
const otherProjectID = ProjectV2.ID.make("project-other")
insertProject(otherProjectID, "/tmp/other")
const a = workspaceInfo(instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_a_list"),
id: WorkspaceV2.ID.ascending("wrk_a_list"),
branch: "a",
directory: "/a",
extra: { a: true },
})
const b = workspaceInfo(instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_b_list"),
id: WorkspaceV2.ID.ascending("wrk_b_list"),
branch: "b",
directory: "/b",
extra: ["b"],
})
const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceID.ascending("wrk_c_list") })
const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceV2.ID.ascending("wrk_c_list") })
insertWorkspace(b)
insertWorkspace(other)
insertWorkspace(a)
@@ -418,7 +418,7 @@ describe("workspace CRUD", () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test"
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.name=opencode-test"
const workspaceID = WorkspaceID.ascending("wrk_create_local")
const workspaceID = WorkspaceV2.ID.ascending("wrk_create_local")
const type = unique("create-local")
const targetDir = path.join(instance.directory, "created-local")
const recorded = recordedAdapter({
@@ -578,7 +578,7 @@ describe("workspace CRUD", () => {
const workspace = yield* Workspace.Service
const type = unique("list-sync")
const existing = workspaceInfo(instance.project.id, type, {
id: WorkspaceID.ascending("wrk_list_sync_existing"),
id: WorkspaceV2.ID.ascending("wrk_list_sync_existing"),
name: "existing",
directory: path.join(instance.directory, "existing"),
})
@@ -748,7 +748,7 @@ describe("workspace CRUD", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
expect(yield* workspace.remove(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined()
expect(yield* workspace.remove(WorkspaceV2.ID.ascending("wrk_missing_remove"))).toBeUndefined()
}),
{ git: true },
)
@@ -793,7 +793,7 @@ describe("workspace CRUD", () => {
const instance = yield* requireInstance
const workspace = yield* Workspace.Service
const type = unique("remove-throws")
const info = workspaceInfo(instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
const info = workspaceInfo(instance.project.id, type, { id: WorkspaceV2.ID.ascending("wrk_remove_throws") })
registerAdapter(
instance.project.id,
type,
@@ -1555,7 +1555,7 @@ describe("workspace waitForSync", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
expect(yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_empty"), {})).toBeUndefined()
expect(yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_empty"), {})).toBeUndefined()
}),
{ git: true },
)
@@ -1568,9 +1568,9 @@ describe("workspace waitForSync", () => {
const sessionID = SessionID.descending("ses_wait_done")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run())
expect(yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 })).toBeUndefined()
expect(yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 })).toBeUndefined()
expect(
yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }),
yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }),
).toBeUndefined()
}),
{ git: true },
@@ -1581,7 +1581,7 @@ describe("workspace waitForSync", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = WorkspaceID.ascending("wrk_wait_event")
const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_event")
const sessionID = SessionID.descending("ses_wait_event")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run())
@@ -1611,7 +1611,7 @@ describe("workspace waitForSync", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = WorkspaceID.ascending("wrk_wait_sync_any")
const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_sync_any")
const sessionID = SessionID.descending("ses_wait_sync_any")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run())
@@ -1628,7 +1628,7 @@ describe("workspace waitForSync", () => {
.run(),
)
GlobalBus.emit("event", {
workspace: WorkspaceID.ascending("wrk_other_workspace"),
workspace: WorkspaceV2.ID.ascending("wrk_other_workspace"),
payload: { type: "sync" },
})
}),
@@ -1648,7 +1648,7 @@ describe("workspace waitForSync", () => {
const reason = new Error("caller aborted")
const fiber = yield* Effect.forkChild(
workspace.waitForSync(
WorkspaceID.ascending("wrk_wait_abort"),
WorkspaceV2.ID.ascending("wrk_wait_abort"),
{ [SessionID.descending("ses_wait_abort")]: 1 },
abort.signal,
),
@@ -1668,7 +1668,7 @@ describe("workspace waitForSync", () => {
const sessionID = SessionID.descending("ses_wait_timeout")
expectExitContains(
yield* Effect.exit(
workspace.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25),
workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25),
),
`Timed out waiting for sync fence: {"${sessionID}":1}`,
)
@@ -2,7 +2,7 @@ import { expect } from "bun:test"
import { Effect, Layer, Context } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { it } from "../lib/effect"
class Shared extends Context.Service<Shared, { readonly id: number }>()("@test/Shared") {}
@@ -79,7 +79,7 @@ it.live("makeRuntime inherits InstanceRef from the current fiber", () =>
directory: testDirectory,
worktree: testDirectory,
project: {
id: ProjectID.global,
id: ProjectV2.ID.global,
worktree: testDirectory,
time: { created: 0, updated: 0 },
sandboxes: [],
@@ -24,12 +24,10 @@ describe("RuntimeFlags", () => {
fromConfig({
OPENCODE_PURE: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_DISABLE_CHANNEL_DB: "true",
OPENCODE_AUTO_SHARE: "true",
OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true",
OPENCODE_DISABLE_EXTERNAL_SKILLS: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_SKIP_MIGRATIONS: "true",
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_ENABLE_EXA: "true",
OPENCODE_ENABLE_PARALLEL: "true",
@@ -43,11 +41,9 @@ describe("RuntimeFlags", () => {
expect(flags.pure).toBe(true)
expect(flags.autoShare).toBe(true)
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableChannelDb).toBe(true)
expect(flags.disableEmbeddedWebUi).toBe(true)
expect(flags.disableExternalSkills).toBe(true)
expect(flags.disableLspDownload).toBe(true)
expect(flags.skipMigrations).toBe(true)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.enableExa).toBe(true)
expect(flags.enableParallel).toBe(true)
@@ -100,11 +96,9 @@ describe("RuntimeFlags", () => {
expect(flags.pure).toBe(false)
expect(flags.autoShare).toBe(false)
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableChannelDb).toBe(false)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.skipMigrations).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
@@ -157,22 +151,6 @@ describe("RuntimeFlags", () => {
}),
)
it.effect("skipMigrations defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.skipMigrations).toBe(false)
}),
)
it.effect("skipMigrations reads OPENCODE_SKIP_MIGRATIONS", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_SKIP_MIGRATIONS: "true" })))
expect(flags.skipMigrations).toBe(true)
}),
)
it.effect("disableClaudeCodePrompt defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
@@ -318,7 +296,6 @@ describe("RuntimeFlags", () => {
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_DISABLE_EXTERNAL_SKILLS: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_SKIP_MIGRATIONS: "true",
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_ENABLE_EXA: "true",
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234",
@@ -330,11 +307,9 @@ describe("RuntimeFlags", () => {
expect(flags.pure).toBe(false)
expect(flags.disableDefaultPlugins).toBe(false)
expect(flags.disableChannelDb).toBe(false)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.skipMigrations).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
+2 -2
View File
@@ -1,4 +1,4 @@
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect, Scope } from "effect"
@@ -7,7 +7,7 @@ import { Effect, Scope } from "effect"
* on entry and restores it via finalizer when the surrounding scope closes
* preserves the original try/finally semantics regardless of test outcome.
*/
export function withFixedWorkspaceID(id: WorkspaceID): Effect.Effect<void, never, Scope.Scope> {
export function withFixedWorkspaceID(id: WorkspaceV2.ID): Effect.Effect<void, never, Scope.Scope> {
return Effect.gen(function* () {
const previous = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = id
@@ -4,7 +4,7 @@ import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { $ } from "bun"
@@ -22,7 +22,7 @@ function legacySessionID() {
return crypto.randomUUID() as SessionID
}
function seed(opts: { id: SessionID; dir: string; project: ProjectID }) {
function seed(opts: { id: SessionID; dir: string; project: ProjectV2.ID }) {
const now = Date.now()
Database.use((db) =>
db
@@ -46,7 +46,7 @@ function ensureGlobal() {
db
.insert(ProjectTable)
.values({
id: ProjectID.global,
id: ProjectV2.ID.global,
worktree: "/",
time_created: Date.now(),
time_updated: Date.now(),
@@ -68,17 +68,17 @@ describe("migrateFromGlobal", () => {
yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
const projects = yield* Project.Service
const { project: pre } = yield* projects.fromDirectory(tmp)
expect(pre.id).toBe(ProjectID.global)
expect(pre.id).toBe(ProjectV2.ID.global)
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectV2.ID.global }))
// 3. Make a commit so the project gets a real ID
yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
const { project: real } = yield* projects.fromDirectory(tmp)
expect(real.id).not.toBe(ProjectID.global)
expect(real.id).not.toBe(ProjectV2.ID.global)
// 4. The session should have been migrated to the real project ID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
@@ -93,7 +93,7 @@ describe("migrateFromGlobal", () => {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
yield* Effect.sync(() => ensureGlobal())
@@ -102,7 +102,7 @@ describe("migrateFromGlobal", () => {
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectV2.ID.global }))
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
@@ -119,20 +119,20 @@ describe("migrateFromGlobal", () => {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* Effect.sync(() => ensureGlobal())
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global }))
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectV2.ID.global }))
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(ProjectID.global)
expect(row!.project_id).toBe(ProjectV2.ID.global)
}),
)
@@ -141,19 +141,19 @@ describe("migrateFromGlobal", () => {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* Effect.sync(() => ensureGlobal())
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global }))
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectV2.ID.global }))
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
// Should remain under "global" — not stolen
expect(row!.project_id).toBe(ProjectID.global)
expect(row!.project_id).toBe(ProjectV2.ID.global)
}),
)
})
+13 -14
View File
@@ -6,7 +6,6 @@ import { $ } from "bun"
import path from "path"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Database } from "@/storage/db"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
@@ -14,13 +13,13 @@ import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -40,7 +39,7 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
}
function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
}
/**
@@ -108,7 +107,7 @@ const iconDiscoveryIt = testEffect(
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
)
function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> {
function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect<Project.Info> {
return Effect.gen(function* () {
const project = Project.get(id)
if (project?.icon?.url) return project
@@ -127,7 +126,7 @@ describe("Project.fromDirectory", () => {
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
expect(project.id).toBe(ProjectV2.ID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
@@ -143,7 +142,7 @@ describe("Project.fromDirectory", () => {
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
}),
@@ -153,7 +152,7 @@ describe("Project.fromDirectory", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(ProjectID.global)
expect(project.id).toBe(ProjectV2.ID.global)
}),
)
@@ -199,7 +198,7 @@ describe("Project.fromDirectory", () => {
const { project: rootProject } = yield* projects.fromDirectory(tmp)
const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceID.ascending()
const workspaceID = WorkspaceV2.ID.ascending()
yield* Effect.sync(() => {
Database.use((db) => {
@@ -264,7 +263,7 @@ describe("Project.fromDirectory git failure paths", () => {
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.id).toBe(ProjectV2.ID.global)
expect(project.worktree).toBe(tmp)
}),
)
@@ -586,7 +585,7 @@ describe("Project.update", () => {
Effect.gen(function* () {
const exit = yield* run((svc) =>
svc.update({
projectID: ProjectID.make("nonexistent-project-id"),
projectID: ProjectV2.ID.make("nonexistent-project-id"),
name: "Should Fail",
}),
).pipe(Effect.exit)
@@ -665,7 +664,7 @@ describe("Project.list and Project.get", () => {
)
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
const found = Project.get(ProjectV2.ID.make("nonexistent"))
expect(found).toBeUndefined()
})
})
@@ -740,7 +739,7 @@ describe("Project.fromDirectory with bare repos", () => {
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
expect(project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
@@ -803,7 +802,7 @@ describe("Project.fromDirectory with bare repos", () => {
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
expect(project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { PtyID } from "../../src/pty/schema"
import { PtyTicket } from "../../src/pty/ticket"
import { testEffect } from "../lib/effect"
@@ -47,10 +47,10 @@ describe("PTY websocket tickets", () => {
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const workspaceID = WorkspaceID.ascending()
const workspaceID = WorkspaceV2.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}),
)
@@ -6,7 +6,7 @@ import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
@@ -198,7 +198,7 @@ describe("HttpApi instance context middleware", () => {
it.live("uses configured workspace id instead of routing to the requested workspace", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceID.ascending()
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
const dir = yield* tmpdirScoped({ git: true })
@@ -226,7 +226,7 @@ describe("HttpApi instance context middleware", () => {
it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceID.ascending()
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
const dir = yield* tmpdirScoped({ git: true })
@@ -238,7 +238,7 @@ describe("HttpApi instance context middleware", () => {
// MissingWorkspace response. With the env set, planRequest must skip the
// MissingWorkspace branch and fall through to Local with the configured
// workspace id.
const unknownWorkspaceID = WorkspaceID.ascending()
const unknownWorkspaceID = WorkspaceV2.ID.ascending()
const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
HttpClient.execute,
@@ -254,7 +254,7 @@ describe("HttpApi instance context middleware", () => {
it.live("keeps configured workspace id on control-plane routes without remote routing", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceID.ascending()
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
const dir = yield* tmpdirScoped({ git: true })
@@ -4,12 +4,12 @@ import { describe, expect } from "bun:test"
import { Config, Context, Effect, FileSystem, Layer, Path } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { PermissionID } from "../../src/permission/schema"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { QuestionID } from "../../src/question/schema"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
@@ -76,7 +76,7 @@ describe("instance HttpApi", () => {
it.live("emits a sync fence header for fixed-workspace mutations", () =>
Effect.gen(function* () {
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
@@ -98,7 +98,7 @@ describe("instance HttpApi", () => {
it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () =>
Effect.gen(function* () {
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
@@ -209,7 +209,7 @@ describe("instance HttpApi", () => {
it.live("returns typed not found bodies for missing projects", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const projectID = ProjectID.make("project_missing")
const projectID = ProjectV2.ID.make("project_missing")
const response = yield* Effect.promise(() =>
HttpApiApp.webHandler().handler(
new Request(`http://localhost/project/${projectID}`, {
@@ -15,7 +15,7 @@ import Http from "node:http"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
@@ -161,7 +161,7 @@ const insertRemoteWorkspaceWithoutSync = (input: {
url: string
}) =>
Effect.sync(() => {
const id = WorkspaceID.ascending()
const id = WorkspaceV2.ID.ascending()
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
return id
@@ -286,9 +286,9 @@ describe("HttpApi workspace routing middleware", () => {
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceID = WorkspaceID.ascending()
const workspaceID = WorkspaceV2.ID.ascending()
const type = "remote-http-fence-target"
const waited = yield* Ref.make<{ workspaceID: WorkspaceID; state: Record<string, number> } | undefined>(undefined)
const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record<string, number> } | undefined>(undefined)
const remoteUrl = yield* startRemoteWorkspaceHttpServer(() =>
HttpServerResponse.json(
@@ -403,7 +403,7 @@ describe("HttpApi workspace routing middleware", () => {
it.live("returns a missing workspace response for unknown workspace ids", () =>
Effect.gen(function* () {
const workspaceID = WorkspaceID.ascending("wrk_missing")
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing")
// If the middleware resolves the workspace first, this handler is never
// reached and the response should be the middleware error response.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
@@ -5,7 +5,7 @@ import path from "node:path"
import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
@@ -255,7 +255,7 @@ describe("workspace HttpApi", () => {
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
const workspaceID = WorkspaceID.ascending("wrk_missing_warp")
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp")
const response = yield* request(WorkspacePaths.warp, dir, {
method: "POST",
@@ -8,8 +8,8 @@ import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { Todo } from "../../src/session/todo"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ProjectID } from "../../src/project/schema"
import { WorkspaceID } from "../../src/control-plane/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
// Covers the session-domain Effect Schema migration. For each migrated
// schema we assert:
@@ -22,8 +22,8 @@ const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3
const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
const projectID = ProjectID.make("proj-alpha")
const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary")
const projectID = ProjectV2.ID.make("proj-alpha")
const workspaceID = Schema.decodeUnknownSync(WorkspaceV2.ID)("wrk-primary")
function decodeUnknown<S extends Schema.Top>(schema: S) {
const decode = Schema.decodeUnknownSync(schema as any)
@@ -1,13 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "../../src/session/schema"
import { Session } from "../../src/session/session"
const info = {
id: SessionID.descending(),
slug: "test-session",
projectID: ProjectID.global,
projectID: ProjectV2.ID.global,
workspaceID: undefined,
directory: "/tmp/opencode",
parentID: undefined,
@@ -43,7 +43,7 @@ describe("Session schema", () => {
const encoded = Schema.encodeUnknownSync(Session.GlobalInfo)({
...info,
project: {
id: ProjectID.global,
id: ProjectV2.ID.global,
name: undefined,
worktree: "/tmp/opencode",
},
+6 -35
View File
@@ -1,38 +1,9 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { describe, expect, it } from "bun:test"
import { Database as CoreDatabase } from "@opencode-ai/core/database/database"
import { Database } from "@/storage/db"
import { it } from "../lib/effect"
describe("Database.getChannelPath", () => {
it.effect("returns database path for the current channel", () =>
Effect.gen(function* () {
const flags = yield* RuntimeFlags.Service
const expected = ["latest", "beta", "prod"].includes(InstallationChannel)
? path.join(Global.Path.data, "opencode.db")
: path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
expect(Database.getChannelPath(flags)).toBe(expected)
}).pipe(Effect.provide(RuntimeFlags.layer())),
)
it.effect("uses the shared database path when channel databases are disabled", () =>
Effect.gen(function* () {
const flags = yield* RuntimeFlags.Service
expect(Database.getChannelPath(flags)).toBe(path.join(Global.Path.data, "opencode.db"))
}).pipe(Effect.provide(RuntimeFlags.layer({ disableChannelDb: true }))),
)
it.effect("accepts RuntimeFlags with skipMigrations for database callers", () =>
Effect.gen(function* () {
const flags = yield* RuntimeFlags.Service
expect(flags.skipMigrations).toBe(true)
expect(Database.getChannelPath(flags)).toBe(Database.getChannelPath({ disableChannelDb: flags.disableChannelDb }))
}).pipe(Effect.provide(RuntimeFlags.layer({ skipMigrations: true }))),
)
describe("Database.getPath", () => {
it("delegates to the core database path", () => {
expect(Database.getPath()).toBe(CoreDatabase.path())
})
})
@@ -8,7 +8,7 @@ import { readFileSync, readdirSync } from "fs"
import { JsonMigration } from "@/storage/json-migration"
import { Global } from "@opencode-ai/core/global"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -127,7 +127,7 @@ describe("JSON to SQLite migration", () => {
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc"))
expect(projects[0].worktree).toBe("/test/path")
expect(projects[0].name).toBe("Test Project")
expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
@@ -151,7 +151,7 @@ describe("JSON to SQLite migration", () => {
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_filename")) // Uses filename, not JSON id
})
test("migrates project with commands", async () => {
@@ -171,7 +171,7 @@ describe("JSON to SQLite migration", () => {
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_with_commands"))
expect(projects[0].commands).toEqual({ start: "npm run dev" })
})
@@ -191,7 +191,7 @@ describe("JSON to SQLite migration", () => {
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_no_commands"))
expect(projects[0].commands).toBeNull()
})
@@ -220,7 +220,7 @@ describe("JSON to SQLite migration", () => {
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
expect(sessions[0].project_id).toBe(ProjectV2.ID.make("proj_test123abc"))
expect(sessions[0].slug).toBe("test-session")
expect(sessions[0].title).toBe("Test Session Title")
expect(sessions[0].summary_additions).toBe(10)
@@ -421,7 +421,7 @@ describe("JSON to SQLite migration", () => {
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
expect(sessions[0].project_id).toBe(ProjectID.make(gitBasedProjectID)) // Uses directory, not stale JSON
expect(sessions[0].project_id).toBe(ProjectV2.ID.make(gitBasedProjectID)) // Uses directory, not stale JSON
})
test("uses filename for session id when JSON has different value", async () => {
@@ -452,7 +452,7 @@ describe("JSON to SQLite migration", () => {
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
expect(sessions[0].project_id).toBe(ProjectV2.ID.make("proj_test123abc"))
})
test("is idempotent (running twice doesn't duplicate)", async () => {
@@ -631,7 +631,7 @@ describe("JSON to SQLite migration", () => {
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc"))
})
test("skips invalid todo entries while preserving source positions", async () => {