mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-09 16:00:52 +00:00
fix(worktrees): route OpenCode calls through workspaces (#521)
## Summary This fixes #518 by moving CodeNomad worktree execution from directory-header routing to OpenCode experimental workspace routing. OpenCode changed existing-session routing so session routes can prefer the session's stored directory over `x-opencode-directory`. That made the old CodeNomad worktree proxy model unreliable for sessions that should execute in a worktree. This PR switches CodeNomad to resolve an OpenCode workspace ID for each CodeNomad worktree and pass that workspace ID on worktree-scoped OpenCode calls. ## What changed - Start OpenCode with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`. - Change the OpenCode server base URL from `/workspaces/:id/worktrees/root/instance` to `/workspaces/:id/instance`. - Add a root OpenCode client helper in `packages/ui/src/stores/opencode-client.ts`. - Add OpenCode workspace sync/cache helpers in `packages/ui/src/stores/opencode-workspaces.ts`. - Sync OpenCode workspaces after CodeNomad worktree hydration and after worktree create/delete flows. - Map CodeNomad worktree slugs/directories to OpenCode `workspace.id` values discovered by `experimental.workspace.syncList` and `experimental.workspace.list`. - Replace all OpenCode worktree clients with the root client plus explicit `workspace` payloads where the active session/worktree requires it. - Route session, permission/question, file browser reads, SDK git status, and prompt/action calls through root OpenCode client + workspace ID. - Keep CodeNomad local git worktree server APIs intact; those still need filesystem directories for local git operations. ## Review fix - Fixed right-panel file saves so they no longer write to the root workspace after reading from a selected worktree. - The existing CodeNomad file-content API now accepts an optional `worktree` query parameter. - Right-panel saves pass `worktreeSlugForViewer()`, and the server resolves that slug to the same worktree directory used by local git worktree APIs before writing. - Root saves continue to use the original root workspace path. ## Removed old routing - Removed `/workspaces/:id/worktrees/:slug/instance` OpenCode proxy routes. - Removed directory override proxy support using `/__dir/<encoded>`. - Removed proxy injection of `x-opencode-directory`. - Removed the remaining background-process completion prompt `x-opencode-directory` header. - Removed `getOrCreateWorktreeClient`, `getOrCreateWorktreeClientWithDirectoryOverride`, and worktree proxy path helpers from the UI worktree store. - Simplified `sdkManager.createClient` because clients are no longer keyed by worktree slug. ## Why this fixes #518 Worktree sessions can now stay visible under the root project session listing while worktree execution is selected through OpenCode's workspace routing model. CodeNomad no longer depends on `x-opencode-directory` to override existing session directories, so sessions should not disappear from the root-directory list or execute in the wrong directory because of stale session directory fallback behavior. Fixes #518 ## Validation - `npm run typecheck --workspace @codenomad/ui` - `npm run typecheck --workspace @neuralnomads/codenomad` - `git diff --check` ## Notes Some touched files are already oversized and were not refactored as part of this migration: `packages/server/src/server/http-server.ts`, `packages/ui/src/stores/instances.ts`, `packages/ui/src/stores/session-api.ts`, `packages/ui/src/stores/session-state.ts`, `packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx`, `packages/ui/src/stores/session-events.ts`, and `packages/server/src/background-processes/manager.ts`.
This commit is contained in:
parent
37a8621063
commit
9d4c304773
19 changed files with 387 additions and 323 deletions
|
|
@ -637,7 +637,6 @@ export class BackgroundProcessManager {
|
|||
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
|
||||
}
|
||||
|
||||
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import { connect as connectTls, type TLSSocket } from "tls"
|
|||
import { fetch, type Headers } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
|
||||
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
|
|
@ -514,64 +512,46 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
|||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
||||
})
|
||||
}
|
||||
|
||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||
|
||||
// Special-case OpenCode directory override.
|
||||
//
|
||||
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
||||
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
||||
// injecting per-request headers, we encode an override into the *path* and strip it
|
||||
// before proxying to the instance.
|
||||
//
|
||||
// Example proxied request path:
|
||||
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
||||
//
|
||||
// The server will decode <base64url> -> absolute directory, validate it, then set
|
||||
// x-opencode-directory accordingly and forward the request to /session/create.
|
||||
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
||||
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
worktreeSlug: string
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const { request, reply, workspaceManager, logger, worktreeSlug } = args
|
||||
const { request, reply, workspaceManager, logger } = args
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
|
|
@ -648,48 +628,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
|
||||
if (!isValidWorktreeSlug(worktreeSlug)) {
|
||||
reply.code(400).send({ error: "Invalid worktree slug" })
|
||||
return
|
||||
}
|
||||
|
||||
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||
try {
|
||||
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
let directory: string | null = null
|
||||
let forwardedSuffix = extracted.forwardedSuffix
|
||||
|
||||
if (extracted.overrideDirectory) {
|
||||
try {
|
||||
directory = validateAndNormalizeOverrideDirectory({
|
||||
overrideDirectory: extracted.overrideDirectory,
|
||||
workspaceRoot: workspace.path,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
|
|
@ -706,13 +645,6 @@ async function proxyWorkspaceRequest(args: {
|
|||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
|
||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||
|
||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
||||
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
const outgoing: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
|
|
@ -732,8 +664,6 @@ async function proxyWorkspaceRequest(args: {
|
|||
workspaceId,
|
||||
method: request.method,
|
||||
targetUrl,
|
||||
worktreeSlug,
|
||||
directory,
|
||||
contentType: request.headers["content-type"],
|
||||
body: bodyToJson(request.body),
|
||||
headers: outgoing,
|
||||
|
|
@ -753,89 +683,6 @@ async function proxyWorkspaceRequest(args: {
|
|||
})
|
||||
}
|
||||
|
||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||
overrideDirectory: string | null
|
||||
forwardedSuffix: string | undefined
|
||||
} {
|
||||
if (!pathSuffix) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
// Fastify wildcard param does not include a leading slash.
|
||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
||||
const slashIndex = rest.indexOf("/")
|
||||
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
||||
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
||||
|
||||
if (!encoded) {
|
||||
throw new Error("Missing directory override")
|
||||
}
|
||||
|
||||
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
||||
throw new Error("Directory override too large")
|
||||
}
|
||||
|
||||
let overrideDirectory = ""
|
||||
try {
|
||||
overrideDirectory = decodeBase64Url(encoded)
|
||||
} catch {
|
||||
throw new Error("Invalid directory override")
|
||||
}
|
||||
const forwardedSuffix = remaining
|
||||
return { overrideDirectory, forwardedSuffix }
|
||||
}
|
||||
|
||||
function decodeBase64Url(input: string): string {
|
||||
// base64url -> base64
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
||||
const base64 = `${normalized}${padding}`
|
||||
return Buffer.from(base64, "base64").toString("utf-8")
|
||||
}
|
||||
|
||||
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
||||
const raw = params.overrideDirectory.trim()
|
||||
if (!raw) {
|
||||
throw new Error("Override directory is empty")
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(raw)) {
|
||||
throw new Error("Override directory must be an absolute path")
|
||||
}
|
||||
|
||||
if (!fs.existsSync(raw)) {
|
||||
throw new Error(`Override directory does not exist: ${raw}`)
|
||||
}
|
||||
|
||||
const stats = fs.statSync(raw)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Override path is not a directory: ${raw}`)
|
||||
}
|
||||
|
||||
const normalizedOverride = fs.realpathSync(raw)
|
||||
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
||||
|
||||
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
||||
throw new Error("Override directory must be within the workspace root")
|
||||
}
|
||||
|
||||
return normalizedOverride
|
||||
}
|
||||
|
||||
function isSubpath(candidate: string, root: string): boolean {
|
||||
const rel = path.relative(root, candidate)
|
||||
if (rel === "") return true
|
||||
if (rel === "..") return false
|
||||
if (rel.startsWith(`..${path.sep}`)) return false
|
||||
if (path.isAbsolute(rel)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const WorkspaceFilesQuerySchema = z.object({
|
|||
const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
encoding: z.enum(["utf-8", "base64"]).optional(),
|
||||
worktree: z.string().trim().optional(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentBodySchema = z.object({
|
||||
|
|
@ -132,10 +133,15 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
Querystring: { path?: string; encoding?: "utf-8" | "base64"; worktree?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
if (query.worktree && query.worktree !== "root") {
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply)
|
||||
if (!directory) return
|
||||
return deps.workspaceManager.readFileInDirectory(request.params.id, directory, query.path, { encoding: query.encoding })
|
||||
}
|
||||
return deps.workspaceManager.readFile(request.params.id, query.path, { encoding: query.encoding })
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
|
|
@ -144,11 +150,18 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
|
||||
app.put<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
Querystring: { path?: string; worktree?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||
if (query.worktree && query.worktree !== "root") {
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply)
|
||||
if (!directory) return
|
||||
deps.workspaceManager.writeFileInDirectory(request.params.id, directory, query.path, body.contents)
|
||||
reply.code(204)
|
||||
return
|
||||
}
|
||||
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ describe("buildWindowsSpawnSpec", () => {
|
|||
env: {
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }),
|
||||
CODENOMAD_INSTANCE_ID: "workspace-123",
|
||||
OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/worktrees/root/instance",
|
||||
OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/instance",
|
||||
OPENCODE_SERVER_PASSWORD: "secret",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"],
|
||||
|
|
|
|||
|
|
@ -89,12 +89,31 @@ export class WorkspaceManager {
|
|||
}
|
||||
}
|
||||
|
||||
readFileInDirectory(workspaceId: string, directory: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): WorkspaceFileResponse {
|
||||
this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: directory })
|
||||
const encoding = options?.encoding ?? "utf-8"
|
||||
const contents = encoding === "base64" ? browser.readFileBase64(relativePath) : browser.readFile(relativePath)
|
||||
return {
|
||||
workspaceId,
|
||||
relativePath,
|
||||
contents,
|
||||
encoding,
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
writeFileInDirectory(workspaceId: string, directory: string, relativePath: string, contents: string): void {
|
||||
this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: directory })
|
||||
browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
|
|
@ -105,7 +124,7 @@ export class WorkspaceManager {
|
|||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
|
|
@ -149,6 +168,7 @@ export class WorkspaceManager {
|
|||
const environment = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_CONTENT: opencodeConfigContent,
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: serverBaseUrl,
|
||||
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f
|
|||
import {
|
||||
getDefaultWorktreeSlug,
|
||||
getGitRepoStatus,
|
||||
getOrCreateWorktreeClient,
|
||||
getWorktreeSlugForSession,
|
||||
getWorktrees,
|
||||
} from "../../../../stores/worktrees"
|
||||
import { getRootClient } from "../../../../stores/opencode-client"
|
||||
import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||
|
|
@ -396,7 +397,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
return branch || null
|
||||
})
|
||||
|
||||
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||
const browserClient = createMemo(() => getRootClient(props.instanceId))
|
||||
const fileWorkspacePayload = async () => {
|
||||
const workspace = await getOpenCodeWorkspaceIdForWorktree(props.instanceId, worktreeSlugForViewer())
|
||||
return workspace ? { workspace } : {}
|
||||
}
|
||||
|
||||
const {
|
||||
gitStatusEntries,
|
||||
|
|
@ -489,7 +494,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
setBrowserLoading(true)
|
||||
setBrowserError(null)
|
||||
try {
|
||||
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
|
||||
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "file.list")
|
||||
setBrowserPath(normalized)
|
||||
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
|
||||
} catch (error) {
|
||||
|
|
@ -513,7 +518,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
setFilesListOpen(false)
|
||||
}
|
||||
try {
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read")
|
||||
const type = (content as any)?.type
|
||||
const encoding = (content as any)?.encoding
|
||||
if (type && type !== "text") {
|
||||
|
|
@ -544,7 +549,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
if (originalContent !== null) {
|
||||
try {
|
||||
const currentDiskContent = await requestData<FileContent>(
|
||||
browserClient().file.read({ path }),
|
||||
browserClient().file.read({ path, ...(await fileWorkspacePayload()) }),
|
||||
"file.read",
|
||||
)
|
||||
const diskContent = (currentDiskContent as any)?.content
|
||||
|
|
@ -573,7 +578,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
|
||||
setBrowserSelectedSaving(true)
|
||||
try {
|
||||
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
|
||||
await serverApi.writeWorkspaceFile(props.instanceId, path, content, { worktree: worktreeSlugForViewer() })
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedOriginalContent(content) // Update original to match saved
|
||||
setBrowserSelectedDirty(false)
|
||||
|
|
@ -697,7 +702,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
try {
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "file.read")
|
||||
const type = (content as any)?.type
|
||||
const encoding = (content as any)?.encoding
|
||||
if (type && type !== "text") {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
|||
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||
import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types"
|
||||
|
||||
import { getOrCreateWorktreeClient } from "../../../../stores/worktrees"
|
||||
import { getRootClient } from "../../../../stores/opencode-client"
|
||||
import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { serverEvents } from "../../../../lib/server-events"
|
||||
|
|
@ -166,12 +167,13 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
const loadGitStatus = async (force = false) => {
|
||||
if (!force && gitStatusEntries() !== null) return
|
||||
const slug = options.worktreeSlug()
|
||||
const client = getOrCreateWorktreeClient(options.instanceId, slug)
|
||||
const client = getRootClient(options.instanceId)
|
||||
const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, slug)
|
||||
const requestVersion = ++gitStatusRequestVersion
|
||||
setGitStatusLoading(true)
|
||||
setGitStatusError(null)
|
||||
try {
|
||||
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status(), "file.status")
|
||||
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status({ ...(workspace ? { workspace } : {}) }), "file.status")
|
||||
const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
|
||||
if (requestVersion !== gitStatusRequestVersion) return
|
||||
if (slug !== options.worktreeSlug()) return
|
||||
|
|
|
|||
|
|
@ -348,8 +348,11 @@ export const serverApi = {
|
|||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
|
||||
writeWorkspaceFile(id: string, relativePath: string, contents: string, options?: { worktree?: string }): Promise<void> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
if (options?.worktree && options.worktree !== "root") {
|
||||
params.set("worktree", options.worktree)
|
||||
}
|
||||
return request(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class SDKManager {
|
|||
return `${instanceId}:${normalizeProxyPath(proxyPath)}`
|
||||
}
|
||||
|
||||
createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient {
|
||||
createClient(instanceId: string, proxyPath: string): OpencodeClient {
|
||||
const key = this.key(instanceId, proxyPath)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ import {
|
|||
import {
|
||||
ensureWorktreesLoaded,
|
||||
ensureWorktreeMapLoaded,
|
||||
getOrCreateWorktreeClient,
|
||||
getWorktreeSlugForSession,
|
||||
reloadWorktreeMap,
|
||||
reloadWorktrees,
|
||||
} from "./worktrees"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, syncOpenCodeWorkspaces } from "./opencode-workspaces"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { sessions, setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
|
|
@ -71,12 +71,8 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
|||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
// Track which worktree a permission was enqueued under (by permission request id).
|
||||
const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
|
||||
|
||||
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
||||
// Track which worktree a question was enqueued under (by question request id).
|
||||
const questionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
|
||||
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const questionSessionCounts = new Map<string, Map<string, number>>()
|
||||
const questionEnqueuedAt = new Map<string, number>()
|
||||
|
|
@ -180,7 +176,7 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
|||
sdkManager.destroyClientsForInstance(descriptor.id)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath, "root")
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
||||
updateInstance(descriptor.id, {
|
||||
client,
|
||||
port: nextPort ?? 0,
|
||||
|
|
@ -200,6 +196,7 @@ function releaseInstanceResources(instanceId: string) {
|
|||
if (instance.client) {
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
clearOpenCodeWorkspaceCache(instanceId)
|
||||
sseManager.seedStatus(instanceId, "disconnected")
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +278,7 @@ async function hydrateInstanceData(instanceId: string, options?: { force?: boole
|
|||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
}
|
||||
await syncOpenCodeWorkspaces(instanceId)
|
||||
resetSessionPagination(instanceId)
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
|
|
@ -862,14 +860,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
|
|||
}
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
|
||||
// Refresh this when duplicate permission events carry better session/worktree hydration.
|
||||
const slug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
let byPermissionId = permissionWorktreeSlugByInstance.get(instanceId)
|
||||
if (!byPermissionId) {
|
||||
byPermissionId = new Map()
|
||||
permissionWorktreeSlugByInstance.set(instanceId, byPermissionId)
|
||||
}
|
||||
byPermissionId.set(queuedPermission.id, slug)
|
||||
}
|
||||
|
||||
drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission)
|
||||
|
|
@ -904,8 +894,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
|||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
// Use the id we were asked to remove (avoids type inference edge cases).
|
||||
permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId)
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
if (removedSessionId) {
|
||||
clearAutoAcceptPermission(instanceId, removedSessionId, permissionId)
|
||||
|
|
@ -944,7 +932,6 @@ function clearPermissionQueue(instanceId: string): void {
|
|||
return next
|
||||
})
|
||||
clearSessionPendingCounts(instanceId)
|
||||
permissionWorktreeSlugByInstance.delete(instanceId)
|
||||
recomputeActiveInterruption(instanceId)
|
||||
}
|
||||
|
||||
|
|
@ -979,15 +966,6 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest): void
|
|||
incrementQuestionSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingQuestion(instanceId, sessionId, true)
|
||||
|
||||
// Record the worktree slug at the time the question is enqueued.
|
||||
// This is used to respond in the same worktree context even from the global permission center.
|
||||
const slug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
let byQuestionId = questionWorktreeSlugByInstance.get(instanceId)
|
||||
if (!byQuestionId) {
|
||||
byQuestionId = new Map()
|
||||
questionWorktreeSlugByInstance.set(instanceId, byQuestionId)
|
||||
}
|
||||
byQuestionId.set(request.id, slug)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1008,7 +986,6 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void {
|
|||
})
|
||||
|
||||
questionEnqueuedAt.delete(requestId)
|
||||
questionWorktreeSlugByInstance.get(instanceId)?.delete(requestId)
|
||||
recomputeActiveInterruption(instanceId)
|
||||
|
||||
if (removedSessionId) {
|
||||
|
|
@ -1021,8 +998,6 @@ function clearQuestionQueue(instanceId: string): void {
|
|||
for (const request of getQuestionQueue(instanceId)) {
|
||||
questionEnqueuedAt.delete(request.id)
|
||||
}
|
||||
questionWorktreeSlugByInstance.delete(instanceId)
|
||||
|
||||
setQuestionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
|
|
@ -1057,14 +1032,13 @@ async function sendQuestionReply(
|
|||
}
|
||||
|
||||
try {
|
||||
const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
|
||||
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
|
||||
const worktreeSlug = stored ?? fallback
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null
|
||||
|
||||
await requestData(
|
||||
client.question.reply({
|
||||
requestID: requestId,
|
||||
...(workspace ? { workspace } : {}),
|
||||
answers,
|
||||
}),
|
||||
"question.reply",
|
||||
|
|
@ -1084,14 +1058,13 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request
|
|||
}
|
||||
|
||||
try {
|
||||
const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
|
||||
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
|
||||
const worktreeSlug = stored ?? fallback
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null
|
||||
|
||||
await requestData(
|
||||
client.question.reject({
|
||||
requestID: requestId,
|
||||
...(workspace ? { workspace } : {}),
|
||||
}),
|
||||
"question.reject",
|
||||
)
|
||||
|
|
@ -1116,14 +1089,13 @@ async function sendPermissionResponse(
|
|||
}
|
||||
|
||||
try {
|
||||
const stored = permissionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
|
||||
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
|
||||
const worktreeSlug = stored ?? fallback
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null
|
||||
|
||||
await requestData(
|
||||
client.permission.reply({
|
||||
requestID: requestId,
|
||||
...(workspace ? { workspace } : {}),
|
||||
reply,
|
||||
...(message ? { message } : {}),
|
||||
}),
|
||||
|
|
|
|||
11
packages/ui/src/stores/opencode-client.ts
Normal file
11
packages/ui/src/stores/opencode-client.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { sdkManager, type OpencodeClient } from "../lib/sdk-manager"
|
||||
|
||||
function buildRootProxyPath(instanceId: string): string {
|
||||
return `/workspaces/${encodeURIComponent(instanceId)}/instance`
|
||||
}
|
||||
|
||||
function getRootClient(instanceId: string): OpencodeClient {
|
||||
return sdkManager.createClient(instanceId, buildRootProxyPath(instanceId))
|
||||
}
|
||||
|
||||
export { buildRootProxyPath, getRootClient }
|
||||
51
packages/ui/src/stores/opencode-workspace-matching.ts
Normal file
51
packages/ui/src/stores/opencode-workspace-matching.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { WorktreeDescriptor } from "../../../server/src/api-types"
|
||||
|
||||
type OpenCodeWorkspaceLike = {
|
||||
id: string
|
||||
directory?: string | null
|
||||
}
|
||||
|
||||
function normalizeWorkspaceDirectory(directory: string | null | undefined): string {
|
||||
const trimmed = (directory ?? "").trim()
|
||||
if (!trimmed) return ""
|
||||
const normalized = trimmed.replace(/\\+/g, "/").replace(/\/+$/g, "")
|
||||
if (/^[\\/]{2}/.test(trimmed) && !normalized.startsWith("//")) {
|
||||
return `/${normalized}`
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isWindowsWorkspaceDirectory(directory: string): boolean {
|
||||
return /^[A-Za-z]:\//.test(directory) || directory.startsWith("//")
|
||||
}
|
||||
|
||||
function normalizeWindowsWorkspaceDirectory(directory: string): string {
|
||||
return normalizeWorkspaceDirectory(directory).toLowerCase()
|
||||
}
|
||||
|
||||
function mapOpenCodeWorkspacesToWorktreeSlugs(
|
||||
worktrees: Pick<WorktreeDescriptor, "slug" | "directory">[],
|
||||
workspaces: OpenCodeWorkspaceLike[],
|
||||
): Map<string, string> {
|
||||
const byDirectory = new Map<string, OpenCodeWorkspaceLike>()
|
||||
const byWindowsDirectory = new Map<string, OpenCodeWorkspaceLike>()
|
||||
for (const workspace of workspaces) {
|
||||
const directory = normalizeWorkspaceDirectory(workspace.directory)
|
||||
if (!directory) continue
|
||||
byDirectory.set(directory, workspace)
|
||||
if (isWindowsWorkspaceDirectory(directory)) {
|
||||
byWindowsDirectory.set(normalizeWindowsWorkspaceDirectory(directory), workspace)
|
||||
}
|
||||
}
|
||||
|
||||
const next = new Map<string, string>()
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.slug === "root") continue
|
||||
const directory = normalizeWorkspaceDirectory(worktree.directory)
|
||||
const workspace = byDirectory.get(directory) ?? (isWindowsWorkspaceDirectory(directory) ? byWindowsDirectory.get(normalizeWindowsWorkspaceDirectory(directory)) : undefined)
|
||||
if (workspace?.id) next.set(worktree.slug, workspace.id)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export { mapOpenCodeWorkspacesToWorktreeSlugs }
|
||||
60
packages/ui/src/stores/opencode-workspaces.test.ts
Normal file
60
packages/ui/src/stores/opencode-workspaces.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching.ts"
|
||||
|
||||
describe("mapOpenCodeWorkspacesToWorktreeSlugs", () => {
|
||||
it("matches POSIX worktree directories case-sensitively", () => {
|
||||
const result = mapOpenCodeWorkspacesToWorktreeSlugs(
|
||||
[
|
||||
{ slug: "feature", directory: "/Users/dev/Repo/.codenomad/worktrees/Feature" },
|
||||
{ slug: "feature-lower", directory: "/Users/dev/Repo/.codenomad/worktrees/feature" },
|
||||
],
|
||||
[
|
||||
{ id: "wrk_exact", directory: "/Users/dev/Repo/.codenomad/worktrees/Feature" },
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(result.get("feature"), "wrk_exact")
|
||||
assert.equal(result.has("feature-lower"), false)
|
||||
})
|
||||
|
||||
it("matches Windows drive paths case-insensitively and normalizes slashes", () => {
|
||||
const result = mapOpenCodeWorkspacesToWorktreeSlugs(
|
||||
[
|
||||
{ slug: "test2", directory: String.raw`C:\Users\Dev\Repo\.codenomad\worktrees\test2` },
|
||||
],
|
||||
[
|
||||
{ id: "wrk_test2", directory: "c:/users/dev/repo/.codenomad/worktrees/test2/" },
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(result.get("test2"), "wrk_test2")
|
||||
})
|
||||
|
||||
it("matches Windows UNC paths case-insensitively and normalizes slashes", () => {
|
||||
const result = mapOpenCodeWorkspacesToWorktreeSlugs(
|
||||
[
|
||||
{ slug: "unc", directory: String.raw`\\server\Share\Repo\.codenomad\worktrees\unc` },
|
||||
],
|
||||
[
|
||||
{ id: "wrk_unc", directory: "//SERVER/share/repo/.codenomad/worktrees/unc" },
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(result.get("unc"), "wrk_unc")
|
||||
})
|
||||
|
||||
it("does not map the root worktree", () => {
|
||||
const result = mapOpenCodeWorkspacesToWorktreeSlugs(
|
||||
[
|
||||
{ slug: "root", directory: "/repo" },
|
||||
],
|
||||
[
|
||||
{ id: "wrk_root", directory: "/repo" },
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
})
|
||||
116
packages/ui/src/stores/opencode-workspaces.ts
Normal file
116
packages/ui/src/stores/opencode-workspaces.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { getRootClient } from "./opencode-client"
|
||||
import { getWorktreeSlugForSession, getWorktrees } from "./worktrees"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
type OpenCodeWorkspace = {
|
||||
id: string
|
||||
type?: string
|
||||
name?: string
|
||||
branch?: string | null
|
||||
directory?: string | null
|
||||
projectID?: string
|
||||
}
|
||||
|
||||
const workspaceIdByWorktreeSlug = new Map<string, Map<string, string>>()
|
||||
const workspaceSyncs = new Map<string, Promise<void>>()
|
||||
|
||||
async function getInstance(instanceId: string) {
|
||||
const { instances } = await import("./instances")
|
||||
return instances().get(instanceId)
|
||||
}
|
||||
|
||||
function getCachedOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: string): string | null {
|
||||
if (!slug || slug === "root") return null
|
||||
return workspaceIdByWorktreeSlug.get(instanceId)?.get(slug) ?? null
|
||||
}
|
||||
|
||||
function getCachedOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: string): string | null {
|
||||
return getCachedOpenCodeWorkspaceIdForWorktree(instanceId, getWorktreeSlugForSession(instanceId, sessionId))
|
||||
}
|
||||
|
||||
async function syncOpenCodeWorkspaces(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
const existing = workspaceSyncs.get(instanceId)
|
||||
if (existing) return existing
|
||||
|
||||
const task = (async () => {
|
||||
const instance = await getInstance(instanceId)
|
||||
if (!instance?.client || !instance.folder) return
|
||||
|
||||
const rootClient = getRootClient(instanceId) as any
|
||||
const workspaceApi = rootClient.experimental?.workspace
|
||||
if (!workspaceApi?.syncList || !workspaceApi?.list) {
|
||||
log.warn("OpenCode experimental workspace API unavailable", { instanceId })
|
||||
workspaceIdByWorktreeSlug.set(instanceId, new Map())
|
||||
return
|
||||
}
|
||||
|
||||
await workspaceApi.syncList({ directory: instance.folder })
|
||||
const result = await workspaceApi.list({ directory: instance.folder })
|
||||
const workspaces = Array.isArray(result?.data) ? (result.data as OpenCodeWorkspace[]) : []
|
||||
const next = mapOpenCodeWorkspacesToWorktreeSlugs(getWorktrees(instanceId), workspaces)
|
||||
|
||||
workspaceIdByWorktreeSlug.set(instanceId, next)
|
||||
})()
|
||||
.catch((error) => {
|
||||
log.warn("Failed to sync OpenCode workspaces", { instanceId, error })
|
||||
workspaceIdByWorktreeSlug.set(instanceId, new Map())
|
||||
})
|
||||
.finally(() => {
|
||||
workspaceSyncs.delete(instanceId)
|
||||
})
|
||||
|
||||
workspaceSyncs.set(instanceId, task)
|
||||
return task
|
||||
}
|
||||
|
||||
async function reloadOpenCodeWorkspaces(instanceId: string): Promise<void> {
|
||||
workspaceSyncs.delete(instanceId)
|
||||
await syncOpenCodeWorkspaces(instanceId)
|
||||
}
|
||||
|
||||
async function getOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: string): Promise<string | null> {
|
||||
if (!slug || slug === "root") return null
|
||||
const cached = getCachedOpenCodeWorkspaceIdForWorktree(instanceId, slug)
|
||||
if (cached) return cached
|
||||
await syncOpenCodeWorkspaces(instanceId)
|
||||
return getCachedOpenCodeWorkspaceIdForWorktree(instanceId, slug)
|
||||
}
|
||||
|
||||
async function getOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: string): Promise<string | null> {
|
||||
const slug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
return getOpenCodeWorkspaceIdForWorktree(instanceId, slug)
|
||||
}
|
||||
|
||||
function clearOpenCodeWorkspaceCache(instanceId: string): void {
|
||||
workspaceSyncs.delete(instanceId)
|
||||
workspaceIdByWorktreeSlug.delete(instanceId)
|
||||
}
|
||||
|
||||
async function removeOpenCodeWorkspaceForWorktree(instanceId: string, slug: string): Promise<void> {
|
||||
const instance = await getInstance(instanceId)
|
||||
if (!instance?.folder || !slug || slug === "root") return
|
||||
const workspaceId = getCachedOpenCodeWorkspaceIdForWorktree(instanceId, slug)
|
||||
if (!workspaceId) return
|
||||
|
||||
const rootClient = getRootClient(instanceId) as any
|
||||
const workspaceApi = rootClient.experimental?.workspace
|
||||
if (!workspaceApi?.remove) return
|
||||
|
||||
await workspaceApi.remove({ directory: instance.folder, id: workspaceId })
|
||||
workspaceIdByWorktreeSlug.get(instanceId)?.delete(slug)
|
||||
}
|
||||
|
||||
export {
|
||||
clearOpenCodeWorkspaceCache,
|
||||
getCachedOpenCodeWorkspaceIdForSession,
|
||||
getCachedOpenCodeWorkspaceIdForWorktree,
|
||||
getOpenCodeWorkspaceIdForSession,
|
||||
getOpenCodeWorkspaceIdForWorktree,
|
||||
reloadOpenCodeWorkspaces,
|
||||
removeOpenCodeWorkspaceForWorktree,
|
||||
syncOpenCodeWorkspaces,
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
|
||||
|
||||
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
|
||||
import { providers, sessions, withSession } from "./session-state"
|
||||
|
|
@ -14,6 +15,11 @@ import { clearConversationPlaybackForSession } from "./conversation-speech"
|
|||
|
||||
const log = getLogger("actions")
|
||||
|
||||
async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> {
|
||||
const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId)
|
||||
return workspace ? { workspace } : {}
|
||||
}
|
||||
|
||||
function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] {
|
||||
if (!model.providerId || !model.modelId) return []
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
|
|
@ -85,8 +91,7 @@ async function sendMessage(
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
|
|
@ -209,9 +214,11 @@ async function sendMessage(
|
|||
|
||||
try {
|
||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||
const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId)
|
||||
await requestData(
|
||||
client.session.promptAsync({
|
||||
sessionID: sessionId,
|
||||
...workspacePayload,
|
||||
...(requestBody as any),
|
||||
}),
|
||||
"session.promptAsync",
|
||||
|
|
@ -233,8 +240,7 @@ async function executeCustomCommand(
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
|
|
@ -267,6 +273,7 @@ async function executeCustomCommand(
|
|||
await requestData(
|
||||
client.session.command({
|
||||
sessionID: sessionId,
|
||||
...(await getSessionWorkspacePayload(instanceId, sessionId)),
|
||||
...(body as any),
|
||||
}),
|
||||
"session.command",
|
||||
|
|
@ -279,8 +286,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
|
|
@ -292,6 +298,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
|||
await requestData(
|
||||
client.session.shell({
|
||||
sessionID: sessionId,
|
||||
...(await getSessionWorkspacePayload(instanceId, sessionId)),
|
||||
agent,
|
||||
command,
|
||||
}),
|
||||
|
|
@ -305,8 +312,7 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
log.info("abortSession", { instanceId, sessionId })
|
||||
|
||||
|
|
@ -315,6 +321,7 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
|
|||
await requestData(
|
||||
client.session.abort({
|
||||
sessionID: sessionId,
|
||||
...(await getSessionWorkspacePayload(instanceId, sessionId)),
|
||||
}),
|
||||
"session.abort",
|
||||
)
|
||||
|
|
@ -385,8 +392,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
|
|
@ -401,6 +407,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
|||
await requestData(
|
||||
client.session.update({
|
||||
sessionID: sessionId,
|
||||
...(await getSessionWorkspacePayload(instanceId, sessionId)),
|
||||
title: trimmedTitle,
|
||||
}),
|
||||
"session.update",
|
||||
|
|
@ -421,12 +428,12 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
await requestData(
|
||||
client.part.delete({
|
||||
sessionID: sessionId,
|
||||
...(await getSessionWorkspacePayload(instanceId, sessionId)),
|
||||
messageID: messageId,
|
||||
partID: partId,
|
||||
}),
|
||||
|
|
@ -445,14 +452,14 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
// The SDK generator does not currently expose a typed method for deleting a message,
|
||||
// but the API is available at DELETE /session/:sessionID/message/:messageID.
|
||||
await requestData(
|
||||
(client as any).client.delete({
|
||||
url: `/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`,
|
||||
query: await getSessionWorkspacePayload(instanceId, sessionId),
|
||||
}),
|
||||
"session.message.delete",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,21 +51,20 @@ import { messageStoreBus } from "./message-v2/bus"
|
|||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import {
|
||||
getOrCreateWorktreeClient,
|
||||
getRootClient,
|
||||
getWorktreeSlugForSession,
|
||||
migrateLegacyWorktreeMapToSessionMetadata,
|
||||
pruneStaleLegacyWorktreeMapEntries,
|
||||
removeLegacyParentSessionMapping,
|
||||
setWorktreeSlugForParentSession,
|
||||
} from "./worktrees"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
import { getWorktreeSlugForSession, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession } from "./worktrees"
|
||||
import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const pendingSessionDiffFetches = new Map<string, Promise<void>>()
|
||||
const pendingSessionChildrenFetches = new Map<string, Promise<Session[]>>()
|
||||
|
||||
async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> {
|
||||
const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId)
|
||||
return workspace ? { workspace } : {}
|
||||
}
|
||||
|
||||
async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise<void> {
|
||||
if (!instanceId || !sessionId) return
|
||||
|
||||
|
|
@ -81,12 +80,11 @@ async function loadSessionDiff(instanceId: string, sessionId: string, force = fa
|
|||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
try {
|
||||
const diffs = await requestData<SnapshotFileDiff[]>(
|
||||
client.session.diff({ sessionID: sessionId }),
|
||||
client.session.diff({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }),
|
||||
"session.diff",
|
||||
)
|
||||
|
||||
|
|
@ -422,12 +420,11 @@ async function fetchSessionChildren(instanceId: string, parentSessionId: string)
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, parentSessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
log.info(`[HTTP] GET /session/{sessionID}/children for instance ${instanceId}`, { sessionId: parentSessionId })
|
||||
const apiChildren = await requestData<any[]>(
|
||||
client.session.children({ sessionID: parentSessionId }),
|
||||
client.session.children({ sessionID: parentSessionId, ...(await getSessionWorkspacePayload(instanceId, parentSessionId)) }),
|
||||
"session.children",
|
||||
)
|
||||
|
||||
|
|
@ -531,7 +528,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||
// If no session is active (fresh instance), fall back to root.
|
||||
const activeId = activeSessionId().get(instanceId)
|
||||
const worktreeSlug = activeId && activeId !== "info" ? getWorktreeSlugForSession(instanceId, activeId) : "root"
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
const primaryAgents = instanceAgents.filter(isSelectablePrimaryAgent)
|
||||
|
|
@ -654,11 +651,11 @@ async function forkSession(
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const request: { sessionID: string; messageID?: string } = {
|
||||
sessionID: sourceSessionId,
|
||||
...(await getSessionWorkspacePayload(instanceId, sourceSessionId)),
|
||||
messageID: options?.messageId,
|
||||
}
|
||||
|
||||
|
|
@ -739,8 +736,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const deletingSession = sessions().get(instanceId)?.get(sessionId)
|
||||
|
||||
|
|
@ -754,7 +750,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
|||
|
||||
try {
|
||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await requestData(client.session.delete({ sessionID: sessionId }), "session.delete")
|
||||
await requestData(client.session.delete({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), "session.delete")
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
|
|
@ -925,8 +921,7 @@ async function loadMessages(
|
|||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
|
|
@ -951,7 +946,7 @@ async function loadMessages(
|
|||
try {
|
||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const apiMessages = await requestData<any[]>(
|
||||
client.session.messages({ sessionID: sessionId }),
|
||||
client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }),
|
||||
"session.messages",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ import { updateSessionInfo } from "./message-v2/session-info"
|
|||
import { tGlobal } from "../lib/i18n"
|
||||
|
||||
import { loadMessages } from "./session-api"
|
||||
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
import { getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { getOpenCodeWorkspaceIdForWorktree } from "./opencode-workspaces"
|
||||
import {
|
||||
applyPartUpdateV2,
|
||||
applyPartDeltaV2,
|
||||
|
|
@ -186,12 +188,12 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
|||
|
||||
const slugFromDirectory = getWorktreeSlugForDirectory(instanceId, directory)
|
||||
const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, slug)
|
||||
const rootClient = getRootClient(instanceId)
|
||||
const client = getRootClient(instanceId)
|
||||
const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug)
|
||||
|
||||
try {
|
||||
const info = await requestData<any>(
|
||||
client.session.get({ sessionID: sessionId }),
|
||||
client.session.get({ sessionID: sessionId, ...(workspace ? { workspace } : {}) }),
|
||||
"session.get",
|
||||
)
|
||||
|
||||
|
|
@ -200,7 +202,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
|||
try {
|
||||
let statuses: Record<string, any> = {}
|
||||
try {
|
||||
statuses = await requestData<Record<string, any>>(rootClient.session.status(), "session.status")
|
||||
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
|
||||
} catch {
|
||||
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { instances } from "./instances"
|
|||
import { showConfirmDialog } from "./alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals"
|
||||
|
||||
|
|
@ -846,10 +847,10 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
|||
}
|
||||
let messages: any[] = []
|
||||
try {
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, session.id)
|
||||
messages = await requestData<any[]>(
|
||||
client.session.messages({ sessionID: session.id }),
|
||||
client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import type { WorktreeDescriptor, WorktreeMap } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { sdkManager, type OpencodeClient } from "../lib/sdk-manager"
|
||||
import { sessions } from "./session-state"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getCodeNomadSessionMetadata, setSessionWorktreeSlugWithClient } from "./session-metadata"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
|
|
@ -115,7 +115,11 @@ async function createWorktree(instanceId: string, slug: string): Promise<{ slug:
|
|||
if (!trimmed) {
|
||||
throw new Error("Worktree name is required")
|
||||
}
|
||||
return await serverApi.createWorktree(instanceId, { slug: trimmed })
|
||||
const worktree = await serverApi.createWorktree(instanceId, { slug: trimmed })
|
||||
await import("./opencode-workspaces").then(({ reloadOpenCodeWorkspaces }) => reloadOpenCodeWorkspaces(instanceId)).catch((error) => {
|
||||
log.warn("Failed to sync OpenCode workspaces after worktree creation", { instanceId, slug: trimmed, error })
|
||||
})
|
||||
return worktree
|
||||
}
|
||||
|
||||
async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise<void> {
|
||||
|
|
@ -129,7 +133,13 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc
|
|||
await moveSessionsFromDeletedWorktree(instanceId, trimmed).catch((error) => {
|
||||
log.warn("Failed to move sessions from deleted worktree", { instanceId, slug: trimmed, error })
|
||||
})
|
||||
await import("./opencode-workspaces").then(({ removeOpenCodeWorkspaceForWorktree }) => removeOpenCodeWorkspaceForWorktree(instanceId, trimmed)).catch((error) => {
|
||||
log.warn("Failed to remove OpenCode workspace for deleted worktree", { instanceId, slug: trimmed, error })
|
||||
})
|
||||
await serverApi.deleteWorktree(instanceId, trimmed, options)
|
||||
await import("./opencode-workspaces").then(({ reloadOpenCodeWorkspaces }) => reloadOpenCodeWorkspaces(instanceId)).catch((error) => {
|
||||
log.warn("Failed to sync OpenCode workspaces after worktree deletion", { instanceId, slug: trimmed, error })
|
||||
})
|
||||
}
|
||||
|
||||
async function moveSessionsFromDeletedWorktree(instanceId: string, slug: string): Promise<void> {
|
||||
|
|
@ -142,7 +152,7 @@ async function moveSessionsFromDeletedWorktree(instanceId: string, slug: string)
|
|||
.map((session) => session.id)
|
||||
|
||||
for (const parentSessionId of parentSessionIds) {
|
||||
const client = getOrCreateWorktreeClient(instanceId, slug)
|
||||
const client = getRootClient(instanceId)
|
||||
await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, "root")
|
||||
await removeLegacyParentSessionMapping(instanceId, parentSessionId)
|
||||
}
|
||||
|
|
@ -298,15 +308,12 @@ async function setWorktreeSlugForParentSession(
|
|||
instanceId: string,
|
||||
parentSessionId: string,
|
||||
slug: string,
|
||||
options: { currentSlug?: string } = {},
|
||||
_options: { currentSlug?: string } = {},
|
||||
): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
const normalizedSlug = normalizeWorktreeSlug(instanceId, slug)
|
||||
const currentSlug = options.currentSlug
|
||||
? normalizeWorktreeSlug(instanceId, options.currentSlug)
|
||||
: getWorktreeSlugForParentSession(instanceId, parentSessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, currentSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, normalizedSlug)
|
||||
await removeLegacyParentSessionMapping(instanceId, parentSessionId, current)
|
||||
}
|
||||
|
|
@ -314,8 +321,7 @@ async function setWorktreeSlugForParentSession(
|
|||
async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
const currentSlug = getWorktreeSlugForParentSession(instanceId, parentSessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, currentSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, "root")
|
||||
await removeLegacyParentSessionMapping(instanceId, parentSessionId, current)
|
||||
}
|
||||
|
|
@ -355,7 +361,7 @@ async function migrateLegacyWorktreeMapToSessionMetadata(instanceId: string): Pr
|
|||
}
|
||||
|
||||
const normalizedSlug = normalizeWorktreeSlug(instanceId, legacySlug || "root")
|
||||
const client = getOrCreateWorktreeClient(instanceId, normalizedSlug)
|
||||
const client = getRootClient(instanceId)
|
||||
await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, normalizedSlug)
|
||||
await removeLegacyParentSessionMapping(instanceId, parentSessionId)
|
||||
}
|
||||
|
|
@ -391,47 +397,6 @@ function getWorktreeSlugForDirectory(instanceId: string, directory: string | und
|
|||
return match?.slug ?? null
|
||||
}
|
||||
|
||||
function buildWorktreeProxyPath(instanceId: string, slug: string): string {
|
||||
const normalizedSlug = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
|
||||
}
|
||||
|
||||
function encodeBase64UrlUtf8(input: string): string {
|
||||
const bytes = new TextEncoder().encode(input)
|
||||
// Convert bytes -> base64 (btoa expects a binary string)
|
||||
let binary = ""
|
||||
const chunkSize = 0x8000
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize)
|
||||
binary += String.fromCharCode(...chunk)
|
||||
}
|
||||
const base64 = btoa(binary)
|
||||
// base64 -> base64url (strip padding)
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
|
||||
}
|
||||
|
||||
function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string {
|
||||
const base = buildWorktreeProxyPath(instanceId, slug)
|
||||
const encoded = encodeBase64UrlUtf8(directory)
|
||||
return `${base}/__dir/${encoded}`
|
||||
}
|
||||
|
||||
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
|
||||
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
const proxyPath = buildWorktreeProxyPath(instanceId, normalized)
|
||||
return sdkManager.createClient(instanceId, proxyPath, normalized)
|
||||
}
|
||||
|
||||
function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient {
|
||||
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory)
|
||||
return sdkManager.createClient(instanceId, proxyPath, normalized)
|
||||
}
|
||||
|
||||
function getRootClient(instanceId: string): OpencodeClient {
|
||||
return getOrCreateWorktreeClient(instanceId, "root")
|
||||
}
|
||||
|
||||
export {
|
||||
worktreesByInstance,
|
||||
worktreeMapByInstance,
|
||||
|
|
@ -453,11 +418,6 @@ export {
|
|||
pruneStaleLegacyWorktreeMapEntries,
|
||||
removeLegacyParentSessionMapping,
|
||||
getWorktreeSlugForDirectory,
|
||||
buildWorktreeProxyPath,
|
||||
buildWorktreeProxyPathWithDirectoryOverride,
|
||||
getOrCreateWorktreeClient,
|
||||
getOrCreateWorktreeClientWithDirectoryOverride,
|
||||
getRootClient,
|
||||
createWorktree,
|
||||
deleteWorktree,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue