fix(session): keep workspace history scoped during local reconnects

Use the active workspace root instead of stale engine directories when reloading local sessions so workspace creation, switching, and restart flows do not hide another workspace's history. Add a regression check for stale session loads overwriting sidebar and route state.
This commit is contained in:
Benjamin Shafii
2026-03-23 22:12:01 -07:00
parent b3278b107e
commit 7e4e8ec153
7 changed files with 245 additions and 28 deletions

View File

@@ -17,6 +17,7 @@
"test:events": "node scripts/events.mjs",
"test:todos": "node scripts/todos.mjs",
"test:permissions": "node scripts/permissions.mjs",
"test:session-scope": "bun scripts/session-scope.ts",
"test:session-switch": "node scripts/session-switch.mjs",
"test:fs-engine": "node scripts/fs-engine.mjs",
"test:local-file-path": "node scripts/local-file-path.mjs",

View File

@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
},
});
const {
resolveScopedClientDirectory,
scopedRootsMatch,
shouldApplyScopedSessionLoad,
shouldRedirectMissingSessionAfterScopedLoad,
} = await import("../src/app/lib/session-scope.ts");
const starterRoot = "/Users/test/OpenWork/starter";
const otherRoot = "/Users/test/OpenWork/second";
const results = {
ok: true,
steps: [] as Array<Record<string, unknown>>,
};
async function step(name: string, fn: () => void | Promise<void>) {
results.steps.push({ name, status: "running" });
const index = results.steps.length - 1;
try {
await fn();
results.steps[index] = { name, status: "ok" };
} catch (error) {
results.ok = false;
results.steps[index] = {
name,
status: "error",
error: error instanceof Error ? error.message : String(error),
};
throw error;
}
}
try {
await step("local connect prefers explicit target root", () => {
assert.equal(
resolveScopedClientDirectory({ workspaceType: "local", targetRoot: starterRoot }),
starterRoot,
);
assert.equal(
resolveScopedClientDirectory({
workspaceType: "local",
directory: otherRoot,
targetRoot: starterRoot,
}),
otherRoot,
);
});
await step("remote connect still waits for remote discovery", () => {
assert.equal(resolveScopedClientDirectory({ workspaceType: "remote", targetRoot: starterRoot }), "");
});
await step("scope matching is stable on desktop-style paths", () => {
assert.equal(scopedRootsMatch(`${starterRoot}/`, starterRoot.toUpperCase()), true);
assert.equal(scopedRootsMatch(starterRoot, otherRoot), false);
});
await step("stale session loads cannot overwrite another workspace sidebar", () => {
for (let index = 0; index < 50; index += 1) {
assert.equal(
shouldApplyScopedSessionLoad({
loadedScopeRoot: otherRoot,
workspaceRoot: starterRoot,
}),
false,
);
}
});
await step("same-scope session loads still update the active workspace", () => {
assert.equal(
shouldApplyScopedSessionLoad({
loadedScopeRoot: `${starterRoot}/`,
workspaceRoot: starterRoot,
}),
true,
);
});
await step("route guard only redirects when the loaded scope matches", () => {
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: otherRoot,
workspaceRoot: starterRoot,
hasMatchingSession: false,
}),
false,
);
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: starterRoot,
workspaceRoot: starterRoot,
hasMatchingSession: false,
}),
true,
);
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: starterRoot,
workspaceRoot: starterRoot,
hasMatchingSession: true,
}),
false,
);
});
console.log(JSON.stringify(results, null, 2));
} catch (error) {
results.ok = false;
console.error(
JSON.stringify(
{
...results,
error: error instanceof Error ? error.message : String(error),
},
null,
2,
),
);
process.exitCode = 1;
}

View File

