worktrees - Implementation
This commit is contained in:
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.42"
|
"@opencode-ai/plugin": "0.0.0-dev-202602062205"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,36 @@ export interface WorkspaceDeleteResponse {
|
|||||||
status: WorkspaceStatus
|
status: WorkspaceStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorktreeKind = "root" | "worktree"
|
||||||
|
|
||||||
|
export interface WorktreeDescriptor {
|
||||||
|
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
|
||||||
|
slug: string
|
||||||
|
/** Absolute directory path on the server host. */
|
||||||
|
directory: string
|
||||||
|
kind: WorktreeKind
|
||||||
|
/** Optional VCS branch name when available. */
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeListResponse {
|
||||||
|
worktrees: WorktreeDescriptor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeCreateRequest {
|
||||||
|
slug: string
|
||||||
|
/** Optional branch name (defaults to slug). */
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeMap {
|
||||||
|
version: 1
|
||||||
|
/** Default worktree to use for new sessions and as fallback. */
|
||||||
|
defaultWorktreeSlug: string
|
||||||
|
/** Mapping of *parent* session IDs to a worktree slug. */
|
||||||
|
parentSessionWorktreeSlug: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
export interface WorkspaceLogEntry {
|
export interface WorkspaceLogEntry {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import path from "path"
|
|||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
|
||||||
import { ConfigStore } from "../config/store"
|
import { ConfigStore } from "../config/store"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
@@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events"
|
|||||||
import { registerStorageRoutes } from "./routes/storage"
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
@@ -222,6 +224,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
instanceStore: deps.instanceStore,
|
instanceStore: deps.instanceStore,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
@@ -312,31 +315,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
|||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||||
|
|
||||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
const proxyBaseHandler = async (
|
||||||
await proxyWorkspaceRequest({
|
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
||||||
request,
|
|
||||||
reply,
|
|
||||||
workspaceManager: deps.workspaceManager,
|
|
||||||
pathSuffix: "",
|
|
||||||
logger: deps.logger,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyWildcardHandler = async (
|
|
||||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) => {
|
) => {
|
||||||
await proxyWorkspaceRequest({
|
await proxyWorkspaceRequest({
|
||||||
request,
|
request,
|
||||||
reply,
|
reply,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
|
worktreeSlug: request.params.slug,
|
||||||
|
pathSuffix: "",
|
||||||
|
logger: deps.logger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyWildcardHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxyWorkspaceRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
workspaceManager: deps.workspaceManager,
|
||||||
|
worktreeSlug: request.params.slug,
|
||||||
pathSuffix: request.params["*"] ?? "",
|
pathSuffix: request.params["*"] ?? "",
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
||||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,9 +355,10 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
worktreeSlug: string
|
||||||
pathSuffix?: string
|
pathSuffix?: string
|
||||||
}) {
|
}) {
|
||||||
const { request, reply, workspaceManager, logger } = args
|
const { request, reply, workspaceManager, logger, worktreeSlug } = args
|
||||||
const workspaceId = (request.params as { id: string }).id
|
const workspaceId = (request.params as { id: string }).id
|
||||||
const workspace = workspaceManager.get(workspaceId)
|
const workspace = workspaceManager.get(workspaceId)
|
||||||
|
|
||||||
@@ -364,6 +373,23 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidWorktreeSlug(worktreeSlug)) {
|
||||||
|
reply.code(400).send({ error: "Invalid worktree slug" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = await resolveWorktreeDirectory({
|
||||||
|
workspaceId,
|
||||||
|
workspacePath: workspace.path,
|
||||||
|
worktreeSlug,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
reply.code(404).send({ error: "Worktree not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
@@ -381,9 +407,7 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
headers.authorization = instanceAuthHeader
|
headers.authorization = instanceAuthHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce per-workspace directory scoping for all proxied OpenCode requests.
|
|
||||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||||
const directory = workspace.path
|
|
||||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||||
|
|
||||||
@@ -409,6 +433,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorktreeCacheEntry = {
|
||||||
|
expiresAt: number
|
||||||
|
repoRoot: string
|
||||||
|
worktrees: Array<{ slug: string; directory: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKTREE_CACHE_TTL_MS = 2000
|
||||||
|
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||||
|
|
||||||
|
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
||||||
|
const cached = worktreeCache.get(params.workspaceId)
|
||||||
|
const now = Date.now()
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||||
|
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||||
|
const entry: WorktreeCacheEntry = {
|
||||||
|
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||||
|
repoRoot,
|
||||||
|
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
||||||
|
}
|
||||||
|
worktreeCache.set(params.workspaceId, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWorktreeDirectory(params: {
|
||||||
|
workspaceId: string
|
||||||
|
workspacePath: string
|
||||||
|
worktreeSlug: string
|
||||||
|
logger: Logger
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const { worktreeSlug } = params
|
||||||
|
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||||
|
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
||||||
|
if (match) {
|
||||||
|
return match.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the slug is new (e.g., created moments ago), refresh once.
|
||||||
|
worktreeCache.delete(params.workspaceId)
|
||||||
|
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||||
|
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||||
if (!uiDir) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
app.log.warn("UI static directory not provided; API endpoints only")
|
||||||
|
|||||||
195
packages/server/src/server/routes/worktrees.ts
Normal file
195
packages/server/src/server/routes/worktrees.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
import {
|
||||||
|
resolveRepoRoot,
|
||||||
|
listWorktrees,
|
||||||
|
isValidWorktreeSlug,
|
||||||
|
createManagedWorktree,
|
||||||
|
removeWorktree,
|
||||||
|
} from "../../workspaces/git-worktrees"
|
||||||
|
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
|
||||||
|
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorktreeMapSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
defaultWorktreeSlug: z.string().min(1).default("root"),
|
||||||
|
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorktreeCreateSchema = z.object({
|
||||||
|
slug: z.string().trim().min(1),
|
||||||
|
branch: z.string().trim().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot } = await resolveRepoRoot(workspace.path, request.log)
|
||||||
|
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||||
|
const response: WorktreeListResponse = { worktrees }
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = WorktreeCreateSchema.parse(request.body ?? {})
|
||||||
|
const slug = body.slug
|
||||||
|
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree slug" }
|
||||||
|
}
|
||||||
|
if (body.branch) {
|
||||||
|
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree branch" }
|
||||||
|
}
|
||||||
|
if (body.branch !== slug) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Branch must match slug" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Workspace is not a Git repository" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
|
||||||
|
|
||||||
|
const created = await createManagedWorktree({
|
||||||
|
repoRoot,
|
||||||
|
workspaceFolder: workspace.path,
|
||||||
|
slug,
|
||||||
|
logger: request.log,
|
||||||
|
})
|
||||||
|
|
||||||
|
reply.code(201)
|
||||||
|
return created
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
|
||||||
|
"/api/workspaces/:id/worktrees/:slug",
|
||||||
|
async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = (request.params.slug ?? "").trim()
|
||||||
|
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree slug" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Workspace is not a Git repository" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||||
|
const match = worktrees.find((wt) => wt.slug === slug)
|
||||||
|
if (!match || match.kind === "root") {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Worktree not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
|
||||||
|
|
||||||
|
// Best-effort: prune any mappings that point at the deleted worktree.
|
||||||
|
const current = await readWorktreeMap(workspace.path, request.log)
|
||||||
|
let changed = false
|
||||||
|
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||||
|
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
|
||||||
|
if (mapped === slug) {
|
||||||
|
delete nextMapping[sessionId]
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
|
||||||
|
if (nextDefault !== current.defaultWorktreeSlug) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await writeWorktreeMap(
|
||||||
|
workspace.path,
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug: nextDefault,
|
||||||
|
parentSessionWorktreeSlug: nextMapping,
|
||||||
|
},
|
||||||
|
request.log,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, reply)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
return await readWorktreeMap(workspace.path, request.log)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
|
||||||
|
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid defaultWorktreeSlug" }
|
||||||
|
}
|
||||||
|
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
|
||||||
|
if (!isValidWorktreeSlug(slug)) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree slug in mapping" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeWorktreeMap(workspace.path, parsed, request.log)
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error: unknown, reply: FastifyReply) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||||
|
}
|
||||||
248
packages/server/src/workspaces/git-worktrees.ts
Normal file
248
packages/server/src/workspaces/git-worktrees.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { spawn } from "child_process"
|
||||||
|
import type { WorktreeDescriptor } from "../api-types"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
|
||||||
|
export interface LogLike {
|
||||||
|
debug?: (obj: any, msg?: string) => void
|
||||||
|
warn?: (obj: any, msg?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||||
|
let stdout = ""
|
||||||
|
let stderr = ""
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString()
|
||||||
|
})
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
})
|
||||||
|
child.once("error", (error) => {
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
})
|
||||||
|
child.once("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ ok: true, stdout })
|
||||||
|
} else {
|
||||||
|
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||||
|
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
||||||
|
if (!result.ok) {
|
||||||
|
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||||
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
|
}
|
||||||
|
const repoRoot = result.stdout.trim()
|
||||||
|
if (!repoRoot) {
|
||||||
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
|
}
|
||||||
|
return { repoRoot, isGitRepo: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
||||||
|
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||||
|
const lines = output.split(/\r?\n/)
|
||||||
|
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (current.worktree) {
|
||||||
|
records.push({ worktree: current.worktree, branch: current.branch })
|
||||||
|
}
|
||||||
|
current = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const [key, ...rest] = trimmed.split(" ")
|
||||||
|
const value = rest.join(" ").trim()
|
||||||
|
if (key === "worktree") {
|
||||||
|
current.worktree = value
|
||||||
|
} else if (key === "branch") {
|
||||||
|
// branch is like refs/heads/foo
|
||||||
|
current.branch = value.replace(/^refs\/heads\//, "")
|
||||||
|
} else if (key === "HEAD") {
|
||||||
|
current.head = value
|
||||||
|
} else if (key === "detached") {
|
||||||
|
current.detached = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorktrees(params: {
|
||||||
|
repoRoot: string
|
||||||
|
workspaceFolder: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<WorktreeDescriptor[]> {
|
||||||
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
|
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||||
|
|
||||||
|
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||||
|
if (!result.ok) {
|
||||||
|
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||||
|
return [rootDescriptor]
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = parseWorktreePorcelain(result.stdout)
|
||||||
|
|
||||||
|
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||||
|
const slugCounts = new Map<string, number>([["root", 1]])
|
||||||
|
|
||||||
|
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
|
||||||
|
const branch = (record.branch ?? "").trim()
|
||||||
|
if (branch) {
|
||||||
|
return branch
|
||||||
|
}
|
||||||
|
const head = (record.head ?? "").trim()
|
||||||
|
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
|
||||||
|
return `detached-${head.slice(0, 7)}`
|
||||||
|
}
|
||||||
|
// Fallback: stable-ish identifier derived from directory basename.
|
||||||
|
const base = path.basename(record.worktree || "")
|
||||||
|
return base ? `worktree-${base}` : "worktree"
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniquify = (slug: string): string => {
|
||||||
|
const count = slugCounts.get(slug) ?? 0
|
||||||
|
slugCounts.set(slug, count + 1)
|
||||||
|
if (count === 0) {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
return `${slug}@${count + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const abs = record.worktree
|
||||||
|
if (!abs || typeof abs !== "string") continue
|
||||||
|
|
||||||
|
// Skip the root record (we always expose it as slug="root").
|
||||||
|
if (path.resolve(abs) === path.resolve(repoRoot)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = uniquify(normalizeSlug(record))
|
||||||
|
// Never emit a worktree slug that collides with our reserved root slug.
|
||||||
|
if (slug === "root") {
|
||||||
|
worktrees.push({ slug: uniquify("root-worktree"), directory: abs, kind: "worktree", branch: record.branch })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
return worktrees
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidWorktreeSlug(slug: string): boolean {
|
||||||
|
if (!slug) return false
|
||||||
|
const trimmed = slug.trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (trimmed.length > 200) return false
|
||||||
|
// Disallow control characters; allow branch-like slugs including '/'.
|
||||||
|
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createManagedWorktree(params: {
|
||||||
|
repoRoot: string
|
||||||
|
workspaceFolder: string
|
||||||
|
slug: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||||
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
|
const branch = params.slug.trim()
|
||||||
|
|
||||||
|
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
|
||||||
|
throw new Error("Invalid worktree slug")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeDirName = (input: string): string => {
|
||||||
|
const normalized = input
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/]+/g, "-")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
return normalized || "worktree"
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
|
||||||
|
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
|
||||||
|
await fsp.mkdir(worktreesDir, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fsp.stat(targetDir)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
throw new Error("Worktree directory already exists")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
if (code !== "ENOENT") {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
|
||||||
|
|
||||||
|
// Prefer creating a new branch from HEAD.
|
||||||
|
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
|
||||||
|
if (first.ok) {
|
||||||
|
return { slug: branch, directory: targetDir, branch }
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
|
||||||
|
if (message.includes("already exists")) {
|
||||||
|
// If the branch already exists, add worktree for that branch.
|
||||||
|
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
|
||||||
|
if (second.ok) {
|
||||||
|
return { slug: branch, directory: targetDir, branch }
|
||||||
|
}
|
||||||
|
throw second.error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw first.error
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeWorktree(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
directory: string
|
||||||
|
force?: boolean
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<void> {
|
||||||
|
const { workspaceFolder, logger } = params
|
||||||
|
const directory = (params.directory ?? "").trim()
|
||||||
|
if (!directory) {
|
||||||
|
throw new Error("Invalid worktree directory")
|
||||||
|
}
|
||||||
|
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
|
||||||
|
|
||||||
|
const args = ["worktree", "remove"]
|
||||||
|
if (params.force) {
|
||||||
|
args.push("--force")
|
||||||
|
}
|
||||||
|
args.push(directory)
|
||||||
|
|
||||||
|
const result = await runGit(args, workspaceFolder)
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort cleanup of stale metadata.
|
||||||
|
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ export class InstanceEventBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
const url = `http://${INSTANCE_HOST}:${port}/global/event`
|
||||||
|
|
||||||
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||||
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
@@ -165,8 +165,17 @@ export class InstanceEventBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
const parsed = JSON.parse(payload) as any
|
||||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
const event: InstanceStreamEvent | null = parsed && typeof parsed === "object"
|
||||||
|
? (parsed.payload && parsed.directory && typeof parsed.payload === "object" ? { ...parsed.payload, directory: parsed.directory } : parsed)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!event || typeof (event as any).type !== "string") {
|
||||||
|
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
|
||||||
if (this.options.logger.isLevelEnabled("trace")) {
|
if (this.options.logger.isLevelEnabled("trace")) {
|
||||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||||
|
|
||||||
const proxyPath = `/workspaces/${id}/instance`
|
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
|
||||||
|
|
||||||
|
|
||||||
const descriptor: WorkspaceRecord = {
|
const descriptor: WorkspaceRecord = {
|
||||||
|
|||||||
129
packages/server/src/workspaces/worktree-map.ts
Normal file
129
packages/server/src/workspaces/worktree-map.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import type { WorktreeMap } from "../api-types"
|
||||||
|
import { resolveRepoRoot } from "./git-worktrees"
|
||||||
|
import type { LogLike } from "./git-worktrees"
|
||||||
|
|
||||||
|
const DEFAULT_MAP: WorktreeMap = {
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug: "root",
|
||||||
|
parentSessionWorktreeSlug: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMapPath(repoRoot: string): string {
|
||||||
|
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGitExcludePath(repoRoot: string): string {
|
||||||
|
return path.join(repoRoot, ".git", "info", "exclude")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
|
||||||
|
const excludePath = getGitExcludePath(repoRoot)
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
".codenomad/worktrees/",
|
||||||
|
".codenomad/worktreeMap.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
let existing = ""
|
||||||
|
try {
|
||||||
|
existing = await fsp.readFile(excludePath, "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
if (code !== "ENOENT") {
|
||||||
|
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
|
||||||
|
const missing = entries.filter((e) => !lines.has(e))
|
||||||
|
if (missing.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
|
||||||
|
const suffix = missing.map((e) => `${e}\n`).join("")
|
||||||
|
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ensureGitExclude(repoRoot, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||||
|
const filePath = getMapPath(repoRoot)
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(filePath, "utf-8")
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
const version = (parsed as any).version
|
||||||
|
if (version !== 1) {
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
|
||||||
|
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
|
||||||
|
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug,
|
||||||
|
parentSessionWorktreeSlug: { ...mapping },
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
if (code === "ENOENT") {
|
||||||
|
if (isGitRepo) {
|
||||||
|
// Best-effort ignore setup on first use.
|
||||||
|
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||||
|
}
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||||
|
const filePath = getMapPath(repoRoot)
|
||||||
|
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
|
|
||||||
|
// Ensure ignore rules are present (local-only).
|
||||||
|
if (isGitRepo) {
|
||||||
|
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: WorktreeMap = {
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
|
||||||
|
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write atomically.
|
||||||
|
const tmpPath = `${filePath}.${process.pid}.tmp`
|
||||||
|
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
|
||||||
|
await fsp.rename(tmpPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worktreeMapExists(repoRoot: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(getMapPath(repoRoot))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import { Accordion } from "@kobalte/core"
|
import { Accordion } from "@kobalte/core"
|
||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
import AppBar from "@suid/material/AppBar"
|
import AppBar from "@suid/material/AppBar"
|
||||||
import Box from "@suid/material/Box"
|
import Box from "@suid/material/Box"
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
getSessionFamily,
|
getSessionFamily,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
getSessionThreads,
|
getSessionThreads,
|
||||||
|
loadMessages,
|
||||||
sessions,
|
sessions,
|
||||||
setActiveParentSession,
|
setActiveParentSession,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
@@ -63,6 +65,17 @@ import { formatTokenTotal } from "../../lib/formatters"
|
|||||||
import { sseManager } from "../../lib/sse-manager"
|
import { sseManager } from "../../lib/sse-manager"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { showToastNotification } from "../../lib/notifications"
|
||||||
|
import {
|
||||||
|
createWorktree,
|
||||||
|
deleteWorktree,
|
||||||
|
getParentSessionId,
|
||||||
|
getWorktreeSlugForParentSession,
|
||||||
|
getWorktrees,
|
||||||
|
reloadWorktrees,
|
||||||
|
reloadWorktreeMap,
|
||||||
|
setWorktreeSlugForParentSession,
|
||||||
|
} from "../../stores/worktrees"
|
||||||
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
||||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
@@ -74,6 +87,9 @@ import {
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
const CREATE_WORKTREE_VALUE = "__codenomad_create_worktree__"
|
||||||
|
const DELETE_WORKTREE_VALUE = "__codenomad_delete_worktree__"
|
||||||
|
|
||||||
interface InstanceShellProps {
|
interface InstanceShellProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
escapeInDebounce: boolean
|
escapeInDebounce: boolean
|
||||||
@@ -149,6 +165,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||||
|
|
||||||
|
const [createWorktreeOpen, setCreateWorktreeOpen] = createSignal(false)
|
||||||
|
const [createWorktreeSlug, setCreateWorktreeSlug] = createSignal("")
|
||||||
|
const [isCreatingWorktree, setIsCreatingWorktree] = createSignal(false)
|
||||||
|
|
||||||
|
const [deleteWorktreeOpen, setDeleteWorktreeOpen] = createSignal(false)
|
||||||
|
const [isDeletingWorktree, setIsDeletingWorktree] = createSignal(false)
|
||||||
|
const [forceDeleteWorktree, setForceDeleteWorktree] = createSignal(false)
|
||||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||||
|
|
||||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
||||||
@@ -920,16 +944,244 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="session-sidebar-separator" />
|
<div class="session-sidebar-separator" />
|
||||||
<Show when={activeSessionForInstance()}>
|
<Show when={activeSessionForInstance()}>
|
||||||
{(activeSession) => (
|
{(activeSession) => (
|
||||||
<>
|
<>
|
||||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||||
<AgentSelector
|
<div class="space-y-1">
|
||||||
instanceId={props.instance.id}
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">Worktree</div>
|
||||||
sessionId={activeSession().id}
|
<select
|
||||||
currentAgent={activeSession().agent}
|
class="selector-input w-full"
|
||||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
value={getWorktreeSlugForParentSession(
|
||||||
/>
|
props.instance.id,
|
||||||
|
getParentSessionId(props.instance.id, activeSession().id),
|
||||||
|
)}
|
||||||
|
disabled={Boolean(activeSession().parentId)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextSlug = e.currentTarget.value
|
||||||
|
const sessionId = activeSession().id
|
||||||
|
const parentId = getParentSessionId(props.instance.id, sessionId)
|
||||||
|
|
||||||
|
if (nextSlug === CREATE_WORKTREE_VALUE) {
|
||||||
|
setCreateWorktreeSlug("")
|
||||||
|
setCreateWorktreeOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSlug === DELETE_WORKTREE_VALUE) {
|
||||||
|
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
|
||||||
|
if (currentSlug && currentSlug !== "root") {
|
||||||
|
setForceDeleteWorktree(false)
|
||||||
|
setDeleteWorktreeOpen(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await setWorktreeSlugForParentSession(props.instance.id, parentId, nextSlug)
|
||||||
|
})().catch((error) => {
|
||||||
|
log.warn("Failed to apply worktree change", error)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={getWorktrees(props.instance.id)}>
|
||||||
|
{(wt) => (
|
||||||
|
<option value={wt.slug}>{wt.slug === "root" ? "root" : wt.slug}</option>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={getWorktrees(props.instance.id).length === 0}>
|
||||||
|
<option value="root">root</option>
|
||||||
|
</Show>
|
||||||
|
<option value={CREATE_WORKTREE_VALUE}>+ Create worktree…</option>
|
||||||
|
<option
|
||||||
|
value={DELETE_WORKTREE_VALUE}
|
||||||
|
disabled={
|
||||||
|
Boolean(activeSession().parentId) ||
|
||||||
|
getWorktreeSlugForParentSession(
|
||||||
|
props.instance.id,
|
||||||
|
getParentSessionId(props.instance.id, activeSession().id),
|
||||||
|
) === "root"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete worktree…
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={createWorktreeOpen()} onOpenChange={(open) => !open && setCreateWorktreeOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||||
|
Creates a git worktree under <span class="font-mono">.codenomad/worktrees/<name></span> from HEAD.
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs font-medium text-muted uppercase tracking-wide">Worktree name</label>
|
||||||
|
<input
|
||||||
|
class="form-input w-full"
|
||||||
|
value={createWorktreeSlug()}
|
||||||
|
onInput={(e) => setCreateWorktreeSlug(e.currentTarget.value)}
|
||||||
|
placeholder="feature-x"
|
||||||
|
disabled={isCreatingWorktree()}
|
||||||
|
spellcheck={false}
|
||||||
|
autocapitalize="off"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div class="text-[11px] text-secondary">
|
||||||
|
Allowed: letters, numbers, <span class="font-mono">_ . - /</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => setCreateWorktreeOpen(false)}
|
||||||
|
disabled={isCreatingWorktree()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary"
|
||||||
|
disabled={
|
||||||
|
isCreatingWorktree() ||
|
||||||
|
!createWorktreeSlug().trim() ||
|
||||||
|
createWorktreeSlug().trim() === "root" ||
|
||||||
|
!/^[a-zA-Z0-9_.\/-]+$/.test(createWorktreeSlug().trim())
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const slug = createWorktreeSlug().trim()
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
setIsCreatingWorktree(true)
|
||||||
|
await createWorktree(props.instance.id, slug)
|
||||||
|
await reloadWorktrees(props.instance.id)
|
||||||
|
|
||||||
|
const sessionId = activeSession().id
|
||||||
|
const parentId = getParentSessionId(props.instance.id, sessionId)
|
||||||
|
await setWorktreeSlugForParentSession(props.instance.id, parentId, slug)
|
||||||
|
|
||||||
|
setCreateWorktreeOpen(false)
|
||||||
|
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("Failed to create worktree", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : "Failed to create worktree",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsCreatingWorktree(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCreatingWorktree() ? "Creating…" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteWorktreeOpen()} onOpenChange={(open) => !open && setDeleteWorktreeOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||||
|
Removes the git worktree checkout directory for this branch.
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||||
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
||||||
|
<p class="text-sm font-mono text-primary break-all">
|
||||||
|
{getWorktreeSlugForParentSession(
|
||||||
|
props.instance.id,
|
||||||
|
getParentSessionId(props.instance.id, activeSession().id),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceDeleteWorktree()}
|
||||||
|
onChange={(e) => setForceDeleteWorktree(e.currentTarget.checked)}
|
||||||
|
disabled={isDeletingWorktree()}
|
||||||
|
/>
|
||||||
|
Force delete (discard local changes)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => setDeleteWorktreeOpen(false)}
|
||||||
|
disabled={isDeletingWorktree()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary"
|
||||||
|
disabled={isDeletingWorktree()}
|
||||||
|
onClick={() => {
|
||||||
|
const sessionId = activeSession().id
|
||||||
|
const parentId = getParentSessionId(props.instance.id, sessionId)
|
||||||
|
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
|
||||||
|
if (!currentSlug || currentSlug === "root") {
|
||||||
|
setDeleteWorktreeOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
setIsDeletingWorktree(true)
|
||||||
|
await deleteWorktree(props.instance.id, currentSlug, { force: forceDeleteWorktree() })
|
||||||
|
await reloadWorktrees(props.instance.id)
|
||||||
|
await reloadWorktreeMap(props.instance.id)
|
||||||
|
|
||||||
|
// If the active session mapped to the deleted worktree, switch to root.
|
||||||
|
await setWorktreeSlugForParentSession(props.instance.id, parentId, "root")
|
||||||
|
|
||||||
|
setDeleteWorktreeOpen(false)
|
||||||
|
showToastNotification({ message: `Deleted worktree ${currentSlug}`, variant: "success" })
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("Failed to delete worktree", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsDeletingWorktree(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDeletingWorktree() ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AgentSelector
|
||||||
|
instanceId={props.instance.id}
|
||||||
|
sessionId={activeSession().id}
|
||||||
|
currentAgent={activeSession().agent}
|
||||||
|
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import type {
|
|||||||
WorkspaceLogEntry,
|
WorkspaceLogEntry,
|
||||||
WorkspaceEventPayload,
|
WorkspaceEventPayload,
|
||||||
WorkspaceEventType,
|
WorkspaceEventType,
|
||||||
|
WorktreeListResponse,
|
||||||
|
WorktreeMap,
|
||||||
|
WorktreeCreateRequest,
|
||||||
} from "../../../server/src/api-types"
|
} from "../../../server/src/api-types"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
|
|
||||||
@@ -127,6 +130,39 @@ export const serverApi = {
|
|||||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||||
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchWorktrees(id: string): Promise<WorktreeListResponse> {
|
||||||
|
return request<WorktreeListResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createWorktree(id: string, payload: WorktreeCreateRequest): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||||
|
return request<{ slug: string; directory: string; branch?: string }>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteWorktree(id: string, slug: string, options?: { force?: boolean }): Promise<void> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options?.force) {
|
||||||
|
params.set("force", "true")
|
||||||
|
}
|
||||||
|
const suffix = params.toString() ? `?${params.toString()}` : ""
|
||||||
|
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}${suffix}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
readWorktreeMap(id: string): Promise<WorktreeMap> {
|
||||||
|
return request<WorktreeMap>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`)
|
||||||
|
},
|
||||||
|
|
||||||
|
writeWorktreeMap(id: string, map: WorktreeMap): Promise<void> {
|
||||||
|
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(map),
|
||||||
|
})
|
||||||
|
},
|
||||||
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
||||||
return request<WorkspaceDescriptor>("/api/workspaces", {
|
return request<WorkspaceDescriptor>("/api/workspaces", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import { CODENOMAD_API_BASE } from "./api-client"
|
|||||||
class SDKManager {
|
class SDKManager {
|
||||||
private clients = new Map<string, OpencodeClient>()
|
private clients = new Map<string, OpencodeClient>()
|
||||||
|
|
||||||
createClient(instanceId: string, proxyPath: string): OpencodeClient {
|
private key(instanceId: string, worktreeSlug: string): string {
|
||||||
const existing = this.clients.get(instanceId)
|
return `${instanceId}:${worktreeSlug || "root"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
|
||||||
|
const key = this.key(instanceId, worktreeSlug)
|
||||||
|
const existing = this.clients.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
@@ -13,17 +18,25 @@ class SDKManager {
|
|||||||
const baseUrl = buildInstanceBaseUrl(proxyPath)
|
const baseUrl = buildInstanceBaseUrl(proxyPath)
|
||||||
const client = createOpencodeClient({ baseUrl })
|
const client = createOpencodeClient({ baseUrl })
|
||||||
|
|
||||||
this.clients.set(instanceId, client)
|
this.clients.set(key, client)
|
||||||
|
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
getClient(instanceId: string): OpencodeClient | null {
|
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
|
||||||
return this.clients.get(instanceId) ?? null
|
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyClient(instanceId: string): void {
|
destroyClient(instanceId: string, worktreeSlug = "root"): void {
|
||||||
this.clients.delete(instanceId)
|
this.clients.delete(this.key(instanceId, worktreeSlug))
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyClientsForInstance(instanceId: string): void {
|
||||||
|
for (const key of Array.from(this.clients.keys())) {
|
||||||
|
if (key === instanceId || key.startsWith(`${instanceId}:`)) {
|
||||||
|
this.clients.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyAll(): void {
|
destroyAll(): void {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
fetchProviders,
|
fetchProviders,
|
||||||
clearInstanceDraftPrompts,
|
clearInstanceDraftPrompts,
|
||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
|
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees"
|
||||||
import { fetchCommands, clearCommands } from "./commands"
|
import { fetchCommands, clearCommands } from "./commands"
|
||||||
import { preferences } from "./preferences"
|
import { preferences } from "./preferences"
|
||||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||||
@@ -136,10 +137,10 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (instance.client) {
|
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, {
|
updateInstance(descriptor.id, {
|
||||||
client,
|
client,
|
||||||
port: nextPort ?? 0,
|
port: nextPort ?? 0,
|
||||||
@@ -157,7 +158,7 @@ function releaseInstanceResources(instanceId: string) {
|
|||||||
if (!instance) return
|
if (!instance) return
|
||||||
|
|
||||||
if (instance.client) {
|
if (instance.client) {
|
||||||
sdkManager.destroyClient(instanceId)
|
sdkManager.destroyClientsForInstance(instanceId)
|
||||||
}
|
}
|
||||||
sseManager.seedStatus(instanceId, "disconnected")
|
sseManager.seedStatus(instanceId, "disconnected")
|
||||||
}
|
}
|
||||||
@@ -227,6 +228,8 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
|
|||||||
|
|
||||||
async function hydrateInstanceData(instanceId: string) {
|
async function hydrateInstanceData(instanceId: string) {
|
||||||
try {
|
try {
|
||||||
|
await ensureWorktreesLoaded(instanceId)
|
||||||
|
await ensureWorktreeMapLoaded(instanceId)
|
||||||
await fetchSessions(instanceId)
|
await fetchSessions(instanceId)
|
||||||
await fetchAgents(instanceId)
|
await fetchAgents(instanceId)
|
||||||
await fetchProviders(instanceId)
|
await fetchProviders(instanceId)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import { instances } from "./instances"
|
import { instances } from "./instances"
|
||||||
|
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||||
|
|
||||||
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
|
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
|
||||||
import { providers, sessions, withSession } from "./session-state"
|
import { providers, sessions, withSession } from "./session-state"
|
||||||
@@ -83,6 +84,9 @@ async function sendMessage(
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
const session = instanceSessions?.get(sessionId)
|
const session = instanceSessions?.get(sessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -204,7 +208,7 @@ async function sendMessage(
|
|||||||
try {
|
try {
|
||||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||||
await requestData(
|
await requestData(
|
||||||
instance.client.session.promptAsync({
|
client.session.promptAsync({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
...(requestBody as any),
|
...(requestBody as any),
|
||||||
}),
|
}),
|
||||||
@@ -227,6 +231,9 @@ async function executeCustomCommand(
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
const session = sessions().get(instanceId)?.get(sessionId)
|
const session = sessions().get(instanceId)?.get(sessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("Session not found")
|
throw new Error("Session not found")
|
||||||
@@ -256,7 +263,7 @@ async function executeCustomCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await requestData(
|
await requestData(
|
||||||
instance.client.session.command({
|
client.session.command({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
...(body as any),
|
...(body as any),
|
||||||
}),
|
}),
|
||||||
@@ -270,6 +277,9 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
const session = sessions().get(instanceId)?.get(sessionId)
|
const session = sessions().get(instanceId)?.get(sessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("Session not found")
|
throw new Error("Session not found")
|
||||||
@@ -278,7 +288,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
|||||||
const agent = session.agent || "build"
|
const agent = session.agent || "build"
|
||||||
|
|
||||||
await requestData(
|
await requestData(
|
||||||
instance.client.session.shell({
|
client.session.shell({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
agent,
|
agent,
|
||||||
command,
|
command,
|
||||||
@@ -293,12 +303,15 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
log.info("abortSession", { instanceId, sessionId })
|
log.info("abortSession", { instanceId, sessionId })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info("session.abort", { instanceId, sessionId })
|
log.info("session.abort", { instanceId, sessionId })
|
||||||
await requestData(
|
await requestData(
|
||||||
instance.client.session.abort({
|
client.session.abort({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
}),
|
}),
|
||||||
"session.abort",
|
"session.abort",
|
||||||
@@ -370,6 +383,9 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
const session = sessions().get(instanceId)?.get(sessionId)
|
const session = sessions().get(instanceId)?.get(sessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("Session not found")
|
throw new Error("Session not found")
|
||||||
@@ -381,7 +397,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
await requestData(
|
await requestData(
|
||||||
instance.client.session.update({
|
client.session.update({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
title: trimmedTitle,
|
title: trimmedTitle,
|
||||||
}),
|
}),
|
||||||
@@ -403,8 +419,11 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
await requestData(
|
await requestData(
|
||||||
instance.client.part.delete({
|
client.part.delete({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
messageID: messageId,
|
messageID: messageId,
|
||||||
partID: partId,
|
partID: partId,
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ import { messageStoreBus } from "./message-v2/bus"
|
|||||||
import { clearCacheForSession } from "../lib/global-cache"
|
import { clearCacheForSession } from "../lib/global-cache"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { requestData } from "../lib/opencode-api"
|
import { requestData } from "../lib/opencode-api"
|
||||||
|
import {
|
||||||
|
getOrCreateWorktreeClient,
|
||||||
|
getRootClient,
|
||||||
|
getWorktreeSlugForSession,
|
||||||
|
removeParentSessionMapping,
|
||||||
|
setWorktreeSlugForParentSession,
|
||||||
|
} from "./worktrees"
|
||||||
|
|
||||||
const log = getLogger("api")
|
const log = getLogger("api")
|
||||||
|
|
||||||
@@ -62,6 +69,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootClient = getRootClient(instanceId)
|
||||||
|
|
||||||
setLoading((prev) => {
|
setLoading((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
next.fetchingSessions.set(instanceId, true)
|
next.fetchingSessions.set(instanceId, true)
|
||||||
@@ -70,7 +79,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log.info("session.list", { instanceId })
|
log.info("session.list", { instanceId })
|
||||||
const response = await instance.client.session.list()
|
const response = await rootClient.session.list()
|
||||||
|
|
||||||
const sessionMap = new Map<string, Session>()
|
const sessionMap = new Map<string, Session>()
|
||||||
|
|
||||||
@@ -80,7 +89,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
|
|
||||||
let statusById: Record<string, any> = {}
|
let statusById: Record<string, any> = {}
|
||||||
try {
|
try {
|
||||||
const statusResponse = await instance.client.session.status()
|
const statusResponse = await rootClient.session.status()
|
||||||
if (statusResponse.data && typeof statusResponse.data === "object") {
|
if (statusResponse.data && typeof statusResponse.data === "object") {
|
||||||
statusById = statusResponse.data as Record<string, any>
|
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")
|
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 instanceAgents = agents().get(instanceId) || []
|
||||||
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
||||||
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
||||||
@@ -189,7 +204,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
|
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) {
|
if (!response.data) {
|
||||||
throw new Error("Failed to create session: No data returned")
|
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)
|
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
|
return session
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to create session:", error)
|
log.error("Failed to create session:", error)
|
||||||
@@ -283,6 +303,9 @@ async function forkSession(
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
const request: { sessionID: string; messageID?: string } = {
|
const request: { sessionID: string; messageID?: string } = {
|
||||||
sessionID: sourceSessionId,
|
sessionID: sourceSessionId,
|
||||||
messageID: options?.messageId,
|
messageID: options?.messageId,
|
||||||
@@ -290,7 +313,7 @@ async function forkSession(
|
|||||||
|
|
||||||
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||||
const info = await requestData<SessionForkResponse>(
|
const info = await requestData<SessionForkResponse>(
|
||||||
instance.client.session.fork(request),
|
client.session.fork(request),
|
||||||
"session.fork",
|
"session.fork",
|
||||||
)
|
)
|
||||||
const forkedSession = {
|
const forkedSession = {
|
||||||
@@ -362,6 +385,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
|||||||
throw new Error("Instance not ready")
|
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) => {
|
setLoading((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
const deleting = next.deletingSession.get(instanceId) || new Set()
|
const deleting = next.deletingSession.get(instanceId) || new Set()
|
||||||
@@ -372,7 +400,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
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) => {
|
setSessions((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
@@ -416,6 +444,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up mapping for deleted parent sessions.
|
||||||
|
if (deletingSession?.parentId === null) {
|
||||||
|
await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to delete session:", error)
|
log.error("Failed to delete session:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -437,9 +470,11 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootClient = getRootClient(instanceId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
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) => ({
|
const agentList = (response.data ?? []).map((agent) => ({
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
description: agent.description || "",
|
description: agent.description || "",
|
||||||
@@ -468,9 +503,11 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
|||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootClient = getRootClient(instanceId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
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
|
if (!response.data) return
|
||||||
|
|
||||||
const providerList = response.data.providers.map((provider) => ({
|
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")
|
throw new Error("Instance not ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
const session = instanceSessions?.get(sessionId)
|
const session = instanceSessions?.get(sessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -541,7 +581,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
try {
|
try {
|
||||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||||
const apiMessages = await requestData<any[]>(
|
const apiMessages = await requestData<any[]>(
|
||||||
instance.client.session.messages({ sessionID: sessionId }),
|
client.session.messages({ sessionID: sessionId }),
|
||||||
"session.messages",
|
"session.messages",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { updateSessionInfo } from "./message-v2/session-info"
|
|||||||
import { tGlobal } from "../lib/i18n"
|
import { tGlobal } from "../lib/i18n"
|
||||||
|
|
||||||
import { loadMessages } from "./session-api"
|
import { loadMessages } from "./session-api"
|
||||||
|
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
||||||
import {
|
import {
|
||||||
applyPartUpdateV2,
|
applyPartUpdateV2,
|
||||||
replaceMessageIdV2,
|
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)
|
const instance = instances().get(instanceId)
|
||||||
if (!instance?.client) return null
|
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 {
|
try {
|
||||||
const info = await requestData<any>(
|
const info = await requestData<any>(
|
||||||
instance.client.session.get({ sessionID: sessionId }),
|
client.session.get({ sessionID: sessionId }),
|
||||||
"session.get",
|
"session.get",
|
||||||
)
|
)
|
||||||
|
|
||||||
let fetchedStatus: SessionStatus = "idle"
|
let fetchedStatus: SessionStatus = "idle"
|
||||||
try {
|
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 rawStatus = (info as any)?.status ?? statuses?.[sessionId]
|
||||||
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
||||||
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
|
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 instanceSessions = sessions().get(instanceId)
|
||||||
const existing = instanceSessions?.get(sessionId)
|
const existing = instanceSessions?.get(sessionId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -149,7 +165,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pending = (async () => {
|
const pending = (async () => {
|
||||||
const fetched = await fetchSessionInfo(instanceId, sessionId)
|
const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
|
||||||
if (!fetched) return
|
if (!fetched) return
|
||||||
applySessionStatus(instanceId, sessionId, status)
|
applySessionStatus(instanceId, sessionId, status)
|
||||||
})()
|
})()
|
||||||
@@ -197,7 +213,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
||||||
if (!sessionId || !messageId) return
|
if (!sessionId || !messageId) return
|
||||||
if (part.type === "compaction") {
|
if (part.type === "compaction") {
|
||||||
ensureSessionStatus(instanceId, sessionId, "compacting")
|
ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
@@ -381,7 +397,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
|||||||
const sessionId = event.properties?.sessionID
|
const sessionId = event.properties?.sessionID
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
ensureSessionStatus(instanceId, sessionId, "idle")
|
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
|
||||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +406,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
|
|||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
const status = mapSdkSessionStatus(event.properties.status)
|
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 })
|
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +422,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
|||||||
session.status = "working"
|
session.status = "working"
|
||||||
})
|
})
|
||||||
} else {
|
} 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))
|
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 { showConfirmDialog } from "./alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { requestData } from "../lib/opencode-api"
|
import { requestData } from "../lib/opencode-api"
|
||||||
|
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||||
import { tGlobal } from "../lib/i18n"
|
import { tGlobal } from "../lib/i18n"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
@@ -602,12 +603,14 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
|||||||
return isFreshSession
|
return isFreshSession
|
||||||
}
|
}
|
||||||
let messages: any[] = []
|
let messages: any[] = []
|
||||||
try {
|
try {
|
||||||
messages = await requestData<any[]>(
|
const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id)
|
||||||
instance.client.session.messages({ sessionID: session.id }),
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
"session.messages",
|
messages = await requestData<any[]>(
|
||||||
)
|
client.session.messages({ sessionID: session.id }),
|
||||||
} catch (error) {
|
"session.messages",
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
||||||
return isFreshSession
|
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