feat(workspace): add safe workspace reset command

This commit is contained in:
Tak Hoffman
2026-04-16 19:52:51 -05:00
parent 8205de84a9
commit 208ea2c330
10 changed files with 546 additions and 3 deletions

View File

@@ -31,7 +31,7 @@ async function loadDevTemplate(name: string, fallback: string): Promise<string>
}
}
const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
export const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
const profile = normalizeOptionalLowercaseString(env.OPENCLAW_PROFILE);
if (profile === "dev") {
@@ -54,7 +54,7 @@ async function writeFileIfMissing(filePath: string, content: string) {
}
}
async function ensureDevWorkspace(dir: string) {
export async function ensureDevWorkspace(dir: string) {
const resolvedDir = resolveUserPath(dir);
await fs.promises.mkdir(resolvedDir, { recursive: true });

View File

@@ -76,6 +76,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
loadModule: () => import("./register.backup.js"),
exportName: "registerBackupCommand",
},
{
commandNames: ["workspace"],
loadModule: () => import("./register.workspace.js"),
exportName: "registerWorkspaceCommand",
},
{
commandNames: ["doctor", "dashboard", "reset", "uninstall"],
loadModule: () => import("./register.maintenance.js"),

View File

@@ -18,6 +18,13 @@ vi.mock("./register.backup.js", () => ({
},
}));
vi.mock("./register.workspace.js", () => ({
registerWorkspaceCommand: (program: Command) => {
const workspace = program.command("workspace");
workspace.command("reset");
},
}));
vi.mock("./register.maintenance.js", () => ({
registerMaintenanceCommands: (program: Command) => {
program.command("doctor");
@@ -70,6 +77,7 @@ describe("command-registry", () => {
expect(names).toContain("mcp");
expect(names).toContain("agent");
expect(names).toContain("agents");
expect(names).toContain("workspace");
});
it("returns only commands that support subcommands", () => {
@@ -77,6 +85,7 @@ describe("command-registry", () => {
expect(names).toContain("config");
expect(names).toContain("agents");
expect(names).toContain("backup");
expect(names).toContain("workspace");
expect(names).toContain("mcp");
expect(names).toContain("sessions");
expect(names).toContain("tasks");

View File

@@ -50,6 +50,11 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
description: "Uninstall the gateway service + local data (CLI remains)",
hasSubcommands: false,
},
{
name: "workspace",
description: "Manage agent workspaces",
hasSubcommands: true,
},
{
name: "message",
description: "Send, read, and manage messages",

View File

@@ -0,0 +1,57 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerWorkspaceCommand } from "./register.workspace.js";
const mocks = vi.hoisted(() => ({
workspaceResetCommand: vi.fn(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
}));
vi.mock("../../commands/workspace.js", () => ({
workspaceResetCommand: mocks.workspaceResetCommand,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
describe("registerWorkspaceCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes workspace reset options through to the command", async () => {
const program = new Command();
registerWorkspaceCommand(program);
await program.parseAsync(
[
"workspace",
"reset",
"--workspace",
"/tmp/ws",
"--agent",
"ops",
"--include-sessions",
"--yes",
"--dry-run",
],
{ from: "user" },
);
expect(mocks.workspaceResetCommand).toHaveBeenCalledWith(
mocks.runtime,
expect.objectContaining({
workspace: "/tmp/ws",
agent: "ops",
includeSessions: true,
yes: true,
dryRun: true,
}),
);
});
});

View File

@@ -0,0 +1,57 @@
import type { Command } from "commander";
import { workspaceResetCommand } from "../../commands/workspace.js";
import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
export function registerWorkspaceCommand(program: Command) {
const workspace = program
.command("workspace")
.description("Manage agent workspaces")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/workspace", "docs.openclaw.ai/cli/workspace")}\n`,
);
workspace
.command("reset")
.description("Reset only the active agent workspace and reseed it as a fresh agent")
.option("--workspace <dir>", "Explicit workspace directory to reset")
.option("--agent <id>", "Agent id to resolve workspace/sessions from the active config")
.option("--include-sessions", "Also clear this agent's session transcripts", false)
.option("--yes", "Skip the confirmation prompt", false)
.option("--dry-run", "Print the reset plan without moving anything", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw workspace reset", "Trash and reseed only the active workspace."],
[
"openclaw workspace reset --include-sessions",
"Also clear the active agent's session transcripts.",
],
[
"openclaw workspace reset --agent ops",
"Reset the workspace resolved for a specific agent id.",
],
[
"openclaw workspace reset --workspace ~/tmp/test-workspace --dry-run",
"Preview a custom workspace-only reset.",
],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await workspaceResetCommand(defaultRuntime, {
workspace: opts.workspace as string | undefined,
agent: opts.agent as string | undefined,
includeSessions: Boolean(opts.includeSessions),
yes: Boolean(opts.yes),
dryRun: Boolean(opts.dryRun),
});
});
});
}

View File

@@ -1,6 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
moveToTrash,
normalizeGatewayTokenInput,
openUrl,
probeGatewayReachable,
@@ -223,6 +226,43 @@ describe("normalizeGatewayTokenInput", () => {
});
});
describe("moveToTrash", () => {
it("falls back to mv into ~/.Trash when trash exits successfully but leaves the path in place", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-home-"));
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-target-"));
const target = path.join(targetDir, "workspace");
await fs.mkdir(target, { recursive: true });
await fs.writeFile(path.join(target, "stale.txt"), "old", "utf-8");
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
mocks.runCommandWithTimeout.mockImplementationOnce(async () => ({
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
}));
mocks.runCommandWithTimeout.mockImplementationOnce(async (argv) => {
await fs.rename(argv[1], argv[2]);
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
};
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await moveToTrash(target, runtime as never);
await expect(fs.access(target)).rejects.toThrow();
await expect(fs.access(path.join(tempHome, ".Trash", "workspace"))).resolves.toBeUndefined();
expect(runtime.log).toHaveBeenCalledWith(`Moved to Trash: ${target}`);
});
});
describe("validateGatewayPasswordInput", () => {
it("requires a non-empty password", () => {
expect(validateGatewayPasswordInput("")).toBe("Required");

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { inspect } from "node:util";
import { cancel, isCancel } from "@clack/prompts";
@@ -187,6 +188,28 @@ export function resolveNodeManagerOptions(): Array<{
];
}
async function pathExists(pathname: string): Promise<boolean> {
try {
await fs.access(pathname);
return true;
} catch {
return false;
}
}
async function resolveTrashDestination(pathname: string): Promise<string> {
const trashDir = path.join(os.homedir(), ".Trash");
await fs.mkdir(trashDir, { recursive: true });
const baseName = path.basename(pathname);
let candidate = path.join(trashDir, baseName);
let suffix = 1;
while (await pathExists(candidate)) {
candidate = path.join(trashDir, `${baseName}.${suffix}`);
suffix += 1;
}
return candidate;
}
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<void> {
if (!pathname) {
return;
@@ -198,8 +221,26 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
}
try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
if (!(await pathExists(pathname))) {
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
return;
}
} catch {
// Fall through to a verified mv-based fallback below.
}
try {
const destination = await resolveTrashDestination(pathname);
await runCommandWithTimeout(["mv", pathname, destination], { timeoutMs: 5000 });
if (!(await pathExists(pathname))) {
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
return;
}
} catch {
// Surface the manual action guidance below.
}
if (await pathExists(pathname)) {
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
}
}

View File

@@ -0,0 +1,195 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { workspaceResetCommand } from "./workspace.js";
const mocks = vi.hoisted(() => ({
readBestEffortConfig: vi.fn(async () => ({})),
resolveDefaultAgentId: vi.fn(() => "main"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"),
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/tmp/sessions-main"),
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "BOOTSTRAP.md"), "# BOOTSTRAP\n", "utf-8");
return { dir };
}),
ensureDevWorkspace: vi.fn(async (dir: string) => {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "AGENTS.md"), "# DEV AGENTS\n", "utf-8");
}),
resolveDevWorkspaceDir: vi.fn(() => "/tmp/workspace-dev"),
moveToTrash: vi.fn(async (target: string) => {
await fs.rm(target, { recursive: true, force: true });
}),
confirm: vi.fn(async () => true),
cancel: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
readBestEffortConfig: mocks.readBestEffortConfig,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
}));
vi.mock("../config/sessions/paths.js", () => ({
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
}));
vi.mock("../agents/workspace.js", () => ({
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
}));
vi.mock("../cli/gateway-cli/dev.js", () => ({
ensureDevWorkspace: mocks.ensureDevWorkspace,
resolveDevWorkspaceDir: mocks.resolveDevWorkspaceDir,
}));
vi.mock("./onboard-helpers.js", () => ({
moveToTrash: mocks.moveToTrash,
}));
vi.mock("@clack/prompts", () => ({
confirm: mocks.confirm,
cancel: mocks.cancel,
isCancel: (value: unknown) => value === Symbol.for("clack.cancel"),
}));
describe("workspaceResetCommand", () => {
let tempRoot: string;
let runtime: {
log: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
exit: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
vi.clearAllMocks();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-reset-"));
runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
});
it("resets only the workspace by default and reseeds it", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
const sessionsDir = path.join(tempRoot, "sessions-main");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "stale.txt"), "old", "utf-8");
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
await workspaceResetCommand(runtime as never, {});
expect(mocks.confirm).toHaveBeenCalledTimes(1);
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
expect(mocks.moveToTrash).not.toHaveBeenCalledWith(sessionsDir, runtime);
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
"BOOTSTRAP",
);
expect(await fs.stat(sessionsDir)).toBeDefined();
});
it("also resets sessions when --include-sessions is enabled", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
const sessionsDir = path.join(tempRoot, "sessions-main");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(path.join(sessionsDir, "old.log"), "old", "utf-8");
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
await workspaceResetCommand(runtime as never, { includeSessions: true });
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
expect(mocks.moveToTrash).toHaveBeenCalledWith(sessionsDir, runtime);
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
"BOOTSTRAP",
);
expect(await fs.readdir(sessionsDir)).toEqual([]);
});
it("rejects combining --workspace with --include-sessions", async () => {
const customWorkspace = path.join(tempRoot, "custom-workspace");
await fs.mkdir(customWorkspace, { recursive: true });
await expect(
workspaceResetCommand(runtime as never, {
workspace: customWorkspace,
includeSessions: true,
}),
).rejects.toThrow(
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
);
expect(mocks.moveToTrash).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.ensureDevWorkspace).not.toHaveBeenCalled();
});
it("supports dry-run without modifying workspace or sessions", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
const sessionsDir = path.join(tempRoot, "sessions-main");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(sessionsDir, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
await workspaceResetCommand(runtime as never, { includeSessions: true, dryRun: true });
expect(mocks.moveToTrash).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(await fs.stat(workspaceDir)).toBeDefined();
expect(await fs.stat(sessionsDir)).toBeDefined();
});
it("skips confirmation when --yes is enabled", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
await fs.mkdir(workspaceDir, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
await workspaceResetCommand(runtime as never, { yes: true });
expect(mocks.confirm).not.toHaveBeenCalled();
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
});
it("cancels without touching anything when confirmation is declined", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
await fs.mkdir(workspaceDir, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.confirm.mockResolvedValueOnce(false);
await workspaceResetCommand(runtime as never, {});
expect(mocks.moveToTrash).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(0);
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Workspace reseeded"));
});
it("reuses the dev reseed helper for the active dev workspace", async () => {
const devWorkspace = path.join(tempRoot, "workspace-dev");
await fs.mkdir(devWorkspace, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(devWorkspace);
mocks.resolveDevWorkspaceDir.mockReturnValue(devWorkspace);
await workspaceResetCommand(runtime as never, { yes: true });
expect(mocks.ensureDevWorkspace).toHaveBeenCalledWith(devWorkspace);
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(await fs.readFile(path.join(devWorkspace, "AGENTS.md"), "utf-8")).toContain(
"DEV AGENTS",
);
});
});