@@ -161,6 +161,10 @@ import {
normalizeModelBehaviorValue,
sanitizeModelBehaviorValue,
} from "./lib/model-behavior";
import {
shouldApplyScopedSessionLoad,
shouldRedirectMissingSessionAfterScopedLoad,
} from "./lib/session-scope";
const fileToDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
@@ -1499,6 +1503,7 @@ export default function App() {
const {
sessions,
loadedScopeRoot: loadedSessionScopeRoot,
sessionById,
sessionStatusById,
selectedSession,
@@ -3331,6 +3336,21 @@ export default function App() {
? activeWorkspace.path
: activeWorkspace?.directory ?? activeWorkspace?.path,
);
if (
!shouldApplyScopedSessionLoad({
loadedScopeRoot: loadedSessionScopeRoot(),
workspaceRoot: activeWorkspaceRoot,
})
) {
if (developerMode()) {
console.log("[sidebar-sync] skip stale session scope", {
wsId,
loadedScopeRoot: loadedSessionScopeRoot(),
activeWorkspaceRoot,
});
}
return;
}
const scopedSessions = activeWorkspaceRoot
? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot)
: allSessions;
@@ -7774,7 +7794,14 @@ export default function App() {
// If the URL points at a session that no longer exists (e.g. after deletion),
// route back to /session so the app can fall back safely.
if (sessionsLoaded() && !sessions().some((session) => session.id === id)) {
if (
sessionsLoaded() &&
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: loadedSessionScopeRoot(),
workspaceRoot: workspaceStore.activeWorkspaceRoot().trim(),
hasMatchingSession: sessions().some((session) => session.id === id),
})
) {
if (selectedSessionId() === id) {
setSelectedSessionId(null);
}

View File

@@ -203,6 +203,7 @@ export function createSessionStore(options: {
const [messageLimitBySession, setMessageLimitBySession] = createSignal<Record<string, number>>({});
const [messageCompleteBySession, setMessageCompleteBySession] = createSignal<Record<string, boolean>>({});
const [messageLoadBusyBySession, setMessageLoadBusyBySession] = createSignal<Record<string, boolean>>({});
const [loadedScopeRoot, setLoadedScopeRoot] = createSignal("");
const reloadDetectionSet = new Set<string>();
const invalidToolDetectionSet = new Set<string>();
const syntheticContinueEventTimesBySession = new Map<string, number[]>();
@@ -819,6 +820,7 @@ export function createSessionStore(options: {
})),
});
sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length });
setLoadedScopeRoot(root);
rememberSessions(filtered);
setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" }));
}
@@ -1727,6 +1729,7 @@ export function createSessionStore(options: {
return {
sessions,
loadedScopeRoot,
sessionById,
sessionErrorTurnsById: (sessionID: string | null) => (sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []),
selectedSessionErrorTurns: createMemo(() => {

View File

@@ -21,6 +21,7 @@ import {
writeStartupPreference,
} from "../utils";
import { unwrap } from "../lib/opencode";
import { resolveScopedClientDirectory } from "../lib/session-scope";
import {
buildOpenworkWorkspaceBaseUrl,
createOpenworkServerClient,
@@ -762,12 +763,13 @@ export function createWorkspaceStore(options: {
) {
const now = Date.now();
if (now - lastEngineReconnectAt > 10_000) {
const reconnectRoot = activeWorkspacePath().trim() || info.projectDir?.trim() || "";
lastEngineReconnectAt = now;
reconnectingEngine = true;
connectToServer(
info.baseUrl,
info.projectDir ?? undefined,
{ reason: "engine-refresh" },
reconnectRoot || undefined,
{ workspaceType: "local", targetRoot: reconnectRoot, reason: "engine-refresh" },
auth ?? undefined,
{ quiet: true, navigate: false },
)
@@ -1174,8 +1176,8 @@ export function createWorkspaceStore(options: {
if (nextInfo.baseUrl) {
connectedToLocalHost = await connectToServer(
nextInfo.baseUrl,
nextInfo.projectDir ?? undefined,
{ reason: "workspace-attach-local" },
next.path,
{ workspaceType: "local", targetRoot: next.path, reason: "workspace-attach-local" },
auth,
{ navigate: false },
);
@@ -1232,8 +1234,8 @@ export function createWorkspaceStore(options: {
if (newInfo.baseUrl) {
const ok = await connectToServer(
newInfo.baseUrl,
newInfo.projectDir ?? undefined,
{ reason: "workspace-orchestrator-switch" },
next.path,
{ workspaceType: "local", targetRoot: next.path, reason: "workspace-orchestrator-switch" },
auth,
{ navigate: false },
);
@@ -1267,8 +1269,8 @@ export function createWorkspaceStore(options: {
if (newInfo.baseUrl) {
const ok = await connectToServer(
newInfo.baseUrl,
newInfo.projectDir ?? undefined,
{ reason: "workspace-restart" },
next.path,
{ workspaceType: "local", targetRoot: next.path, reason: "workspace-restart" },
auth,
{ navigate: false },
);
@@ -1363,7 +1365,11 @@ export function createWorkspaceStore(options: {
const connectMetrics: NonNullable<OpencodeConnectStatus["metrics"]> = {};
try {
let resolvedDirectory = directory?.trim() ?? "";
let resolvedDirectory = resolveScopedClientDirectory({
directory,
targetRoot: context?.targetRoot,
workspaceType: context?.workspaceType ?? "local",
});
let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined, auth);
const healthTimeoutMs = resolveConnectHealthTimeoutMs(context?.reason);
const health = await waitForHealthy(nextClient, { timeoutMs: healthTimeoutMs });
@@ -2855,16 +2861,16 @@ export function createWorkspaceStore(options: {
const auth = username && password ? { username, password } : undefined;
setEngineAuth(auth ?? null);
if (info.baseUrl) {
const ok = await connectToServer(
info.baseUrl,
info.projectDir ?? undefined,
{ reason: "host-start" },
auth,
{ navigate: optionsOverride?.navigate ?? true },
);
if (!ok) return false;
}
if (info.baseUrl) {
const ok = await connectToServer(
info.baseUrl,
dir,
{ workspaceType: "local", targetRoot: dir, reason: "host-start" },
auth,
{ navigate: optionsOverride?.navigate ?? true },
);
if (!ok) return false;
}
markOnboardingComplete();
return true;
@@ -3022,8 +3028,8 @@ export function createWorkspaceStore(options: {
if (nextInfo.baseUrl) {
const ok = await connectToServer(
nextInfo.baseUrl,
nextInfo.projectDir ?? undefined,
{ reason: "engine-reload-orchestrator" },
root,
{ workspaceType: "local", targetRoot: root, reason: "engine-reload-orchestrator" },
auth,
);
if (!ok) {
@@ -3057,8 +3063,8 @@ export function createWorkspaceStore(options: {
if (nextInfo.baseUrl) {
const ok = await connectToServer(
nextInfo.baseUrl,
nextInfo.projectDir ?? undefined,
{ reason: "engine-reload" },
root,
{ workspaceType: "local", targetRoot: root, reason: "engine-reload" },
auth,
);
if (!ok) {
@@ -3343,11 +3349,12 @@ export function createWorkspaceStore(options: {
options.setStartupPreference("local");
if (info?.running && info.baseUrl) {
const bootstrapRoot = activeWorkspacePath().trim() || info.projectDir?.trim() || "";
options.setOnboardingStep("connecting");
const ok = await connectToServer(
info.baseUrl,
info.projectDir ?? undefined,
{ reason: "bootstrap-local" },
bootstrapRoot || undefined,
{ workspaceType: "local", targetRoot: bootstrapRoot, reason: "bootstrap-local" },
engineAuth() ?? undefined,
);
if (!ok) {
@@ -3417,10 +3424,11 @@ export function createWorkspaceStore(options: {
async function onAttachHost() {
options.setStartupPreference("local");
options.setOnboardingStep("connecting");
const attachRoot = activeWorkspacePath().trim() || engine()?.projectDir?.trim() || "";
const ok = await connectToServer(
engine()?.baseUrl ?? "",
engine()?.projectDir ?? undefined,
{ reason: "attach-local" },
attachRoot || undefined,
{ workspaceType: "local", targetRoot: attachRoot, reason: "attach-local" },
engineAuth() ?? undefined,
);
if (!ok) {

View File

@@ -0,0 +1,45 @@
import { normalizeDirectoryPath } from "../utils";
type WorkspaceType = "local" | "remote";
export function resolveScopedClientDirectory(input: {
directory?: string | null;
targetRoot?: string | null;
workspaceType?: WorkspaceType | null;
}) {
const directory = input.directory?.trim() ?? "";
if (directory) return directory;
if (input.workspaceType === "remote") return "";
return input.targetRoot?.trim() ?? "";
}
export function scopedRootsMatch(a?: string | null, b?: string | null) {
const left = normalizeDirectoryPath(a ?? "");
const right = normalizeDirectoryPath(b ?? "");
if (!left || !right) return false;
return left === right;
}
export function shouldApplyScopedSessionLoad(input: {
loadedScopeRoot?: string | null;
workspaceRoot?: string | null;
}) {
const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? "");
if (!workspaceRoot) return true;
return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot);
}
export function shouldRedirectMissingSessionAfterScopedLoad(input: {
loadedScopeRoot?: string | null;
workspaceRoot?: string | null;
hasMatchingSession: boolean;
}) {
if (input.hasMatchingSession) return false;
const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? "");
if (!workspaceRoot) return false;
return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot);
}

View File

@@ -22,6 +22,7 @@
"test:events": "pnpm --filter @openwork/app test:events",
"test:todos": "pnpm --filter @openwork/app test:todos",
"test:permissions": "pnpm --filter @openwork/app test:permissions",
"test:session-scope": "pnpm --filter @openwork/app test:session-scope",
"test:session-switch": "pnpm --filter @openwork/app test:session-switch",
"test:fs-engine": "pnpm --filter @openwork/app test:fs-engine",
"test:e2e": "pnpm --filter @openwork/app test:e2e",