mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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",
|
||||
|
||||
132
apps/app/scripts/session-scope.ts
Normal file
132
apps/app/scripts/session-scope.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
45
apps/app/src/app/lib/session-scope.ts
Normal file
45
apps/app/src/app/lib/session-scope.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user