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`
This commit is contained in:
Shantur Rathore 2026-06-01 10:16:17 +01:00 committed by GitHub
parent b2855f4312
commit 873235ee1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 265 additions and 53 deletions

13
package-lock.json generated
View file

@ -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",

View file

@ -28,6 +28,7 @@ async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<voi
}
const entries = [
".codenomad/background_processes/",
".codenomad/worktrees/",
".codenomad/worktreeMap.json",
]
@ -108,6 +109,11 @@ export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMa
await ensureGitExclude(repoRoot, logger).catch(() => 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<void> {
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))

View file

@ -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",

View file

@ -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<FileDiff[]>(
const diffs = await requestData<SnapshotFileDiff[]>(
client.session.diff({ sessionID: sessionId }),
"session.diff",
)
@ -105,6 +107,7 @@ interface SessionForkResponse {
providerID?: string
modelID?: string
}
metadata?: Record<string, unknown>
time?: {
created?: number
updated?: number
@ -189,6 +192,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
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<void> {
.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<Sessio
time: {
...response.data.time,
},
metadata: response.data.metadata,
revert: response.data.revert
? {
messageID: response.data.revert.messageID,
@ -439,7 +451,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
}
// Persist mapping for this *parent* session (best-effort).
await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug).catch((error) => {
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<voi
// Clean up mapping for deleted parent sessions.
if (deletingSession?.parentId === null) {
await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined)
await removeLegacyParentSessionMapping(instanceId, sessionId).catch(() => undefined)
}
} catch (error) {
log.error("Failed to delete session:", error)

View file

@ -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
? {

View file

@ -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<string, unknown>
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<MetadataRecord> {
const latest = await requestData<any>(client.session.get({ sessionID: sessionId }), "session.get")
const nextMetadata = updater(normalizeMetadata(latest?.metadata))
const updated = await requestData<any>(
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<CodeNomadSessionMetadata> {
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<void> {
await updateCodeNomadSessionMetadataWithClient(client, instanceId, sessionId, (metadata) => ({
...metadata,
worktreeSlug,
}))
}

View file

@ -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<Map<s
const worktreeLoads = new Map<string, Promise<void>>()
const mapLoads = new Map<string, Promise<void>>()
const mapMigrations = new Map<string, Promise<void>>()
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<void> {
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<void> {
if (!instanceId) return
if (worktreeMapByInstance().has(instanceId)) return
@ -146,6 +167,9 @@ async function ensureWorktreeMapLoaded(instanceId: string): Promise<void> {
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<boolean> {
}
function getDefaultWorktreeSlug(instanceId: string): string {
return normalizeWorktreeSlug(instanceId, getWorktreeMap(instanceId).defaultWorktreeSlug || "root")
}
async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise<void> {
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<void> {
async function setWorktreeSlugForParentSession(
instanceId: string,
parentSessionId: string,
slug: string,
options: { currentSlug?: string } = {},
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,

View file

@ -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<string, unknown> // Session metadata persisted by OpenCode
diff?: SnapshotFileDiff[] // Session-level file diffs (hydrated via session.diff)
}
// Adapter function to convert SDK Session to client Session