From 873235ee1e23754444425202b7ef97bb9b4bc7aa Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 1 Jun 2026 10:16:17 +0100 Subject: [PATCH] Migrate worktree mappings to session metadata (#514) ## Summary - Upgrade `@opencode-ai/sdk` to `1.15.13` and adapt UI types for the current session/diff API surface. - Add a session metadata store helper that safely updates OpenCode session metadata via read-merge-full-replace while preserving non-CodeNomad metadata. - Migrate CodeNomad worktree assignments from `.codenomad/worktreeMap.json` into `metadata.codenomad.worktreeSlug`, with legacy fallback, stale-entry pruning, and server-side deletion of the legacy map once empty. - Add `.codenomad/background_processes/` to CodeNomad-managed `.git/info/exclude` entries. ## Details - New sessions and worktree reassignment now persist explicit session worktree assignments in OpenCode session metadata under: `metadata.codenomad.worktreeSlug` - Worktree resolution now prefers session metadata, then falls back to legacy `parentSessionWorktreeSlug`, then `root`. - Legacy migration runs after session/map loading; after the full session list is known, missing legacy session IDs are treated as stale and pruned. - The UI no longer uses `defaultWorktreeSlug`; the fallback is `root`. - `writeWorktreeMap` deletes `.codenomad/worktreeMap.json` server-side when no legacy parent-session mappings remain. ## Validation - `npm run typecheck --workspace @codenomad/ui` - `npm run typecheck --workspace @neuralnomads/codenomad` --- package-lock.json | 13 +- .../server/src/workspaces/worktree-map.ts | 17 +++ packages/ui/package.json | 2 +- packages/ui/src/stores/session-api.ts | 37 +++-- packages/ui/src/stores/session-events.ts | 2 + packages/ui/src/stores/session-metadata.ts | 107 ++++++++++++++ packages/ui/src/stores/worktrees.ts | 135 +++++++++++++----- packages/ui/src/types/session.ts | 5 +- 8 files changed, 265 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/stores/session-metadata.ts diff --git a/package-lock.json b/package-lock.json index 5341e629..44d405bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3266,10 +3266,13 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz", - "integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==", - "license": "MIT" + "version": "1.15.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.13.tgz", + "integrity": "sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } }, "node_modules/@pinojs/redact": { "version": "0.4.0", @@ -13500,7 +13503,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.2.6", + "@opencode-ai/sdk": "1.15.13", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/server/src/workspaces/worktree-map.ts b/packages/server/src/workspaces/worktree-map.ts index 07a289f0..b54f0dc2 100644 --- a/packages/server/src/workspaces/worktree-map.ts +++ b/packages/server/src/workspaces/worktree-map.ts @@ -28,6 +28,7 @@ async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise undefined) } + if (Object.keys(next.parentSessionWorktreeSlug ?? {}).length === 0) { + await deleteWorktreeMap(workspaceFolder, logger) + return + } + const payload: WorktreeMap = { version: 1, defaultWorktreeSlug: next.defaultWorktreeSlug || "root", @@ -120,6 +126,17 @@ export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMa await fsp.rename(tmpPath, filePath) } +export async function deleteWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise { + const { repoRoot } = await resolveRepoRoot(workspaceFolder, logger) + const filePath = getMapPath(repoRoot) + try { + await fsp.rm(filePath, { force: true }) + } catch (error) { + logger?.warn?.({ err: error, filePath }, "Failed to delete worktree map") + throw error + } +} + export function worktreeMapExists(repoRoot: string): boolean { try { return fs.existsSync(getMapPath(repoRoot)) diff --git a/packages/ui/package.json b/packages/ui/package.json index bf7293ae..b7e92a16 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,7 +13,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.2.6", + "@opencode-ai/sdk": "1.15.13", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 36609380..7c9aad46 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -7,7 +7,7 @@ import { type SessionStatus, } from "../types/session" import type { Message } from "../types/message" -import type { FileDiff } from "@opencode-ai/sdk/v2/client" +import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2/client" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -46,7 +46,9 @@ import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForSession, - removeParentSessionMapping, + migrateLegacyWorktreeMapToSessionMetadata, + pruneStaleLegacyWorktreeMapEntries, + removeLegacyParentSessionMapping, setWorktreeSlugForParentSession, } from "./worktrees" @@ -74,7 +76,7 @@ async function loadSessionDiff(instanceId: string, sessionId: string, force = fa const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) try { - const diffs = await requestData( + const diffs = await requestData( client.session.diff({ sessionID: sessionId }), "session.diff", ) @@ -105,6 +107,7 @@ interface SessionForkResponse { providerID?: string modelID?: string } + metadata?: Record time?: { created?: number updated?: number @@ -189,6 +192,7 @@ async function fetchSessions(instanceId: string): Promise { time: { ...apiSession.time, }, + metadata: apiSession.metadata ?? existingSession?.metadata, revert: apiSession.revert ? { messageID: apiSession.revert.messageID, @@ -233,6 +237,12 @@ async function fetchSessions(instanceId: string): Promise { .map((session) => session.id) await Promise.all(parentIds.map((parentId) => fetchSessionChildren(instanceId, parentId))) + void (async () => { + await migrateLegacyWorktreeMapToSessionMetadata(instanceId) + await pruneStaleLegacyWorktreeMapEntries(instanceId) + })().catch((error) => { + log.warn("Failed to finish legacy worktree map migration", { instanceId, error }) + }) } catch (error) { log.error("Failed to fetch sessions:", error) throw error @@ -260,6 +270,7 @@ function toClientSession(instanceId: string, apiSession: any, existingSession?: time: { ...apiSession.time, }, + metadata: apiSession.metadata ?? existingSession?.metadata, revert: apiSession.revert ? { messageID: apiSession.revert.messageID, @@ -384,6 +395,7 @@ async function createSession(instanceId: string, agent?: string): Promise { + await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug, { currentSlug: worktreeSlug }).catch((error) => { log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error }) }) @@ -485,13 +497,14 @@ async function forkSession( title: info.title || "Forked Session", parentId: info.parentID || null, agent: info.agent || "", - model: { - providerId: info.model?.providerID || "", - modelId: info.model?.modelID || "", - }, - status: "idle", - idleSince: null, - version: "0", + model: { + providerId: info.model?.providerID || "", + modelId: info.model?.modelID || "", + }, + status: "idle", + idleSince: null, + version: "0", + metadata: info.metadata, time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() }, revert: info.revert ? { @@ -612,7 +625,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise undefined) + await removeLegacyParentSessionMapping(instanceId, sessionId).catch(() => undefined) } } catch (error) { log.error("Failed to delete session:", error) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index f9a8decc..7279b3f1 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -478,6 +478,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo retry: null, idleSince: null, version: info.version || "0", + metadata: (info as any).metadata, time: info.time ? { ...info.time } : { @@ -523,6 +524,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo parentId: info.parentID ?? existingSession.parentId, status: existingSession.status ?? "idle", retry: existingSession.retry ?? null, + metadata: (info as any).metadata ?? existingSession.metadata, time: mergedTime, revert: info.revert ? { diff --git a/packages/ui/src/stores/session-metadata.ts b/packages/ui/src/stores/session-metadata.ts new file mode 100644 index 00000000..729b8923 --- /dev/null +++ b/packages/ui/src/stores/session-metadata.ts @@ -0,0 +1,107 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { requestData } from "../lib/opencode-api" +import { sessions, withSession } from "./session-state" + +const CODENOMAD_METADATA_KEY = "codenomad" +const CODENOMAD_METADATA_VERSION = 1 + +export interface CodeNomadSessionMetadata { + version: 1 + worktreeSlug?: string +} + +type MetadataRecord = Record + +function isRecord(value: unknown): value is MetadataRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function normalizeMetadata(value: unknown): MetadataRecord { + return isRecord(value) ? { ...value } : {} +} + +function normalizeCodeNomadMetadata(value: unknown): CodeNomadSessionMetadata { + const source = isRecord(value) ? value : {} + const metadata: CodeNomadSessionMetadata = { version: CODENOMAD_METADATA_VERSION } + if (typeof source.worktreeSlug === "string" && source.worktreeSlug.trim()) { + metadata.worktreeSlug = source.worktreeSlug + } + return metadata +} + +function mergeCodeNomadMetadata( + metadata: MetadataRecord, + updater: (current: CodeNomadSessionMetadata) => CodeNomadSessionMetadata, +): MetadataRecord { + const currentCodeNomad = isRecord(metadata[CODENOMAD_METADATA_KEY]) + ? { ...(metadata[CODENOMAD_METADATA_KEY] as MetadataRecord) } + : {} + const nextCodeNomad = updater(normalizeCodeNomadMetadata(currentCodeNomad)) + const mergedCodeNomad: MetadataRecord = { + ...currentCodeNomad, + ...nextCodeNomad, + version: CODENOMAD_METADATA_VERSION, + } + + if (!nextCodeNomad.worktreeSlug) { + delete mergedCodeNomad.worktreeSlug + } + + return { + ...metadata, + [CODENOMAD_METADATA_KEY]: mergedCodeNomad, + } +} + +export function getSessionMetadata(instanceId: string, sessionId: string): MetadataRecord { + return normalizeMetadata(sessions().get(instanceId)?.get(sessionId)?.metadata) +} + +export function getCodeNomadSessionMetadata(instanceId: string, sessionId: string): CodeNomadSessionMetadata { + return normalizeCodeNomadMetadata(getSessionMetadata(instanceId, sessionId)[CODENOMAD_METADATA_KEY]) +} + +export async function updateSessionMetadataWithClient( + client: OpencodeClient, + instanceId: string, + sessionId: string, + updater: (metadata: MetadataRecord) => MetadataRecord, +): Promise { + const latest = await requestData(client.session.get({ sessionID: sessionId }), "session.get") + const nextMetadata = updater(normalizeMetadata(latest?.metadata)) + const updated = await requestData( + client.session.update({ sessionID: sessionId, metadata: nextMetadata }), + "session.update", + ) + const persistedMetadata = normalizeMetadata(updated?.metadata ?? nextMetadata) + + withSession(instanceId, sessionId, (session) => { + session.metadata = persistedMetadata + }) + + return persistedMetadata +} + +export async function updateCodeNomadSessionMetadataWithClient( + client: OpencodeClient, + instanceId: string, + sessionId: string, + updater: (metadata: CodeNomadSessionMetadata) => CodeNomadSessionMetadata, +): Promise { + const persisted = await updateSessionMetadataWithClient(client, instanceId, sessionId, (metadata) => + mergeCodeNomadMetadata(metadata, updater), + ) + return normalizeCodeNomadMetadata(persisted[CODENOMAD_METADATA_KEY]) +} + +export async function setSessionWorktreeSlugWithClient( + client: OpencodeClient, + instanceId: string, + sessionId: string, + worktreeSlug: string, +): Promise { + await updateCodeNomadSessionMetadataWithClient(client, instanceId, sessionId, (metadata) => ({ + ...metadata, + worktreeSlug, + })) +} diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 4337d32c..28c57ba8 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -4,6 +4,7 @@ import { serverApi } from "../lib/api-client" import { sdkManager, type OpencodeClient } from "../lib/sdk-manager" import { sessions } from "./session-state" import { getLogger } from "../lib/logger" +import { getCodeNomadSessionMetadata, setSessionWorktreeSlugWithClient } from "./session-metadata" const log = getLogger("api") @@ -13,6 +14,7 @@ const [gitRepoStatusByInstance, setGitRepoStatusByInstance] = createSignal>() const mapLoads = new Map>() +const mapMigrations = new Map>() function normalizeMap(input?: WorktreeMap | null): WorktreeMap { if (!input || typeof input !== "object") { @@ -124,9 +126,28 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc if (!trimmed || trimmed === "root") { throw new Error("Invalid worktree") } + await moveSessionsFromDeletedWorktree(instanceId, trimmed).catch((error) => { + log.warn("Failed to move sessions from deleted worktree", { instanceId, slug: trimmed, error }) + }) await serverApi.deleteWorktree(instanceId, trimmed, options) } +async function moveSessionsFromDeletedWorktree(instanceId: string, slug: string): Promise { + const instanceSessions = sessions().get(instanceId) + if (!instanceSessions) return + + const parentSessionIds = Array.from(instanceSessions.values()) + .filter((session) => !session.parentId) + .filter((session) => getWorktreeSlugForParentSession(instanceId, session.id) === slug) + .map((session) => session.id) + + for (const parentSessionId of parentSessionIds) { + const client = getOrCreateWorktreeClient(instanceId, slug) + await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, "root") + await removeLegacyParentSessionMapping(instanceId, parentSessionId) + } +} + async function ensureWorktreeMapLoaded(instanceId: string): Promise { if (!instanceId) return if (worktreeMapByInstance().has(instanceId)) return @@ -146,6 +167,9 @@ async function ensureWorktreeMapLoaded(instanceId: string): Promise { if (worktreesByInstance().has(instanceId)) { void pruneWorktreeMap(instanceId).catch(() => undefined) } + void migrateLegacyWorktreeMapToSessionMetadata(instanceId).catch((error) => { + log.warn("Failed to migrate legacy worktree map", { instanceId, error }) + }) }) .catch((error) => { log.warn("Failed to load worktree map", { instanceId, error }) @@ -245,23 +269,7 @@ async function pruneWorktreeMap(instanceId: string): Promise { } function getDefaultWorktreeSlug(instanceId: string): string { - return normalizeWorktreeSlug(instanceId, getWorktreeMap(instanceId).defaultWorktreeSlug || "root") -} - -async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise { - await ensureWorktreeMapLoaded(instanceId) - const current = getWorktreeMap(instanceId) - const nextSlug = normalizeWorktreeSlug(instanceId, slug) - const next: WorktreeMap = { ...current, defaultWorktreeSlug: nextSlug } - 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: nextSlug, error }) - }) + return normalizeWorktreeSlug(instanceId, "root") } function getParentSessionId(instanceId: string, sessionId: string): string { @@ -271,8 +279,13 @@ function getParentSessionId(instanceId: string, sessionId: string): string { } function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string { + const metadataSlug = getCodeNomadSessionMetadata(instanceId, parentSessionId).worktreeSlug + if (metadataSlug) { + return normalizeWorktreeSlug(instanceId, metadataSlug) + } + const map = getWorktreeMap(instanceId) - const candidate = map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root" + const candidate = map.parentSessionWorktreeSlug[parentSessionId] ?? "root" return normalizeWorktreeSlug(instanceId, candidate) } @@ -281,27 +294,33 @@ function getWorktreeSlugForSession(instanceId: string, sessionId: string): strin return getWorktreeSlugForParentSession(instanceId, parentId) } -async function setWorktreeSlugForParentSession(instanceId: string, parentSessionId: string, slug: string): Promise { +async function setWorktreeSlugForParentSession( + instanceId: string, + parentSessionId: string, + slug: string, + options: { currentSlug?: string } = {}, +): Promise { await ensureWorktreeMapLoaded(instanceId) const current = getWorktreeMap(instanceId) const normalizedSlug = normalizeWorktreeSlug(instanceId, slug) - const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) } - nextMapping[parentSessionId] = normalizedSlug - 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: normalizedSlug, error }) - }) + const currentSlug = options.currentSlug + ? normalizeWorktreeSlug(instanceId, options.currentSlug) + : getWorktreeSlugForParentSession(instanceId, parentSessionId) + const client = getOrCreateWorktreeClient(instanceId, currentSlug) + await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, normalizedSlug) + await removeLegacyParentSessionMapping(instanceId, parentSessionId, current) } async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise { await ensureWorktreeMapLoaded(instanceId) const current = getWorktreeMap(instanceId) + const currentSlug = getWorktreeSlugForParentSession(instanceId, parentSessionId) + const client = getOrCreateWorktreeClient(instanceId, currentSlug) + await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, "root") + await removeLegacyParentSessionMapping(instanceId, parentSessionId, current) +} + +async function removeLegacyParentSessionMapping(instanceId: string, parentSessionId: string, current = getWorktreeMap(instanceId)): Promise { if (!current.parentSessionWorktreeSlug[parentSessionId]) return const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) } delete nextMapping[parentSessionId] @@ -313,10 +332,58 @@ async function removeParentSessionMapping(instanceId: string, parentSessionId: s }) await serverApi.writeWorktreeMap(instanceId, next).catch((error) => { - log.warn("Failed to persist session worktree mapping removal", { instanceId, parentSessionId, error }) + log.warn("Failed to persist legacy worktree mapping removal", { instanceId, parentSessionId, error }) }) } +async function migrateLegacyWorktreeMapToSessionMetadata(instanceId: string): Promise { + if (!instanceId) return + const existing = mapMigrations.get(instanceId) + if (existing) return existing + + const task = (async () => { + const map = getWorktreeMap(instanceId) + const entries = Object.entries(map.parentSessionWorktreeSlug ?? {}) + if (entries.length === 0) return + + for (const [parentSessionId, legacySlug] of entries) { + const parentSession = sessions().get(instanceId)?.get(parentSessionId) + if (!parentSession) continue + if (getCodeNomadSessionMetadata(instanceId, parentSessionId).worktreeSlug) { + await removeLegacyParentSessionMapping(instanceId, parentSessionId) + continue + } + + const normalizedSlug = normalizeWorktreeSlug(instanceId, legacySlug || "root") + const client = getOrCreateWorktreeClient(instanceId, normalizedSlug) + await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, normalizedSlug) + await removeLegacyParentSessionMapping(instanceId, parentSessionId) + } + })().finally(() => { + mapMigrations.delete(instanceId) + }) + + mapMigrations.set(instanceId, task) + return task +} + +async function pruneStaleLegacyWorktreeMapEntries(instanceId: string): Promise { + if (!instanceId) return + const migration = mapMigrations.get(instanceId) + if (migration) await migration.catch(() => undefined) + + const map = getWorktreeMap(instanceId) + const entries = Object.entries(map.parentSessionWorktreeSlug ?? {}) + if (entries.length === 0) return + + const instanceSessions = sessions().get(instanceId) ?? new Map() + for (const [parentSessionId] of entries) { + if (!instanceSessions.has(parentSessionId)) { + await removeLegacyParentSessionMapping(instanceId, parentSessionId) + } + } +} + function getWorktreeSlugForDirectory(instanceId: string, directory: string | undefined): string | null { if (!directory) return null const list = getWorktrees(instanceId) @@ -377,12 +444,14 @@ export { getWorktrees, getWorktreeMap, getDefaultWorktreeSlug, - setDefaultWorktreeSlug, getParentSessionId, getWorktreeSlugForParentSession, getWorktreeSlugForSession, setWorktreeSlugForParentSession, removeParentSessionMapping, + migrateLegacyWorktreeMapToSessionMetadata, + pruneStaleLegacyWorktreeMapEntries, + removeLegacyParentSessionMapping, getWorktreeSlugForDirectory, buildWorktreeProxyPath, buildWorktreeProxyPathWithDirectoryOverride, diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 58cfa1e9..be7dea9b 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -4,7 +4,7 @@ import type { Provider as SDKProvider, Model as SDKModel, } from "@opencode-ai/sdk" -import type { SessionStatus as SDKSessionStatus, FileDiff } from "@opencode-ai/sdk/v2/client" +import type { SessionStatus as SDKSessionStatus, SnapshotFileDiff } from "@opencode-ai/sdk/v2/client" // Export SDK types for external use export type { @@ -76,7 +76,8 @@ export interface Session status: SessionStatus // Single source of truth for session status retry?: SessionRetryState | null // Retry metadata for transient backoff states idleSince?: number | null // Timestamp set when work finished but the session has not been viewed yet - diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff) + metadata?: Record // Session metadata persisted by OpenCode + diff?: SnapshotFileDiff[] // Session-level file diffs (hydrated via session.diff) } // Adapter function to convert SDK Session to client Session