Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { StoredPath } from "@/path/path"

declare global {
const OPENCODE_WORKER_PATH: string
Expand Down Expand Up @@ -115,18 +116,20 @@ export const TuiThreadCommand = cmd({

// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const root = StoredPath.parse(process.env.PWD ?? process.cwd())
const next = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: Filesystem.resolve(process.cwd())
? StoredPath.parse(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: StoredPath.parse(process.cwd())
const file = await target()
try {
process.chdir(next)
} catch {
UI.error("Failed to change directory to " + next)
return
}
const cwd = Filesystem.resolve(process.cwd())
// After chdir we intentionally collapse to the physical cwd so the thread,
// worker, and downstream session keys agree on one target directory.
const cwd = StoredPath.parse(Filesystem.resolve(process.cwd()))

const worker = new Worker(file, {
env: Object.fromEntries(
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/control-plane/adaptors/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import z from "zod"
import { StoredPath } from "@/path/path"
import { Worktree } from "@/worktree"
import { type Adaptor, WorkspaceInfo } from "../types"

Expand All @@ -17,7 +18,7 @@ export const WorktreeAdaptor: Adaptor = {
...info,
name: worktree.name,
branch: worktree.branch,
directory: worktree.directory,
directory: StoredPath.parse(worktree.directory),
}
},
async create(info) {
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/control-plane/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import z from "zod"
import { ProjectID } from "@/project/schema"
import { StoredPath } from "@/path/path"
import { WorkspaceID } from "./schema"

export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(),
directory: (z.string() as unknown as z.ZodType<StoredPath>).nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
})
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/control-plane/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
import { StoredPath } from "@/path/path"

export namespace Workspace {
export const Event = {
Expand Down Expand Up @@ -40,7 +41,7 @@ export namespace Workspace {
type: row.type,
branch: row.branch,
name: row.name,
directory: row.directory,
directory: row.directory ? StoredPath.unsafe(row.directory) : null,
extra: row.extra,
projectID: row.project_id,
}
Expand All @@ -65,7 +66,7 @@ export namespace Workspace {
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
directory: config.directory ? StoredPath.parse(config.directory) : null,
extra: config.extra ?? null,
projectID: input.projectID,
}
Expand Down
69 changes: 69 additions & 0 deletions packages/opencode/src/path/migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import path from "path"
import { eq } from "@/storage/db"
import { Global } from "@/global"
import { Log } from "@/util/log"
import { existsSync, writeFileSync } from "fs"
import { ProjectTable } from "@/project/project.sql"
import { SessionTable } from "@/session/session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { StoredPath } from "./path"

export namespace PathMigration {
const log = Log.create({ service: "path-migration" })

const uniq = (input: string[]) => [...new Set(input.map((item) => StoredPath.parse(item)))]

export type Stats = {
projects: number
sessions: number
workspaces: number
}

export function run(db: SQLiteBunDatabase, input: { force?: boolean; marker?: string } = {}) {
if (process.platform !== "win32") {
return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats
}

const mark = input.marker ?? path.join(Global.Path.state, "stored-path-v1")
if (!input.force && existsSync(mark)) {
return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats
}

const stats = { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats

for (const row of db.select().from(ProjectTable).all()) {
const worktree = StoredPath.parse(row.worktree)
const sandboxes = uniq(row.sandboxes)
if (
worktree === row.worktree &&
sandboxes.every((item, idx) => item === row.sandboxes[idx]) &&
sandboxes.length === row.sandboxes.length
)
continue

db.update(ProjectTable).set({ worktree, sandboxes }).where(eq(ProjectTable.id, row.id)).run()
stats.projects += 1
}

for (const row of db.select().from(SessionTable).all()) {
const directory = StoredPath.parse(row.directory)
if (directory === row.directory) continue

db.update(SessionTable).set({ directory }).where(eq(SessionTable.id, row.id)).run()
stats.sessions += 1
}

for (const row of db.select().from(WorkspaceTable).all()) {
const directory = row.directory ? StoredPath.parse(row.directory) : null
if (directory === row.directory) continue

db.update(WorkspaceTable).set({ directory }).where(eq(WorkspaceTable.id, row.id)).run()
stats.workspaces += 1
}

log.info("completed", stats)
if (!input.force) writeFileSync(mark, "1")
return stats
}
}
117 changes: 117 additions & 0 deletions packages/opencode/src/path/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Filesystem } from "@/util/filesystem"
import { lstatSync, readdirSync, realpathSync } from "fs"
import path from "path"

/**
* Any legal path text we ingest from the outside world.
*
* You might see:
* - `C:\Users\RUNNER~1\repo`
* - `C:/Users/runneradmin/repo`
* - `/c/Users/runneradmin/repo`
* - `/cygdrive/c/Users/runneradmin/repo`
*
* You should not assume:
* - native separators
* - canonical casing
* - long Windows names
* - symlinks resolved
* - safe to persist directly
*/
export type RawPath = string & { readonly __raw: unique symbol }

/**
* Canonical path value we keep in storage and long-lived runtime state.
*
* You might see:
* - `C:\Users\runneradmin\repo`
* - `C:\Repos\my-link`
* - `/Users/luke/repo`
*
* You should not see:
* - `RUNNER~1`
* - `/cygdrive/c/...`
* - slash-only key forms used just for comparison
*
* Stored paths preserve the user's chosen route. They should not resolve
* symlink, junction, mapped-drive, or UNC roots to some different target.
*/
export type StoredPath = string & { readonly __stored: unique symbol }

const caseMatch = (dir: string, part: string) => {
try {
const low = part.toLowerCase()
return readdirSync(dir).find((item) => item.toLowerCase() === low)
} catch {
return
}
}

const root = (input: string) => input.replace(/^[a-z]:/, (x) => x.toUpperCase())

const storedWin = (input: string) => {
const full = path.resolve(Filesystem.windowsPath(input))
const base = root(path.parse(full).root)
const parts = full
.slice(base.length)
.split(/[\\/]+/)
.filter(Boolean)
let dir = base

for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const next = path.join(dir, part)
const hit = (() => {
try {
return lstatSync(next)
} catch {
return
}
})()

if (!hit) return path.join(dir, ...parts.slice(i)) as StoredPath
if (hit.isSymbolicLink()) {
dir = path.join(dir, caseMatch(dir, part) ?? part)
continue
}

const name = (() => {
try {
return path.basename(realpathSync.native(next))
} catch {
return caseMatch(dir, part) ?? part
}
})()
dir = path.join(dir, name)
}

return dir as StoredPath
}

export namespace StoredPath {
/**
* Parse raw external input into the canonical stored path form.
*/
export function parse(input: RawPath | string): StoredPath {
// Legacy sentinel: "/" is used internally to represent the global/non-project
// pseudo-root. It is not a real Windows StoredPath, but we preserve it here
// for backward compatibility with existing project/session/instance logic.
// Tech debt: split this sentinel out from StoredPath entirely.
if (!input || input === "/") return input as StoredPath
if (process.platform !== "win32") return path.resolve(input) as StoredPath
return storedWin(input)
}

/**
* Rehydrate a value that is already stored in canonical form.
*
* Use this for trusted values we previously persisted ourselves.
*/
export function unsafe(input: string): StoredPath {
return input as StoredPath
}

export function same(a: RawPath | string, b: RawPath | string) {
return Filesystem.resolve(parse(a)) === Filesystem.resolve(parse(b))
}
}
15 changes: 8 additions & 7 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { disposeInstance } from "@/effect/instance-registry"
import { Filesystem } from "@/util/filesystem"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { StoredPath } from "@/path/path"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
Expand All @@ -13,7 +14,7 @@ export interface Shape {
project: Project.Info
}
const context = Context.create<Shape>("instance")
const cache = new Map<string, Promise<Shape>>()
const cache = new Map<StoredPath, Promise<Shape>>()

const disposal = {
all: undefined as Promise<void> | undefined,
Expand Down Expand Up @@ -52,18 +53,18 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
})
}

function track(directory: string, next: Promise<Shape>) {
function track(dir: StoredPath, next: Promise<Shape>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
if (cache.get(dir) === task) cache.delete(dir)
throw error
})
cache.set(directory, task)
cache.set(dir, task)
return task
}

export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = Filesystem.resolve(input.directory)
const directory = StoredPath.parse(input.directory)
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
Expand Down Expand Up @@ -117,7 +118,7 @@ export const Instance = {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
const directory = StoredPath.parse(input.directory)
Log.Default.info("reloading instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
Expand All @@ -129,7 +130,7 @@ export const Instance = {
const directory = Instance.directory
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
cache.delete(directory as StoredPath)
emit(directory)
},
async disposeAll() {
Expand Down
Loading
Loading