worktrees - Implementation
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
fetchProviders,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
@@ -136,10 +137,10 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
||||
}
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(descriptor.id)
|
||||
sdkManager.destroyClientsForInstance(descriptor.id)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath, "root")
|
||||
updateInstance(descriptor.id, {
|
||||
client,
|
||||
port: nextPort ?? 0,
|
||||
@@ -157,7 +158,7 @@ function releaseInstanceResources(instanceId: string) {
|
||||
if (!instance) return
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(instanceId)
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
sseManager.seedStatus(instanceId, "disconnected")
|
||||
}
|
||||
@@ -227,6 +228,8 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
try {
|
||||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
|
||||
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
|
||||
import { providers, sessions, withSession } from "./session-state"
|
||||
@@ -83,6 +84,9 @@ async function sendMessage(
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
@@ -204,7 +208,7 @@ async function sendMessage(
|
||||
try {
|
||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||
await requestData(
|
||||
instance.client.session.promptAsync({
|
||||
client.session.promptAsync({
|
||||
sessionID: sessionId,
|
||||
...(requestBody as any),
|
||||
}),
|
||||
@@ -227,6 +231,9 @@ async function executeCustomCommand(
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
@@ -256,7 +263,7 @@ async function executeCustomCommand(
|
||||
}
|
||||
|
||||
await requestData(
|
||||
instance.client.session.command({
|
||||
client.session.command({
|
||||
sessionID: sessionId,
|
||||
...(body as any),
|
||||
}),
|
||||
@@ -270,6 +277,9 @@ 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 session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
@@ -278,7 +288,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
||||
const agent = session.agent || "build"
|
||||
|
||||
await requestData(
|
||||
instance.client.session.shell({
|
||||
client.session.shell({
|
||||
sessionID: sessionId,
|
||||
agent,
|
||||
command,
|
||||
@@ -293,12 +303,15 @@ 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)
|
||||
|
||||
log.info("abortSession", { instanceId, sessionId })
|
||||
|
||||
try {
|
||||
log.info("session.abort", { instanceId, sessionId })
|
||||
await requestData(
|
||||
instance.client.session.abort({
|
||||
client.session.abort({
|
||||
sessionID: sessionId,
|
||||
}),
|
||||
"session.abort",
|
||||
@@ -370,6 +383,9 @@ 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 session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
@@ -381,7 +397,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
}
|
||||
|
||||
await requestData(
|
||||
instance.client.session.update({
|
||||
client.session.update({
|
||||
sessionID: sessionId,
|
||||
title: trimmedTitle,
|
||||
}),
|
||||
@@ -403,8 +419,11 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
await requestData(
|
||||
instance.client.part.delete({
|
||||
client.part.delete({
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
partID: partId,
|
||||
|
||||
@@ -32,6 +32,13 @@ 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,
|
||||
removeParentSessionMapping,
|
||||
setWorktreeSlugForParentSession,
|
||||
} from "./worktrees"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -62,6 +69,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, true)
|
||||
@@ -70,7 +79,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
|
||||
try {
|
||||
log.info("session.list", { instanceId })
|
||||
const response = await instance.client.session.list()
|
||||
const response = await rootClient.session.list()
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
@@ -80,7 +89,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
|
||||
let statusById: Record<string, any> = {}
|
||||
try {
|
||||
const statusResponse = await instance.client.session.status()
|
||||
const statusResponse = await rootClient.session.status()
|
||||
if (statusResponse.data && typeof statusResponse.data === "object") {
|
||||
statusById = statusResponse.data as Record<string, any>
|
||||
}
|
||||
@@ -171,6 +180,12 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
// New parent sessions inherit the currently active session's worktree.
|
||||
// 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 instanceAgents = agents().get(instanceId) || []
|
||||
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
||||
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
||||
@@ -189,7 +204,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
const response = await instance.client.session.create()
|
||||
const response = await client.session.create()
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create session: No data returned")
|
||||
@@ -260,6 +275,11 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
await cleanupBlankSessions(instanceId, session.id)
|
||||
}
|
||||
|
||||
// Persist mapping for this *parent* session (best-effort).
|
||||
await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug).catch((error) => {
|
||||
log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error })
|
||||
})
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
log.error("Failed to create session:", error)
|
||||
@@ -283,6 +303,9 @@ async function forkSession(
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const request: { sessionID: string; messageID?: string } = {
|
||||
sessionID: sourceSessionId,
|
||||
messageID: options?.messageId,
|
||||
@@ -290,7 +313,7 @@ async function forkSession(
|
||||
|
||||
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
const info = await requestData<SessionForkResponse>(
|
||||
instance.client.session.fork(request),
|
||||
client.session.fork(request),
|
||||
"session.fork",
|
||||
)
|
||||
const forkedSession = {
|
||||
@@ -362,6 +385,11 @@ 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 deletingSession = sessions().get(instanceId)?.get(sessionId)
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId) || new Set()
|
||||
@@ -372,7 +400,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await requestData(instance.client.session.delete({ sessionID: sessionId }), "session.delete")
|
||||
await requestData(client.session.delete({ sessionID: sessionId }), "session.delete")
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
@@ -416,6 +444,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up mapping for deleted parent sessions.
|
||||
if (deletingSession?.parentId === null) {
|
||||
await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete session:", error)
|
||||
throw error
|
||||
@@ -437,9 +470,11 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const response = await rootClient.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
@@ -468,9 +503,11 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
const response = await instance.client.config.providers()
|
||||
const response = await rootClient.config.providers()
|
||||
if (!response.data) return
|
||||
|
||||
const providerList = response.data.providers.map((provider) => ({
|
||||
@@ -524,6 +561,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
@@ -541,7 +581,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
try {
|
||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const apiMessages = await requestData<any[]>(
|
||||
instance.client.session.messages({ sessionID: sessionId }),
|
||||
client.session.messages({ sessionID: sessionId }),
|
||||
"session.messages",
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ 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 {
|
||||
applyPartUpdateV2,
|
||||
replaceMessageIdV2,
|
||||
@@ -81,19 +82,34 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<Session | null> {
|
||||
async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return null
|
||||
|
||||
const slugFromDirectory = getWorktreeSlugForDirectory(instanceId, directory)
|
||||
const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, slug)
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
try {
|
||||
const info = await requestData<any>(
|
||||
instance.client.session.get({ sessionID: sessionId }),
|
||||
client.session.get({ sessionID: sessionId }),
|
||||
"session.get",
|
||||
)
|
||||
|
||||
let fetchedStatus: SessionStatus = "idle"
|
||||
try {
|
||||
const statuses = await requestData<Record<string, any>>(instance.client.session.status(), "session.status")
|
||||
let statuses: Record<string, any> = {}
|
||||
try {
|
||||
statuses = await requestData<Record<string, any>>(rootClient.session.status(), "session.status")
|
||||
} catch {
|
||||
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
|
||||
}
|
||||
// Session status is global-ish; prefer the root context when available.
|
||||
// (OpenCode may scope status by directory in older builds.)
|
||||
// If root fails, fall back to the worktree-scoped client.
|
||||
//
|
||||
// Note: requestData throws on error, so we catch below.
|
||||
const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
|
||||
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
||||
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
|
||||
@@ -132,7 +148,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
|
||||
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const existing = instanceSessions?.get(sessionId)
|
||||
if (existing) {
|
||||
@@ -149,7 +165,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
|
||||
}
|
||||
|
||||
const pending = (async () => {
|
||||
const fetched = await fetchSessionInfo(instanceId, sessionId)
|
||||
const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
|
||||
if (!fetched) return
|
||||
applySessionStatus(instanceId, sessionId, status)
|
||||
})()
|
||||
@@ -197,7 +213,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
||||
if (!sessionId || !messageId) return
|
||||
if (part.type === "compaction") {
|
||||
ensureSessionStatus(instanceId, sessionId, "compacting")
|
||||
ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory)
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
@@ -381,7 +397,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
ensureSessionStatus(instanceId, sessionId, "idle")
|
||||
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
|
||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
@@ -390,7 +406,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
|
||||
if (!sessionId) return
|
||||
|
||||
const status = mapSdkSessionStatus(event.properties.status)
|
||||
ensureSessionStatus(instanceId, sessionId, status)
|
||||
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory)
|
||||
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
|
||||
}
|
||||
|
||||
@@ -406,7 +422,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
session.status = "working"
|
||||
})
|
||||
} else {
|
||||
ensureSessionStatus(instanceId, sessionID, "working")
|
||||
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)
|
||||
}
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { tGlobal } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
@@ -602,12 +603,14 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
||||
return isFreshSession
|
||||
}
|
||||
let messages: any[] = []
|
||||
try {
|
||||
messages = await requestData<any[]>(
|
||||
instance.client.session.messages({ sessionID: session.id }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
try {
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
messages = await requestData<any[]>(
|
||||
client.session.messages({ sessionID: session.id }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
||||
return isFreshSession
|
||||
}
|
||||
|
||||
264
packages/ui/src/stores/worktrees.ts
Normal file
264
packages/ui/src/stores/worktrees.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const [worktreesByInstance, setWorktreesByInstance] = createSignal<Map<string, WorktreeDescriptor[]>>(new Map())
|
||||
const [worktreeMapByInstance, setWorktreeMapByInstance] = createSignal<Map<string, WorktreeMap>>(new Map())
|
||||
|
||||
const worktreeLoads = new Map<string, Promise<void>>()
|
||||
const mapLoads = new Map<string, Promise<void>>()
|
||||
|
||||
function normalizeMap(input?: WorktreeMap | null): WorktreeMap {
|
||||
if (!input || typeof input !== "object") {
|
||||
return { version: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: input.defaultWorktreeSlug || "root",
|
||||
parentSessionWorktreeSlug: input.parentSessionWorktreeSlug ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
if (worktreesByInstance().has(instanceId)) return
|
||||
const existing = worktreeLoads.get(instanceId)
|
||||
if (existing) return existing
|
||||
|
||||
const task = serverApi
|
||||
.fetchWorktrees(instanceId)
|
||||
.then((response) => {
|
||||
setWorktreesByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, response.worktrees ?? [])
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to load worktrees", { instanceId, error })
|
||||
setWorktreesByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, [])
|
||||
return next
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
worktreeLoads.delete(instanceId)
|
||||
})
|
||||
|
||||
worktreeLoads.set(instanceId, task)
|
||||
return task
|
||||
}
|
||||
|
||||
async function reloadWorktrees(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
await serverApi
|
||||
.fetchWorktrees(instanceId)
|
||||
.then((response) => {
|
||||
setWorktreesByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, response.worktrees ?? [])
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to reload worktrees", { instanceId, error })
|
||||
})
|
||||
}
|
||||
|
||||
async function createWorktree(instanceId: string, slug: string): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||
if (!instanceId) {
|
||||
throw new Error("Missing instanceId")
|
||||
}
|
||||
const trimmed = (slug ?? "").trim()
|
||||
if (!trimmed) {
|
||||
throw new Error("Worktree name is required")
|
||||
}
|
||||
return await serverApi.createWorktree(instanceId, { slug: trimmed })
|
||||
}
|
||||
|
||||
async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise<void> {
|
||||
if (!instanceId) {
|
||||
throw new Error("Missing instanceId")
|
||||
}
|
||||
const trimmed = (slug ?? "").trim()
|
||||
if (!trimmed || trimmed === "root") {
|
||||
throw new Error("Invalid worktree")
|
||||
}
|
||||
await serverApi.deleteWorktree(instanceId, trimmed, options)
|
||||
}
|
||||
|
||||
async function ensureWorktreeMapLoaded(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
if (worktreeMapByInstance().has(instanceId)) return
|
||||
const existing = mapLoads.get(instanceId)
|
||||
if (existing) return existing
|
||||
|
||||
const task = serverApi
|
||||
.readWorktreeMap(instanceId)
|
||||
.then((map) => {
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, normalizeMap(map))
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to load worktree map", { instanceId, error })
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, normalizeMap(null))
|
||||
return next
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
mapLoads.delete(instanceId)
|
||||
})
|
||||
|
||||
mapLoads.set(instanceId, task)
|
||||
return task
|
||||
}
|
||||
|
||||
async function reloadWorktreeMap(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
await serverApi
|
||||
.readWorktreeMap(instanceId)
|
||||
.then((map) => {
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, normalizeMap(map))
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to reload worktree map", { instanceId, error })
|
||||
})
|
||||
}
|
||||
|
||||
function getWorktrees(instanceId: string): WorktreeDescriptor[] {
|
||||
return worktreesByInstance().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
function getWorktreeMap(instanceId: string): WorktreeMap {
|
||||
return worktreeMapByInstance().get(instanceId) ?? normalizeMap(null)
|
||||
}
|
||||
|
||||
function getDefaultWorktreeSlug(instanceId: string): string {
|
||||
return getWorktreeMap(instanceId).defaultWorktreeSlug || "root"
|
||||
}
|
||||
|
||||
async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
const next: WorktreeMap = { ...current, defaultWorktreeSlug: slug }
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const map = new Map(prev)
|
||||
map.set(instanceId, next)
|
||||
return map
|
||||
})
|
||||
|
||||
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
|
||||
log.warn("Failed to persist default worktree", { instanceId, slug, error })
|
||||
})
|
||||
}
|
||||
|
||||
function getParentSessionId(instanceId: string, sessionId: string): string {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return sessionId
|
||||
return session.parentId ?? session.id
|
||||
}
|
||||
|
||||
function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string {
|
||||
const map = getWorktreeMap(instanceId)
|
||||
return map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root"
|
||||
}
|
||||
|
||||
function getWorktreeSlugForSession(instanceId: string, sessionId: string): string {
|
||||
const parentId = getParentSessionId(instanceId, sessionId)
|
||||
return getWorktreeSlugForParentSession(instanceId, parentId)
|
||||
}
|
||||
|
||||
async function setWorktreeSlugForParentSession(instanceId: string, parentSessionId: string, slug: string): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
nextMapping[parentSessionId] = slug
|
||||
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const map = new Map(prev)
|
||||
map.set(instanceId, next)
|
||||
return map
|
||||
})
|
||||
|
||||
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
|
||||
log.warn("Failed to persist session worktree mapping", { instanceId, parentSessionId, slug, error })
|
||||
})
|
||||
}
|
||||
|
||||
async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
if (!current.parentSessionWorktreeSlug[parentSessionId]) return
|
||||
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
delete nextMapping[parentSessionId]
|
||||
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const map = new Map(prev)
|
||||
map.set(instanceId, next)
|
||||
return map
|
||||
})
|
||||
|
||||
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
|
||||
log.warn("Failed to persist session worktree mapping removal", { instanceId, parentSessionId, error })
|
||||
})
|
||||
}
|
||||
|
||||
function getWorktreeSlugForDirectory(instanceId: string, directory: string | undefined): string | null {
|
||||
if (!directory) return null
|
||||
const list = getWorktrees(instanceId)
|
||||
const match = list.find((wt) => wt.directory === directory)
|
||||
return match?.slug ?? null
|
||||
}
|
||||
|
||||
function buildWorktreeProxyPath(instanceId: string, slug: string): string {
|
||||
const normalizedSlug = slug || "root"
|
||||
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
|
||||
}
|
||||
|
||||
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
|
||||
const proxyPath = buildWorktreeProxyPath(instanceId, slug)
|
||||
return sdkManager.createClient(instanceId, proxyPath, slug)
|
||||
}
|
||||
|
||||
function getRootClient(instanceId: string): OpencodeClient {
|
||||
return getOrCreateWorktreeClient(instanceId, "root")
|
||||
}
|
||||
|
||||
export {
|
||||
worktreesByInstance,
|
||||
worktreeMapByInstance,
|
||||
ensureWorktreesLoaded,
|
||||
reloadWorktrees,
|
||||
reloadWorktreeMap,
|
||||
ensureWorktreeMapLoaded,
|
||||
getWorktrees,
|
||||
getWorktreeMap,
|
||||
getDefaultWorktreeSlug,
|
||||
setDefaultWorktreeSlug,
|
||||
getParentSessionId,
|
||||
getWorktreeSlugForParentSession,
|
||||
getWorktreeSlugForSession,
|
||||
setWorktreeSlugForParentSession,
|
||||
removeParentSessionMapping,
|
||||
getWorktreeSlugForDirectory,
|
||||
buildWorktreeProxyPath,
|
||||
getOrCreateWorktreeClient,
|
||||
getRootClient,
|
||||
createWorktree,
|
||||
deleteWorktree,
|
||||
}
|
||||
Reference in New Issue
Block a user