fix(worktree): stop writing worktree registration into OpenCode's storage
Creating a worktree wrote the new directory straight into OpenCode's own project storage: the web server updated `storage/project/<id>.json` and ran an `UPDATE project SET sandboxes` against `opencode.db` through better-sqlite3, and the VS Code extension wrote the same JSON. Both wrote behind the back of a running OpenCode process. OpenCode registers a sandbox through `project.addSandbox`, which emits a project-updated event; a direct row write emits nothing, so a worktree created while OpenCode was running stayed unknown to it until a restart. The SQLite write also opened a database file owned by another live process. The VS Code write was inert on top of that: OpenCode v2 reads sandboxes from the database, not from that JSON. Registration is not ours to perform. OpenCode records a worktree as a sandbox itself when an instance boots for that directory, and filters entries whose directory no longer exists when reading them back, so removal needs no counterpart either. The only consumer on our side, the project seed in sync/bootstrap.ts, already falls back to `project.current()` when the seed is absent; the worktree list itself comes from git, not from sandboxes. Reported symptom this targets: a worktree created after `openchamber restart` never answers prompts, and restarting OpenChamber makes it work. Not reproduced locally, so this is not confirmed as the cause.
This commit is contained in:
@@ -1385,74 +1385,11 @@ const loadProjectStartCommand = async (projectID: string): Promise<string> => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectStoragePath = (projectID: string) => {
|
||||
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
|
||||
};
|
||||
|
||||
const updateProjectSandboxes = async (
|
||||
projectID: string,
|
||||
primaryWorktree: string,
|
||||
updater: (project: {
|
||||
id: string;
|
||||
worktree: string;
|
||||
vcs: string;
|
||||
sandboxes: string[];
|
||||
time: { created: number; updated: number };
|
||||
}) => void
|
||||
) => {
|
||||
const storagePath = getProjectStoragePath(projectID);
|
||||
await fs.promises.mkdir(path.dirname(storagePath), { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const base = {
|
||||
id: projectID,
|
||||
worktree: primaryWorktree,
|
||||
vcs: 'git',
|
||||
sandboxes: [] as string[],
|
||||
time: { created: now, updated: now },
|
||||
};
|
||||
|
||||
const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null);
|
||||
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
|
||||
current.id = String(current.id || projectID);
|
||||
current.worktree = String(current.worktree || primaryWorktree);
|
||||
current.vcs = current.vcs || 'git';
|
||||
current.sandboxes = Array.isArray(current.sandboxes)
|
||||
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const createdAt = Number(current?.time?.created);
|
||||
current.time = {
|
||||
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
updater(current);
|
||||
|
||||
current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))];
|
||||
await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
if (!project.sandboxes.includes(sandbox)) {
|
||||
project.sandboxes.push(sandbox);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
|
||||
});
|
||||
};
|
||||
// OpenCode owns its own project/sandbox registry and records a worktree as a
|
||||
// sandbox itself when an instance boots for that directory. OpenChamber used to
|
||||
// write that state into OpenCode's storage JSON directly, behind the back of the
|
||||
// running process — and since OpenCode v2 reads sandboxes from its database, the
|
||||
// JSON write did not even reach it. Registration is not ours to perform.
|
||||
|
||||
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
|
||||
const relative = path.relative(root, target);
|
||||
@@ -1477,14 +1414,6 @@ const cleanupFailedFastWorktreeCreate = async (
|
||||
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
|
||||
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
|
||||
|
||||
if (!isAttached) {
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInsideWorktreeRoot || isAttached) {
|
||||
return;
|
||||
}
|
||||
@@ -1963,12 +1892,6 @@ async function attachGitWorktreeToCandidate(
|
||||
|
||||
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const shouldSetUpstream = Boolean(input?.setUpstream);
|
||||
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
||||
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
||||
@@ -2033,12 +1956,6 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
if (input?.returnAfterDirectoryCreated === true) {
|
||||
await fs.promises.mkdir(candidate.directory, { recursive: false });
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
@@ -2129,12 +2046,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(targetDirectory);
|
||||
|
||||
return true;
|
||||
@@ -2157,12 +2068,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(matchedEntry.worktree);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1644,94 +1644,13 @@ const loadProjectStartCommand = async (projectID) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectStoragePath = (projectID) => {
|
||||
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
|
||||
};
|
||||
|
||||
const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
|
||||
try {
|
||||
const Database = require('better-sqlite3');
|
||||
const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
|
||||
if (!fs.existsSync(dbPath)) return;
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
|
||||
if (!row) return;
|
||||
const json = JSON.stringify(sandboxes);
|
||||
db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
|
||||
const storagePath = getProjectStoragePath(projectID);
|
||||
await fsp.mkdir(path.dirname(storagePath), { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const base = {
|
||||
id: projectID,
|
||||
worktree: primaryWorktree,
|
||||
vcs: 'git',
|
||||
sandboxes: [],
|
||||
time: {
|
||||
created: now,
|
||||
updated: now,
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
|
||||
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
|
||||
current.id = String(current.id || projectID);
|
||||
current.worktree = String(current.worktree || primaryWorktree);
|
||||
current.vcs = current.vcs || 'git';
|
||||
current.sandboxes = Array.isArray(current.sandboxes)
|
||||
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const createdAt = Number(current?.time?.created);
|
||||
current.time = {
|
||||
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
updater(current);
|
||||
|
||||
current.sandboxes = [...new Set(
|
||||
(Array.isArray(current.sandboxes) ? current.sandboxes : [])
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
|
||||
|
||||
// Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK
|
||||
syncSandboxesToOpenCodeDb(projectID, current.sandboxes);
|
||||
};
|
||||
|
||||
const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
if (!project.sandboxes.includes(sandbox)) {
|
||||
project.sandboxes.push(sandbox);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
|
||||
});
|
||||
};
|
||||
// OpenCode owns its own project/sandbox registry. It records a worktree as a
|
||||
// sandbox itself when an instance boots for that directory, and filters entries
|
||||
// whose directory no longer exists when reading them back. OpenChamber used to
|
||||
// write that state directly into OpenCode's storage JSON and SQLite database,
|
||||
// behind the back of the running process: the row changed but the server was
|
||||
// never told, so a worktree created while OpenCode was running stayed unknown
|
||||
// to it until a restart. Registration is not ours to perform.
|
||||
|
||||
const isAttachedGitWorktreeDirectory = async (directory) => {
|
||||
try {
|
||||
@@ -1748,14 +1667,6 @@ const cleanupFailedFastWorktreeCreate = async (context, candidate) => {
|
||||
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
|
||||
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
|
||||
|
||||
if (!isAttached) {
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInsideWorktreeRoot || isAttached) {
|
||||
return;
|
||||
}
|
||||
@@ -3940,12 +3851,6 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
||||
|
||||
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const shouldSetUpstream = Boolean(input?.setUpstream);
|
||||
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
||||
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
||||
@@ -4005,12 +3910,6 @@ export async function createWorktree(directory, input = {}) {
|
||||
if (input?.returnAfterDirectoryCreated === true) {
|
||||
await fsp.mkdir(candidate.directory, { recursive: false });
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
@@ -4103,12 +4002,6 @@ export async function removeWorktree(directory, input = {}) {
|
||||
await fsp.rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(targetDirectory);
|
||||
|
||||
return true;
|
||||
@@ -4131,12 +4024,6 @@ export async function removeWorktree(directory, input = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(matchedEntry.worktree);
|
||||
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user