mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-10 17:16:55 +00:00
fix: allow write-scoped archived session deletion (#117468)
This commit is contained in:
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Control UI archived session deletion:** send archive-gated delete requests from Sessions-page row and mixed-selection actions so write-scoped operators can remove archived threads while active-session deletion remains admin-only. Thanks @shakkernerd.
|
||||
- **Control UI command recovery:** keep delayed detached and immediate command failures scoped to their submitting session, preserving failed drafts and attachments for that pane without overwriting the active session. Fixes #116846. Thanks @shakkernerd.
|
||||
- **Microsoft Teams message-tool replies:** keep automatic live previews from duplicating a message already delivered to the current Teams conversation, while preserving distinct follow-up text and cross-conversation sends. Fixes #116397. (#116398) Thanks @a-tokyo.
|
||||
- **Buzz plugin packaging:** keep the live QA runner on the shipped QA runner SDK surface and remove the obsolete package shrinkwrap so standalone npm and ClawHub package builds use current host exports and dependency resolutions. Thanks @shakkernerd.
|
||||
|
||||
@@ -502,7 +502,7 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a session row when the Gateway reports no deletion", async () => {
|
||||
it("archive-gates a row-menu delete and keeps the row when the Gateway reports no deletion", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
@@ -515,7 +515,9 @@ suite.define(() => {
|
||||
"sessions.delete": { ok: true, deleted: false },
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")),
|
||||
sessionRow(key, "Research notes", Date.parse("2026-07-01T15:00:00.000Z")),
|
||||
sessionRow(key, "Research notes", Date.parse("2026-07-01T15:00:00.000Z"), {
|
||||
archived: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -523,7 +525,7 @@ suite.define(() => {
|
||||
page.on("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}sessions`);
|
||||
await page.goto(`${suite.server.baseUrl}sessions?status=archived`);
|
||||
const row = page.locator(".session-data-row").filter({ hasText: "Research notes" });
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
@@ -533,7 +535,11 @@ suite.define(() => {
|
||||
);
|
||||
|
||||
const request = await gateway.waitForRequest("sessions.delete");
|
||||
expect(requireRecord(request.params)).toMatchObject({ key });
|
||||
expect(requireRecord(request.params)).toMatchObject({
|
||||
archivedOnly: true,
|
||||
deleteTranscript: true,
|
||||
key,
|
||||
});
|
||||
await row.waitFor({ state: "visible" });
|
||||
} finally {
|
||||
await context.close();
|
||||
|
||||
@@ -548,24 +548,87 @@ describe("sessions page lifecycle", () => {
|
||||
expect(page.selectedKeys).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("routes a confirmed row-menu deletion through the scoped bulk owner", async () => {
|
||||
it("archive-gates a confirmed archived row-menu deletion", async () => {
|
||||
const key = "agent:main:work";
|
||||
const sessions = createSessions({
|
||||
deleteMany: vi.fn(async () => ({ deleted: [key], errors: [], preservedWorktrees: [] })),
|
||||
});
|
||||
const { gateway } = createGateway({} as GatewayBrowserClient);
|
||||
const page = await createPage(createContext(gateway, sessions));
|
||||
const row = { key, label: "Work" } as GatewaySessionRow;
|
||||
const row = { key, label: "Work", archived: true } as GatewaySessionRow;
|
||||
page.result = { count: 1, sessions: [row] } as SessionsListResult;
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
await page.deleteSessionFromMenu(row);
|
||||
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(sessions.deleteMany).toHaveBeenCalledWith([{ key, agentId: undefined }]);
|
||||
expect(sessions.deleteMany).toHaveBeenCalledWith([
|
||||
{ key, agentId: undefined, archivedOnly: true },
|
||||
]);
|
||||
expect(page.result?.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["active", false],
|
||||
["unknown", undefined],
|
||||
] as const)("keeps %s row-menu deletion admin-only", async (_state, archived) => {
|
||||
const key = `agent:main:${_state}`;
|
||||
const sessions = createSessions({
|
||||
deleteMany: vi.fn(async () => ({ deleted: [], errors: [], preservedWorktrees: [] })),
|
||||
});
|
||||
const { gateway } = createGateway({} as GatewayBrowserClient);
|
||||
const page = await createPage(createContext(gateway, sessions));
|
||||
const row = {
|
||||
key,
|
||||
label: _state,
|
||||
...(archived === undefined ? {} : { archived }),
|
||||
} as GatewaySessionRow;
|
||||
page.result = { count: 1, sessions: [row] } as SessionsListResult;
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
await page.deleteSessionFromMenu(row);
|
||||
|
||||
expect(sessions.deleteMany).toHaveBeenCalledWith([{ key, agentId: undefined }]);
|
||||
});
|
||||
|
||||
it("derives archive gates per selected row and keeps unknown rows admin-only", async () => {
|
||||
const activeKey = "agent:main:active";
|
||||
const archivedKey = "agent:main:archived";
|
||||
const unknownKey = "agent:main:unknown";
|
||||
const sessions = createSessions({
|
||||
deleteMany: vi.fn(async () => ({
|
||||
deleted: [archivedKey],
|
||||
errors: ["active denied", "unknown denied"],
|
||||
preservedWorktrees: [],
|
||||
})),
|
||||
});
|
||||
const { gateway } = createGateway({} as GatewayBrowserClient);
|
||||
const page = await createPage(createContext(gateway, sessions));
|
||||
page.result = {
|
||||
count: 2,
|
||||
sessions: [
|
||||
{ key: activeKey, archived: false },
|
||||
{ key: archivedKey, archived: true },
|
||||
],
|
||||
} as SessionsListResult;
|
||||
page.selectedKeys = new Set([activeKey, archivedKey, unknownKey]);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
await page.deleteSelected();
|
||||
|
||||
expect(sessions.deleteMany).toHaveBeenCalledWith([
|
||||
{ key: activeKey, agentId: undefined },
|
||||
{ key: archivedKey, agentId: undefined, archivedOnly: true },
|
||||
{ key: unknownKey, agentId: undefined },
|
||||
]);
|
||||
expect(page.result).toMatchObject({
|
||||
count: 1,
|
||||
sessions: [{ key: activeKey, archived: false }],
|
||||
});
|
||||
expect(page.selectedKeys).toEqual(new Set([activeKey, unknownKey]));
|
||||
expect(page.error).toBe("active denied; unknown denied");
|
||||
});
|
||||
|
||||
it("stops an active cloud worker and refreshes the session roster", async () => {
|
||||
const request = vi.fn(() => Promise.resolve({ ok: true }));
|
||||
const list = vi.fn(async () => ({ count: 0, sessions: [] }) as unknown as SessionsListResult);
|
||||
|
||||
@@ -88,6 +88,8 @@ type SessionsPageRequestScope = {
|
||||
|
||||
type SessionsPageMutationResult = "completed" | "failed" | "stale";
|
||||
|
||||
type SessionDeleteRow = Pick<GatewaySessionRow, "key" | "archived">;
|
||||
|
||||
class SessionsPage extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
private context?: ApplicationContext;
|
||||
@@ -693,14 +695,17 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.deleteSessions(keys);
|
||||
const rowsByKey = new Map(this.result?.sessions.map((row) => [row.key, row]) ?? []);
|
||||
// Only current row state may opt into write-scoped archive deletion.
|
||||
// Unknown selections stay unflagged and therefore admin-only.
|
||||
await this.deleteSessions(keys.map((key) => rowsByKey.get(key) ?? { key }));
|
||||
}
|
||||
|
||||
private async deleteSessions(
|
||||
keys: string[],
|
||||
options: { deleteTranscript?: boolean; archivedOnly?: boolean } = {},
|
||||
rows: SessionDeleteRow[],
|
||||
options: { deleteTranscript?: boolean } = {},
|
||||
) {
|
||||
if (keys.length === 0 || this.loading || this.sessionMutationPending) {
|
||||
if (rows.length === 0 || this.loading || this.sessionMutationPending) {
|
||||
return;
|
||||
}
|
||||
const scope = this.captureRequestScope();
|
||||
@@ -710,10 +715,11 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
this.sessionMutationPending = true;
|
||||
try {
|
||||
const result = await scope.sessions.deleteMany(
|
||||
keys.map((key) => ({
|
||||
key,
|
||||
agentId: this.sessionAgentId(key, scope.context),
|
||||
rows.map((row) => ({
|
||||
key: row.key,
|
||||
agentId: this.sessionAgentId(row.key, scope.context),
|
||||
...options,
|
||||
...(row.archived === true ? { archivedOnly: true } : {}),
|
||||
})),
|
||||
);
|
||||
if (!this.isRequestScopeCurrent(scope)) {
|
||||
@@ -824,14 +830,16 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const keys = rows.filter((row) => row.archived === true).map((row) => row.key);
|
||||
const archivedRows = rows.filter((row) => row.archived === true);
|
||||
if (
|
||||
keys.length === 0 ||
|
||||
!window.confirm(t("sessionsView.deleteAllArchivedConfirm", { count: String(keys.length) }))
|
||||
archivedRows.length === 0 ||
|
||||
!window.confirm(
|
||||
t("sessionsView.deleteAllArchivedConfirm", { count: String(archivedRows.length) }),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.deleteSessions(keys, { deleteTranscript: true, archivedOnly: true });
|
||||
await this.deleteSessions(archivedRows, { deleteTranscript: true });
|
||||
}
|
||||
|
||||
private async deleteSessionFromMenu(row: GatewaySessionRow) {
|
||||
@@ -839,7 +847,7 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
if (!window.confirm(t("sessionsView.deleteSessionConfirm", { session: label }))) {
|
||||
return;
|
||||
}
|
||||
await this.deleteSessions([row.key]);
|
||||
await this.deleteSessions([row]);
|
||||
}
|
||||
|
||||
private async stopCloudWorker(row: GatewaySessionRow) {
|
||||
|
||||
Reference in New Issue
Block a user