134
src/commands/workspace.ts Normal file
View File

@@ -0,0 +1,134 @@
import fs from "node:fs/promises";
import { cancel, confirm, isCancel } from "@clack/prompts";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { ensureAgentWorkspace } from "../agents/workspace.js";
import { formatCliCommand } from "../cli/command-format.js";
import { ensureDevWorkspace, resolveDevWorkspaceDir } from "../cli/gateway-cli/dev.js";
import { readBestEffortConfig } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { moveToTrash } from "./onboard-helpers.js";
export type WorkspaceResetOptions = {
workspace?: string;
agent?: string;
includeSessions?: boolean;
yes?: boolean;
dryRun?: boolean;
};
function hasValue(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
async function pathExists(target: string): Promise<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
function describeResetPlan(params: {
workspaceDir: string;
sessionsDir: string;
includeSessions: boolean;
}): string[] {
return [
`Workspace: ${shortenHomePath(params.workspaceDir)}`,
params.includeSessions ? `Sessions: ${shortenHomePath(params.sessionsDir)}` : undefined,
"Preserves: config, credentials, channels, and gateway auth.",
].filter((line): line is string => Boolean(line));
}
function isActiveDevWorkspaceTarget(workspaceDir: string): boolean {
return resolveUserPath(workspaceDir) === resolveUserPath(resolveDevWorkspaceDir(process.env));
}
export async function workspaceResetCommand(
runtime: RuntimeEnv,
opts: WorkspaceResetOptions,
): Promise<void> {
const cfg = await readBestEffortConfig();
const hasExplicitWorkspace = hasValue(opts.workspace);
const agentId = hasValue(opts.agent) ? opts.agent.trim() : resolveDefaultAgentId(cfg);
const workspaceDir = hasExplicitWorkspace
? resolveUserPath(opts.workspace!.trim())
: resolveAgentWorkspaceDir(cfg, agentId);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
const includeSessions = Boolean(opts.includeSessions);
const dryRun = Boolean(opts.dryRun);
if (hasExplicitWorkspace && includeSessions) {
throw new Error(
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
);
}
for (const line of describeResetPlan({ workspaceDir, sessionsDir, includeSessions })) {
runtime.log(line);
}
if (dryRun) {
runtime.log(`[dry-run] trash ${shortenHomePath(workspaceDir)}`);
if (includeSessions) {
runtime.log(`[dry-run] trash ${shortenHomePath(sessionsDir)}`);
}
runtime.log(
`[dry-run] reseed ${shortenHomePath(workspaceDir)} with default workspace files and BOOTSTRAP.md`,
);
return;
}
if (!opts.yes) {
const ok = await confirm({
message: stylePromptMessage(
`Trash and reseed ${shortenHomePath(workspaceDir)}${includeSessions ? " and clear this agent's sessions" : ""}?`,
),
});
if (isCancel(ok) || !ok) {
cancel(stylePromptTitle("Workspace reset cancelled.") ?? "Workspace reset cancelled.");
runtime.exit(0);
return;
}
}
await moveToTrash(workspaceDir, runtime);
if (await pathExists(workspaceDir)) {
throw new Error(
`Workspace reset did not remove ${shortenHomePath(workspaceDir)}. Move it manually or retry.`,
);
}
if (includeSessions) {
await moveToTrash(sessionsDir, runtime);
if (await pathExists(sessionsDir)) {
throw new Error(
`Session reset did not remove ${shortenHomePath(sessionsDir)}. Move it manually or retry.`,
);
}
}
if (isActiveDevWorkspaceTarget(workspaceDir)) {
await ensureDevWorkspace(workspaceDir);
} else {
await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: true,
});
}
runtime.log(`Workspace reseeded: ${shortenHomePath(workspaceDir)}`);
if (includeSessions) {
await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions reset: ${shortenHomePath(sessionsDir)}`);
}
runtime.log("Workspace reset complete.");
runtime.log("Recommended next steps:");
runtime.log(`- ${formatCliCommand("openclaw onboard")}`);
runtime.log(`- ${formatCliCommand("openclaw gateway run")}`);
}