mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-01 21:18:11 +00:00
fix: prevent stale Files saves after workspace replacement (#178336)
Bind Files edits to the workspace and lookup roots from the original read. Refuse saves after a workspace change, preserve the draft, and refresh the listing so a fresh reopen can save to the current workspace. Refs #178310.
This commit is contained in:
parent
fc374815c3
commit
a44dc77340
11 changed files with 636 additions and 44 deletions
|
|
@ -595,7 +595,7 @@ Run-error banners offer **Refresh** to reload the conversation without resending
|
|||
- Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal.
|
||||
- The **Files** tab in each Chat pane's unified side panel lists thread files, project files, and artifacts. Search at the top covers session files, artifacts, and the project tree; surrounding whitespace is ignored while spaces inside the query remain literal; filter chips show changed files, read files, or artifacts, and collapsible groups share one scroll region. **Show in Files** from Review clears search and filters so the destination project directory is visible. For an active repository-only session it reads the node checkout. After Stop it exposes retained changed-file previews; unchanged upstream files, editing, and full diffs require the worker to run again. The stopped diff panel explains this limitation. Reopen it with ⇧⌘B, **Show session files** in the chat header's **Layout** menu, or the panel's **+** menu.
|
||||
- File paths recognized in chat messages read as their basename with a small glyph for the file type in front — a Markdown page, a `package.json` manifest, a TypeScript source, a `.tsx` component, a config or data file, a shell script, and an image each get their own mark, and anything else falls back to a plain document. When two links in the same message share a basename, each keeps just enough of its trailing path to stay distinct. The full path stays on the link: it is what the tooltip shows, what opens in the file panel, and what the message's **Copy** action returns, since copy hands back the original Markdown. Hover or keyboard-focus a file link to copy its path with the tooltip's copy button. Clicking or tapping the filename still opens the file panel directly; touch does not require opening the tooltip first. Labels you write yourself in a `[label](path)` link are never rewritten. The glyph is drawn from the bundled icon set, never fetched from the network, and is decorative only: it is not read by screen readers and is not part of copied text. Text that is not a recognizable path — anything carrying spaces, parentheses, a `#` fragment, or a `?` query — stays plain prose.
|
||||
- Clicking a file reference in chat, a file path in an expanded read/edit/write tool card, or a file row in **Files** opens its own filename tab in the shared side-panel header. Reopening the same file from the same owning session selects its existing tab and rereads its content when there is no unsaved draft. Selecting a filename tab keeps its current preview; unsaved drafts are never replaced by a file reopen. The folder action returns to the file browser without closing previews. The last opened file stays highlighted in both session and project lists, including after refreshing the file list. A pending listing cannot clear a newer file selection or replace results and errors for a different folder or search. If the folder being browsed becomes unavailable, **Files** keeps its parent-folder action so you can continue browsing without reloading or changing sessions. Session file labels show the filename and enough parent folders to distinguish matching names; hovering or copying a path keeps the full path. Closing a filename tab closes only its preview, never the underlying file. Closing or replacing a file preview cancels a delayed copy fallback; an already issued native clipboard write may still finish. Open previews are scoped to the current pane, session, agent, and connection, and are not persisted across reconnects. A forwarded file retains its sending session as its read and edit target; identical paths from different sessions have separate tabs and drafts. HTML files open a sandboxed **Preview**, with **Source** in the same filename tab. Other UTF-8 text files use a CodeMirror-based code view with syntax highlighting, line numbers, jump-to-line, in-file search, copy actions, and an open-in-external-editor menu. The code view has a **Word wrap** toolbar toggle, including in HTML **Source** view. Wrapping starts off; the browser remembers your choice across files and reloads without changing file contents. Search follows the displayed line numbers for LF, CRLF, and CR line endings; editing preserves the original line endings, including when pasted text uses different line endings. Read-only previews, including files with mixed line endings, do not create unsaved drafts or block interface reloads. Escape closes in-file search and returns keyboard focus to **Search in file** in the toolbar. AVIF, GIF, JPEG, PNG, and WebP images no larger than 256 KiB render inline; other binary files show metadata without lossy text decoding. When the Gateway advertises `sessions.files.set` to an `operator.admin` connection, the text panel adds an Edit mode with dirty tracking and Cmd/Ctrl-S save; unsaved drafts survive file, panel, and session navigation in the current browser tab until explicitly saved or discarded. If a server update blocks editing and reloading, choose **Review file drafts** in the reload notification to copy or download the retained edits, then explicitly discard each resolved draft and try **Refresh** again. **Keep drafts** leaves them protected in this tab; recovery never writes files on the disconnected or updated Gateway. Saves are compare-and-swap on a content hash returned by `sessions.files.get`: if the file changed on disk since it was loaded (for example because the agent kept working), the panel shows a conflict notice with Reload (take the latest content) and Overwrite (keep the local edit) actions. Writes retain fs-safe workspace guards — path containment, symlink/hardlink rejection, and a 256 KiB UTF-8 cap — and only overwrite existing files; the editor never creates or deletes them. If the editor cannot load, use **Retry** or **View Raw Text**. A missing editor chunk after an update offers **Reload**, which waits for the Gateway to become reachable.
|
||||
- Clicking a file reference in chat, a file path in an expanded read/edit/write tool card, or a file row in **Files** opens its own filename tab in the shared side-panel header. Reopening the same file from the same owning session selects its existing tab and rereads its content when there is no unsaved draft. Selecting a filename tab keeps its current preview; unsaved drafts are never replaced by a file reopen. The folder action returns to the file browser without closing previews. The last opened file stays highlighted in both session and project lists, including after refreshing the file list. A pending listing cannot clear a newer file selection or replace results and errors for a different folder or search. If the folder being browsed becomes unavailable, **Files** keeps its parent-folder action so you can continue browsing without reloading or changing sessions. Session file labels show the filename and enough parent folders to distinguish matching names; hovering or copying a path keeps the full path. Closing a filename tab closes only its preview, never the underlying file. Closing or replacing a file preview cancels a delayed copy fallback; an already issued native clipboard write may still finish. Open previews are scoped to the current pane, session, agent, and connection, and are not persisted across reconnects. A forwarded file retains its sending session as its read and edit target; identical paths from different sessions have separate tabs and drafts. HTML files open a sandboxed **Preview**, with **Source** in the same filename tab. Other UTF-8 text files use a CodeMirror-based code view with syntax highlighting, line numbers, jump-to-line, in-file search, copy actions, and an open-in-external-editor menu. The code view has a **Word wrap** toolbar toggle, including in HTML **Source** view. Wrapping starts off; the browser remembers your choice across files and reloads without changing file contents. Search follows the displayed line numbers for LF, CRLF, and CR line endings; editing preserves the original line endings, including when pasted text uses different line endings. Read-only previews, including files with mixed line endings, do not create unsaved drafts or block interface reloads. Escape closes in-file search and returns keyboard focus to **Search in file** in the toolbar. AVIF, GIF, JPEG, PNG, and WebP images no larger than 256 KiB render inline; other binary files show metadata without lossy text decoding. When the Gateway advertises `sessions.files.set` to an `operator.admin` connection, the text panel adds an Edit mode with dirty tracking and Cmd/Ctrl-S save; unsaved drafts survive file, panel, and session navigation in the current browser tab until explicitly saved or discarded. If a server update blocks editing and reloading, choose **Review file drafts** in the reload notification to copy or download the retained edits, then explicitly discard each resolved draft and try **Refresh** again. **Keep drafts** leaves them protected in this tab; recovery never writes files on the disconnected or updated Gateway. An editor opened in a managed workspace also retains that workspace’s identity and file lookup roots. If a reset replaces the workspace or changes its working directory, saving refuses the changed target even when the filename and bytes match. The old draft stays recoverable; close and reopen the file to edit the current workspace. Older API clients that omit the optional source precondition retain their existing behavior. Saves are compare-and-swap on a content hash returned by `sessions.files.get`: if the file changed on disk since it was loaded (for example because the agent kept working), the panel shows a conflict notice with Reload (take the latest content) and Overwrite (keep the local edit) actions. Writes retain fs-safe workspace guards — path containment, symlink/hardlink rejection, and a 256 KiB UTF-8 cap — and only overwrite existing files; the editor never creates or deletes them. If the editor cannot load, use **Retry** or **View Raw Text**. A missing editor chunk after an update offers **Reload**, which waits for the Gateway to become reachable.
|
||||
- Open the side panel and select **Subagents** from its **+** menu to follow child sessions, including swarm workers, or select the subagent count or a subagent's name in the transcript. A pane too narrow to show the side panel beside the conversation shows **Subagents** in place of the conversation instead of under it, however the panel was opened; closing the panel returns to the conversation, and a wider pane shows the two side by side again. Keyboard focus moves to the panel's tab when it takes the conversation's place, and returns to the control that had it once the panel is closed, or to the first control in the pane's header when that control is gone. **Running** and **Finished** keep the list compact: titles and Stop controls sit above available call counts and public activity text, with elapsed time on the right. Activity falls back to the tool’s display name when no public description is available; a count is omitted when the bounded history cannot establish an exact total. All child pages load automatically so older running workers remain visible. The parent’s parallel-tasks view also shows aggregate swarm progress. Selecting a child opens its existing view-only transcript in the panel. **Back to Subagents**, the task title, and the existing **Stop** action appear above the transcript; there is no elapsed-time row, composer, or parent notice in that embedded view. Opening the child directly keeps a compact **View-only subagent** notice naming its parent and an **Open parent session** button. Subagents remain outside left-sidebar navigation. Saved-draft recovery stays in the parent or another ordinary conversation; neither the embedded nor direct subagent view shows its Restore or Delete actions. Message input, reply actions, model and access pickers, microphone, and attachment controls remain unavailable; copy and fork keep their existing behavior. Persistent sessions created with `visible: true` remain ordinary conversations that you can type in and steer.
|
||||
- **Processes** is a separate side-panel tab for the conversation's background exec commands. Open it through **Panels → Processes** or the side-panel **+** menu. A narrow pane shows it in place of the conversation, as it does **Subagents**. Its **Running** and **Finished** lists show derived command names, status, elapsed time, and available exit codes. Select a row to inspect the retained output tail without consuming the agent's pending output. **Stop** requests termination of that exact process; **Stopping…** remains until the process owner reports completion. Finished records expire under the normal process retention limits. An unavailable worker or failed read is shown as an error, not an empty process list. The panel refreshes only while visible and preserves the parent draft.
|
||||
- **Review** and file tabs retain their own content. A pending file or artifact updates only its own open tab: it cannot select itself over a newer tab, reopen a closed preview, or return after you leave the chat page. Switching tabs or hiding the whole side panel preserves the pending preview without changing your chosen layout when it finishes. Text attachments retain their Preview or View Raw Text mode while switching between open files. Background download-link refreshes keep an unchanged attachment's reader in place, including keyboard focus and code-block controls.
|
||||
|
|
|
|||
|
|
@ -263,8 +263,15 @@ export const SessionsFilesGetParamsSchema = closedObject({
|
|||
agentId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
const SessionFileSourceSchema = closedObject({
|
||||
workspaceId: NonEmptyString,
|
||||
root: Type.Union([NonEmptyString, Type.Null()]),
|
||||
fileRoot: Type.Union([NonEmptyString, Type.Null()]),
|
||||
});
|
||||
|
||||
export const SessionsFilesGetResultSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
source: Type.Optional(SessionFileSourceSchema),
|
||||
root: Type.Optional(NonEmptyString),
|
||||
file: SessionFileEntrySchema,
|
||||
});
|
||||
|
|
@ -314,9 +321,10 @@ export const SessionsFilesAssetsResultSchema = closedObject({
|
|||
),
|
||||
});
|
||||
|
||||
/** Overwrites one existing session workspace file with hash-based CAS. */
|
||||
/** Overwrites an existing file with content and optional managed-source preconditions. */
|
||||
export const SessionsFilesSetParamsSchema = closedObject({
|
||||
...SessionsFilesGetParamsSchema.properties,
|
||||
expectedSource: Type.Optional(SessionFileSourceSchema),
|
||||
content: Type.String(),
|
||||
expectedHash: SessionFileHashSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
type SessionFileEntry,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { WORKSPACE_PREVIEW_MAX_BYTES } from "../workspace-file-limits.js";
|
||||
import type { RespondFn } from "./types.js";
|
||||
|
||||
export function sessionFilesError(
|
||||
type: string,
|
||||
|
|
@ -12,3 +18,30 @@ export function sessionFilesError(
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function respondSessionFileTooLarge(
|
||||
respond: RespondFn,
|
||||
file: SessionFileEntry,
|
||||
filePath: string,
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
sessionFilesError("session_file_too_large", "session file is too large to preview", {
|
||||
maxPreviewBytes: WORKSPACE_PREVIEW_MAX_BYTES,
|
||||
path: file.path || filePath,
|
||||
size: file.size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function respondSessionFileNotFound(respond: RespondFn, filePath: string, reason?: string) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
sessionFilesError("session_file_not_found", "session file not found", {
|
||||
path: filePath,
|
||||
...(reason ? { reason } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { resolve as resolvePath } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
isCloudWorkerPlacementState,
|
||||
type SessionFileEntry,
|
||||
type SessionsFilesGetParams,
|
||||
type SessionsFilesListParams,
|
||||
type SessionsFilesAssetsParams,
|
||||
|
|
@ -47,7 +47,11 @@ import {
|
|||
createSessionFileReadAuthority,
|
||||
sessionFileWorkspaceMatches,
|
||||
} from "./session-file-read-authority.js";
|
||||
import { sessionFilesError } from "./session-files-errors.js";
|
||||
import {
|
||||
respondSessionFileNotFound,
|
||||
respondSessionFileTooLarge,
|
||||
sessionFilesError,
|
||||
} from "./session-files-errors.js";
|
||||
import {
|
||||
getRepositoryArtifact,
|
||||
listRepositoryArtifacts,
|
||||
|
|
@ -92,24 +96,17 @@ function rejectDeviceFolderFiles(
|
|||
}
|
||||
|
||||
async function loadSessionFileRoot(loaded: GatewaySessionEntryRead) {
|
||||
if (!loaded.entry?.sessionId) {
|
||||
return {
|
||||
...loaded,
|
||||
agentId: undefined,
|
||||
root: undefined,
|
||||
fileRoot: undefined,
|
||||
diffCwd: undefined,
|
||||
fileWorkspace: undefined,
|
||||
};
|
||||
}
|
||||
if (loaded.entry.repositoryWorkspaceId) {
|
||||
return {
|
||||
...loaded,
|
||||
if (!loaded.entry?.sessionId || loaded.entry.repositoryWorkspaceId) {
|
||||
const unavailable = {
|
||||
source: undefined,
|
||||
root: undefined,
|
||||
fileRoot: undefined,
|
||||
diffCwd: undefined,
|
||||
fileWorkspace: undefined,
|
||||
};
|
||||
return loaded.entry?.sessionId
|
||||
? { ...loaded, ...unavailable }
|
||||
: { ...loaded, ...unavailable, agentId: undefined };
|
||||
}
|
||||
const fileWorkspace = await prepareSessionFileWorkspace(
|
||||
loaded.cfg,
|
||||
|
|
@ -118,11 +115,16 @@ async function loadSessionFileRoot(loaded: GatewaySessionEntryRead) {
|
|||
loaded.entry,
|
||||
);
|
||||
const { spawnedCwd, root, diffCwd } = fileWorkspace;
|
||||
const fileRoot = resolveFileRoot({ root, spawnedCwd });
|
||||
const source = loaded.entry.worktree?.id
|
||||
? { workspaceId: loaded.entry.worktree.id, root: root ?? null, fileRoot: fileRoot ?? null }
|
||||
: undefined;
|
||||
return {
|
||||
...loaded,
|
||||
fileWorkspace,
|
||||
root,
|
||||
fileRoot: resolveFileRoot({ root, spawnedCwd }),
|
||||
fileRoot,
|
||||
source,
|
||||
diffCwd,
|
||||
};
|
||||
}
|
||||
|
|
@ -254,29 +256,6 @@ async function loadSessionFiles(
|
|||
};
|
||||
}
|
||||
|
||||
function respondSessionFileNotFound(respond: RespondFn, filePath: string, reason?: string) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
sessionFilesError("session_file_not_found", "session file not found", {
|
||||
path: filePath,
|
||||
...(reason ? { reason } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function respondSessionFileTooLarge(respond: RespondFn, file: SessionFileEntry, filePath: string) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
sessionFilesError("session_file_too_large", "session file is too large to preview", {
|
||||
maxPreviewBytes: WORKSPACE_PREVIEW_MAX_BYTES,
|
||||
path: file.path || filePath,
|
||||
size: file.size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function requireSessionFilesAgentId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
|
|
@ -468,6 +447,7 @@ async function handleSessionFilesRead(
|
|||
: {
|
||||
sessionKey: params.sessionKey,
|
||||
...result,
|
||||
...(request.kind === "get" && source.source ? { source: source.source } : {}),
|
||||
...(repository ? { root: undefined } : {}),
|
||||
},
|
||||
);
|
||||
|
|
@ -553,10 +533,24 @@ export const sessionsFilesHandlers: GatewayRequestHandlers = {
|
|||
respondSessionFileNotFound(respond, params.path);
|
||||
return;
|
||||
}
|
||||
const source = loaded.source;
|
||||
const authorize = () => {
|
||||
assertSourceCurrent();
|
||||
sessionMutationAuthorization?.assertCurrent();
|
||||
if (
|
||||
params.expectedSource !== undefined &&
|
||||
!isDeepStrictEqual(params.expectedSource, source)
|
||||
) {
|
||||
throw new SessionMutationAuthorizationChangedError(
|
||||
sessionFilesError(
|
||||
"session_file_source_changed",
|
||||
"The file's workspace changed. Reopen the file before saving; your draft was preserved.",
|
||||
{ path: params.path },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
authorize();
|
||||
using repository = await resolveRepositoryWorkspaceAccess(loaded, context, authorize);
|
||||
if (repository?.kind === "stored") {
|
||||
throw new Error("Start this cloud session before editing its repository files.");
|
||||
|
|
@ -613,6 +607,7 @@ export const sessionsFilesHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
respond(true, {
|
||||
sessionKey: params.sessionKey,
|
||||
...(source ? { source } : {}),
|
||||
...(repository ? {} : { root: update.root }),
|
||||
file: update.file,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -8,6 +9,7 @@ import { writeSessionEntry } from "../../config/sessions/session-accessor.sqlite
|
|||
import { retainSessionActorEntryFacts } from "../../config/sessions/session-actor-replica.js";
|
||||
import { readRetainedSessionEntryFacts } from "../../config/sessions/session-entry-read-facts.js";
|
||||
import { readSessionEntriesFromStoreInWorker } from "../../config/sessions/session-entry-read-runtime.js";
|
||||
import * as fsSafe from "../../infra/fs-safe.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
|
|
@ -16,7 +18,10 @@ import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
|
|||
import { createDirectChatContext } from "../server-chat.agent-events.test-helpers.js";
|
||||
import { handleGatewayRequest } from "../server-methods.js";
|
||||
import { sessionsFilesHandlers } from "./sessions-files.js";
|
||||
import { createSessionFileDownloadClient } from "./sessions-files.test-support.js";
|
||||
import {
|
||||
createSessionFileDownloadClient,
|
||||
createSessionFilesHandlerInvoker,
|
||||
} from "./sessions-files.test-support.js";
|
||||
import type { GatewayClient } from "./types.js";
|
||||
|
||||
const hooks = vi.hoisted(() => ({
|
||||
|
|
@ -133,6 +138,114 @@ function seedReplicaPressure(env: NodeJS.ProcessEnv) {
|
|||
}
|
||||
|
||||
describe("sessions.files for managed-worktree sessions", () => {
|
||||
it("refuses a changed file lookup root inside the same managed workspace", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
|
||||
const entry = await seedWorktreeSession(state.workspaceDir);
|
||||
const original = { ...entry, spawnedWorkspaceDir: state.workspaceDir };
|
||||
await sessions.upsertSessionEntryCore({ agentId: "main", sessionKey: key }, original);
|
||||
const source = {
|
||||
workspaceId: entry.worktree.id,
|
||||
root: state.workspaceDir,
|
||||
fileRoot: entry.spawnedCwd,
|
||||
};
|
||||
const opened = await dispatch("sessions.files.get", "preview.html");
|
||||
expect(opened.mock.calls[0]?.[1]).toMatchObject({
|
||||
source,
|
||||
file: { content: "<h1>boo</h1>\n" },
|
||||
});
|
||||
const replacement = path.join(state.workspaceDir, "replacement");
|
||||
fs.mkdirSync(replacement);
|
||||
fs.writeFileSync(path.join(replacement, "preview.html"), "<h1>boo</h1>\n");
|
||||
await sessions.upsertSessionEntryCore(
|
||||
{ agentId: "main", sessionKey: key },
|
||||
{ ...original, spawnedCwd: replacement },
|
||||
);
|
||||
const invoke = createSessionFilesHandlerInvoker(sessionsFilesHandlers);
|
||||
const save = {
|
||||
sessionKey: key,
|
||||
path: "preview.html",
|
||||
content: "new draft",
|
||||
expectedHash: createHash("sha256").update("<h1>boo</h1>\n").digest("hex"),
|
||||
expectedSource: source,
|
||||
};
|
||||
await expect(invoke("sessions.files.set", save)).rejects.toMatchObject({
|
||||
error: { details: { type: "session_file_source_changed" } },
|
||||
});
|
||||
expect(fs.readFileSync(path.join(replacement, "preview.html"), "utf8")).toBe(
|
||||
"<h1>boo</h1>\n",
|
||||
);
|
||||
const freshSource = { ...source, fileRoot: replacement };
|
||||
const fresh = await dispatch("sessions.files.get", "preview.html");
|
||||
expect(fresh.mock.calls[0]?.[1]).toMatchObject({ source: freshSource });
|
||||
expect(
|
||||
await invoke("sessions.files.set", { ...save, expectedSource: freshSource }),
|
||||
).toMatchObject([{ ok: true }]);
|
||||
expect(fs.readFileSync(path.join(replacement, "preview.html"), "utf8")).toBe("new draft");
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["workspace", "authorization"] as const)(
|
||||
"preserves the file when %s changes during a source-aware save",
|
||||
async (change) => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
|
||||
const entry = await seedWorktreeSession(state.workspaceDir);
|
||||
let authorized = true;
|
||||
const revoked = new Error("editor authorization revoked");
|
||||
const assertCurrent = () => {
|
||||
if (!authorized) {
|
||||
throw revoked;
|
||||
}
|
||||
};
|
||||
const createRoot = fsSafe.root;
|
||||
using rootSpy = vi.spyOn(fsSafe, "root").mockImplementation(async (...args) => {
|
||||
const root = await createRoot(...args);
|
||||
const write = root.write.bind(root);
|
||||
root.write = async (...writeArgs) => {
|
||||
if (change === "workspace") {
|
||||
await sessions.upsertSessionEntryCore(
|
||||
{ agentId: "main", sessionKey: key },
|
||||
{ ...entry, worktree: { ...entry.worktree, id: "wt-2" } },
|
||||
);
|
||||
} else {
|
||||
authorized = false;
|
||||
}
|
||||
return write(...writeArgs);
|
||||
};
|
||||
return root;
|
||||
});
|
||||
const invoke = createSessionFilesHandlerInvoker(sessionsFilesHandlers);
|
||||
const saving = invoke(
|
||||
"sessions.files.set",
|
||||
{
|
||||
sessionKey: key,
|
||||
path: "preview.html",
|
||||
content: "unapproved replacement",
|
||||
expectedHash: createHash("sha256").update("<h1>boo</h1>\n").digest("hex"),
|
||||
expectedSource: {
|
||||
workspaceId: entry.worktree.id,
|
||||
root: entry.spawnedCwd,
|
||||
fileRoot: entry.spawnedCwd,
|
||||
},
|
||||
},
|
||||
{},
|
||||
{
|
||||
sessionMutationAuthorization: {
|
||||
assertCurrent,
|
||||
assertTargetCurrent: assertCurrent,
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(saving).rejects.toThrow(
|
||||
change === "workspace" ? "Session workspace changed during file access" : revoked,
|
||||
);
|
||||
expect(rootSpy).toHaveBeenCalled();
|
||||
expect(fs.readFileSync(path.join(entry.spawnedCwd, "preview.html"), "utf8")).toBe(
|
||||
"<h1>boo</h1>\n",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reads files from a durable session's worktree", async () => {
|
||||
hooks.duringSettlement = undefined;
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import { promisify } from "node:util";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { SessionsFilesGetResult } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
||||
import { captureMethodCall } from "../../test/helpers/capture-method-call.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
|
|
@ -410,6 +411,83 @@ test("sessions.create reset-in-place detaches the prior worktree permission boun
|
|||
expect(getRegistryWorktree(process.env, successorWorktree.id)?.removedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an open Files editor cannot save into a replacement managed workspace", async () => {
|
||||
await using fixture = await setupResetWorktree("openclaw-files-source-reset-");
|
||||
const created = await mainSession({ worktree: true });
|
||||
expect(created).toMatchObject({ ok: true, payload: { key: "agent:main:main" } });
|
||||
const original = created.payload!.worktree;
|
||||
fixture.track(original.id);
|
||||
const request = { sessionKey: "agent:main:main", path: "README.md" };
|
||||
const options = { client: { connect: { scopes: ["operator.admin"] } } as never };
|
||||
const read = () =>
|
||||
directSessionReq<SessionsFilesGetResult>("sessions.files.get", request, options);
|
||||
const opened = await read();
|
||||
expect(opened.ok, JSON.stringify(opened.error)).toBe(true);
|
||||
expect(opened.payload).toMatchObject({
|
||||
source: { workspaceId: original.id },
|
||||
file: { content: "base\n" },
|
||||
});
|
||||
const staleSave = () =>
|
||||
directSessionReq(
|
||||
"sessions.files.set",
|
||||
{
|
||||
...request,
|
||||
content: "draft from the original workspace\n",
|
||||
expectedHash: opened.payload!.file.hash,
|
||||
expectedSource: opened.payload!.source,
|
||||
},
|
||||
options,
|
||||
);
|
||||
const sourceChanged = { error: { details: { type: "session_file_source_changed" } } };
|
||||
|
||||
expectDetached(await mainSession({}, "operator.write"));
|
||||
await expect(staleSave()).rejects.toMatchObject(sourceChanged);
|
||||
await expect(fs.readFile(path.join(fixture.workspace, "README.md"), "utf8")).resolves.toBe(
|
||||
"base\n",
|
||||
);
|
||||
const replacement = await mainSession({ worktree: true });
|
||||
expect(replacement.ok, JSON.stringify(replacement.error)).toBe(true);
|
||||
const current = replacement.payload!.worktree;
|
||||
fixture.track(current.id);
|
||||
expect(current.id).not.toBe(original.id);
|
||||
await expect(staleSave()).rejects.toMatchObject(sourceChanged);
|
||||
await expect(fs.readFile(path.join(current.path, "README.md"), "utf8")).resolves.toBe("base\n");
|
||||
|
||||
const fresh = await read();
|
||||
expect(fresh.payload).toMatchObject({
|
||||
source: { workspaceId: current.id },
|
||||
file: { content: "base\n" },
|
||||
});
|
||||
const saved = await directSessionReq<SessionsFilesGetResult>(
|
||||
"sessions.files.set",
|
||||
{
|
||||
...request,
|
||||
content: "fresh edit\n",
|
||||
expectedHash: fresh.payload!.file.hash,
|
||||
expectedSource: fresh.payload!.source,
|
||||
},
|
||||
options,
|
||||
);
|
||||
expect(saved.ok, JSON.stringify(saved.error)).toBe(true);
|
||||
expect(saved.payload?.source?.workspaceId).toBe(current.id);
|
||||
await expect(fs.readFile(path.join(current.path, "README.md"), "utf8")).resolves.toBe(
|
||||
"fresh edit\n",
|
||||
);
|
||||
const legacy = await directSessionReq(
|
||||
"sessions.files.set",
|
||||
{
|
||||
...request,
|
||||
content: "legacy client edit\n",
|
||||
expectedHash: saved.payload!.file.hash,
|
||||
},
|
||||
options,
|
||||
);
|
||||
expect(legacy.ok, JSON.stringify(legacy.error)).toBe(true);
|
||||
await expect(fs.readFile(path.join(current.path, "README.md"), "utf8")).resolves.toBe(
|
||||
"legacy client edit\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("sessions.create rejects worktrees for agent workspaces without a commit", async () => {
|
||||
const workspace = await makeNonGitTempDir("openclaw-session-unborn-workspace-");
|
||||
await execFileAsync("git", ["init", workspace]);
|
||||
|
|
@ -424,3 +502,66 @@ test("sessions.create rejects worktrees for agent workspaces without a commit",
|
|||
testState.agentConfig = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test("an explicit cwd reset refuses the previous managed file source", async () => {
|
||||
await using fixture = await setupResetWorktree("openclaw-files-explicit-cwd-");
|
||||
const created = await mainSession({ worktree: true });
|
||||
expect(created.ok, JSON.stringify(created.error)).toBe(true);
|
||||
const original = created.payload!.worktree;
|
||||
fixture.track(original.id);
|
||||
const request = { sessionKey: "agent:main:main", path: "README.md" };
|
||||
const options = { client: { connect: { scopes: ["operator.admin"] } } as never };
|
||||
const opened = await directSessionReq<SessionsFilesGetResult>(
|
||||
"sessions.files.get",
|
||||
request,
|
||||
options,
|
||||
);
|
||||
expect(opened.payload).toMatchObject({
|
||||
source: { workspaceId: original.id },
|
||||
file: { content: "base\n" },
|
||||
});
|
||||
const replacement = path.join(fixture.workspace, "replacement");
|
||||
await fs.mkdir(replacement);
|
||||
await fs.writeFile(path.join(replacement, "README.md"), "base\n");
|
||||
const reset = await mainSession({ cwd: replacement });
|
||||
expect(reset.ok, JSON.stringify(reset.error)).toBe(true);
|
||||
expect(reset.payload?.entry.spawnedCwd).toBe(replacement);
|
||||
expect(reset.payload?.entry.worktree?.id).toBe(original.id);
|
||||
const fresh = await directSessionReq<SessionsFilesGetResult>(
|
||||
"sessions.files.get",
|
||||
request,
|
||||
options,
|
||||
);
|
||||
expect(fresh.payload).toMatchObject({
|
||||
source: { workspaceId: original.id },
|
||||
file: { content: "base\n" },
|
||||
});
|
||||
await expect(
|
||||
directSessionReq(
|
||||
"sessions.files.set",
|
||||
{
|
||||
...request,
|
||||
content: "draft from A\n",
|
||||
expectedHash: opened.payload!.file.hash,
|
||||
expectedSource: opened.payload!.source,
|
||||
},
|
||||
options,
|
||||
),
|
||||
).rejects.toMatchObject({ error: { details: { type: "session_file_source_changed" } } });
|
||||
await expect(fs.readFile(path.join(replacement, "README.md"), "utf8")).resolves.toBe("base\n");
|
||||
expect(fresh.payload?.source).not.toEqual(opened.payload?.source);
|
||||
const saved = await directSessionReq(
|
||||
"sessions.files.set",
|
||||
{
|
||||
...request,
|
||||
content: "fresh edit in B\n",
|
||||
expectedHash: fresh.payload!.file.hash,
|
||||
expectedSource: fresh.payload!.source,
|
||||
},
|
||||
options,
|
||||
);
|
||||
expect(saved.ok, JSON.stringify(saved.error)).toBe(true);
|
||||
await expect(fs.readFile(path.join(replacement, "README.md"), "utf8")).resolves.toBe(
|
||||
"fresh edit in B\n",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
132
ui/src/e2e/chat-file-source-save.e2e.test.ts
Normal file
132
ui/src/e2e/chat-file-source-save.e2e.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { takeControlUiScreenshotFrame } from "../test-helpers/control-ui-e2e-screenshot.ts";
|
||||
import {
|
||||
defaultControlUiFeatureMethods,
|
||||
installMockGateway,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { chatMessage } from "./chat-flow.test-support.ts";
|
||||
import { exactButton, polledText } from "./chat-presentation.test-support.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({ name: "Files source-aware save" });
|
||||
const sessionKey = "agent:main:main";
|
||||
const viewport = { width: 1280, height: 900 };
|
||||
const changedSourceMessage =
|
||||
"The file's workspace changed. Reopen the file before saving; your draft was preserved.";
|
||||
|
||||
suite.define(() => {
|
||||
it("keeps an old workspace draft separate when the same file is reopened", async () => {
|
||||
const initial = "# Workspace notes\n\nOriginal project notes.\n";
|
||||
const draft = "# Workspace notes\n\nUnsaved work from workspace A.\n";
|
||||
let workspaceId = "workspace-a";
|
||||
let savedContent = initial;
|
||||
let saves = 0;
|
||||
await suite.withPage(
|
||||
{ viewport, recordVideo: { dir: suite.artifactDir, size: viewport } },
|
||||
async ({ page }) => {
|
||||
const gateway = await installMockGateway(
|
||||
page,
|
||||
{
|
||||
featureMethods: [...defaultControlUiFeatureMethods, "sessions.files.set"],
|
||||
historyMessages: [chatMessage("assistant", "Review `notes.md`.", { timestamp: 1 })],
|
||||
},
|
||||
{
|
||||
requestHandlers: {
|
||||
"sessions.files.get": () => ({
|
||||
sessionKey,
|
||||
root: "/workspace",
|
||||
source: { workspaceId, root: "/workspace", fileRoot: "/workspace" },
|
||||
file: {
|
||||
name: "notes.md",
|
||||
path: "notes.md",
|
||||
workspacePath: "notes.md",
|
||||
kind: "read",
|
||||
missing: false,
|
||||
previewKind: "text",
|
||||
contentEncoding: "utf8",
|
||||
content: savedContent,
|
||||
hash: "unchanged-file-hash",
|
||||
},
|
||||
}),
|
||||
"sessions.files.set": (params) => {
|
||||
const request = params as {
|
||||
content: string;
|
||||
expectedSource?: { workspaceId: string; root: string; fileRoot: string };
|
||||
};
|
||||
if (
|
||||
request.expectedSource !== undefined &&
|
||||
(request.expectedSource.workspaceId !== workspaceId ||
|
||||
request.expectedSource.root !== "/workspace" ||
|
||||
request.expectedSource.fileRoot !== "/workspace")
|
||||
) {
|
||||
return {
|
||||
__mockError: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: changedSourceMessage,
|
||||
details: { type: "session_file_source_changed" },
|
||||
},
|
||||
};
|
||||
}
|
||||
saves += 1;
|
||||
savedContent = request.content;
|
||||
return {
|
||||
sessionKey,
|
||||
source: { workspaceId, root: "/workspace", fileRoot: "/workspace" },
|
||||
file: { hash: "saved-file-hash" },
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const link = page.locator('a.markdown-file-link[data-file-path="notes.md"]');
|
||||
await link.click();
|
||||
const file = page.locator(".sidebar-file-view");
|
||||
const editor = file.locator(".cm-content");
|
||||
await exactButton(file, "Edit file").click();
|
||||
await editor.fill(draft);
|
||||
workspaceId = "workspace-b";
|
||||
await page.evaluate(() => {
|
||||
const cue = document.createElement("div");
|
||||
cue.textContent =
|
||||
"Proof fixture: workspace A replaced by B; file path and bytes unchanged";
|
||||
cue.style.cssText =
|
||||
"position:fixed;bottom:12px;left:12px;z-index:99999;background:#17212b;color:white;padding:10px 16px;border-radius:6px;font:14px system-ui;pointer-events:none";
|
||||
document.body.append(cue);
|
||||
});
|
||||
await exactButton(file, "Save").click();
|
||||
await gateway.waitForRequest("sessions.files.set");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await file.locator('[role="alert"]').count()) > 0 ||
|
||||
(await exactButton(file, "Save").isDisabled()),
|
||||
)
|
||||
.toBe(true);
|
||||
const frame = await takeControlUiScreenshotFrame(page, file, [editor], {
|
||||
animations: "disabled",
|
||||
});
|
||||
await fs.writeFile(path.join(suite.artifactDir, "stale-save.png"), frame.png);
|
||||
console.log(`Files source save capture: ${suite.artifactDir}`);
|
||||
expect(saves).toBe(0);
|
||||
expect(savedContent).toBe(initial);
|
||||
await polledText(file.locator('[role="alert"]')).toContain("workspace changed");
|
||||
await polledText(editor).toContain("Unsaved work from workspace A.");
|
||||
expect(await exactButton(file, "Save").isDisabled()).toBe(false);
|
||||
expect(await file.getByRole("button", { name: "Overwrite", exact: true }).count()).toBe(0);
|
||||
|
||||
await exactButton(page, "Close tab: notes.md").click();
|
||||
await link.click();
|
||||
await polledText(editor).toContain("Original project notes.");
|
||||
expect(await editor.textContent()).not.toContain("Unsaved work from workspace A.");
|
||||
await exactButton(file, "Edit file").click();
|
||||
await editor.fill("Fresh edit in workspace B.\n");
|
||||
await exactButton(file, "Save").click();
|
||||
await expect.poll(() => saves).toBe(1);
|
||||
expect(savedContent).toBe("Fresh edit in workspace B.\n");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -126,13 +126,18 @@ export function createSessionScopedOperations(host: SessionScopedOperationsHost)
|
|||
key: string,
|
||||
path: string,
|
||||
content: string,
|
||||
options: { agentId?: string | null; expectedHash: string },
|
||||
options: {
|
||||
agentId?: string | null;
|
||||
expectedHash: string;
|
||||
expectedSource?: SessionWorkspaceGetResult["source"];
|
||||
},
|
||||
) =>
|
||||
requestCurrent<SessionWorkspaceSetResult>("sessions.files.set", {
|
||||
sessionKey: key,
|
||||
path,
|
||||
content,
|
||||
expectedHash: options.expectedHash,
|
||||
...(options.expectedSource !== undefined ? { expectedSource: options.expectedSource } : {}),
|
||||
...(options.agentId?.trim() ? { agentId: options.agentId.trim() } : {}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ function updatePreviewContent(
|
|||
content.kind === "file" &&
|
||||
previous.content === content.content &&
|
||||
previous.edit?.hash === content.edit?.hash &&
|
||||
previous.draftKey === content.draftKey &&
|
||||
previous.path === content.path &&
|
||||
previous.root === content.root &&
|
||||
previous.mimeType === content.mimeType
|
||||
|
|
|
|||
|
|
@ -435,6 +435,150 @@ describe("openSessionWorkspaceFile", () => {
|
|||
expect(getFile).toHaveBeenLastCalledWith(state.sessionKey, path, { agentId: "main" });
|
||||
});
|
||||
|
||||
it.each(["workspaceId", "root", "fileRoot"] as const)(
|
||||
"keeps hash-conflict recovery on the opened source when %s changes",
|
||||
async (field) => {
|
||||
const originalSource = {
|
||||
workspaceId: "managed-a",
|
||||
root: "/workspace",
|
||||
fileRoot: "/workspace",
|
||||
};
|
||||
let source = originalSource;
|
||||
const getFile = vi.fn(async () => ({
|
||||
sessionKey: "agent:main:current",
|
||||
root: "/workspace",
|
||||
source,
|
||||
file: textFile("notes.md", "before", { hash: "old" }),
|
||||
}));
|
||||
const message = "The file's workspace changed. Reopen the file before saving.";
|
||||
const setFile = vi.fn().mockRejectedValue(
|
||||
new GatewayRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message,
|
||||
details: { type: "session_file_source_changed" },
|
||||
}),
|
||||
);
|
||||
const state = createLoadingHost(
|
||||
{ getFile, setFile, listFiles: vi.fn(async () => workspaceList(source.root)) },
|
||||
{
|
||||
handleOpenSidebar: createSidebarContentRecorder(),
|
||||
hello: gatewayHelloForMethods(["sessions.files.set"]),
|
||||
},
|
||||
);
|
||||
openSessionWorkspaceFile(state, { path: "notes.md" });
|
||||
const file = await loadedSidebarContent(state);
|
||||
if (file?.kind !== "file" || !file.edit) {
|
||||
throw new Error("Expected an editable file");
|
||||
}
|
||||
expect(await file.edit.fetchLatest()).toMatchObject({ content: "before", hash: "old" });
|
||||
source = { ...originalSource, [field]: `${originalSource[field]}-replacement` };
|
||||
expect(await file.edit.fetchLatest()).toBeNull();
|
||||
expect(
|
||||
await file.edit.save({ content: "retained draft", expectedHash: "old" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
code: "error",
|
||||
message: expect.stringContaining("workspace changed"),
|
||||
});
|
||||
expect(setFile).toHaveBeenLastCalledWith("agent:main:current", "notes.md", "retained draft", {
|
||||
agentId: "main",
|
||||
expectedHash: "old",
|
||||
expectedSource: originalSource,
|
||||
});
|
||||
const reopened = nextWorkspaceUpdate(
|
||||
state,
|
||||
() => state.sessionWorkspaceState?.previews[0]?.content !== file,
|
||||
);
|
||||
openSessionWorkspaceFile(state, { path: "notes.md" });
|
||||
await reopened;
|
||||
const fresh = await loadedSidebarContent(state);
|
||||
if (fresh.kind !== "file" || !fresh.edit) {
|
||||
throw new Error("Expected the freshly opened file");
|
||||
}
|
||||
expect(fresh.draftKey).not.toBe(file.draftKey);
|
||||
await fresh.edit.save({ content: "fresh draft", expectedHash: "old" });
|
||||
expect(setFile).toHaveBeenLastCalledWith("agent:main:current", "notes.md", "fresh draft", {
|
||||
agentId: "main",
|
||||
expectedHash: "old",
|
||||
expectedSource: source,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["current", "forwarded", "navigated"] as const)(
|
||||
"refreshes only the owning listing after a source-change refusal (%s)",
|
||||
async (view) => {
|
||||
const oldSource = {
|
||||
workspaceId: "managed-a",
|
||||
root: "/workspace/a",
|
||||
fileRoot: "/workspace/a",
|
||||
};
|
||||
const newSource = { ...oldSource, root: "/workspace/b", fileRoot: "/workspace/b" };
|
||||
const key = view === "forwarded" ? "agent:main:other" : "agent:main:current";
|
||||
const getFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionKey: key,
|
||||
root: oldSource.root,
|
||||
source: oldSource,
|
||||
file: textFile("notes.md", "before", { hash: "old" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionKey: key,
|
||||
root: newSource.root,
|
||||
source: newSource,
|
||||
file: textFile("notes.md", "before", { hash: "old" }),
|
||||
});
|
||||
const refreshed = createDeferred<SessionWorkspaceListResult>();
|
||||
const listFiles = vi.fn(() => refreshed.promise);
|
||||
const setFile = vi.fn().mockRejectedValue(
|
||||
new GatewayRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Workspace changed",
|
||||
details: { type: "session_file_source_changed" },
|
||||
}),
|
||||
);
|
||||
const state = createLoadingHost(
|
||||
{ getFile, setFile, listFiles },
|
||||
{
|
||||
handleOpenSidebar: createSidebarContentRecorder(),
|
||||
hello: gatewayHelloForMethods(["sessions.files.set"]),
|
||||
},
|
||||
);
|
||||
const workspace = getSessionWorkspace(state);
|
||||
workspace.list = workspaceList(oldSource.root);
|
||||
if (view === "forwarded") {
|
||||
openSessionWorkspaceFile(state, { path: "notes.md", sessionKey: key });
|
||||
} else {
|
||||
workspaceProps(state).onOpenFile("notes.md", "workspace");
|
||||
}
|
||||
const file = await loadedSidebarContent(state);
|
||||
if (file.kind !== "file" || !file.edit) {
|
||||
throw new Error("Expected an editable file");
|
||||
}
|
||||
const saving = file.edit.save({ content: "retained draft", expectedHash: "old" });
|
||||
if (view === "navigated") {
|
||||
state.sessionKey = "agent:main:next";
|
||||
}
|
||||
expect(await saving).toMatchObject({ ok: false, code: "error" });
|
||||
expect(workspace.previews.some((preview) => preview.content === file)).toBe(true);
|
||||
if (view !== "current") {
|
||||
expect(listFiles).not.toHaveBeenCalled();
|
||||
expect(workspace.list?.root).toBe(oldSource.root);
|
||||
return;
|
||||
}
|
||||
expect(listFiles).toHaveBeenCalledOnce();
|
||||
expect(workspace.list).toBeNull();
|
||||
const loaded = nextWorkspaceUpdate(state, () => workspace.list?.root === newSource.root);
|
||||
refreshed.resolve(workspaceList(newSource.root));
|
||||
await loaded;
|
||||
workspaceProps(state).onOpenFile("notes.md", "workspace");
|
||||
const fresh = await loadedSidebarContent(state);
|
||||
expect(fresh.kind === "file" && fresh.edit).toBeTruthy();
|
||||
expect(getFile).toHaveBeenLastCalledWith(key, "/workspace/b/notes.md", { agentId: "main" });
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves existing Review content when the Gateway is disconnected", () => {
|
||||
const existingContent = {
|
||||
kind: "markdown",
|
||||
|
|
|
|||
|
|
@ -289,6 +289,7 @@ function openFile(
|
|||
{
|
||||
agentId,
|
||||
expectedHash,
|
||||
...(result.source ? { expectedSource: result.source } : {}),
|
||||
},
|
||||
);
|
||||
const hash = saved?.file.hash;
|
||||
|
|
@ -312,6 +313,14 @@ function openFile(
|
|||
typeof error.details === "object"
|
||||
? (error.details as { type?: unknown })
|
||||
: null;
|
||||
if (
|
||||
details?.type === "session_file_source_changed" &&
|
||||
viewingSession &&
|
||||
isCurrentSessionWorkspace(state, workspace)
|
||||
) {
|
||||
// Reopened browser rows must use the current root, without replacing this draft.
|
||||
loadSessionWorkspace(state, workspace, true);
|
||||
}
|
||||
if (details?.type === "session_file_conflict") {
|
||||
return {
|
||||
ok: false as const,
|
||||
|
|
@ -329,6 +338,14 @@ function openFile(
|
|||
const latest = await state.sessions.getFile(result.sessionKey, requestPath, {
|
||||
agentId,
|
||||
});
|
||||
// A hash-conflict recovery still belongs to the file source that was opened.
|
||||
if (
|
||||
latest?.source?.workspaceId !== result.source?.workspaceId ||
|
||||
latest?.source?.root !== result.source?.root ||
|
||||
latest?.source?.fileRoot !== result.source?.fileRoot
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const latestFile = latest?.file;
|
||||
if (
|
||||
!latestFile ||
|
||||
|
|
@ -365,6 +382,9 @@ function openFile(
|
|||
result.sessionKey,
|
||||
result.root ?? "",
|
||||
filePath,
|
||||
...(result.source
|
||||
? [result.source.workspaceId, result.source.root ?? "", result.source.fileRoot ?? ""]
|
||||
: []),
|
||||
].join("\u0000"),
|
||||
draftContext: {
|
||||
sessionKey: result.sessionKey,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue