mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor: store workspace setup state in SQLite (#109147)
* refactor(state): move workspace setup state to SQLite * test(state): track workspace cleanup temp dirs * fix(state): satisfy workspace migration gates * docs(state): fix migration list indentation * fix(state): verify sibling migration claims * fix(state): remove obsolete claim probe
This commit is contained in:
committed by
GitHub
parent
2db77fb3fb
commit
2cd3ee43ee
+6
-5
@@ -27,17 +27,18 @@ openclaw reset --scope full --yes --non-interactive
|
||||
|
||||
## Scopes
|
||||
|
||||
| Scope | Removes | Stops gateway first |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| `config` | config file only | no |
|
||||
| `config+creds+sessions` | config file, OAuth/credentials dir, per-agent session directories | yes |
|
||||
| `full` | state dir (including config/creds if nested inside it) plus workspace dirs and workspace attestations | yes |
|
||||
| Scope | Removes | Stops gateway first |
|
||||
| ----------------------- | --------------------------------------------------------------------------- | ------------------- |
|
||||
| `config` | config file only | no |
|
||||
| `config+creds+sessions` | config file, OAuth/credentials dir, per-agent session directories | yes |
|
||||
| `full` | state dir (including the shared SQLite database) plus workspace directories | yes |
|
||||
|
||||
`config+creds+sessions` and `full` stop a running managed gateway service before deleting state.
|
||||
|
||||
## Notes
|
||||
|
||||
- Run `openclaw backup create` first for a restorable snapshot before removing local state.
|
||||
- Workspace setup state and attestations are rows in the shared SQLite database, so `full` removes them with the state directory; there are no current attestation sidecar files to remove separately.
|
||||
- Without `--scope`, `openclaw reset` prompts interactively for the scope to remove.
|
||||
- `--non-interactive` is only valid when both `--scope` and `--yes` are set.
|
||||
- `config+creds+sessions` and `full` print `Next: openclaw onboard --install-daemon` when done.
|
||||
|
||||
@@ -111,6 +111,7 @@ If a bootstrap file is missing, OpenClaw injects a "missing file" marker into th
|
||||
These live under `~/.openclaw/` and should NOT be committed to the workspace repo:
|
||||
|
||||
- `~/.openclaw/openclaw.json` (config)
|
||||
- `~/.openclaw/state/openclaw.sqlite` (shared workspace setup state and attestations)
|
||||
- `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (model auth profiles: OAuth + API keys)
|
||||
- `~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite` (session rows, transcripts, and per-agent runtime state)
|
||||
- `~/.openclaw/agents/<agentId>/agent/codex-home/` (per-agent Codex runtime account, config, skills, plugins, and native thread state)
|
||||
@@ -120,6 +121,12 @@ These live under `~/.openclaw/` and should NOT be committed to the workspace rep
|
||||
|
||||
If you need to migrate sessions or config, copy them separately and keep them out of version control.
|
||||
|
||||
Older OpenClaw releases wrote `openclaw-workspace-state.json`,
|
||||
`.openclaw/workspace-state.json`, and `.attested` workspace sidecars. Current
|
||||
runtime uses only the shared SQLite database for that state. If Doctor reports
|
||||
one of these files, run `openclaw doctor --fix`; Doctor imports valid legacy
|
||||
state and deletes a source only after verifying the database rows.
|
||||
|
||||
## Git backup (recommended, private)
|
||||
|
||||
Treat the workspace as private memory. Put it in a **private** git repo so it is backed up and recoverable.
|
||||
|
||||
+10
-1
@@ -47,7 +47,16 @@ Blank files are skipped. Large files are trimmed and truncated with a marker so
|
||||
|
||||
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). While it is pending, OpenClaw keeps it in Project Context and adds system-prompt bootstrap guidance for the initial ritual instead of copying it into the user message. If you delete it after completing the ritual, it is not recreated on later restarts.
|
||||
|
||||
After a workspace has been observed, OpenClaw also keeps a state-dir attestation marker for the workspace path. If a recently attested workspace disappears or is wiped, startup refuses to silently reseed `BOOTSTRAP.md`; restore the workspace or use a full onboard reset so the workspace and marker are cleared together.
|
||||
After a workspace has been observed, OpenClaw stores its setup state and
|
||||
attestation in the shared SQLite database at
|
||||
`~/.openclaw/state/openclaw.sqlite`. If a recently attested workspace
|
||||
disappears or is wiped, startup refuses to silently reseed `BOOTSTRAP.md`;
|
||||
restore the workspace or use a full onboard reset so the workspace and its
|
||||
database state are cleared together.
|
||||
|
||||
Older releases used workspace JSON and `.attested` sidecar files. Runtime does
|
||||
not read those files. Run `openclaw doctor --fix` to validate them, import their
|
||||
state into SQLite, and remove each source after the imported rows are verified.
|
||||
|
||||
To disable bootstrap file creation entirely (for pre-seeded workspaces), set:
|
||||
|
||||
|
||||
@@ -146,6 +146,10 @@ without exceptions outside doctor/import/export/debug boundaries.
|
||||
- Backup: `sqlite-runtime`. Backup stages compact SQLite snapshots, omits live
|
||||
WAL/SHM sidecars, verifies SQLite integrity, and records backup runs in the
|
||||
global database.
|
||||
- Workspace setup: `sqlite-runtime`. Setup completion, workspace attestations,
|
||||
and generated bootstrap hashes live in typed shared SQLite tables. Runtime
|
||||
does not read or write the retired workspace JSON and `.attested` sidecars;
|
||||
Doctor owns their validated import and verified removal.
|
||||
- Doctor migration: `migrating`, intentionally. Doctor imports legacy JSON,
|
||||
JSONL, and retired sidecar stores into SQLite, records migration runs/sources,
|
||||
and removes successful sources.
|
||||
@@ -315,7 +319,8 @@ The branch already has a real shared SQLite base:
|
||||
`skill_uploads`, `capture_sessions`, `capture_events`, `capture_blobs`,
|
||||
`sandbox_registry_entries`, `cron_jobs`, `commitments`,
|
||||
`delivery_queue_entries`, `model_capability_cache`,
|
||||
`workspace_setup_state`, `native_hook_relay_bridges`,
|
||||
`workspace_setup_state`, `workspace_path_aliases`, `workspace_attestations`,
|
||||
`workspace_generated_bootstrap_hashes`, `native_hook_relay_bridges`,
|
||||
`current_conversation_bindings`, `plugin_binding_approvals`,
|
||||
`tui_last_sessions`, `acp_sessions`, `acp_replay_sessions`,
|
||||
`acp_replay_events`, `task_runs`, `task_delivery_state`, `flow_runs`,
|
||||
@@ -455,11 +460,18 @@ The branch already has a real shared SQLite base:
|
||||
instead of scanning a whole namespace or relying on `LIKE` path matching.
|
||||
- `src/agents/runtime-worker.entry.ts` creates per-run SQLite VFS, tool artifact,
|
||||
run artifact, and scoped cache stores for workers.
|
||||
- Workspace bootstrap completion markers now live in typed shared
|
||||
`workspace_setup_state` rows keyed by resolved workspace path instead of
|
||||
`.openclaw/workspace-state.json`; runtime no longer reads or rewrites the
|
||||
legacy workspace marker, and helper APIs no longer pass around a fake
|
||||
`.openclaw/setup-state` path just to derive storage identity.
|
||||
- Workspace bootstrap completion, attestation recency, and generated bootstrap
|
||||
hashes now live in typed shared `workspace_setup_state`,
|
||||
`workspace_path_aliases`, `workspace_attestations`, and
|
||||
`workspace_generated_bootstrap_hashes` rows keyed by canonical workspace
|
||||
identity. Persisted lexical and real-path aliases keep vanished-workspace
|
||||
protection stable after a configured symlink disappears; repointed aliases
|
||||
fail closed. Runtime no longer reads or writes
|
||||
`openclaw-workspace-state.json`, `.openclaw/workspace-state.json`, state-dir
|
||||
`workspace-attestations/*.attested`, or sibling `<workspace>.attested`
|
||||
sidecars. `openclaw doctor --fix` validates and claims legacy sources,
|
||||
imports them into SQLite with migration receipts, verifies the canonical
|
||||
rows, and only then removes the claimed files.
|
||||
- The shared schema reserves an `exec_approvals_config` singleton row, but the
|
||||
runtime cutover remains pending. TypeScript and the macOS companion still use
|
||||
the state-scoped JSON file and must move to SQLite together.
|
||||
@@ -1503,6 +1515,9 @@ device_identities(identity_key, device_id, public_key_pem, private_key_pem, crea
|
||||
device_auth_tokens(device_id, role, token, scopes_json, updated_at_ms)
|
||||
macos_port_guardian_records(pid, port, command, mode, timestamp)
|
||||
workspace_setup_state(workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at)
|
||||
workspace_path_aliases(alias_key, alias_path, workspace_key, workspace_path, updated_at_ms)
|
||||
workspace_attestations(workspace_key, attested_at_ms, updated_at_ms)
|
||||
workspace_generated_bootstrap_hashes(workspace_key, filename, sha256)
|
||||
native_hook_relay_bridges(relay_id, pid, hostname, port, token, expires_at_ms, updated_at_ms)
|
||||
model_capability_cache(provider_id, model_id, name, input_text, input_image, reasoning, supports_tools, context_window, max_tokens, cost_input, cost_output, cost_cache_read, cost_cache_write, updated_at_ms)
|
||||
agent_model_catalogs(catalog_key, agent_dir, raw_json, updated_at)
|
||||
@@ -2194,7 +2209,10 @@ Add a repo check that fails new runtime writes to legacy state paths:
|
||||
- `auth-profiles.json`
|
||||
- `auth-state.json`
|
||||
- `exec-approvals.json`
|
||||
- `openclaw-workspace-state.json`
|
||||
- `workspace-state.json`
|
||||
- `workspace-attestations/*.attested`
|
||||
- sibling `<workspace>.attested`
|
||||
- Matrix `credentials*.json` and `recovery-key.json`
|
||||
- `cron/runs/*.jsonl`
|
||||
- `cron/jobs.json`
|
||||
|
||||
@@ -89,7 +89,10 @@ const legacyStorePatterns = [
|
||||
/\bacp\/event-ledger\.json\b/u,
|
||||
/\bcache\/[^"'`]*\.json\b/u,
|
||||
/\bagents\/[^"'`]+\/agent\/(?:auth|models)\.json\b/u,
|
||||
/\b(?:credentials\/oauth|github-copilot\.token|openrouter-models|auth-profiles|auth-state|exec-approvals|workspace-state)\.json\b/u,
|
||||
/\b(?:credentials\/oauth|github-copilot\.token|openrouter-models|auth-profiles|auth-state|exec-approvals|(?:openclaw-)?workspace-state)\.json\b/u,
|
||||
// Dynamic template spans resolve to `*`, so the start alternative also
|
||||
// catches `${workspaceKey}.attested` and `${workspaceDir}.attested`.
|
||||
/(?:^|[/\\])[^/\\"'`]+\.attested\b/u,
|
||||
/\btui\/last-session\.json\b/u,
|
||||
/\bcommitments\/commitments\.json\b/u,
|
||||
/\bmedia\/outgoing\/records\/[^"'`]*\.json\b/u,
|
||||
@@ -116,6 +119,7 @@ const allowedRuntimeMigrationPaths = [
|
||||
"src/infra/state-migrations.commitments.ts",
|
||||
"src/infra/state-migrations.managed-outgoing-images.ts",
|
||||
"src/infra/state-migrations.apns.ts",
|
||||
"src/infra/state-migrations.workspace-setup.ts",
|
||||
"src/infra/state-migrations.web-push.ts",
|
||||
"src/infra/state-migrations.node-host.ts",
|
||||
"src/infra/state-migrations.subagent-registry.ts",
|
||||
|
||||
@@ -9,8 +9,13 @@ import {
|
||||
registerInternalHook,
|
||||
type AgentBootstrapHookContext,
|
||||
} from "../hooks/internal-hooks.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { makeTempWorkspace } from "../test-helpers/workspace.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
} from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
hasCompletedBootstrapTurn,
|
||||
@@ -19,8 +24,12 @@ import {
|
||||
resolveBootstrapFilesForRun,
|
||||
resolveContextInjectionMode,
|
||||
} from "./bootstrap-files.js";
|
||||
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
|
||||
import { mergeWorkspaceSetupState } from "./workspace-state-store.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
|
||||
let testState: OpenClawTestState | undefined;
|
||||
|
||||
function registerExtraBootstrapFileHook() {
|
||||
registerInternalHook("agent:bootstrap", (event) => {
|
||||
const context = event.context as AgentBootstrapHookContext;
|
||||
@@ -111,15 +120,10 @@ async function createHeartbeatAgentsWorkspace() {
|
||||
}
|
||||
|
||||
async function writeCompletedWorkspaceState(workspaceDir: string): Promise<void> {
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "openclaw-workspace-state.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
|
||||
setupCompletedAt: "2026-05-16T00:00:01.000Z",
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
mergeWorkspaceSetupState(workspaceDir, {
|
||||
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
|
||||
setupCompletedAt: "2026-05-16T00:00:01.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
async function writeLegacyCompletedWorkspaceState(workspaceDir: string): Promise<void> {
|
||||
@@ -144,8 +148,21 @@ function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) {
|
||||
}
|
||||
|
||||
describe("resolveBootstrapFilesForRun", () => {
|
||||
beforeEach(() => clearInternalHooks());
|
||||
afterEach(() => clearInternalHooks());
|
||||
beforeEach(async () => {
|
||||
clearInternalHooks();
|
||||
resetLegacyWorkspaceStateCheckForTest();
|
||||
testState = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-bootstrap-state-",
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
clearInternalHooks();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetLegacyWorkspaceStateCheckForTest();
|
||||
await testState?.cleanup();
|
||||
testState = undefined;
|
||||
});
|
||||
|
||||
it("applies bootstrap hook overrides", async () => {
|
||||
registerExtraBootstrapFileHook();
|
||||
@@ -211,7 +228,7 @@ describe("resolveBootstrapFilesForRun", () => {
|
||||
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
|
||||
});
|
||||
|
||||
it("ignores stale workspace BOOTSTRAP.md when legacy setup state is completed", async () => {
|
||||
it("keeps BOOTSTRAP.md until Doctor migrates legacy setup state", async () => {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await writeLegacyCompletedWorkspaceState(workspaceDir);
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
|
||||
@@ -220,7 +237,7 @@ describe("resolveBootstrapFilesForRun", () => {
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir });
|
||||
|
||||
expect(files.map((file) => file.name)).toContain("AGENTS.md");
|
||||
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
|
||||
expect(files.map((file) => file.name)).toContain("BOOTSTRAP.md");
|
||||
});
|
||||
|
||||
it("keeps BOOTSTRAP.md when current setup state cannot be read", async () => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import "./workspace-legacy-state.js";
|
||||
|
||||
type WorkspaceLegacyStateTestApi = {
|
||||
resetLegacyWorkspaceStateCheckForTest(): void;
|
||||
};
|
||||
|
||||
function getTestApi(): WorkspaceLegacyStateTestApi {
|
||||
const api = (globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.workspaceLegacyStateTestApi")
|
||||
];
|
||||
if (!api) {
|
||||
throw new Error("workspace legacy state test API is unavailable");
|
||||
}
|
||||
return api as WorkspaceLegacyStateTestApi;
|
||||
}
|
||||
|
||||
export function resetLegacyWorkspaceStateCheckForTest(): void {
|
||||
getTestApi().resetLegacyWorkspaceStateCheckForTest();
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import {
|
||||
assertNoUnmigratedWorkspaceState,
|
||||
LEGACY_WORKSPACE_ATTESTATION_HEADER,
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
resolveLegacyWorkspaceSourcePaths,
|
||||
} from "./workspace-legacy-state.js";
|
||||
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
|
||||
import { resolveWorkspaceStateIdentity } from "./workspace-state-store.js";
|
||||
|
||||
describe("legacy workspace reset cleanup", () => {
|
||||
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => afterEach(cleanup));
|
||||
afterEach(() => resetLegacyWorkspaceStateCheckForTest());
|
||||
|
||||
function setup() {
|
||||
const homeDir = tempDirs.make("openclaw-workspace-legacy-cleanup-");
|
||||
const stateDir = path.join(homeDir, "state");
|
||||
const workspaceDir = path.join(homeDir, "workspace");
|
||||
const env = { ...process.env, HOME: homeDir, OPENCLAW_STATE_DIR: stateDir };
|
||||
const homedir = () => homeDir;
|
||||
return {
|
||||
env,
|
||||
homeDir,
|
||||
homedir,
|
||||
stateDir,
|
||||
workspaceDir,
|
||||
paths: resolveLegacyWorkspaceSourcePaths(workspaceDir, { env, homedir }),
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(context: ReturnType<typeof setup>) {
|
||||
return prepareLegacyWorkspaceStateReset(context.workspaceDir, {
|
||||
env: context.env,
|
||||
homedir: context.homedir,
|
||||
});
|
||||
}
|
||||
|
||||
it("removes retired setup files, claims, and owned attestations", async () => {
|
||||
const context = setup();
|
||||
await fs.mkdir(context.workspaceDir, { recursive: true });
|
||||
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`;
|
||||
const candidates = [
|
||||
context.paths.setupStatePaths[0]!,
|
||||
`${context.paths.setupStatePaths[1]!}.doctor-importing`,
|
||||
context.paths.stateDirAttestationPaths[0]!,
|
||||
`${context.paths.stateDirAttestationPaths.at(-1)!}.doctor-importing`,
|
||||
context.paths.siblingAttestationPaths[0]!,
|
||||
`${context.paths.siblingAttestationPaths[0]!}.doctor-importing`,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
await fs.mkdir(path.dirname(candidate), { recursive: true });
|
||||
await fs.writeFile(
|
||||
candidate,
|
||||
candidate.includes("workspace-state") ? '{"version":1}\n' : marker,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(new Set(result.removedPaths)).toEqual(new Set(candidates));
|
||||
for (const candidate of candidates) {
|
||||
await expect(fs.lstat(candidate)).rejects.toHaveProperty("code", "ENOENT");
|
||||
}
|
||||
});
|
||||
|
||||
it("previews retired state cleanup without deleting files", async () => {
|
||||
const context = setup();
|
||||
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`;
|
||||
const candidates = [
|
||||
context.paths.setupStatePaths[0]!,
|
||||
context.paths.siblingAttestationPaths[0]!,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
await fs.mkdir(path.dirname(candidate), { recursive: true });
|
||||
await fs.writeFile(
|
||||
candidate,
|
||||
candidate.includes("workspace-state") ? '{"version":1}\n' : marker,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await removeLegacyWorkspaceStateForReset(prepare(context), { dryRun: true });
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(new Set(result.removedPaths)).toEqual(new Set(candidates));
|
||||
for (const candidate of candidates) {
|
||||
await expect(fs.lstat(candidate)).resolves.toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a foreign sibling attestation", async () => {
|
||||
const context = setup();
|
||||
await fs.mkdir(context.workspaceDir, { recursive: true });
|
||||
const siblingPath = context.paths.siblingAttestationPaths[0]!;
|
||||
await fs.writeFile(siblingPath, "foreign marker\n", "utf8");
|
||||
|
||||
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.removedPaths).toEqual([]);
|
||||
await expect(fs.readFile(siblingPath, "utf8")).resolves.toBe("foreign marker\n");
|
||||
});
|
||||
|
||||
it("preserves a malformed sibling claim and foreign marker", async () => {
|
||||
const context = setup();
|
||||
const siblingPath = context.paths.siblingAttestationPaths[0]!;
|
||||
const claimPath = `${siblingPath}.doctor-importing`;
|
||||
await fs.mkdir(context.workspaceDir, { recursive: true });
|
||||
await fs.writeFile(siblingPath, "foreign marker\n", "utf8");
|
||||
await fs.writeFile(claimPath, "truncated claim\n", "utf8");
|
||||
|
||||
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.removedPaths).toEqual([]);
|
||||
await expect(fs.readFile(siblingPath, "utf8")).resolves.toBe("foreign marker\n");
|
||||
await expect(fs.readFile(claimPath, "utf8")).resolves.toBe("truncated claim\n");
|
||||
expect(() =>
|
||||
assertNoUnmigratedWorkspaceState({ workspaceDir: context.workspaceDir }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("checks lexical legacy markers separately for aliases of one workspace", async () => {
|
||||
const context = setup();
|
||||
const targetDir = path.join(context.homeDir, "workspace-target");
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.symlink(
|
||||
targetDir,
|
||||
context.workspaceDir,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
assertNoUnmigratedWorkspaceState({ workspaceDir: targetDir });
|
||||
await fs.writeFile(
|
||||
`${context.workspaceDir}.attested`,
|
||||
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(() => assertNoUnmigratedWorkspaceState({ workspaceDir: context.workspaceDir })).toThrow(
|
||||
/run openclaw doctor --fix/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("checks canonical legacy markers when configuration uses a symlink alias", async () => {
|
||||
const context = setup();
|
||||
const targetDir = path.join(context.homeDir, "workspace-target");
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.symlink(
|
||||
targetDir,
|
||||
context.workspaceDir,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const identity = resolveWorkspaceStateIdentity(targetDir);
|
||||
const canonicalSiblingPath = `${identity.workspacePath}.attested`;
|
||||
const sources = resolveLegacyWorkspaceSourcePaths(context.workspaceDir, {
|
||||
env: context.env,
|
||||
homedir: context.homedir,
|
||||
});
|
||||
await fs.writeFile(
|
||||
canonicalSiblingPath,
|
||||
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(sources.siblingAttestationPaths).toContain(canonicalSiblingPath);
|
||||
expect(sources.stateDirAttestationPaths).toContain(
|
||||
path.join(context.stateDir, "workspace-attestations", `${identity.workspaceKey}.attested`),
|
||||
);
|
||||
expect(() => assertNoUnmigratedWorkspaceState({ workspaceDir: context.workspaceDir })).toThrow(
|
||||
/run openclaw doctor --fix/u,
|
||||
);
|
||||
const cleanup = await removeLegacyWorkspaceStateForReset(prepare(context));
|
||||
expect(cleanup.removedPaths).toContain(canonicalSiblingPath);
|
||||
await expect(fs.lstat(canonicalSiblingPath)).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("removes canonical legacy paths after the configured symlink is removed", async () => {
|
||||
const context = setup();
|
||||
const targetDir = path.join(context.homeDir, "workspace-target");
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.symlink(
|
||||
targetDir,
|
||||
context.workspaceDir,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const identity = resolveWorkspaceStateIdentity(targetDir);
|
||||
const setupPath = path.join(targetDir, "openclaw-workspace-state.json");
|
||||
const stateAttestationPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
const siblingPath = `${identity.workspacePath}.attested`;
|
||||
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`;
|
||||
await fs.writeFile(setupPath, '{"version":1}\n', "utf8");
|
||||
await fs.mkdir(path.dirname(stateAttestationPath), { recursive: true });
|
||||
await fs.writeFile(stateAttestationPath, marker, "utf8");
|
||||
await fs.writeFile(siblingPath, marker, "utf8");
|
||||
|
||||
const plan = prepare(context);
|
||||
await fs.unlink(context.workspaceDir);
|
||||
const result = await removeLegacyWorkspaceStateForReset(plan);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
for (const candidate of [setupPath, stateAttestationPath, siblingPath]) {
|
||||
await expect(fs.lstat(candidate)).rejects.toHaveProperty("code", "ENOENT");
|
||||
}
|
||||
});
|
||||
|
||||
it("removes malformed markers from reserved state-directory paths", async () => {
|
||||
const context = setup();
|
||||
const candidates = [
|
||||
context.paths.stateDirAttestationPaths[0]!,
|
||||
`${context.paths.stateDirAttestationPaths.at(-1)!}.doctor-importing`,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
await fs.mkdir(path.dirname(candidate), { recursive: true });
|
||||
await fs.writeFile(candidate, "truncated marker\n", "utf8");
|
||||
}
|
||||
|
||||
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(new Set(result.removedPaths)).toEqual(new Set(candidates));
|
||||
for (const candidate of candidates) {
|
||||
await expect(fs.lstat(candidate)).rejects.toHaveProperty("code", "ENOENT");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not follow a symlinked attestation directory during reset", async () => {
|
||||
const context = setup();
|
||||
const markerPath = context.paths.stateDirAttestationPaths[0]!;
|
||||
const externalDir = path.join(context.homeDir, "external-attestations");
|
||||
const externalMarker = path.join(externalDir, path.basename(markerPath));
|
||||
await fs.mkdir(path.dirname(markerPath), { recursive: true });
|
||||
await fs.rm(path.dirname(markerPath), { recursive: true, force: true });
|
||||
await fs.mkdir(externalDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
externalMarker,
|
||||
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n2026-07-15T11:00:00.000Z\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(externalDir, path.dirname(markerPath));
|
||||
|
||||
const result = await removeLegacyWorkspaceStateForReset(prepare(context));
|
||||
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(externalMarker, "utf8")).resolves.toContain(
|
||||
LEGACY_WORKSPACE_ATTESTATION_HEADER,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
// Legacy workspace state paths remain here solely for Doctor discovery and a
|
||||
// presence-only runtime upgrade gate. Runtime state never parses these files.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveLegacyStateDirs, resolveStateDir } from "../config/paths.js";
|
||||
import { root } from "../infra/fs-safe.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveWorkspaceStateIdentity } from "./workspace-state-store.js";
|
||||
|
||||
export const LEGACY_WORKSPACE_STATE_DIRNAME = ".openclaw";
|
||||
const LEGACY_WORKSPACE_STATE_FILENAME = "workspace-state.json";
|
||||
export const LEGACY_WORKSPACE_STATE_CURRENT_FILENAME = "openclaw-workspace-state.json";
|
||||
export const LEGACY_WORKSPACE_ATTESTATION_DIRNAME = "workspace-attestations";
|
||||
const LEGACY_WORKSPACE_ATTESTATION_SUFFIX = ".attested";
|
||||
export const LEGACY_WORKSPACE_ATTESTATION_HEADER = "openclaw-workspace-attestation:v1";
|
||||
export const LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES = 2048;
|
||||
export const WORKSPACE_DOCTOR_CLAIM_SUFFIX = ".doctor-importing";
|
||||
|
||||
// Legacy files are upgrade-time inputs. Cache only verified absence so every
|
||||
// agent turn does not poll retired paths; Doctor/restart owns later changes.
|
||||
const checkedWorkspaceSourceSets = new Set<string>();
|
||||
|
||||
type LegacyWorkspaceSourcePaths = {
|
||||
workspacePath: string;
|
||||
setupStatePaths: string[];
|
||||
stateDirAttestationPaths: string[];
|
||||
siblingAttestationPaths: string[];
|
||||
};
|
||||
|
||||
type LegacyWorkspaceResetCleanup = {
|
||||
removedPaths: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type LegacyWorkspaceResetCandidate = {
|
||||
rootDir: string;
|
||||
sourcePath: string;
|
||||
requireAttestationHeader: boolean;
|
||||
};
|
||||
|
||||
type LegacyWorkspaceResetPlan = {
|
||||
candidates: LegacyWorkspaceResetCandidate[];
|
||||
};
|
||||
|
||||
function uniqueSiblingPaths(paths: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
return paths.filter((candidate) => {
|
||||
let key = path.resolve(candidate);
|
||||
try {
|
||||
key = path.join(fs.realpathSync.native(path.dirname(candidate)), path.basename(candidate));
|
||||
} catch {
|
||||
// Missing parents stay distinct lexical migration inputs.
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveLegacyWorkspaceSourcePaths(
|
||||
workspaceDir: string,
|
||||
options?: { env?: NodeJS.ProcessEnv; homedir?: () => string },
|
||||
): LegacyWorkspaceSourcePaths {
|
||||
// Hashed and sibling legacy filenames used the lexical configured path.
|
||||
// Setup files live inside the workspace, so bind them to the canonical root
|
||||
// while it still exists; destructive cleanup may remove the alias first.
|
||||
const workspacePath = path.resolve(resolveUserPath(workspaceDir));
|
||||
const canonicalIdentity = resolveWorkspaceStateIdentity(workspaceDir);
|
||||
const workspaceKeys = [
|
||||
createHash("sha256").update(workspacePath).digest("hex"),
|
||||
canonicalIdentity.workspaceKey,
|
||||
];
|
||||
const workspacePaths = [workspacePath, canonicalIdentity.workspacePath];
|
||||
const env = options?.env ?? process.env;
|
||||
const stateDirs = [
|
||||
resolveStateDir(env, options?.homedir),
|
||||
...resolveLegacyStateDirs(options?.homedir),
|
||||
];
|
||||
return {
|
||||
workspacePath,
|
||||
setupStatePaths: [
|
||||
path.join(canonicalIdentity.workspacePath, LEGACY_WORKSPACE_STATE_CURRENT_FILENAME),
|
||||
path.join(
|
||||
canonicalIdentity.workspacePath,
|
||||
LEGACY_WORKSPACE_STATE_DIRNAME,
|
||||
LEGACY_WORKSPACE_STATE_FILENAME,
|
||||
),
|
||||
],
|
||||
stateDirAttestationPaths: [...new Set(stateDirs)].flatMap((stateDir) =>
|
||||
[...new Set(workspaceKeys)].map((workspaceKey) =>
|
||||
path.join(
|
||||
stateDir,
|
||||
LEGACY_WORKSPACE_ATTESTATION_DIRNAME,
|
||||
`${workspaceKey}${LEGACY_WORKSPACE_ATTESTATION_SUFFIX}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
siblingAttestationPaths: uniqueSiblingPaths(
|
||||
[...new Set(workspacePaths)].map(
|
||||
(candidate) => `${candidate}${LEGACY_WORKSPACE_ATTESTATION_SUFFIX}`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function pathOrClaimExists(filePath: string): boolean {
|
||||
for (const candidate of [filePath, `${filePath}${WORKSPACE_DOCTOR_CLAIM_SUFFIX}`]) {
|
||||
try {
|
||||
fs.lstatSync(candidate);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function siblingPathIsOwnedMarker(filePath: string): boolean {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
||||
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
|
||||
try {
|
||||
const buffer = Buffer.alloc(
|
||||
Math.min(stat.size, LEGACY_WORKSPACE_ATTESTATION_HEADER.length + 1),
|
||||
);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
||||
return (
|
||||
buffer.subarray(0, bytesRead).toString("utf8") ===
|
||||
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n`
|
||||
);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
// A regular file at the exact retired path is ambiguous when it cannot be
|
||||
// inspected. Doctor owns the safe decision; runtime must not assume absence.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail closed on unmigrated owned state without reading it as runtime data. */
|
||||
export function assertNoUnmigratedWorkspaceState(params: { workspaceDir: string }): void {
|
||||
const identity = resolveWorkspaceStateIdentity(params.workspaceDir);
|
||||
const sources = resolveLegacyWorkspaceSourcePaths(params.workspaceDir);
|
||||
const sourceSetKey = JSON.stringify([
|
||||
identity.workspaceKey,
|
||||
...sources.setupStatePaths,
|
||||
...sources.stateDirAttestationPaths,
|
||||
...sources.siblingAttestationPaths,
|
||||
]);
|
||||
if (checkedWorkspaceSourceSets.has(sourceSetKey)) {
|
||||
return;
|
||||
}
|
||||
const hasLegacy =
|
||||
sources.setupStatePaths.some(pathOrClaimExists) ||
|
||||
sources.stateDirAttestationPaths.some(pathOrClaimExists) ||
|
||||
sources.siblingAttestationPaths.some(
|
||||
(sourcePath) =>
|
||||
siblingPathIsOwnedMarker(`${sourcePath}${WORKSPACE_DOCTOR_CLAIM_SUFFIX}`) ||
|
||||
siblingPathIsOwnedMarker(sourcePath),
|
||||
);
|
||||
if (hasLegacy) {
|
||||
throw new Error(
|
||||
`Legacy workspace setup state requires migration for ${identity.workspacePath}; run openclaw doctor --fix.`,
|
||||
);
|
||||
}
|
||||
checkedWorkspaceSourceSets.add(sourceSetKey);
|
||||
}
|
||||
|
||||
function resetLegacyWorkspaceStateCheckForTest(): void {
|
||||
checkedWorkspaceSourceSets.clear();
|
||||
}
|
||||
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.workspaceLegacyStateTestApi")] =
|
||||
{ resetLegacyWorkspaceStateCheckForTest };
|
||||
}
|
||||
|
||||
function isOwnedAttestationBuffer(buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.subarray(0, LEGACY_WORKSPACE_ATTESTATION_HEADER.length + 1).toString("utf8") ===
|
||||
`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n`
|
||||
);
|
||||
}
|
||||
|
||||
/** Capture canonical legacy paths before a destructive workspace removal. */
|
||||
export function prepareLegacyWorkspaceStateReset(
|
||||
workspaceDir: string,
|
||||
options?: { env?: NodeJS.ProcessEnv; homedir?: () => string },
|
||||
): LegacyWorkspaceResetPlan {
|
||||
const sources = resolveLegacyWorkspaceSourcePaths(workspaceDir, options);
|
||||
const candidates = [
|
||||
...sources.setupStatePaths.map((sourcePath) => ({
|
||||
rootDir: sourcePath.endsWith(LEGACY_WORKSPACE_STATE_CURRENT_FILENAME)
|
||||
? path.dirname(sourcePath)
|
||||
: path.dirname(path.dirname(sourcePath)),
|
||||
sourcePath,
|
||||
requireAttestationHeader: false,
|
||||
})),
|
||||
...sources.stateDirAttestationPaths.map((sourcePath) => ({
|
||||
rootDir: path.dirname(path.dirname(sourcePath)),
|
||||
sourcePath,
|
||||
// Hashed paths inside OpenClaw-owned attestation directories are
|
||||
// reserved state. Explicit reset must remove malformed blockers too.
|
||||
requireAttestationHeader: false,
|
||||
})),
|
||||
...sources.siblingAttestationPaths.map((sourcePath) => ({
|
||||
rootDir: path.dirname(sourcePath),
|
||||
sourcePath,
|
||||
requireAttestationHeader: true,
|
||||
})),
|
||||
].flatMap((candidate) => [
|
||||
candidate,
|
||||
{
|
||||
...candidate,
|
||||
sourcePath: `${candidate.sourcePath}${WORKSPACE_DOCTOR_CLAIM_SUFFIX}`,
|
||||
// Sibling claims remain outside OpenClaw-owned roots. Renaming a claimed
|
||||
// marker preserves its header, so require that ownership proof there too.
|
||||
requireAttestationHeader: candidate.requireAttestationHeader,
|
||||
},
|
||||
]);
|
||||
return { candidates };
|
||||
}
|
||||
|
||||
/** Discard retired workspace files from a pre-removal reset plan. */
|
||||
export async function removeLegacyWorkspaceStateForReset(
|
||||
plan: LegacyWorkspaceResetPlan,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<LegacyWorkspaceResetCleanup> {
|
||||
const removedPaths: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const candidate of plan.candidates) {
|
||||
const rootDir = path.resolve(candidate.rootDir);
|
||||
const sourcePath = path.resolve(candidate.sourcePath);
|
||||
const relativePath = path.relative(rootDir, sourcePath);
|
||||
try {
|
||||
fs.lstatSync(rootDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
warnings.push(`Could not inspect retired workspace state at ${sourcePath}: ${String(error)}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const sourceRoot = await root(rootDir, {
|
||||
hardlinks: "reject",
|
||||
maxBytes: LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
symlinks: "reject",
|
||||
});
|
||||
if (!(await sourceRoot.exists(relativePath))) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.requireAttestationHeader) {
|
||||
const snapshot = await sourceRoot.read(relativePath);
|
||||
if (!isOwnedAttestationBuffer(snapshot.buffer)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!options?.dryRun) {
|
||||
await sourceRoot.remove(relativePath);
|
||||
}
|
||||
removedPaths.push(sourcePath);
|
||||
} catch (error) {
|
||||
warnings.push(`Could not remove retired workspace state at ${sourcePath}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
return { removedPaths, warnings };
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Setup-only SQLite safety tests cover attestation-write failure and
|
||||
// independently migrated setup state.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { makeTempWorkspace } from "../test-helpers/workspace.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
} from "../test-utils/openclaw-test-state.js";
|
||||
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
|
||||
import {
|
||||
mergeWorkspaceSetupState,
|
||||
readWorkspaceStateSnapshot,
|
||||
resolveWorkspaceStateIdentity,
|
||||
} from "./workspace-state-store.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
ensureAgentWorkspace,
|
||||
WORKSPACE_VANISHED_ERROR_CODE,
|
||||
} from "./workspace.js";
|
||||
|
||||
let testState: OpenClawTestState | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetLegacyWorkspaceStateCheckForTest();
|
||||
testState = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-workspace-sqlite-safety-",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetLegacyWorkspaceStateCheckForTest();
|
||||
await testState?.cleanup();
|
||||
testState = undefined;
|
||||
});
|
||||
|
||||
function deleteWorkspaceAttestation(workspaceDir: string): void {
|
||||
const identity = resolveWorkspaceStateIdentity(workspaceDir);
|
||||
openOpenClawStateDatabase()
|
||||
.db.prepare("DELETE FROM workspace_attestations WHERE workspace_key = ?")
|
||||
.run(identity.workspaceKey);
|
||||
}
|
||||
|
||||
describe("workspace setup-only SQLite safety", () => {
|
||||
it("clears expired setup-only state when one generated remnant survives", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf8");
|
||||
const identity = resolveWorkspaceStateIdentity(tempDir);
|
||||
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
deleteWorkspaceAttestation(tempDir);
|
||||
db.prepare("UPDATE workspace_setup_state SET updated_at = ? WHERE workspace_key = ?").run(
|
||||
expiredAtMs,
|
||||
identity.workspaceKey,
|
||||
);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expect(
|
||||
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
|
||||
).resolves.toBeUndefined();
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setup.setupCompletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears expired state when only one generated bootstrap file survives", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
|
||||
const identity = resolveWorkspaceStateIdentity(tempDir);
|
||||
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
db.prepare(
|
||||
"UPDATE workspace_attestations SET attested_at_ms = ?, updated_at_ms = ? WHERE workspace_key = ?",
|
||||
).run(expiredAtMs, expiredAtMs, identity.workspaceKey);
|
||||
db.prepare("UPDATE workspace_setup_state SET updated_at = ? WHERE workspace_key = ?").run(
|
||||
expiredAtMs,
|
||||
identity.workspaceKey,
|
||||
);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expect(
|
||||
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
|
||||
).resolves.toBeUndefined();
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setup.setupCompletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses an empty recent setup-only workspace when bootstrap creation is disabled", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
mergeWorkspaceSetupState(tempDir, {
|
||||
bootstrapSeededAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: false }),
|
||||
).rejects.toMatchObject({
|
||||
code: WORKSPACE_VANISHED_ERROR_CODE,
|
||||
name: "WorkspaceVanishedError",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake an old generated template for setup-only customization", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "old generated agents\n");
|
||||
mergeWorkspaceSetupState(tempDir, {
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).rejects.toMatchObject({
|
||||
code: WORKSPACE_VANISHED_ERROR_CODE,
|
||||
name: "WorkspaceVanishedError",
|
||||
});
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toHaveProperty(
|
||||
"code",
|
||||
"ENOENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to reseed a missing workspace with recent setup-only state", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
mergeWorkspaceSetupState(tempDir, {
|
||||
bootstrapSeededAt: new Date().toISOString(),
|
||||
});
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).rejects.toMatchObject({
|
||||
code: WORKSPACE_VANISHED_ERROR_CODE,
|
||||
name: "WorkspaceVanishedError",
|
||||
});
|
||||
await expect(fs.access(tempDir)).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("refuses to trust setup-only state after only generated remnants survive", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
|
||||
deleteWorkspaceAttestation(tempDir);
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).rejects.toMatchObject({
|
||||
code: WORKSPACE_VANISHED_ERROR_CODE,
|
||||
name: "WorkspaceVanishedError",
|
||||
});
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toHaveProperty(
|
||||
"code",
|
||||
"ENOENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an intact generated workspace with setup-only state", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await fs.rm(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
deleteWorkspaceAttestation(tempDir);
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).resolves.toMatchObject({ dir: tempDir });
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toHaveProperty(
|
||||
"code",
|
||||
"ENOENT",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
// SQLite workspace state tests cover persistence, monotonic setup completion,
|
||||
// and atomic attestation hash replacement.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
} from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
clearExpiredWorkspaceStateForVanishedWorkspace,
|
||||
deleteWorkspaceState,
|
||||
mergeWorkspaceSetupState,
|
||||
prepareWorkspaceStateDeletion,
|
||||
readWorkspaceStateSnapshot,
|
||||
replaceWorkspaceAttestation,
|
||||
resolveWorkspaceStateIdentity,
|
||||
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
|
||||
} from "./workspace-state-store.js";
|
||||
|
||||
let testState: OpenClawTestState | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
testState = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-workspace-store-",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await testState?.cleanup();
|
||||
testState = undefined;
|
||||
});
|
||||
|
||||
function workspaceDir(): string {
|
||||
if (!testState) {
|
||||
throw new Error("test state unavailable");
|
||||
}
|
||||
return testState.workspaceDir;
|
||||
}
|
||||
|
||||
function deleteState(targetDir: string): void {
|
||||
deleteWorkspaceState(prepareWorkspaceStateDeletion(targetDir));
|
||||
}
|
||||
|
||||
describe("workspace state store", () => {
|
||||
it("round-trips setup and attestation state after a database restart", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, {
|
||||
bootstrapSeededAt: "2026-07-16T01:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-16T02:00:00.000Z",
|
||||
});
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 1_752_628_800_000,
|
||||
generatedHashes: new Map([
|
||||
["AGENTS.md", "a".repeat(64)],
|
||||
["TOOLS.md", "b".repeat(64)],
|
||||
]),
|
||||
});
|
||||
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
const snapshot = readWorkspaceStateSnapshot(dir);
|
||||
expect(snapshot.setupExists).toBe(true);
|
||||
expect(snapshot.setup).toStrictEqual({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-07-16T01:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-16T02:00:00.000Z",
|
||||
});
|
||||
expect(snapshot.attestation?.attestedAtMs).toBe(1_752_628_800_000);
|
||||
expect([...snapshot.attestation!.generatedHashes.entries()]).toStrictEqual([
|
||||
["AGENTS.md", "a".repeat(64)],
|
||||
["TOOLS.md", "b".repeat(64)],
|
||||
]);
|
||||
});
|
||||
|
||||
it("never regresses persisted setup milestones", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
mergeWorkspaceSetupState(dir, { setupCompletedAt: "2026-07-16T02:00:00.000Z" }, 2_000);
|
||||
const state = mergeWorkspaceSetupState(
|
||||
dir,
|
||||
{
|
||||
bootstrapSeededAt: "2026-07-16T03:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-16T04:00:00.000Z",
|
||||
},
|
||||
3_000,
|
||||
);
|
||||
|
||||
expect(state).toStrictEqual({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-07-16T01:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-16T02:00:00.000Z",
|
||||
});
|
||||
expect(readWorkspaceStateSnapshot(dir).setup).toStrictEqual(state);
|
||||
});
|
||||
|
||||
it("replaces generated hashes atomically and ignores older attestations", () => {
|
||||
const dir = workspaceDir();
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 2_000,
|
||||
generatedHashes: new Map([
|
||||
["AGENTS.md", "a".repeat(64)],
|
||||
["TOOLS.md", "b".repeat(64)],
|
||||
]),
|
||||
nowMs: 2_000,
|
||||
});
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 3_000,
|
||||
generatedHashes: new Map([["SOUL.md", "c".repeat(64)]]),
|
||||
nowMs: 3_000,
|
||||
});
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 1_000,
|
||||
generatedHashes: new Map([["USER.md", "d".repeat(64)]]),
|
||||
nowMs: 4_000,
|
||||
});
|
||||
|
||||
const attestation = readWorkspaceStateSnapshot(dir).attestation;
|
||||
expect(attestation?.attestedAtMs).toBe(3_000);
|
||||
expect([...attestation!.generatedHashes.entries()]).toStrictEqual([
|
||||
["SOUL.md", "c".repeat(64)],
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces a future-dated attestation with a live refresh", () => {
|
||||
const dir = workspaceDir();
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 100_000,
|
||||
generatedHashes: new Map([["AGENTS.md", "a".repeat(64)]]),
|
||||
nowMs: 100_000,
|
||||
});
|
||||
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 2_000,
|
||||
generatedHashes: new Map([["TOOLS.md", "b".repeat(64)]]),
|
||||
nowMs: 2_000,
|
||||
});
|
||||
|
||||
const attestation = readWorkspaceStateSnapshot(dir).attestation;
|
||||
expect(attestation?.attestedAtMs).toBe(2_000);
|
||||
expect([...attestation!.generatedHashes.entries()]).toStrictEqual([
|
||||
["TOOLS.md", "b".repeat(64)],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves future-dated state for a vanished workspace", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 100_000,
|
||||
generatedHashes: new Map(),
|
||||
nowMs: 100_000,
|
||||
});
|
||||
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 2_000)).toBe(false);
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
|
||||
expect(readWorkspaceStateSnapshot(dir).attestation?.attestedAtMs).toBe(100_000);
|
||||
});
|
||||
|
||||
it("preserves recent setup-only state for a vanished workspace", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 2_000)).toBe(false);
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps symlink aliases on one identity after the workspace target vanishes", () => {
|
||||
const dir = workspaceDir();
|
||||
const alias = testState!.path("workspace-link");
|
||||
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
const identity = resolveWorkspaceStateIdentity(dir);
|
||||
|
||||
expect(resolveWorkspaceStateIdentity(alias)).toStrictEqual(identity);
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
expect(resolveWorkspaceStateIdentity(alias)).toStrictEqual(identity);
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(alias, 2_000)).toBe(false);
|
||||
expect(readWorkspaceStateSnapshot(alias).setupExists).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a persisted alias after the configured symlink itself disappears", () => {
|
||||
const dir = workspaceDir();
|
||||
const alias = testState!.path("workspace-link");
|
||||
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
const identity = resolveWorkspaceStateIdentity(dir);
|
||||
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
|
||||
fs.unlinkSync(alias);
|
||||
|
||||
expect(resolveWorkspaceStateIdentity(alias)).not.toStrictEqual(identity);
|
||||
expect(readWorkspaceStateSnapshot(alias).identity).toStrictEqual(identity);
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(alias, 2_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed when a persisted symlink alias is repointed", () => {
|
||||
const dir = workspaceDir();
|
||||
const alias = testState!.path("workspace-link");
|
||||
const replacement = testState!.path("replacement-workspace");
|
||||
fs.mkdirSync(replacement, { recursive: true });
|
||||
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
fs.unlinkSync(alias);
|
||||
fs.symlinkSync(replacement, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
|
||||
expect(() => readWorkspaceStateSnapshot(alias)).toThrow(/different current target/u);
|
||||
});
|
||||
|
||||
it("cleans current state and only the stale association for a repointed alias", () => {
|
||||
const dir = workspaceDir();
|
||||
const alias = testState!.path("workspace-link");
|
||||
const replacement = testState!.path("replacement-workspace");
|
||||
fs.mkdirSync(replacement, { recursive: true });
|
||||
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
mergeWorkspaceSetupState(replacement, { bootstrapSeededAt: "2026-07-16T02:00:00.000Z" }, 2_000);
|
||||
fs.unlinkSync(alias);
|
||||
fs.symlinkSync(replacement, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
|
||||
const deletion = prepareWorkspaceStateDeletion(alias);
|
||||
fs.unlinkSync(alias);
|
||||
deleteWorkspaceState(deletion);
|
||||
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
|
||||
expect(readWorkspaceStateSnapshot(replacement).setupExists).toBe(false);
|
||||
const staleAlias = openOpenClawStateDatabase()
|
||||
.db.prepare("SELECT alias_key FROM workspace_path_aliases WHERE alias_path = ?")
|
||||
.get(alias);
|
||||
expect(staleAlias).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deletes canonical state through a missing persisted alias", () => {
|
||||
const dir = workspaceDir();
|
||||
const alias = testState!.path("workspace-link");
|
||||
fs.symlinkSync(dir, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
mergeWorkspaceSetupState(alias, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
fs.unlinkSync(alias);
|
||||
|
||||
deleteState(alias);
|
||||
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(false);
|
||||
const aliases = openOpenClawStateDatabase()
|
||||
.db.prepare("SELECT alias_key FROM workspace_path_aliases")
|
||||
.all();
|
||||
expect(aliases).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears expired setup-only state for a vanished workspace", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 86_401_001)).toBe(true);
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(false);
|
||||
});
|
||||
|
||||
it("does not protect a markerless setup row", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, {}, 1_000);
|
||||
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 2_000)).toBe(true);
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves recent setup state when its attestation is stale", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, { setupCompletedAt: "2026-07-16T01:00:00.000Z" }, 100_000_000);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 1_000,
|
||||
generatedHashes: new Map(),
|
||||
nowMs: 1_000,
|
||||
});
|
||||
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 100_001_000)).toBe(false);
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes future-version state without parsing it", () => {
|
||||
const dir = workspaceDir();
|
||||
const identity = resolveWorkspaceStateIdentity(dir);
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
db.prepare(
|
||||
`INSERT INTO workspace_setup_state (
|
||||
workspace_key,
|
||||
workspace_path,
|
||||
version,
|
||||
bootstrap_seeded_at,
|
||||
setup_completed_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, 99, NULL, NULL, 1)`,
|
||||
).run(identity.workspaceKey, identity.workspacePath);
|
||||
|
||||
expect(() => readWorkspaceStateSnapshot(dir)).toThrow(/version requires openclaw doctor/u);
|
||||
expect(() => deleteState(dir)).not.toThrow();
|
||||
const row = db
|
||||
.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not recreate a missing database during delete-only cleanup", () => {
|
||||
const dir = workspaceDir();
|
||||
const databasePath = resolveOpenClawStateSqlitePath();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
fs.rmSync(path.dirname(databasePath), { recursive: true, force: true });
|
||||
|
||||
deleteState(dir);
|
||||
|
||||
expect(fs.existsSync(databasePath)).toBe(false);
|
||||
expect(fs.existsSync(path.dirname(databasePath))).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes migration receipts owned by the workspace", () => {
|
||||
const dir = workspaceDir();
|
||||
const identity = resolveWorkspaceStateIdentity(dir);
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" });
|
||||
const insertRun = db.prepare(
|
||||
"INSERT INTO migration_runs (id, started_at, finished_at, status, report_json) VALUES (?, 1, 1, 'completed', '{}')",
|
||||
);
|
||||
insertRun.run("owned-run");
|
||||
insertRun.run("unrelated-run");
|
||||
const insertReceipt = db.prepare(
|
||||
`INSERT INTO migration_sources (
|
||||
source_key,
|
||||
migration_kind,
|
||||
source_path,
|
||||
target_table,
|
||||
last_run_id,
|
||||
status,
|
||||
imported_at,
|
||||
report_json
|
||||
) VALUES (?, ?, ?, 'workspace_setup_state', ?, 'completed', 1, ?)`,
|
||||
);
|
||||
insertReceipt.run(
|
||||
"owned-receipt",
|
||||
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
|
||||
path.join(dir, ".openclaw", "workspace-state.json"),
|
||||
"owned-run",
|
||||
JSON.stringify({ workspaceKey: identity.workspaceKey }),
|
||||
);
|
||||
insertReceipt.run(
|
||||
"unrelated-receipt",
|
||||
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
|
||||
"/other/workspace-state.json",
|
||||
"unrelated-run",
|
||||
JSON.stringify({ workspaceKey: "other-workspace" }),
|
||||
);
|
||||
|
||||
deleteState(dir);
|
||||
|
||||
const receipts = db
|
||||
.prepare("SELECT source_key FROM migration_sources ORDER BY source_key")
|
||||
.all();
|
||||
expect(receipts).toEqual([{ source_key: "unrelated-receipt" }]);
|
||||
const runs = db.prepare("SELECT id FROM migration_runs ORDER BY id").all();
|
||||
expect(runs).toEqual([{ id: "unrelated-run" }]);
|
||||
});
|
||||
|
||||
it("clears expired missing-workspace state but preserves a concurrent refresh", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 1_000,
|
||||
generatedHashes: new Map(),
|
||||
nowMs: 1_000,
|
||||
});
|
||||
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 86_401_001)).toBe(true);
|
||||
expect(readWorkspaceStateSnapshot(dir)).toMatchObject({ setupExists: false });
|
||||
expect(readWorkspaceStateSnapshot(dir).attestation).toBeUndefined();
|
||||
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T02:00:00.000Z" }, 86_401_000);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs: 86_401_000,
|
||||
generatedHashes: new Map(),
|
||||
nowMs: 86_401_000,
|
||||
});
|
||||
expect(clearExpiredWorkspaceStateForVanishedWorkspace(dir, 86_401_001)).toBe(false);
|
||||
expect(readWorkspaceStateSnapshot(dir).setupExists).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,715 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs, { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import { runSqliteDeferredTransactionSync } from "../infra/sqlite-transaction.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export const WORKSPACE_SETUP_STATE_VERSION = 1 as const;
|
||||
export const WORKSPACE_ATTESTATION_RECENT_MS = 24 * 60 * 60 * 1000;
|
||||
export const WORKSPACE_LEGACY_STATE_MIGRATION_KIND = "legacy-workspace-setup-files";
|
||||
export const WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES: ReadonlySet<string> = new Set([
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
]);
|
||||
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u;
|
||||
|
||||
function isCanonicalIsoTimestamp(value: string): boolean {
|
||||
const timestamp = new Date(value);
|
||||
return Number.isFinite(timestamp.getTime()) && timestamp.toISOString() === value;
|
||||
}
|
||||
|
||||
function assertCanonicalTimestamp(value: string | null, label: string): void {
|
||||
if (value !== null && !isCanonicalIsoTimestamp(value)) {
|
||||
throw new Error(`workspace ${label} timestamp is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCanonicalIntegerTimestamp(value: number, label: string): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`workspace ${label} timestamp is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkspaceSetupState = {
|
||||
version: typeof WORKSPACE_SETUP_STATE_VERSION;
|
||||
bootstrapSeededAt?: string;
|
||||
setupCompletedAt?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceAttestation = {
|
||||
attestedAtMs: number;
|
||||
generatedHashes: ReadonlyMap<string, string>;
|
||||
};
|
||||
|
||||
export type WorkspaceStateSnapshot = {
|
||||
identity: WorkspaceStateIdentity;
|
||||
setupExists: boolean;
|
||||
setupUpdatedAtMs?: number;
|
||||
setup: WorkspaceSetupState;
|
||||
attestation?: WorkspaceAttestation;
|
||||
};
|
||||
|
||||
type WorkspaceStateIdentity = {
|
||||
workspaceKey: string;
|
||||
workspacePath: string;
|
||||
};
|
||||
|
||||
type WorkspaceStateDeletionPlan = {
|
||||
lexicalAlias: WorkspaceStateIdentity;
|
||||
currentCanonicalIdentity: WorkspaceStateIdentity;
|
||||
pathEntryExisted: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceStateDatabase = Pick<
|
||||
OpenClawStateKyselyDatabase,
|
||||
| "workspace_setup_state"
|
||||
| "workspace_path_aliases"
|
||||
| "workspace_attestations"
|
||||
| "workspace_generated_bootstrap_hashes"
|
||||
| "migration_runs"
|
||||
| "migration_sources"
|
||||
>;
|
||||
|
||||
const MAX_WORKSPACE_IDENTITY_SYMLINKS = 40;
|
||||
|
||||
type WorkspaceIdentityResolution = {
|
||||
identity: WorkspaceStateIdentity;
|
||||
aliases: WorkspaceStateIdentity[];
|
||||
missingAliasKeys: string[];
|
||||
};
|
||||
|
||||
function normalizeWorkspaceIdentityPath(value: string): string {
|
||||
const normalized = path.normalize(value).normalize("NFC");
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function canonicalizeWorkspaceIdentityPath(workspaceDir: string): string {
|
||||
const fallback = normalizeWorkspaceIdentityPath(path.resolve(resolveUserPath(workspaceDir)));
|
||||
let candidate = fallback;
|
||||
const followedSymlinks = new Set<string>();
|
||||
|
||||
for (let redirectCount = 0; redirectCount < MAX_WORKSPACE_IDENTITY_SYMLINKS; redirectCount += 1) {
|
||||
const missingSegments: string[] = [];
|
||||
let current = candidate;
|
||||
while (true) {
|
||||
try {
|
||||
return normalizeWorkspaceIdentityPath(
|
||||
path.join(fs.realpathSync.native(current), ...missingSegments.toReversed()),
|
||||
);
|
||||
} catch {
|
||||
// A dangling symlink still carries the stable target identity. Resolve
|
||||
// it lexically so vanished-workspace protection cannot be bypassed.
|
||||
}
|
||||
try {
|
||||
if (fs.lstatSync(current).isSymbolicLink()) {
|
||||
const normalizedLink = normalizeWorkspaceIdentityPath(current);
|
||||
if (followedSymlinks.has(normalizedLink)) {
|
||||
return fallback;
|
||||
}
|
||||
followedSymlinks.add(normalizedLink);
|
||||
candidate = path.resolve(
|
||||
path.dirname(current),
|
||||
fs.readlinkSync(current),
|
||||
...missingSegments.toReversed(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking to a real existing ancestor.
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return fallback;
|
||||
}
|
||||
missingSegments.push(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function createWorkspaceStateIdentity(workspacePath: string): WorkspaceStateIdentity {
|
||||
return {
|
||||
workspacePath,
|
||||
workspaceKey: createHash("sha256").update(workspacePath).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWorkspaceStateAliases(workspaceDir: string): WorkspaceStateIdentity[] {
|
||||
const lexicalPath = normalizeWorkspaceIdentityPath(path.resolve(resolveUserPath(workspaceDir)));
|
||||
const canonicalPath = canonicalizeWorkspaceIdentityPath(workspaceDir);
|
||||
return [...new Set([lexicalPath, canonicalPath])].map(createWorkspaceStateIdentity);
|
||||
}
|
||||
|
||||
function workspacePathEntryExists(workspaceDir: string): boolean {
|
||||
try {
|
||||
fs.lstatSync(path.resolve(resolveUserPath(workspaceDir)));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorkspaceStateIdentity(workspaceDir: string): WorkspaceStateIdentity {
|
||||
return createWorkspaceStateIdentity(canonicalizeWorkspaceIdentityPath(workspaceDir));
|
||||
}
|
||||
|
||||
function resolveWorkspaceIdentityFromDatabase(params: {
|
||||
workspaceDir: string;
|
||||
database: ReturnType<typeof openOpenClawStateDatabase>;
|
||||
}): WorkspaceIdentityResolution {
|
||||
const aliases = resolveWorkspaceStateAliases(params.workspaceDir);
|
||||
const canonicalIdentity = aliases.at(-1)!;
|
||||
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(params.database.db);
|
||||
const rows = executeSqliteQuerySync(
|
||||
params.database.db,
|
||||
kysely
|
||||
.selectFrom("workspace_path_aliases")
|
||||
.selectAll()
|
||||
.where(
|
||||
"alias_key",
|
||||
"in",
|
||||
aliases.map((alias) => alias.workspaceKey),
|
||||
),
|
||||
).rows;
|
||||
const aliasesByKey = new Map(aliases.map((alias) => [alias.workspaceKey, alias]));
|
||||
let storedIdentity: WorkspaceStateIdentity | undefined;
|
||||
for (const row of rows) {
|
||||
const alias = aliasesByKey.get(row.alias_key);
|
||||
if (!alias || alias.workspacePath !== row.alias_path) {
|
||||
throw new Error("workspace path alias key collision");
|
||||
}
|
||||
const rowIdentity = createWorkspaceStateIdentity(row.workspace_path);
|
||||
if (rowIdentity.workspaceKey !== row.workspace_key) {
|
||||
throw new Error("workspace path alias target is invalid");
|
||||
}
|
||||
if (storedIdentity && storedIdentity.workspaceKey !== rowIdentity.workspaceKey) {
|
||||
throw new Error("workspace path aliases resolve to conflicting state");
|
||||
}
|
||||
storedIdentity = rowIdentity;
|
||||
}
|
||||
if (
|
||||
storedIdentity &&
|
||||
workspacePathEntryExists(params.workspaceDir) &&
|
||||
storedIdentity.workspaceKey !== canonicalIdentity.workspaceKey
|
||||
) {
|
||||
throw new Error("workspace path alias points to a different current target");
|
||||
}
|
||||
const existingAliasKeys = new Set(rows.map((row) => row.alias_key));
|
||||
return {
|
||||
identity: storedIdentity ?? canonicalIdentity,
|
||||
aliases,
|
||||
missingAliasKeys: aliases
|
||||
.map((alias) => alias.workspaceKey)
|
||||
.filter((aliasKey) => !existingAliasKeys.has(aliasKey)),
|
||||
};
|
||||
}
|
||||
|
||||
function registerWorkspacePathAliases(params: {
|
||||
database: ReturnType<typeof openOpenClawStateDatabase>;
|
||||
identity: WorkspaceStateIdentity;
|
||||
aliases: readonly WorkspaceStateIdentity[];
|
||||
updatedAtMs: number;
|
||||
}): void {
|
||||
assertCanonicalIntegerTimestamp(params.updatedAtMs, "path alias update");
|
||||
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(params.database.db);
|
||||
for (const alias of params.aliases) {
|
||||
const existing = executeSqliteQueryTakeFirstSync(
|
||||
params.database.db,
|
||||
kysely
|
||||
.selectFrom("workspace_path_aliases")
|
||||
.selectAll()
|
||||
.where("alias_key", "=", alias.workspaceKey),
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.alias_path !== alias.workspacePath ||
|
||||
existing.workspace_key !== params.identity.workspaceKey ||
|
||||
existing.workspace_path !== params.identity.workspacePath
|
||||
) {
|
||||
throw new Error("workspace path alias conflicts with canonical state");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
params.database.db,
|
||||
kysely.insertInto("workspace_path_aliases").values({
|
||||
alias_key: alias.workspaceKey,
|
||||
alias_path: alias.workspacePath,
|
||||
workspace_key: params.identity.workspaceKey,
|
||||
workspace_path: params.identity.workspacePath,
|
||||
updated_at_ms: params.updatedAtMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorkspaceStateAliasesInTransaction(params: {
|
||||
database: ReturnType<typeof openOpenClawStateDatabase>;
|
||||
workspaceDirs: readonly string[];
|
||||
identity: WorkspaceStateIdentity;
|
||||
updatedAtMs: number;
|
||||
}): void {
|
||||
const aliases = new Map<string, WorkspaceStateIdentity>();
|
||||
for (const workspaceDir of params.workspaceDirs) {
|
||||
for (const alias of resolveWorkspaceStateAliases(workspaceDir)) {
|
||||
aliases.set(alias.workspaceKey, alias);
|
||||
}
|
||||
}
|
||||
registerWorkspacePathAliases({
|
||||
database: params.database,
|
||||
identity: params.identity,
|
||||
aliases: [...aliases.values()],
|
||||
updatedAtMs: params.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function readSnapshotFromDatabase(params: {
|
||||
identity: WorkspaceStateIdentity;
|
||||
database: ReturnType<typeof openOpenClawStateDatabase>;
|
||||
}): WorkspaceStateSnapshot {
|
||||
const identity = params.identity;
|
||||
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(params.database.db);
|
||||
const setupRow = executeSqliteQueryTakeFirstSync(
|
||||
params.database.db,
|
||||
kysely
|
||||
.selectFrom("workspace_setup_state")
|
||||
.selectAll()
|
||||
.where("workspace_key", "=", identity.workspaceKey),
|
||||
);
|
||||
if (setupRow && setupRow.workspace_path !== identity.workspacePath) {
|
||||
throw new Error("workspace state key collision");
|
||||
}
|
||||
if (setupRow && setupRow.version !== WORKSPACE_SETUP_STATE_VERSION) {
|
||||
throw new Error("workspace setup state version requires openclaw doctor --fix");
|
||||
}
|
||||
if (setupRow) {
|
||||
assertCanonicalTimestamp(setupRow.bootstrap_seeded_at, "bootstrap seeded");
|
||||
assertCanonicalTimestamp(setupRow.setup_completed_at, "setup completed");
|
||||
assertCanonicalIntegerTimestamp(setupRow.updated_at, "setup update");
|
||||
}
|
||||
const attestationRow = executeSqliteQueryTakeFirstSync(
|
||||
params.database.db,
|
||||
kysely
|
||||
.selectFrom("workspace_attestations")
|
||||
.selectAll()
|
||||
.where("workspace_key", "=", identity.workspaceKey),
|
||||
);
|
||||
const generatedHashes = new Map<string, string>();
|
||||
if (attestationRow) {
|
||||
assertCanonicalIntegerTimestamp(attestationRow.attested_at_ms, "attestation");
|
||||
const hashRows = executeSqliteQuerySync(
|
||||
params.database.db,
|
||||
kysely
|
||||
.selectFrom("workspace_generated_bootstrap_hashes")
|
||||
.select(["filename", "sha256"])
|
||||
.where("workspace_key", "=", identity.workspaceKey)
|
||||
.orderBy("filename", "asc"),
|
||||
).rows;
|
||||
for (const row of hashRows) {
|
||||
if (
|
||||
!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(row.filename) ||
|
||||
!SHA256_HEX_PATTERN.test(row.sha256)
|
||||
) {
|
||||
throw new Error("workspace attestation hash row is invalid");
|
||||
}
|
||||
generatedHashes.set(row.filename, row.sha256);
|
||||
}
|
||||
}
|
||||
return {
|
||||
identity,
|
||||
setupExists: Boolean(setupRow),
|
||||
...(setupRow ? { setupUpdatedAtMs: setupRow.updated_at } : {}),
|
||||
setup: {
|
||||
version: WORKSPACE_SETUP_STATE_VERSION,
|
||||
...(setupRow?.bootstrap_seeded_at ? { bootstrapSeededAt: setupRow.bootstrap_seeded_at } : {}),
|
||||
...(setupRow?.setup_completed_at ? { setupCompletedAt: setupRow.setup_completed_at } : {}),
|
||||
},
|
||||
...(attestationRow
|
||||
? {
|
||||
attestation: {
|
||||
attestedAtMs: attestationRow.attested_at_ms,
|
||||
generatedHashes,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function readWorkspaceStateSnapshot(workspaceDir: string): WorkspaceStateSnapshot {
|
||||
const database = openOpenClawStateDatabase();
|
||||
const initial = runSqliteDeferredTransactionSync(database.db, () => {
|
||||
const resolution = resolveWorkspaceIdentityFromDatabase({ workspaceDir, database });
|
||||
return {
|
||||
resolution,
|
||||
snapshot: readSnapshotFromDatabase({ identity: resolution.identity, database }),
|
||||
};
|
||||
});
|
||||
if (
|
||||
initial.resolution.missingAliasKeys.length === 0 ||
|
||||
(!initial.snapshot.setupExists && !initial.snapshot.attestation)
|
||||
) {
|
||||
return initial.snapshot;
|
||||
}
|
||||
// Register a newly observed configured spelling once state proves the target
|
||||
// identity. Later disappearance must still find the same safety evidence.
|
||||
return runOpenClawStateWriteTransaction((writeDatabase) => {
|
||||
const currentAliases = resolveWorkspaceStateAliases(workspaceDir);
|
||||
const currentCanonicalIdentity = currentAliases.at(-1)!;
|
||||
if (
|
||||
workspacePathEntryExists(workspaceDir) &&
|
||||
currentCanonicalIdentity.workspaceKey !== initial.resolution.identity.workspaceKey
|
||||
) {
|
||||
throw new Error("workspace path alias points to a different current target");
|
||||
}
|
||||
const snapshot = readSnapshotFromDatabase({
|
||||
identity: initial.resolution.identity,
|
||||
database: writeDatabase,
|
||||
});
|
||||
if (snapshot.setupExists || snapshot.attestation) {
|
||||
const aliases = new Map(
|
||||
[...initial.resolution.aliases, ...currentAliases].map((alias) => [
|
||||
alias.workspaceKey,
|
||||
alias,
|
||||
]),
|
||||
);
|
||||
registerWorkspacePathAliases({
|
||||
database: writeDatabase,
|
||||
identity: initial.resolution.identity,
|
||||
aliases: [...aliases.values()],
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeWorkspaceSetupState(
|
||||
workspaceDir: string,
|
||||
next: Partial<Omit<WorkspaceSetupState, "version">>,
|
||||
nowMs = Date.now(),
|
||||
): WorkspaceSetupState {
|
||||
assertCanonicalIntegerTimestamp(nowMs, "setup update");
|
||||
if (next.bootstrapSeededAt) {
|
||||
assertCanonicalTimestamp(next.bootstrapSeededAt, "bootstrap seeded");
|
||||
}
|
||||
if (next.setupCompletedAt) {
|
||||
assertCanonicalTimestamp(next.setupCompletedAt, "setup completed");
|
||||
}
|
||||
return runOpenClawStateWriteTransaction((database) => {
|
||||
const resolution = resolveWorkspaceIdentityFromDatabase({ workspaceDir, database });
|
||||
const identity = resolution.identity;
|
||||
const snapshot = readSnapshotFromDatabase({ identity, database });
|
||||
const bootstrapSeededAt = snapshot.setup.bootstrapSeededAt ?? next.bootstrapSeededAt;
|
||||
const setupCompletedAt = snapshot.setup.setupCompletedAt ?? next.setupCompletedAt;
|
||||
const merged: WorkspaceSetupState = {
|
||||
version: WORKSPACE_SETUP_STATE_VERSION,
|
||||
...(bootstrapSeededAt ? { bootstrapSeededAt } : {}),
|
||||
...(setupCompletedAt ? { setupCompletedAt } : {}),
|
||||
};
|
||||
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.insertInto("workspace_setup_state")
|
||||
.values({
|
||||
workspace_key: identity.workspaceKey,
|
||||
workspace_path: identity.workspacePath,
|
||||
version: WORKSPACE_SETUP_STATE_VERSION,
|
||||
bootstrap_seeded_at: merged.bootstrapSeededAt ?? null,
|
||||
setup_completed_at: merged.setupCompletedAt ?? null,
|
||||
updated_at: nowMs,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("workspace_key").doUpdateSet({
|
||||
workspace_path: identity.workspacePath,
|
||||
version: WORKSPACE_SETUP_STATE_VERSION,
|
||||
bootstrap_seeded_at: merged.bootstrapSeededAt ?? null,
|
||||
setup_completed_at: merged.setupCompletedAt ?? null,
|
||||
updated_at: nowMs,
|
||||
}),
|
||||
),
|
||||
);
|
||||
registerWorkspacePathAliases({
|
||||
database,
|
||||
identity,
|
||||
aliases: resolution.aliases,
|
||||
updatedAtMs: nowMs,
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceWorkspaceAttestation(params: {
|
||||
workspaceDir: string;
|
||||
attestedAtMs: number;
|
||||
generatedHashes: ReadonlyMap<string, string>;
|
||||
nowMs?: number;
|
||||
}): WorkspaceAttestation {
|
||||
assertCanonicalIntegerTimestamp(params.attestedAtMs, "attestation");
|
||||
if (params.nowMs !== undefined) {
|
||||
assertCanonicalIntegerTimestamp(params.nowMs, "attestation update");
|
||||
}
|
||||
for (const [filename, sha256] of params.generatedHashes) {
|
||||
if (!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(filename) || !SHA256_HEX_PATTERN.test(sha256)) {
|
||||
throw new Error("workspace attestation hash is invalid");
|
||||
}
|
||||
}
|
||||
const sortedHashes = [...params.generatedHashes.entries()].toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
return runOpenClawStateWriteTransaction((database) => {
|
||||
// Capture the comparison clock only after BEGIN IMMEDIATE acquires the
|
||||
// writer lock, so a newer committed row cannot look future-dated.
|
||||
const updatedAtMs = params.nowMs ?? Date.now();
|
||||
assertCanonicalIntegerTimestamp(updatedAtMs, "attestation update");
|
||||
const resolution = resolveWorkspaceIdentityFromDatabase({
|
||||
workspaceDir: params.workspaceDir,
|
||||
database,
|
||||
});
|
||||
const identity = resolution.identity;
|
||||
const snapshot = readSnapshotFromDatabase({ identity, database });
|
||||
if (
|
||||
snapshot.attestation &&
|
||||
snapshot.attestation.attestedAtMs > params.attestedAtMs &&
|
||||
snapshot.attestation.attestedAtMs <= updatedAtMs
|
||||
) {
|
||||
registerWorkspacePathAliases({
|
||||
database,
|
||||
identity,
|
||||
aliases: resolution.aliases,
|
||||
updatedAtMs,
|
||||
});
|
||||
return snapshot.attestation;
|
||||
}
|
||||
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.insertInto("workspace_attestations")
|
||||
.values({
|
||||
workspace_key: identity.workspaceKey,
|
||||
attested_at_ms: params.attestedAtMs,
|
||||
updated_at_ms: updatedAtMs,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("workspace_key").doUpdateSet({
|
||||
attested_at_ms: params.attestedAtMs,
|
||||
updated_at_ms: updatedAtMs,
|
||||
}),
|
||||
),
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.deleteFrom("workspace_generated_bootstrap_hashes")
|
||||
.where("workspace_key", "=", identity.workspaceKey),
|
||||
);
|
||||
if (sortedHashes.length > 0) {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely.insertInto("workspace_generated_bootstrap_hashes").values(
|
||||
sortedHashes.map(([filename, sha256]) => ({
|
||||
workspace_key: identity.workspaceKey,
|
||||
filename,
|
||||
sha256,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
registerWorkspacePathAliases({
|
||||
database,
|
||||
identity,
|
||||
aliases: resolution.aliases,
|
||||
updatedAtMs,
|
||||
});
|
||||
return {
|
||||
attestedAtMs: params.attestedAtMs,
|
||||
generatedHashes: new Map(sortedHashes),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function deleteWorkspaceRows(
|
||||
database: ReturnType<typeof openOpenClawStateDatabase>,
|
||||
workspaceKey: string,
|
||||
): void {
|
||||
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
|
||||
const receiptRows = executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.selectFrom("migration_sources")
|
||||
.select(["source_key", "last_run_id", "report_json"])
|
||||
.where("migration_kind", "=", WORKSPACE_LEGACY_STATE_MIGRATION_KIND),
|
||||
).rows.filter((row) => {
|
||||
try {
|
||||
const report = JSON.parse(row.report_json) as Record<string, unknown>;
|
||||
return report.workspaceKey === workspaceKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (receiptRows.length > 0) {
|
||||
const receiptKeys = receiptRows.map((row) => row.source_key);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely.deleteFrom("migration_sources").where("source_key", "in", receiptKeys),
|
||||
);
|
||||
const runIds = [...new Set(receiptRows.map((row) => row.last_run_id))];
|
||||
const referencedRunIds = new Set(
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.selectFrom("migration_sources")
|
||||
.select("last_run_id")
|
||||
.where("last_run_id", "in", runIds),
|
||||
).rows.map((row) => row.last_run_id),
|
||||
);
|
||||
const orphanedRunIds = runIds.filter((runId) => !referencedRunIds.has(runId));
|
||||
if (orphanedRunIds.length > 0) {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely.deleteFrom("migration_runs").where("id", "in", orphanedRunIds),
|
||||
);
|
||||
}
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.deleteFrom("workspace_generated_bootstrap_hashes")
|
||||
.where("workspace_key", "=", workspaceKey),
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely.deleteFrom("workspace_attestations").where("workspace_key", "=", workspaceKey),
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely.deleteFrom("workspace_setup_state").where("workspace_key", "=", workspaceKey),
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely.deleteFrom("workspace_path_aliases").where("workspace_key", "=", workspaceKey),
|
||||
);
|
||||
}
|
||||
|
||||
/** Clear expired state only when no concurrent writer refreshed the vanished workspace. */
|
||||
export function clearExpiredWorkspaceStateForVanishedWorkspace(
|
||||
workspaceDir: string,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
assertCanonicalIntegerTimestamp(nowMs, "workspace expiry check");
|
||||
return runOpenClawStateWriteTransaction((database) => {
|
||||
const resolution = resolveWorkspaceIdentityFromDatabase({ workspaceDir, database });
|
||||
const identity = resolution.identity;
|
||||
const snapshot = readSnapshotFromDatabase({ identity, database });
|
||||
const preserveRecentState = () => {
|
||||
registerWorkspacePathAliases({
|
||||
database,
|
||||
identity,
|
||||
aliases: resolution.aliases,
|
||||
updatedAtMs: nowMs,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
if (snapshot.attestation) {
|
||||
const ageMs = nowMs - snapshot.attestation.attestedAtMs;
|
||||
if (ageMs <= WORKSPACE_ATTESTATION_RECENT_MS) {
|
||||
return preserveRecentState();
|
||||
}
|
||||
}
|
||||
if (
|
||||
(snapshot.setup.bootstrapSeededAt || snapshot.setup.setupCompletedAt) &&
|
||||
snapshot.setupUpdatedAtMs !== undefined
|
||||
) {
|
||||
const ageMs = nowMs - snapshot.setupUpdatedAtMs;
|
||||
if (ageMs <= WORKSPACE_ATTESTATION_RECENT_MS) {
|
||||
return preserveRecentState();
|
||||
}
|
||||
}
|
||||
deleteWorkspaceRows(database, identity.workspaceKey);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture workspace identity before the filesystem entry is removed. */
|
||||
export function prepareWorkspaceStateDeletion(workspaceDir: string): WorkspaceStateDeletionPlan {
|
||||
const aliases = resolveWorkspaceStateAliases(workspaceDir);
|
||||
return {
|
||||
lexicalAlias: aliases[0]!,
|
||||
currentCanonicalIdentity: aliases.at(-1)!,
|
||||
pathEntryExisted: workspacePathEntryExists(workspaceDir),
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteWorkspaceState(plan: WorkspaceStateDeletionPlan): void {
|
||||
// Delete-only cleanup must not recreate state after reset/uninstall removed
|
||||
// the canonical database successfully or partially.
|
||||
if (!existsSync(resolveOpenClawStateSqlitePath())) {
|
||||
return;
|
||||
}
|
||||
runOpenClawStateWriteTransaction((database) => {
|
||||
const { lexicalAlias, currentCanonicalIdentity } = plan;
|
||||
const kysely = getNodeSqliteKysely<WorkspaceStateDatabase>(database.db);
|
||||
const storedAlias = executeSqliteQueryTakeFirstSync(
|
||||
database.db,
|
||||
kysely
|
||||
.selectFrom("workspace_path_aliases")
|
||||
.selectAll()
|
||||
.where("alias_key", "=", lexicalAlias.workspaceKey),
|
||||
);
|
||||
if (storedAlias && storedAlias.alias_path !== lexicalAlias.workspacePath) {
|
||||
throw new Error("workspace path alias key collision");
|
||||
}
|
||||
const storedIdentity = storedAlias
|
||||
? createWorkspaceStateIdentity(storedAlias.workspace_path)
|
||||
: undefined;
|
||||
if (storedIdentity && storedIdentity.workspaceKey !== storedAlias?.workspace_key) {
|
||||
throw new Error("workspace path alias target is invalid");
|
||||
}
|
||||
if (
|
||||
storedIdentity &&
|
||||
plan.pathEntryExisted &&
|
||||
storedIdentity.workspaceKey !== currentCanonicalIdentity.workspaceKey
|
||||
) {
|
||||
// A repointed configured alias no longer owns its former canonical
|
||||
// workspace. Remove only that stale association, then clean current state.
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.deleteFrom("workspace_path_aliases")
|
||||
.where("alias_key", "=", lexicalAlias.workspaceKey),
|
||||
);
|
||||
const currentResolution = resolveWorkspaceIdentityFromDatabase({
|
||||
workspaceDir: currentCanonicalIdentity.workspacePath,
|
||||
database,
|
||||
});
|
||||
deleteWorkspaceRows(database, currentResolution.identity.workspaceKey);
|
||||
return;
|
||||
}
|
||||
if (storedIdentity) {
|
||||
deleteWorkspaceRows(database, storedIdentity.workspaceKey);
|
||||
return;
|
||||
}
|
||||
const resolution = resolveWorkspaceIdentityFromDatabase({
|
||||
workspaceDir: currentCanonicalIdentity.workspacePath,
|
||||
database,
|
||||
});
|
||||
deleteWorkspaceRows(database, resolution.identity.workspaceKey);
|
||||
});
|
||||
}
|
||||
+251
-148
@@ -6,11 +6,24 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
} from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
LEGACY_WORKSPACE_ATTESTATION_HEADER,
|
||||
LEGACY_WORKSPACE_STATE_CURRENT_FILENAME,
|
||||
LEGACY_WORKSPACE_STATE_DIRNAME,
|
||||
} from "./workspace-legacy-state.js";
|
||||
import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js";
|
||||
import {
|
||||
mergeWorkspaceSetupState,
|
||||
readWorkspaceStateSnapshot,
|
||||
replaceWorkspaceAttestation,
|
||||
resolveWorkspaceStateIdentity,
|
||||
} from "./workspace-state-store.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
@@ -26,7 +39,6 @@ import {
|
||||
loadWorkspaceBootstrapFiles,
|
||||
resolveWorkspaceBootstrapStatus,
|
||||
resolveDefaultAgentWorkspaceDir,
|
||||
resolveWorkspaceAttestationPaths,
|
||||
WORKSPACE_VANISHED_ERROR_CODE,
|
||||
type WorkspaceBootstrapFile,
|
||||
} from "./workspace.js";
|
||||
@@ -34,6 +46,7 @@ import {
|
||||
let testState: OpenClawTestState | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetLegacyWorkspaceStateCheckForTest();
|
||||
testState = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-workspace-state-",
|
||||
@@ -41,6 +54,8 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetLegacyWorkspaceStateCheckForTest();
|
||||
await testState?.cleanup();
|
||||
testState = undefined;
|
||||
});
|
||||
@@ -66,28 +81,17 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const WORKSPACE_STATE_PATH_SEGMENTS = ["openclaw-workspace-state.json"] as const;
|
||||
const LEGACY_WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
|
||||
|
||||
function resolveCurrentWorkspaceAttestationPath(dir: string): string {
|
||||
const [attestationPath] = resolveWorkspaceAttestationPaths(dir);
|
||||
if (!attestationPath) {
|
||||
throw new Error("expected current workspace attestation path");
|
||||
}
|
||||
return attestationPath;
|
||||
}
|
||||
const LEGACY_WORKSPACE_STATE_PATH_SEGMENTS = [
|
||||
LEGACY_WORKSPACE_STATE_DIRNAME,
|
||||
"workspace-state.json",
|
||||
] as const;
|
||||
|
||||
async function readWorkspaceState(dir: string): Promise<{
|
||||
version: number;
|
||||
bootstrapSeededAt?: string;
|
||||
setupCompletedAt?: string;
|
||||
}> {
|
||||
const raw = await fs.readFile(path.join(dir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
|
||||
return JSON.parse(raw) as {
|
||||
version: number;
|
||||
bootstrapSeededAt?: string;
|
||||
setupCompletedAt?: string;
|
||||
};
|
||||
return readWorkspaceStateSnapshot(dir).setup;
|
||||
}
|
||||
|
||||
async function writeLegacyWorkspaceState(dir: string, state: unknown): Promise<void> {
|
||||
@@ -108,19 +112,28 @@ async function expectPathMissing(filePath: string): Promise<void> {
|
||||
await expect(fs.access(filePath)).rejects.toHaveProperty("code", "ENOENT");
|
||||
}
|
||||
|
||||
async function expectWorkspaceVanished(
|
||||
action: Promise<unknown>,
|
||||
expected?: { attestationPath?: string },
|
||||
): Promise<void> {
|
||||
async function expectWorkspaceVanished(action: Promise<unknown>): Promise<void> {
|
||||
// Recently attested generated workspaces must not be silently recreated after
|
||||
// deletion or wipe; that could hide user data loss.
|
||||
await expect(action).rejects.toMatchObject({
|
||||
code: WORKSPACE_VANISHED_ERROR_CODE,
|
||||
name: "WorkspaceVanishedError",
|
||||
...expected,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoLegacyWorkspaceStateWrites(dir: string): Promise<void> {
|
||||
const { workspaceKey } = resolveWorkspaceStateIdentity(dir);
|
||||
const paths = [
|
||||
path.join(dir, LEGACY_WORKSPACE_STATE_CURRENT_FILENAME),
|
||||
path.join(dir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
|
||||
`${dir}.attested`,
|
||||
path.join(testState?.stateDir ?? "", "workspace-attestations", `${workspaceKey}.attested`),
|
||||
];
|
||||
for (const filePath of paths) {
|
||||
await expectPathMissing(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCompletedWithoutBootstrap(dir: string) {
|
||||
await expect(fs.access(path.join(dir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
|
||||
await expectPathMissing(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
@@ -145,8 +158,9 @@ describe("ensureAgentWorkspace", () => {
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
await expectPathMissing(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS));
|
||||
await expectNoLegacyWorkspaceStateWrites(tempDir);
|
||||
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
|
||||
expect(readWorkspaceStateSnapshot(tempDir).attestation).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not overwrite a foreign root workspace-state.json file", async () => {
|
||||
@@ -161,27 +175,47 @@ describe("ensureAgentWorkspace", () => {
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
});
|
||||
|
||||
it("ignores unreadable legacy nested state while writing current setup state", async () => {
|
||||
it("requires Doctor before using legacy JSON setup state", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await fs.mkdir(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS), {
|
||||
recursive: true,
|
||||
await writeLegacyWorkspaceState(tempDir, {
|
||||
version: 1,
|
||||
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
|
||||
});
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).rejects.toThrow(/run openclaw doctor --fix/u);
|
||||
await expect(
|
||||
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
|
||||
).resolves.toBeUndefined();
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(false);
|
||||
});
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
const legacyStateStat = await fs.stat(
|
||||
path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
|
||||
);
|
||||
expect(legacyStateStat.isDirectory()).toBe(true);
|
||||
it("requires Doctor when partial SQLite state coexists with legacy setup state", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const seededAt = "2026-07-15T10:00:00.000Z";
|
||||
mergeWorkspaceSetupState(tempDir, { bootstrapSeededAt: seededAt });
|
||||
await writeLegacyWorkspaceState(tempDir, {
|
||||
version: 1,
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).rejects.toThrow(/run openclaw doctor --fix/u);
|
||||
await expect(
|
||||
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
|
||||
).resolves.toBeUndefined();
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setup).toEqual({
|
||||
version: 1,
|
||||
bootstrapSeededAt: seededAt,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to re-seed a recently attested workspace after the directory disappears", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await expect(
|
||||
fs.access(resolveCurrentWorkspaceAttestationPath(tempDir)),
|
||||
).resolves.toBeUndefined();
|
||||
expect(readWorkspaceStateSnapshot(tempDir).attestation).toBeDefined();
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -202,7 +236,7 @@ describe("ensureAgentWorkspace", () => {
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
);
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
await expectPathMissing(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS));
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to re-seed a recently attested workspace after only generated remnants survive", async () => {
|
||||
@@ -218,7 +252,28 @@ describe("ensureAgentWorkspace", () => {
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
);
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
await expectPathMissing(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS));
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to re-seed a future-attested workspace after only generated remnants survive", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
const snapshot = readWorkspaceStateSnapshot(tempDir);
|
||||
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: tempDir,
|
||||
attestedAtMs: Date.now() + 60_000,
|
||||
generatedHashes: snapshot.attestation!.generatedHashes,
|
||||
});
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
|
||||
|
||||
await expectWorkspaceVanished(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
);
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
});
|
||||
|
||||
it("refuses to re-seed a recently attested workspace after only generated git metadata survives", async () => {
|
||||
@@ -235,21 +290,21 @@ describe("ensureAgentWorkspace", () => {
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
});
|
||||
|
||||
it("refuses to accept old generated bootstrap files recorded by the attestation marker", async () => {
|
||||
it("refuses to accept old generated bootstrap files recorded by SQLite attestation", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const oldGeneratedAgents = "old generated agents\n";
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), oldGeneratedAgents);
|
||||
const attestationPath = resolveCurrentWorkspaceAttestationPath(tempDir);
|
||||
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
attestationPath,
|
||||
[
|
||||
"openclaw-workspace-attestation:v1",
|
||||
new Date().toISOString(),
|
||||
`generated:${DEFAULT_AGENTS_FILENAME}:${createHash("sha256").update(oldGeneratedAgents).digest("hex")}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
mergeWorkspaceSetupState(tempDir, {
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
});
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: tempDir,
|
||||
attestedAtMs: Date.now(),
|
||||
generatedHashes: new Map([
|
||||
[DEFAULT_AGENTS_FILENAME, createHash("sha256").update(oldGeneratedAgents).digest("hex")],
|
||||
]),
|
||||
});
|
||||
|
||||
await expectWorkspaceVanished(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
@@ -257,16 +312,34 @@ describe("ensureAgentWorkspace", () => {
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
});
|
||||
|
||||
it("refuses a recently attested workspace when generated state and only one generated file survive", async () => {
|
||||
it("refuses a recently attested workspace when only one generated file survives", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
|
||||
const state = await fs.readFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
|
||||
await fs.writeFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), state);
|
||||
|
||||
await expectWorkspaceVanished(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
);
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
});
|
||||
|
||||
it("uses template comparison when an attestation has no generated hashes", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
const generatedAgents = await fs.readFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "utf-8");
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: tempDir,
|
||||
attestedAtMs: Date.now(),
|
||||
generatedHashes: new Map(),
|
||||
});
|
||||
|
||||
await expectWorkspaceVanished(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
@@ -280,7 +353,6 @@ describe("ensureAgentWorkspace", () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), "custom instructions\n");
|
||||
await fs.rm(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), { force: true });
|
||||
await fs.rm(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME), { force: true });
|
||||
|
||||
await expect(
|
||||
@@ -370,19 +442,104 @@ describe("ensureAgentWorkspace", () => {
|
||||
).resolves.toMatchObject({ dir: tempDir });
|
||||
});
|
||||
|
||||
it("allows a brand new workspace when the only attestation marker is stale", async () => {
|
||||
it("allows a brand new workspace when its SQLite attestation is stale", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
|
||||
mergeWorkspaceSetupState(
|
||||
tempDir,
|
||||
{
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
},
|
||||
expiredAtMs,
|
||||
);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: tempDir,
|
||||
attestedAtMs: expiredAtMs,
|
||||
generatedHashes: new Map(),
|
||||
});
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
const staleDate = new Date(Date.now() - 25 * 60 * 60 * 1000);
|
||||
await fs.utimes(resolveCurrentWorkspaceAttestationPath(tempDir), staleDate, staleDate);
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not overwrite a sibling file that is not an OpenClaw attestation marker", async () => {
|
||||
it("clears expired setup state when a wiped workspace retains only .git", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
|
||||
mergeWorkspaceSetupState(
|
||||
tempDir,
|
||||
{
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
},
|
||||
expiredAtMs,
|
||||
);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: tempDir,
|
||||
attestedAtMs: expiredAtMs,
|
||||
generatedHashes: new Map(),
|
||||
});
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears expired setup-only state before reseeding an empty workspace", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
mergeWorkspaceSetupState(
|
||||
tempDir,
|
||||
{
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
},
|
||||
Date.now() - 25 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires Doctor before using a legacy owned attestation marker", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const attestationPath = `${tempDir}.attested`;
|
||||
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n${new Date().toISOString()}\n`;
|
||||
await fs.writeFile(attestationPath, marker);
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).rejects.toThrow(/run openclaw doctor --fix/u);
|
||||
|
||||
expect(await fs.readFile(attestationPath, "utf-8")).toBe(marker);
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(false);
|
||||
});
|
||||
|
||||
it("requires Doctor when SQLite setup state coexists with a legacy attestation", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
mergeWorkspaceSetupState(tempDir, {
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
});
|
||||
const attestationPath = `${tempDir}.attested`;
|
||||
const marker = `${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n${new Date().toISOString()}\n`;
|
||||
await fs.writeFile(attestationPath, marker);
|
||||
|
||||
await expect(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
).rejects.toThrow(/run openclaw doctor --fix/u);
|
||||
|
||||
expect(await fs.readFile(attestationPath, "utf-8")).toBe(marker);
|
||||
expect(readWorkspaceStateSnapshot(tempDir).setupExists).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores and preserves a foreign sibling attestation file", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const attestationPath = `${tempDir}.attested`;
|
||||
const siblingContent = "external attestation data\n";
|
||||
@@ -394,75 +551,6 @@ describe("ensureAgentWorkspace", () => {
|
||||
expect(await fs.readFile(attestationPath, "utf-8")).toBe(siblingContent);
|
||||
});
|
||||
|
||||
it("does not read or overwrite a large sibling file at the marker path", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const attestationPath = `${tempDir}.attested`;
|
||||
const siblingContent = "x".repeat(1024);
|
||||
await fs.writeFile(attestationPath, siblingContent);
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
expect(await fs.readFile(attestationPath, "utf-8")).toBe(siblingContent);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses to re-seed when a recent owned marker becomes unreadable",
|
||||
async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const attestationPath = resolveCurrentWorkspaceAttestationPath(tempDir);
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await fs.chmod(attestationPath, 0o000);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
await expectWorkspaceVanished(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
);
|
||||
} finally {
|
||||
await fs.chmod(attestationPath, 0o600);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses to re-seed when the state marker directory is unreadable",
|
||||
async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const attestationDir = path.dirname(resolveCurrentWorkspaceAttestationPath(tempDir));
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await fs.chmod(attestationDir, 0o000);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
await expectWorkspaceVanished(
|
||||
ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }),
|
||||
);
|
||||
} finally {
|
||||
await fs.chmod(attestationDir, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"ignores symlinked attestation markers without overwriting the target",
|
||||
async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const attestationPath = resolveCurrentWorkspaceAttestationPath(tempDir);
|
||||
const symlinkTargetPath = `${attestationPath}-target`;
|
||||
const targetContent = "outside-marker\n";
|
||||
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fs.writeFile(symlinkTargetPath, targetContent);
|
||||
await fs.symlink(symlinkTargetPath, attestationPath);
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
expect(await fs.readFile(symlinkTargetPath, "utf-8")).toBe(targetContent);
|
||||
expect((await fs.lstat(attestationPath)).isSymbolicLink()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("recovers partial initialization by creating BOOTSTRAP.md when marker is missing", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "existing" });
|
||||
@@ -572,27 +660,6 @@ describe("ensureAgentWorkspace", () => {
|
||||
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("migrates legacy onboardingCompletedAt markers to setupCompletedAt", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await writeLegacyWorkspaceState(tempDir, {
|
||||
version: 1,
|
||||
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
|
||||
});
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
const state = await readWorkspaceState(tempDir);
|
||||
expect(state.setupCompletedAt).toBe("2026-03-15T02:30:00.000Z");
|
||||
await expect(
|
||||
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
|
||||
).resolves.toBeUndefined();
|
||||
const persisted = await fs.readFile(
|
||||
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
|
||||
"utf-8",
|
||||
);
|
||||
expect(persisted).toContain('"setupCompletedAt": "2026-03-15T02:30:00.000Z"');
|
||||
});
|
||||
|
||||
it("reports bootstrap pending while BOOTSTRAP.md exists and setup is incomplete", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
|
||||
@@ -822,6 +889,42 @@ describe("ensureAgentWorkspace", () => {
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("observes setup completed concurrently before writing optional bootstrap files", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
const agentsPath = path.join(tempDir, DEFAULT_AGENTS_FILENAME);
|
||||
await fs.writeFile(agentsPath, "custom agents instructions\n", "utf8");
|
||||
mergeWorkspaceSetupState(tempDir, {
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
});
|
||||
const realAccess = fs.access.bind(fs);
|
||||
let completed = false;
|
||||
const accessSpy = vi.spyOn(fs, "access").mockImplementation(async (filePath, mode) => {
|
||||
if (!completed && filePath === agentsPath) {
|
||||
completed = true;
|
||||
mergeWorkspaceSetupState(tempDir, {
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
});
|
||||
}
|
||||
return await realAccess(filePath, mode);
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
} finally {
|
||||
accessSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(completed).toBe(true);
|
||||
for (const filename of [
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
]) {
|
||||
await expectPathMissing(path.join(tempDir, filename));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWorkspaceBootstrapFiles", () => {
|
||||
|
||||
+205
-339
@@ -7,12 +7,9 @@ import { createHash } from "node:crypto";
|
||||
import syncFs from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
||||
import { extractFrontmatterBlock } from "../../packages/markdown-core/src/frontmatter.js";
|
||||
import { resolveLegacyStateDirs, resolveStateDir } from "../config/paths.js";
|
||||
import { openRootFile } from "../infra/boundary-file-read.js";
|
||||
import { pathExists } from "../infra/fs-safe.js";
|
||||
import { replaceFileAtomic } from "../infra/replace-file.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import {
|
||||
CANONICAL_ROOT_MEMORY_FILENAME,
|
||||
@@ -22,6 +19,21 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace-default.js";
|
||||
import {
|
||||
assertNoUnmigratedWorkspaceState,
|
||||
LEGACY_WORKSPACE_STATE_CURRENT_FILENAME,
|
||||
LEGACY_WORKSPACE_STATE_DIRNAME,
|
||||
} from "./workspace-legacy-state.js";
|
||||
import {
|
||||
clearExpiredWorkspaceStateForVanishedWorkspace,
|
||||
mergeWorkspaceSetupState,
|
||||
readWorkspaceStateSnapshot,
|
||||
replaceWorkspaceAttestation,
|
||||
WORKSPACE_ATTESTATION_RECENT_MS,
|
||||
type WorkspaceAttestation,
|
||||
type WorkspaceStateSnapshot,
|
||||
type WorkspaceSetupState,
|
||||
} from "./workspace-state-store.js";
|
||||
import {
|
||||
resolveWorkspaceTemplateDir,
|
||||
resolveWorkspaceTemplateSearchDirs,
|
||||
@@ -38,15 +50,6 @@ export const DEFAULT_USER_FILENAME = "USER.md";
|
||||
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
|
||||
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
|
||||
export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME;
|
||||
const LEGACY_WORKSPACE_STATE_DIRNAME = ".openclaw";
|
||||
const LEGACY_WORKSPACE_STATE_FILENAME = "workspace-state.json";
|
||||
const WORKSPACE_STATE_FILENAME = "openclaw-workspace-state.json";
|
||||
const WORKSPACE_STATE_VERSION = 1;
|
||||
const WORKSPACE_ATTESTATION_SUFFIX = ".attested";
|
||||
const WORKSPACE_ATTESTATION_DIRNAME = "workspace-attestations";
|
||||
const WORKSPACE_ATTESTATION_RECENT_MS = 24 * 60 * 60 * 1000;
|
||||
const WORKSPACE_ATTESTATION_HEADER = "openclaw-workspace-attestation:v1";
|
||||
const WORKSPACE_ATTESTATION_MAX_BYTES = 2048;
|
||||
const WORKSPACE_ONBOARDING_PROFILE_FILENAMES = [
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
@@ -204,13 +207,6 @@ export type ExtraBootstrapLoadDiagnostic = {
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type WorkspaceSetupState = {
|
||||
version: typeof WORKSPACE_STATE_VERSION;
|
||||
bootstrapSeededAt?: string;
|
||||
setupCompletedAt?: string;
|
||||
};
|
||||
type WorkspaceAttestationMarkerStatus = "marker" | "not-marker" | "missing" | "unknown";
|
||||
|
||||
/** Set of recognized bootstrap filenames for runtime validation */
|
||||
const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -235,17 +231,15 @@ export const WORKSPACE_VANISHED_ERROR_CODE = "WORKSPACE_VANISHED";
|
||||
export class WorkspaceVanishedError extends Error {
|
||||
readonly code = WORKSPACE_VANISHED_ERROR_CODE;
|
||||
readonly workspaceDir: string;
|
||||
readonly attestationPath: string;
|
||||
|
||||
constructor(params: { workspaceDir: string; attestationPath: string }) {
|
||||
constructor(params: { workspaceDir: string }) {
|
||||
super(
|
||||
`OpenClaw workspace appears to have disappeared after a recent initialization: ${params.workspaceDir}. ` +
|
||||
`Refusing to reseed BOOTSTRAP.md over a recently attested workspace. ` +
|
||||
`Restore the workspace or remove ${params.attestationPath} if this reset was intentional.`,
|
||||
"Restore the workspace or run a full OpenClaw reset if this reset was intentional.",
|
||||
);
|
||||
this.name = "WorkspaceVanishedError";
|
||||
this.workspaceDir = params.workspaceDir;
|
||||
this.attestationPath = params.attestationPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +339,7 @@ async function hasSkipBootstrapWorkspaceContentEvidence(dir: string): Promise<bo
|
||||
if (
|
||||
entry.name === ".DS_Store" ||
|
||||
entry.name === LEGACY_WORKSPACE_STATE_DIRNAME ||
|
||||
entry.name === WORKSPACE_STATE_FILENAME
|
||||
entry.name === LEGACY_WORKSPACE_STATE_CURRENT_FILENAME
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -384,13 +378,11 @@ async function workspaceProfileLooksConfigured(params: {
|
||||
|
||||
async function workspaceRequiredBootstrapLooksCustomized(
|
||||
dir: string,
|
||||
opts?: { attestationPath?: string },
|
||||
opts?: { generatedHashes?: ReadonlyMap<string, string> },
|
||||
): Promise<boolean> {
|
||||
const fileNames = [DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_HEARTBEAT_FILENAME];
|
||||
const generatedHashes = opts?.attestationPath
|
||||
? await readWorkspaceAttestationGeneratedHashes(opts.attestationPath)
|
||||
: undefined;
|
||||
if (generatedHashes) {
|
||||
const generatedHashes = opts?.generatedHashes;
|
||||
if (generatedHashes && generatedHashes.size > 0) {
|
||||
for (const fileName of fileNames) {
|
||||
const filePath = path.join(dir, fileName);
|
||||
const generatedHash = generatedHashes.get(fileName);
|
||||
@@ -416,10 +408,12 @@ async function workspaceRequiredBootstrapLooksCustomized(
|
||||
|
||||
async function workspaceAttestedGeneratedFilesIntact(
|
||||
dir: string,
|
||||
attestationPath: string,
|
||||
generatedHashes: ReadonlyMap<string, string>,
|
||||
): Promise<boolean> {
|
||||
const generatedHashes = await readWorkspaceAttestationGeneratedHashes(attestationPath);
|
||||
if (!generatedHashes) {
|
||||
if (
|
||||
!generatedHashes.has(DEFAULT_AGENTS_FILENAME) ||
|
||||
!generatedHashes.has(DEFAULT_TOOLS_FILENAME)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (const [fileName, generatedHash] of generatedHashes) {
|
||||
@@ -449,7 +443,6 @@ type WorkspaceBootstrapCompletionReconcileResult = {
|
||||
async function reconcileWorkspaceBootstrapCompletionState(params: {
|
||||
dir: string;
|
||||
bootstrapPath: string;
|
||||
statePath: string;
|
||||
state: WorkspaceSetupState;
|
||||
bootstrapExists?: boolean;
|
||||
}): Promise<WorkspaceBootstrapCompletionReconcileResult> {
|
||||
@@ -466,8 +459,8 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
|
||||
...params.state,
|
||||
setupCompletedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeWorkspaceSetupState(params.statePath, completedState);
|
||||
return { repaired: true, bootstrapExists: false, state: completedState };
|
||||
const persistedState = mergeWorkspaceSetupState(params.dir, completedState);
|
||||
return { repaired: true, bootstrapExists: false, state: persistedState };
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -485,131 +478,13 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
|
||||
bootstrapSeededAt: params.state.bootstrapSeededAt ?? now,
|
||||
setupCompletedAt: now,
|
||||
};
|
||||
await writeWorkspaceSetupState(params.statePath, repairedState);
|
||||
const persistedState = mergeWorkspaceSetupState(params.dir, repairedState);
|
||||
try {
|
||||
await fs.rm(params.bootstrapPath, { force: true });
|
||||
return { repaired: true, bootstrapExists: false, state: repairedState };
|
||||
return { repaired: true, bootstrapExists: false, state: persistedState };
|
||||
} catch {
|
||||
// Completion state is authoritative; stale BOOTSTRAP cleanup is best-effort.
|
||||
return { repaired: true, bootstrapExists: true, state: repairedState };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceStatePath(dir: string): string {
|
||||
return path.join(dir, WORKSPACE_STATE_FILENAME);
|
||||
}
|
||||
|
||||
function resolveLegacyWorkspaceStatePath(dir: string): string {
|
||||
return path.join(dir, LEGACY_WORKSPACE_STATE_DIRNAME, LEGACY_WORKSPACE_STATE_FILENAME);
|
||||
}
|
||||
|
||||
function resolveWorkspaceAttestationPathInStateDir(dir: string, stateDir: string): string {
|
||||
const key = createHash("sha256").update(path.resolve(dir)).digest("hex");
|
||||
return path.join(stateDir, WORKSPACE_ATTESTATION_DIRNAME, `${key}.attested`);
|
||||
}
|
||||
|
||||
function resolveLegacyWorkspaceAttestationPath(dir: string): string {
|
||||
return `${dir}${WORKSPACE_ATTESTATION_SUFFIX}`;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceAttestationPaths(dir: string): string[] {
|
||||
const stateAttestationPaths = [resolveStateDir(), ...resolveLegacyStateDirs()].map((stateDir) =>
|
||||
resolveWorkspaceAttestationPathInStateDir(dir, stateDir),
|
||||
);
|
||||
const legacy = resolveLegacyWorkspaceAttestationPath(dir);
|
||||
return [...new Set([...stateAttestationPaths, legacy])];
|
||||
}
|
||||
|
||||
async function findRecentWorkspaceAttestationPath(
|
||||
attestationPaths: string[],
|
||||
): Promise<string | null> {
|
||||
for (const [index, attestationPath] of attestationPaths.entries()) {
|
||||
if (await hasRecentWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
|
||||
return attestationPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function hasRecentWorkspaceAttestation(
|
||||
attestationPath: string,
|
||||
opts?: { trustUnknown?: boolean },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(attestationPath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size > WORKSPACE_ATTESTATION_MAX_BYTES ||
|
||||
Date.now() - stat.mtimeMs > WORKSPACE_ATTESTATION_RECENT_MS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const status = await readWorkspaceAttestationMarkerStatus(attestationPath);
|
||||
return status === "marker" || (opts?.trustUnknown === true && status === "unknown");
|
||||
} catch (err) {
|
||||
const anyErr = err as { code?: string };
|
||||
if (anyErr.code !== "ENOENT") {
|
||||
return opts?.trustUnknown === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function shouldRemoveWorkspaceAttestation(
|
||||
attestationPath: string,
|
||||
opts?: { trustUnknown?: boolean },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return (
|
||||
(await readWorkspaceAttestationMarkerStatus(attestationPath)) === "marker" ||
|
||||
(await hasRecentWorkspaceAttestation(attestationPath, opts))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readWorkspaceAttestationMarkerStatus(
|
||||
attestationPath: string,
|
||||
): Promise<WorkspaceAttestationMarkerStatus> {
|
||||
try {
|
||||
const stat = await fs.lstat(attestationPath);
|
||||
if (!stat.isFile() || stat.size > WORKSPACE_ATTESTATION_MAX_BYTES) {
|
||||
return "not-marker";
|
||||
}
|
||||
const raw = await fs.readFile(attestationPath, "utf-8");
|
||||
if (raw.startsWith(`${WORKSPACE_ATTESTATION_HEADER}\n`)) {
|
||||
return "marker";
|
||||
}
|
||||
return "not-marker";
|
||||
} catch (err) {
|
||||
const anyErr = err as { code?: string };
|
||||
return anyErr.code === "ENOENT" ? "missing" : "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function readWorkspaceAttestationGeneratedHashes(
|
||||
attestationPath: string,
|
||||
): Promise<Map<string, string> | undefined> {
|
||||
try {
|
||||
const stat = await fs.lstat(attestationPath);
|
||||
if (!stat.isFile() || stat.size > WORKSPACE_ATTESTATION_MAX_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = await fs.readFile(attestationPath, "utf-8");
|
||||
if (!raw.startsWith(`${WORKSPACE_ATTESTATION_HEADER}\n`)) {
|
||||
return undefined;
|
||||
}
|
||||
const hashes = new Map<string, string>();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const match = /^generated:([^:]+):([a-f0-9]{64})$/.exec(line);
|
||||
if (match?.[1] && match[2]) {
|
||||
hashes.set(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
return hashes.size > 0 ? hashes : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
return { repaired: true, bootstrapExists: true, state: persistedState };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,74 +511,37 @@ async function collectGeneratedBootstrapHashes(dir: string): Promise<Map<string,
|
||||
return hashes;
|
||||
}
|
||||
|
||||
async function buildWorkspaceAttestationContent(dir: string, now: Date): Promise<string> {
|
||||
const hashes = await collectGeneratedBootstrapHashes(dir);
|
||||
const lines = [WORKSPACE_ATTESTATION_HEADER, now.toISOString()];
|
||||
for (const [fileName, hash] of [...hashes.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
|
||||
lines.push(`generated:${fileName}:${hash}`);
|
||||
function recentWorkspaceAttestation(
|
||||
attestation: WorkspaceAttestation | undefined,
|
||||
nowMs = Date.now(),
|
||||
): WorkspaceAttestation | undefined {
|
||||
if (!attestation) {
|
||||
return undefined;
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
const ageMs = nowMs - attestation.attestedAtMs;
|
||||
// Clock rollback must not turn disappearance protection into permission to
|
||||
// reseed. A healthy workspace refreshes the future-dated row below.
|
||||
if (ageMs > WORKSPACE_ATTESTATION_RECENT_MS) {
|
||||
return undefined;
|
||||
}
|
||||
return attestation;
|
||||
}
|
||||
|
||||
async function writeWorkspaceAttestation(attestationPath: string, dir: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
const now = new Date();
|
||||
const content = await buildWorkspaceAttestationContent(dir, now);
|
||||
async function maybeWriteWorkspaceAttestation(dir: string): Promise<void> {
|
||||
try {
|
||||
const status = await readWorkspaceAttestationMarkerStatus(attestationPath);
|
||||
if (status === "marker") {
|
||||
await fs.writeFile(attestationPath, content, "utf-8");
|
||||
await fs.utimes(attestationPath, now, now);
|
||||
return;
|
||||
}
|
||||
if (status !== "missing") {
|
||||
return;
|
||||
}
|
||||
// Order snapshots by when their filesystem observation starts. The store
|
||||
// compares against a separate lock-time clock, so a newer committed scan
|
||||
// wins when this async collection finishes later.
|
||||
const attestedAtMs = Date.now();
|
||||
const generatedHashes = await collectGeneratedBootstrapHashes(dir);
|
||||
replaceWorkspaceAttestation({
|
||||
workspaceDir: dir,
|
||||
attestedAtMs,
|
||||
generatedHashes,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const noFollowFlag =
|
||||
typeof syncFs.constants.O_NOFOLLOW === "number" ? syncFs.constants.O_NOFOLLOW : 0;
|
||||
const handle = await fs.open(
|
||||
attestationPath,
|
||||
syncFs.constants.O_WRONLY | syncFs.constants.O_CREAT | syncFs.constants.O_EXCL | noFollowFlag,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(content, "utf-8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeWriteWorkspaceAttestation(attestationPath: string, dir: string): Promise<void> {
|
||||
try {
|
||||
await writeWorkspaceAttestation(attestationPath, dir);
|
||||
} catch {
|
||||
// The marker is a lifecycle guard; setup should not fail solely because it
|
||||
// could not refresh auxiliary disappearance evidence.
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkspaceSetupState(raw: string): WorkspaceSetupState | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
bootstrapSeededAt?: unknown;
|
||||
setupCompletedAt?: unknown;
|
||||
onboardingCompletedAt?: unknown;
|
||||
};
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
const legacyCompletedAt = readStringValue(parsed.onboardingCompletedAt);
|
||||
return {
|
||||
version: WORKSPACE_STATE_VERSION,
|
||||
bootstrapSeededAt: readStringValue(parsed.bootstrapSeededAt),
|
||||
setupCompletedAt: readStringValue(parsed.setupCompletedAt) ?? legacyCompletedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
// Attestation is a lifecycle guard; setup should not fail solely because
|
||||
// the auxiliary disappearance evidence could not be refreshed.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,68 +549,80 @@ function hasWorkspaceSetupStateMarker(state: WorkspaceSetupState): boolean {
|
||||
return Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
|
||||
}
|
||||
|
||||
function needsWorkspaceSetupStateRewrite(raw: string, state: WorkspaceSetupState): boolean {
|
||||
function hasRecentWorkspaceSetupState(
|
||||
snapshot: WorkspaceStateSnapshot,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
if (!hasWorkspaceSetupStateMarker(snapshot.setup) || snapshot.setupUpdatedAtMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
return nowMs - snapshot.setupUpdatedAtMs <= WORKSPACE_ATTESTATION_RECENT_MS;
|
||||
}
|
||||
|
||||
async function workspaceAttestationHasSurvivalEvidence(params: {
|
||||
dir: string;
|
||||
bootstrapPath: string;
|
||||
state: WorkspaceSetupState;
|
||||
attestation: WorkspaceAttestation;
|
||||
}): Promise<boolean> {
|
||||
if (await pathExists(params.bootstrapPath)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
await workspaceRequiredBootstrapLooksCustomized(params.dir, {
|
||||
generatedHashes: params.attestation.generatedHashes,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (await workspaceProfileLooksConfigured({ dir: params.dir })) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
raw.includes('"onboardingCompletedAt"') &&
|
||||
!raw.includes('"setupCompletedAt"') &&
|
||||
Boolean(state.setupCompletedAt)
|
||||
hasWorkspaceSetupStateMarker(params.state) &&
|
||||
(await workspaceAttestedGeneratedFilesIntact(params.dir, params.attestation.generatedHashes))
|
||||
);
|
||||
}
|
||||
|
||||
async function readWorkspaceSetupStateFile(statePath: string): Promise<{
|
||||
raw: string;
|
||||
state: WorkspaceSetupState;
|
||||
} | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(statePath, "utf-8");
|
||||
const parsed = parseWorkspaceSetupState(raw);
|
||||
return parsed ? { raw, state: parsed } : null;
|
||||
} catch (err) {
|
||||
const anyErr = err as { code?: string };
|
||||
if (anyErr.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
async function workspaceSetupStateHasSurvivalEvidence(params: {
|
||||
dir: string;
|
||||
bootstrapPath: string;
|
||||
initialState: WorkspaceStateSnapshot;
|
||||
}): Promise<boolean> {
|
||||
if (await pathExists(params.bootstrapPath)) {
|
||||
return true;
|
||||
}
|
||||
if (await hasWorkspaceUserContentEvidence(params.dir)) {
|
||||
return true;
|
||||
}
|
||||
const currentState = readCanonicalWorkspaceStateSnapshot(params.dir);
|
||||
if (
|
||||
currentState.setup.bootstrapSeededAt !== params.initialState.setup.bootstrapSeededAt ||
|
||||
currentState.setup.setupCompletedAt !== params.initialState.setup.setupCompletedAt
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const generatedHashes = await collectGeneratedBootstrapHashes(params.dir);
|
||||
return [
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
].every((fileName) => generatedHashes.has(fileName));
|
||||
}
|
||||
|
||||
async function readWorkspaceSetupStateForDir(
|
||||
dir: string,
|
||||
opts?: { persistLegacyMigration?: boolean },
|
||||
): Promise<WorkspaceSetupState> {
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
const statePath = resolveWorkspaceStatePath(resolvedDir);
|
||||
const canonical = await readWorkspaceSetupStateFile(statePath);
|
||||
if (canonical) {
|
||||
if (
|
||||
opts?.persistLegacyMigration &&
|
||||
needsWorkspaceSetupStateRewrite(canonical.raw, canonical.state)
|
||||
) {
|
||||
await writeWorkspaceSetupState(statePath, canonical.state);
|
||||
}
|
||||
return canonical.state;
|
||||
}
|
||||
|
||||
const legacyStatePath = resolveLegacyWorkspaceStatePath(resolvedDir);
|
||||
let legacy: Awaited<ReturnType<typeof readWorkspaceSetupStateFile>>;
|
||||
try {
|
||||
legacy = await readWorkspaceSetupStateFile(legacyStatePath);
|
||||
} catch {
|
||||
// Legacy state lived under a dot directory that some workspaces reject.
|
||||
// Treat inaccessible legacy metadata as absent so current setup can proceed.
|
||||
legacy = null;
|
||||
}
|
||||
if (!legacy) {
|
||||
return { version: WORKSPACE_STATE_VERSION };
|
||||
}
|
||||
if (opts?.persistLegacyMigration && hasWorkspaceSetupStateMarker(legacy.state)) {
|
||||
await writeWorkspaceSetupState(statePath, legacy.state);
|
||||
}
|
||||
return legacy.state;
|
||||
function readCanonicalWorkspaceStateSnapshot(dir: string): WorkspaceStateSnapshot {
|
||||
const snapshot = readWorkspaceStateSnapshot(dir);
|
||||
assertNoUnmigratedWorkspaceState({
|
||||
workspaceDir: dir,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function isWorkspaceSetupCompleted(dir: string): Promise<boolean> {
|
||||
const state = await readWorkspaceSetupStateForDir(dir);
|
||||
const state = readCanonicalWorkspaceStateSnapshot(dir).setup;
|
||||
return typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -780,7 +630,7 @@ export async function resolveWorkspaceBootstrapStatus(
|
||||
dir: string,
|
||||
): Promise<"pending" | "complete"> {
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
const state = await readWorkspaceSetupStateForDir(resolvedDir);
|
||||
const state = readCanonicalWorkspaceStateSnapshot(resolvedDir).setup;
|
||||
if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) {
|
||||
return "complete";
|
||||
}
|
||||
@@ -796,17 +646,6 @@ export async function isWorkspaceBootstrapPending(dir: string): Promise<boolean>
|
||||
return (await resolveWorkspaceBootstrapStatus(dir)) === "pending";
|
||||
}
|
||||
|
||||
async function writeWorkspaceSetupState(
|
||||
statePath: string,
|
||||
state: WorkspaceSetupState,
|
||||
): Promise<void> {
|
||||
await replaceFileAtomic({
|
||||
filePath: statePath,
|
||||
content: `${JSON.stringify(state, null, 2)}\n`,
|
||||
tempPrefix: WORKSPACE_STATE_FILENAME,
|
||||
});
|
||||
}
|
||||
|
||||
async function hasGitRepo(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path.join(dir, ".git"));
|
||||
@@ -872,32 +711,47 @@ export async function ensureAgentWorkspace(params?: {
|
||||
}> {
|
||||
const rawDir = params?.dir?.trim() ? params.dir.trim() : DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const dir = resolveUserPath(rawDir);
|
||||
const [attestationPath, ...legacyAttestationPaths] = resolveWorkspaceAttestationPaths(dir);
|
||||
if (!attestationPath) {
|
||||
throw new Error("Workspace attestation path could not be resolved");
|
||||
}
|
||||
const attestationPaths = [attestationPath, ...legacyAttestationPaths];
|
||||
const recentAttestationPath = await findRecentWorkspaceAttestationPath(attestationPaths);
|
||||
let initialState = readCanonicalWorkspaceStateSnapshot(dir);
|
||||
let reseedingExpiredWorkspaceState = false;
|
||||
const recentAttestation = recentWorkspaceAttestation(initialState.attestation);
|
||||
const recentSetupState = hasRecentWorkspaceSetupState(initialState);
|
||||
const workspaceExists = await pathExists(dir);
|
||||
|
||||
if (!(await pathExists(dir)) && recentAttestationPath) {
|
||||
throw new WorkspaceVanishedError({
|
||||
workspaceDir: dir,
|
||||
attestationPath: recentAttestationPath,
|
||||
});
|
||||
if (!workspaceExists) {
|
||||
if (recentAttestation) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
// Old setup state lived inside the workspace and disappeared with it.
|
||||
// Expired SQLite evidence must preserve that reseed contract. The write
|
||||
// transaction also catches a concurrent attestation refresh.
|
||||
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
|
||||
if (!params?.ensureBootstrapFiles) {
|
||||
const hasContentEvidence = await hasSkipBootstrapWorkspaceContentEvidence(dir);
|
||||
if (recentAttestationPath && !hasContentEvidence) {
|
||||
throw new WorkspaceVanishedError({
|
||||
workspaceDir: dir,
|
||||
attestationPath: recentAttestationPath,
|
||||
});
|
||||
if (recentAttestation && !hasContentEvidence) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
if (
|
||||
hasWorkspaceSetupStateMarker(initialState.setup) &&
|
||||
!initialState.attestation &&
|
||||
!(await workspaceSetupStateHasSurvivalEvidence({
|
||||
dir,
|
||||
bootstrapPath,
|
||||
initialState,
|
||||
}))
|
||||
) {
|
||||
if (recentSetupState || !clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
}
|
||||
if (hasContentEvidence) {
|
||||
await maybeWriteWorkspaceAttestation(attestationPath, dir);
|
||||
await maybeWriteWorkspaceAttestation(dir);
|
||||
}
|
||||
return { dir };
|
||||
}
|
||||
@@ -908,8 +762,6 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME);
|
||||
const userPath = path.join(dir, DEFAULT_USER_FILENAME);
|
||||
const heartbeatPath = path.join(dir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
|
||||
const statePath = resolveWorkspaceStatePath(dir);
|
||||
|
||||
const isBrandNewWorkspace = await (async () => {
|
||||
const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
|
||||
@@ -928,36 +780,49 @@ export async function ensureAgentWorkspace(params?: {
|
||||
})();
|
||||
|
||||
if (isBrandNewWorkspace) {
|
||||
if (recentAttestationPath) {
|
||||
throw new WorkspaceVanishedError({
|
||||
workspaceDir: dir,
|
||||
attestationPath: recentAttestationPath,
|
||||
});
|
||||
if (recentAttestation) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
reseedingExpiredWorkspaceState = initialState.setupExists || Boolean(initialState.attestation);
|
||||
// A wiped workspace can leave its directory (or only .git) behind. Clear
|
||||
// expired SQLite evidence before deciding whether setup already completed.
|
||||
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
}
|
||||
|
||||
if (recentAttestationPath && !isBrandNewWorkspace) {
|
||||
const bootstrapExists = await pathExists(bootstrapPath);
|
||||
const state = await readWorkspaceSetupStateForDir(dir, {
|
||||
persistLegacyMigration: true,
|
||||
});
|
||||
const hasSetupState = hasWorkspaceSetupStateMarker(state);
|
||||
const hasCustomizedRequiredBootstrap = await workspaceRequiredBootstrapLooksCustomized(dir, {
|
||||
attestationPath: recentAttestationPath,
|
||||
});
|
||||
const hasConfiguredProfile = await workspaceProfileLooksConfigured({
|
||||
if (initialState.attestation && !isBrandNewWorkspace) {
|
||||
const hasWorkspaceEvidence = await workspaceAttestationHasSurvivalEvidence({
|
||||
dir,
|
||||
bootstrapPath,
|
||||
state: initialState.setup,
|
||||
attestation: initialState.attestation,
|
||||
});
|
||||
const hasWorkspaceEvidence =
|
||||
bootstrapExists ||
|
||||
hasCustomizedRequiredBootstrap ||
|
||||
hasConfiguredProfile ||
|
||||
(hasSetupState && (await workspaceAttestedGeneratedFilesIntact(dir, recentAttestationPath)));
|
||||
if (!hasWorkspaceEvidence) {
|
||||
throw new WorkspaceVanishedError({
|
||||
workspaceDir: dir,
|
||||
attestationPath: recentAttestationPath,
|
||||
});
|
||||
if (recentAttestation) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
reseedingExpiredWorkspaceState = true;
|
||||
// The transaction rejects a concurrent refresh. Only the expired
|
||||
// snapshot we just inspected may be cleared before reseeding.
|
||||
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
hasWorkspaceSetupStateMarker(initialState.setup) &&
|
||||
!isBrandNewWorkspace &&
|
||||
!(await workspaceSetupStateHasSurvivalEvidence({ dir, bootstrapPath, initialState }))
|
||||
) {
|
||||
// Setup can outlive a best-effort attestation write or arrive alone from
|
||||
// Doctor. Ambiguous partial remnants must fail closed, not inherit stale
|
||||
// completion state and silently suppress BOOTSTRAP reseeding.
|
||||
if (recentSetupState) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
reseedingExpiredWorkspaceState = true;
|
||||
if (!clearExpiredWorkspaceStateForVanishedWorkspace(dir)) {
|
||||
throw new WorkspaceVanishedError({ workspaceDir: dir });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,12 +832,15 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
|
||||
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
|
||||
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
|
||||
// Template and filesystem checks above are async. Another process may have
|
||||
// completed setup while they ran, so optional-file policy needs fresh state.
|
||||
initialState = readCanonicalWorkspaceStateSnapshot(dir);
|
||||
const skipOptionalBootstrapFiles = new Set(params?.skipOptionalBootstrapFiles ?? []);
|
||||
// When the workspace is already configured, skip optional bootstrap files to
|
||||
// prevent subagent spawns from recreating root-level SOUL.md, USER.md,
|
||||
// IDENTITY.md, or HEARTBEAT.md that were removed intentionally or only exist
|
||||
// under agent-specific subdirectories.
|
||||
if (await isWorkspaceSetupCompleted(dir)) {
|
||||
if (initialState.setup.setupCompletedAt) {
|
||||
for (const filename of OPTIONAL_BOOTSTRAP_FILENAMES) {
|
||||
skipOptionalBootstrapFiles.add(filename);
|
||||
}
|
||||
@@ -995,9 +863,7 @@ export async function ensureAgentWorkspace(params?: {
|
||||
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
|
||||
}
|
||||
|
||||
let state = await readWorkspaceSetupStateForDir(dir, {
|
||||
persistLegacyMigration: true,
|
||||
});
|
||||
let state = readCanonicalWorkspaceStateSnapshot(dir).setup;
|
||||
let stateDirty = false;
|
||||
const markState = (next: Partial<WorkspaceSetupState>) => {
|
||||
state = { ...state, ...next };
|
||||
@@ -1014,7 +880,6 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const repair = await reconcileWorkspaceBootstrapCompletionState({
|
||||
dir,
|
||||
bootstrapPath,
|
||||
statePath,
|
||||
state,
|
||||
bootstrapExists,
|
||||
});
|
||||
@@ -1026,19 +891,20 @@ export async function ensureAgentWorkspace(params?: {
|
||||
}
|
||||
|
||||
if (!state.bootstrapSeededAt && !state.setupCompletedAt && !bootstrapExists) {
|
||||
// Legacy migration path: if USER/IDENTITY diverged from templates, or if user-content
|
||||
// indicators exist, treat setup as complete and avoid recreating BOOTSTRAP for
|
||||
// already-configured workspaces.
|
||||
const hasRecentAttestedCustomization = recentAttestationPath
|
||||
// If USER/IDENTITY diverged from templates, or if user-content indicators
|
||||
// exist, treat setup as complete and avoid recreating BOOTSTRAP.
|
||||
const hasRecentAttestedCustomization = recentAttestation
|
||||
? await workspaceRequiredBootstrapLooksCustomized(dir, {
|
||||
attestationPath: recentAttestationPath,
|
||||
generatedHashes: recentAttestation.generatedHashes,
|
||||
})
|
||||
: false;
|
||||
if (
|
||||
hasRecentAttestedCustomization ||
|
||||
(await workspaceProfileLooksConfigured({
|
||||
dir,
|
||||
includeGitEvidence: true,
|
||||
// A preexisting Git repository is user evidence. Git metadata left by
|
||||
// an expired, wiped OpenClaw workspace is not completion evidence.
|
||||
includeGitEvidence: !reseedingExpiredWorkspaceState,
|
||||
}))
|
||||
) {
|
||||
markState({ setupCompletedAt: nowIso() });
|
||||
@@ -1057,10 +923,10 @@ export async function ensureAgentWorkspace(params?: {
|
||||
}
|
||||
|
||||
if (stateDirty) {
|
||||
await writeWorkspaceSetupState(statePath, state);
|
||||
state = mergeWorkspaceSetupState(dir, state);
|
||||
}
|
||||
await ensureGitRepo(dir, isBrandNewWorkspace);
|
||||
await maybeWriteWorkspaceAttestation(attestationPath, dir);
|
||||
await maybeWriteWorkspaceAttestation(dir);
|
||||
|
||||
return {
|
||||
dir,
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import {
|
||||
resolveWorkspaceAttestationPaths,
|
||||
shouldRemoveWorkspaceAttestation,
|
||||
} from "../agents/workspace.js";
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
} from "../agents/workspace-legacy-state.js";
|
||||
import {
|
||||
deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion,
|
||||
} from "../agents/workspace-state-store.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { replaceConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
@@ -164,22 +168,32 @@ export async function agentsDeleteCommand(
|
||||
// Only trash the workspace if no other agent can depend on that path (#70890).
|
||||
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir);
|
||||
const workspaceRetained = workspaceSharedWith.length > 0;
|
||||
let workspaceCleanupError: Error | undefined;
|
||||
if (workspaceRetained) {
|
||||
quietRuntime.log(
|
||||
`Skipped workspace removal (shared with other agents: ${workspaceSharedWith.join(", ")}): ${workspaceDir}`,
|
||||
);
|
||||
} else {
|
||||
await moveToTrash(workspaceDir, quietRuntime);
|
||||
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
|
||||
workspaceDir,
|
||||
).entries()) {
|
||||
if (await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
|
||||
await moveToTrash(attestationPath, quietRuntime);
|
||||
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
|
||||
const statePlan = prepareWorkspaceStateDeletion(workspaceDir);
|
||||
const workspaceRemoved = await moveToTrash(workspaceDir, quietRuntime);
|
||||
if (workspaceRemoved) {
|
||||
try {
|
||||
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan);
|
||||
for (const warning of legacyCleanup.warnings) {
|
||||
quietRuntime.log(warning);
|
||||
}
|
||||
deleteWorkspaceState(statePlan);
|
||||
} catch (error) {
|
||||
workspaceCleanupError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
await moveToTrash(agentDir, quietRuntime);
|
||||
await moveToTrash(sessionsDir, quietRuntime);
|
||||
if (workspaceCleanupError) {
|
||||
throw workspaceCleanupError;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Agents delete tests cover config removal, workspace attestation cleanup, and binding updates.
|
||||
// Agents delete tests cover config removal, workspace-state cleanup, and binding updates.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveWorkspaceAttestationPaths } from "../agents/workspace.js";
|
||||
import { resolveStorePath } from "../config/sessions.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { listSessionEntries, replaceSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
@@ -30,13 +29,10 @@ const gatewayMocks = vi.hoisted(() => ({
|
||||
isGatewayTransportError: vi.fn(),
|
||||
}));
|
||||
|
||||
function resolveCurrentWorkspaceAttestationPath(dir: string): string {
|
||||
const [attestationPath] = resolveWorkspaceAttestationPaths(dir);
|
||||
if (!attestationPath) {
|
||||
throw new Error("expected current workspace attestation path");
|
||||
}
|
||||
return attestationPath;
|
||||
}
|
||||
const workspaceStateMocks = vi.hoisted(() => ({
|
||||
deleteWorkspaceState: vi.fn(),
|
||||
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
|
||||
@@ -58,6 +54,14 @@ vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: processMocks.runCommandWithTimeout,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/workspace-state-store.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../agents/workspace-state-store.js")>(
|
||||
"../agents/workspace-state-store.js",
|
||||
)),
|
||||
deleteWorkspaceState: workspaceStateMocks.deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion: workspaceStateMocks.prepareWorkspaceStateDeletion,
|
||||
}));
|
||||
|
||||
import { agentsDeleteCommand } from "./agents.commands.delete.js";
|
||||
|
||||
const runtime = createTestRuntime();
|
||||
@@ -130,6 +134,7 @@ describe("agents delete command", () => {
|
||||
configMocks.readConfigFileSnapshot.mockReset();
|
||||
configMocks.replaceConfigFile.mockReset();
|
||||
fsSafeMocks.movePathToTrash.mockClear();
|
||||
workspaceStateMocks.deleteWorkspaceState.mockClear();
|
||||
processMocks.runCommandWithTimeout.mockClear();
|
||||
gatewayMocks.callGateway.mockReset();
|
||||
gatewayMocks.callGateway.mockRejectedValue(
|
||||
@@ -273,13 +278,14 @@ describe("agents delete command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("trashes workspace attestations during local deletion", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-attestation-", async ({ stateDir }) => {
|
||||
it("deletes workspace state after local workspace removal", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-workspace-state-", async ({ stateDir }) => {
|
||||
const opsWorkspace = path.join(stateDir, "workspace-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
|
||||
{ id: "ops", workspace: path.join(stateDir, "workspace-ops") },
|
||||
{ id: "ops", workspace: opsWorkspace },
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
@@ -289,23 +295,44 @@ describe("agents delete command", () => {
|
||||
deletedAgentId: "ops",
|
||||
sessions: {},
|
||||
});
|
||||
const attestationPath = resolveCurrentWorkspaceAttestationPath(
|
||||
path.join(stateDir, "workspace-ops"),
|
||||
);
|
||||
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
attestationPath,
|
||||
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
|
||||
);
|
||||
const resolvedAttestationPath = path.join(
|
||||
await fs.realpath(path.dirname(attestationPath)),
|
||||
path.basename(attestationPath),
|
||||
);
|
||||
|
||||
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
||||
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({
|
||||
workspaceDir: opsWorkspace,
|
||||
});
|
||||
const workspaceTrashOrder = fsSafeMocks.movePathToTrash.mock.invocationCallOrder[0];
|
||||
const stateDeleteOrder = workspaceStateMocks.deleteWorkspaceState.mock.invocationCallOrder[0];
|
||||
expect(workspaceTrashOrder).toBeLessThan(stateDeleteOrder ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("finishes agent-directory cleanup when workspace state deletion fails", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-state-failure-", async ({ stateDir }) => {
|
||||
const opsWorkspace = path.join(stateDir, "workspace-ops");
|
||||
const opsAgentDir = path.join(stateDir, "agents", "ops", "agent");
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
|
||||
{ id: "ops", workspace: opsWorkspace },
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
await arrangeAgentsDeleteTest({ stateDir, cfg, deletedAgentId: "ops", sessions: {} });
|
||||
workspaceStateMocks.deleteWorkspaceState.mockImplementationOnce(() => {
|
||||
throw new Error("state database unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime),
|
||||
).rejects.toThrow("state database unavailable");
|
||||
|
||||
const trashedPaths = fsSafeMocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
|
||||
expect(trashedPaths).toContain(resolvedAttestationPath);
|
||||
const expectedAgentDir = path.join(
|
||||
await fs.realpath(path.dirname(opsAgentDir)),
|
||||
path.basename(opsAgentDir),
|
||||
);
|
||||
expect(trashedPaths).toContain(expectedAgentDir);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -385,12 +412,6 @@ describe("agents delete command", () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-shared-workspace-", async ({ stateDir }) => {
|
||||
const sharedWorkspace = path.join(stateDir, "workspace-shared");
|
||||
await fs.mkdir(sharedWorkspace, { recursive: true });
|
||||
const attestationPath = resolveCurrentWorkspaceAttestationPath(sharedWorkspace);
|
||||
await fs.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
attestationPath,
|
||||
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const cfg: OpenClawConfig = {
|
||||
@@ -425,7 +446,7 @@ describe("agents delete command", () => {
|
||||
expect(jsonOutput[0]?.workspaceSharedWith).toEqual(["main"]);
|
||||
const trashedPaths = fsSafeMocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
|
||||
expect(trashedPaths).not.toContain(sharedWorkspace);
|
||||
expect(trashedPaths).not.toContain(attestationPath);
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -576,7 +597,30 @@ describe("agents delete command", () => {
|
||||
expect(fsSafeMocks.movePathToTrash).toHaveBeenCalledWith(expectedOpsWorkspace, {
|
||||
allowedRoots: [path.dirname(expectedOpsWorkspace)],
|
||||
});
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({
|
||||
workspaceDir: opsWorkspace,
|
||||
});
|
||||
expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("retains workspace state when workspace trash fails", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-trash-failure-", async ({ stateDir }) => {
|
||||
const opsWorkspace = path.join(stateDir, "workspace-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
|
||||
{ id: "ops", workspace: opsWorkspace },
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
await arrangeAgentsDeleteTest({ stateDir, cfg, sessions: {} });
|
||||
fsSafeMocks.movePathToTrash.mockRejectedValueOnce(new Error("trash unavailable"));
|
||||
|
||||
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
||||
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,15 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
const resolveCleanupPlanFromDisk = vi.fn();
|
||||
const removePath = vi.fn();
|
||||
const listAgentSessionDirs = vi.fn();
|
||||
export const prepareLegacyWorkspaceStateReset = vi.fn();
|
||||
export const removeLegacyWorkspaceStateForReset = vi.fn();
|
||||
export const removeStateAndLinkedPaths = vi.fn();
|
||||
const removeWorkspaceDirs = vi.fn();
|
||||
export const removeWorkspaceAttestationPaths = vi.fn();
|
||||
export const removeWorkspaceDirs = vi.fn();
|
||||
|
||||
vi.mock("../agents/workspace-legacy-state.js", () => ({
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
isNixMode: false,
|
||||
@@ -22,7 +28,6 @@ vi.mock("./cleanup-utils.js", () => ({
|
||||
removePath,
|
||||
listAgentSessionDirs,
|
||||
removeStateAndLinkedPaths,
|
||||
removeWorkspaceAttestationPaths,
|
||||
removeWorkspaceDirs,
|
||||
}));
|
||||
|
||||
@@ -42,9 +47,10 @@ export function resetCleanupCommandMocks() {
|
||||
});
|
||||
removePath.mockResolvedValue({ ok: true });
|
||||
listAgentSessionDirs.mockResolvedValue(["/tmp/.openclaw/agents/main/sessions"]);
|
||||
removeStateAndLinkedPaths.mockResolvedValue(undefined);
|
||||
prepareLegacyWorkspaceStateReset.mockImplementation((workspaceDir: string) => ({ workspaceDir }));
|
||||
removeLegacyWorkspaceStateForReset.mockResolvedValue({ removedPaths: [], warnings: [] });
|
||||
removeStateAndLinkedPaths.mockResolvedValue(true);
|
||||
removeWorkspaceDirs.mockResolvedValue(undefined);
|
||||
removeWorkspaceAttestationPaths.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
export function silenceCleanupCommandRuntime(runtime: RuntimeEnv) {
|
||||
|
||||
@@ -3,15 +3,31 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
const workspaceStateMocks = vi.hoisted(() => ({
|
||||
deleteWorkspaceState: vi.fn(),
|
||||
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/workspace-state-store.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../agents/workspace-state-store.js")>(
|
||||
"../agents/workspace-state-store.js",
|
||||
)),
|
||||
deleteWorkspaceState: workspaceStateMocks.deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion: workspaceStateMocks.prepareWorkspaceStateDeletion,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildCleanupPlan,
|
||||
removePath,
|
||||
removeStateAndLinkedPaths,
|
||||
removeWorkspaceAttestationPaths,
|
||||
removeWorkspaceDirs,
|
||||
} from "./cleanup-utils.js";
|
||||
|
||||
@@ -71,6 +87,10 @@ describe("buildCleanupPlan", () => {
|
||||
});
|
||||
|
||||
describe("cleanup path removals", () => {
|
||||
beforeEach(() => {
|
||||
workspaceStateMocks.deleteWorkspaceState.mockClear();
|
||||
});
|
||||
|
||||
function createRuntimeMock() {
|
||||
return {
|
||||
log: vi.fn<(message: string) => void>(),
|
||||
@@ -84,7 +104,7 @@ describe("cleanup path removals", () => {
|
||||
it("removes state and only linked paths outside state", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const tmpRoot = path.join(path.parse(process.cwd()).root, "tmp", "openclaw-cleanup");
|
||||
await removeStateAndLinkedPaths(
|
||||
const stateRemoved = await removeStateAndLinkedPaths(
|
||||
{
|
||||
stateDir: path.join(tmpRoot, "state"),
|
||||
configPath: path.join(tmpRoot, "state", "openclaw.json"),
|
||||
@@ -100,6 +120,28 @@ describe("cleanup path removals", () => {
|
||||
"[dry-run] remove /tmp/openclaw-cleanup/state",
|
||||
"[dry-run] remove /tmp/openclaw-cleanup/oauth",
|
||||
]);
|
||||
expect(stateRemoved).toBe(true);
|
||||
});
|
||||
|
||||
it("reports when the state directory survives removal", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied"));
|
||||
|
||||
try {
|
||||
const stateRemoved = await removeStateAndLinkedPaths(
|
||||
{
|
||||
stateDir: "/tmp/openclaw-cleanup-state-failure",
|
||||
configPath: "/tmp/openclaw-cleanup-state-failure/openclaw.json",
|
||||
oauthDir: "/tmp/openclaw-cleanup-state-failure/credentials",
|
||||
configInsideState: true,
|
||||
oauthInsideState: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
expect(stateRemoved).toBe(false);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves nested workspace paths during state-only removal", async () => {
|
||||
@@ -150,27 +192,115 @@ describe("cleanup path removals", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes owned legacy workspace attestations", async () => {
|
||||
it("deletes workspace state only after workspace removal succeeds", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-attest-"));
|
||||
const tmpRoot = tempDirs.make("openclaw-cleanup-workspace-");
|
||||
const workspaceDir = path.join(tmpRoot, "workspace");
|
||||
const legacyAttestationPath = `${workspaceDir}.attested`;
|
||||
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
await removeWorkspaceDirs([workspaceDir], runtime, { removeStateRows: true });
|
||||
|
||||
await expect(fs.stat(workspaceDir)).rejects.toThrow();
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({ workspaceDir });
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans workspace state when the workspace directory is already missing", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const tmpRoot = tempDirs.make("openclaw-cleanup-missing-workspace-");
|
||||
const workspaceDir = path.join(tmpRoot, "workspace");
|
||||
const siblingMarker = `${workspaceDir}.attested`;
|
||||
|
||||
try {
|
||||
await fs.writeFile(
|
||||
siblingMarker,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
);
|
||||
|
||||
await removeWorkspaceDirs([workspaceDir], runtime, { removeStateRows: true });
|
||||
|
||||
await expect(fs.stat(siblingMarker)).rejects.toThrow();
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).toHaveBeenCalledWith({ workspaceDir });
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes a retired sibling marker after workspace removal without opening SQLite", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const tmpRoot = tempDirs.make("openclaw-cleanup-legacy-");
|
||||
const workspaceDir = path.join(tmpRoot, "workspace");
|
||||
const siblingMarker = `${workspaceDir}.attested`;
|
||||
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyAttestationPath,
|
||||
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
|
||||
siblingMarker,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
);
|
||||
|
||||
await removeWorkspaceAttestationPaths([workspaceDir], runtime);
|
||||
await removeWorkspaceDirs([workspaceDir], runtime);
|
||||
|
||||
await expect(fs.stat(legacyAttestationPath)).rejects.toThrow();
|
||||
await expect(fs.stat(workspaceDir)).rejects.toThrow();
|
||||
await expect(fs.stat(siblingMarker)).rejects.toThrow();
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not delete workspace state during dry-run", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
|
||||
await removeWorkspaceDirs(["/tmp/openclaw-workspace"], runtime, {
|
||||
dryRun: true,
|
||||
removeStateRows: true,
|
||||
});
|
||||
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews retired sibling-marker cleanup during workspace dry-run", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const tmpRoot = tempDirs.make("openclaw-cleanup-dry-run-legacy-");
|
||||
const workspaceDir = path.join(tmpRoot, "workspace");
|
||||
const siblingMarker = `${workspaceDir}.attested`;
|
||||
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
siblingMarker,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
);
|
||||
|
||||
await removeWorkspaceDirs([workspaceDir], runtime, { dryRun: true });
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledWith(`[dry-run] remove ${siblingMarker}`);
|
||||
await expect(fs.lstat(siblingMarker)).resolves.toBeDefined();
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retains workspace state when filesystem removal fails", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied"));
|
||||
|
||||
try {
|
||||
await removeWorkspaceDirs(["/tmp/openclaw-workspace"], runtime, {
|
||||
removeStateRows: true,
|
||||
});
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to remove the current working directory", async () => {
|
||||
const runtime = createRuntimeMock();
|
||||
const result = await removePath(process.cwd(), runtime, { dryRun: true });
|
||||
|
||||
@@ -4,9 +4,13 @@ import path from "node:path";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js";
|
||||
import {
|
||||
resolveWorkspaceAttestationPaths,
|
||||
shouldRemoveWorkspaceAttestation,
|
||||
} from "../agents/workspace.js";
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
} from "../agents/workspace-legacy-state.js";
|
||||
import {
|
||||
deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion,
|
||||
} from "../agents/workspace-state-store.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -119,23 +123,6 @@ export async function removePath(
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove workspace attestation files associated with cleanup-target workspaces. */
|
||||
export async function removeWorkspaceAttestationPaths(
|
||||
workspaceDirs: readonly string[],
|
||||
runtime: RuntimeEnv,
|
||||
opts?: RemovalOptions,
|
||||
): Promise<void> {
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
|
||||
workspaceDir,
|
||||
).entries()) {
|
||||
if (await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
|
||||
await removePath(attestationPath, runtime, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function existingPaths(paths: readonly string[]): Promise<string[]> {
|
||||
const existing: string[] = [];
|
||||
for (const target of paths) {
|
||||
@@ -213,24 +200,23 @@ export async function removeStateAndLinkedPaths(
|
||||
cleanup: CleanupResolvedPaths,
|
||||
runtime: RuntimeEnv,
|
||||
opts?: StateRemovalOptions,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const stateDir = path.resolve(cleanup.stateDir);
|
||||
const preservePaths = (
|
||||
opts?.dryRun
|
||||
? (opts.preservePaths ?? []).map((target) => path.resolve(target))
|
||||
: await existingPaths(opts?.preservePaths ?? [])
|
||||
).filter((target) => isPathWithin(target, stateDir));
|
||||
if (preservePaths.length > 0) {
|
||||
await removePathPreserving(stateDir, preservePaths, runtime, {
|
||||
dryRun: opts?.dryRun,
|
||||
label: cleanup.stateDir,
|
||||
});
|
||||
} else {
|
||||
await removePath(cleanup.stateDir, runtime, {
|
||||
dryRun: opts?.dryRun,
|
||||
label: cleanup.stateDir,
|
||||
});
|
||||
}
|
||||
const stateRemoval =
|
||||
preservePaths.length > 0
|
||||
? await removePathPreserving(stateDir, preservePaths, runtime, {
|
||||
dryRun: opts?.dryRun,
|
||||
label: cleanup.stateDir,
|
||||
})
|
||||
: await removePath(cleanup.stateDir, runtime, {
|
||||
dryRun: opts?.dryRun,
|
||||
label: cleanup.stateDir,
|
||||
});
|
||||
if (!cleanup.configInsideState) {
|
||||
await removePath(cleanup.configPath, runtime, {
|
||||
dryRun: opts?.dryRun,
|
||||
@@ -243,19 +229,52 @@ export async function removeStateAndLinkedPaths(
|
||||
label: cleanup.oauthDir,
|
||||
});
|
||||
}
|
||||
return stateRemoval.ok;
|
||||
}
|
||||
|
||||
/** Remove all workspace directories selected by the cleanup plan. */
|
||||
export async function removeWorkspaceDirs(
|
||||
workspaceDirs: readonly string[],
|
||||
runtime: RuntimeEnv,
|
||||
opts?: { dryRun?: boolean },
|
||||
opts?: { dryRun?: boolean; removeStateRows?: boolean },
|
||||
): Promise<void> {
|
||||
for (const workspace of workspaceDirs) {
|
||||
await removePath(workspace, runtime, {
|
||||
const legacyPlan = prepareLegacyWorkspaceStateReset(workspace);
|
||||
const statePlan = opts?.removeStateRows ? prepareWorkspaceStateDeletion(workspace) : undefined;
|
||||
const result = await removePath(workspace, runtime, {
|
||||
dryRun: opts?.dryRun,
|
||||
label: workspace,
|
||||
});
|
||||
if (opts?.dryRun) {
|
||||
if (!result.ok) {
|
||||
continue;
|
||||
}
|
||||
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan, { dryRun: true });
|
||||
for (const removedPath of legacyCleanup.removedPaths) {
|
||||
runtime.log(`[dry-run] remove ${shortenHomeInString(removedPath)}`);
|
||||
}
|
||||
for (const warning of legacyCleanup.warnings) {
|
||||
runtime.error(warning);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!result.ok || result.skipped) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan);
|
||||
for (const warning of legacyCleanup.warnings) {
|
||||
runtime.error(warning);
|
||||
}
|
||||
if (!opts?.removeStateRows) {
|
||||
continue;
|
||||
}
|
||||
deleteWorkspaceState(statePlan!);
|
||||
} catch (error) {
|
||||
runtime.error(
|
||||
`Failed to remove workspace state for ${shortenHomeInString(workspace)}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -292,6 +292,10 @@ function createLegacyStateMigrationDetectionResult(params?: {
|
||||
sourcePath: "/tmp/state/push/apns-registrations.json",
|
||||
hasLegacy: false,
|
||||
},
|
||||
workspace: {
|
||||
sources: [],
|
||||
hasLegacy: false,
|
||||
},
|
||||
webPush: {
|
||||
subscriptionsPath: "/tmp/state/push/web-push-subscriptions.json",
|
||||
vapidKeysPath: "/tmp/state/push/vapid-keys.json",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Onboard helper tests cover workspace setup, control UI links, and gateway reachability probes.
|
||||
// Onboard helper tests cover workspace setup, state cleanup, control UI links, and gateway probes.
|
||||
import * as fs from "node:fs";
|
||||
import fsPromises from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { withMockedPlatform } from "../test-utils/vitest-spies.js";
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
waitForGatewayReachable,
|
||||
} from "./onboard-helpers.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
describe("onboard error summaries", () => {
|
||||
it("keeps the bounded first line UTF-16 well-formed", () => {
|
||||
expect(testing.summarizeError(`${"x".repeat(118)}🚀tail\nignored`)).toBe(`${"x".repeat(118)}…`);
|
||||
@@ -86,6 +89,8 @@ const mocks = vi.hoisted(() => ({
|
||||
pickPrimaryTailnetIPv4: vi.fn<() => string | undefined>(() => undefined),
|
||||
resolveAdvertisedLanHost: vi.fn<() => Promise<string | null>>(async () => null),
|
||||
probeGateway: vi.fn(),
|
||||
deleteWorkspaceState: vi.fn(),
|
||||
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/fs-safe.js", () => ({
|
||||
@@ -108,6 +113,14 @@ vi.mock("../gateway/probe.js", () => ({
|
||||
probeGateway: mocks.probeGateway,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/workspace-state-store.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../agents/workspace-state-store.js")>(
|
||||
"../agents/workspace-state-store.js",
|
||||
)),
|
||||
deleteWorkspaceState: mocks.deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion: mocks.prepareWorkspaceStateDeletion,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
@@ -140,7 +153,6 @@ describe("handleReset", () => {
|
||||
const profileCredentialsDir = path.join(profileStateDir, "credentials");
|
||||
const profileSessionsDir = path.join(profileStateDir, "agents", "main", "sessions");
|
||||
const workspaceDir = path.join(profileStateDir, "workspace");
|
||||
const workspaceAttestationPath = `${workspaceDir}.attested`;
|
||||
const defaultCredentialsDir = path.join(defaultStateDir, "credentials");
|
||||
|
||||
fs.mkdirSync(profileCredentialsDir, { recursive: true });
|
||||
@@ -148,10 +160,6 @@ describe("handleReset", () => {
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.mkdirSync(defaultCredentialsDir, { recursive: true });
|
||||
fs.writeFileSync(profileConfigPath, "{}\n");
|
||||
fs.writeFileSync(
|
||||
workspaceAttestationPath,
|
||||
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
|
||||
);
|
||||
|
||||
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
|
||||
const expectedTrashedPaths = [
|
||||
@@ -159,7 +167,6 @@ describe("handleReset", () => {
|
||||
profileCredentialsDir,
|
||||
profileSessionsDir,
|
||||
workspaceDir,
|
||||
workspaceAttestationPath,
|
||||
].map(expectedTrashSourcePath);
|
||||
const expectedDefaultCredentialsDir = expectedTrashSourcePath(defaultCredentialsDir);
|
||||
|
||||
@@ -181,25 +188,28 @@ describe("handleReset", () => {
|
||||
const trashedPaths = mocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
|
||||
expect(trashedPaths).toEqual(expectedTrashedPaths);
|
||||
expect(trashedPaths).not.toContain(expectedDefaultCredentialsDir);
|
||||
expect(mocks.deleteWorkspaceState).toHaveBeenCalledWith({ workspaceDir });
|
||||
});
|
||||
|
||||
it("does not trash an unowned sibling attestation path during full reset", async () => {
|
||||
it("retains workspace state when workspace removal fails", async () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-"));
|
||||
const profileStateDir = path.join(homeDir, ".openclaw-work");
|
||||
const profileConfigPath = path.join(profileStateDir, "openclaw.json");
|
||||
const profileCredentialsDir = path.join(profileStateDir, "credentials");
|
||||
const profileSessionsDir = path.join(profileStateDir, "agents", "main", "sessions");
|
||||
const workspaceDir = path.join(profileStateDir, "workspace");
|
||||
const workspaceAttestationPath = `${workspaceDir}.attested`;
|
||||
|
||||
fs.mkdirSync(profileCredentialsDir, { recursive: true });
|
||||
fs.mkdirSync(profileSessionsDir, { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.writeFileSync(profileConfigPath, "{}\n");
|
||||
fs.writeFileSync(workspaceAttestationPath, "external data\n");
|
||||
|
||||
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
|
||||
const unownedAttestationTrashPath = expectedTrashSourcePath(workspaceAttestationPath);
|
||||
mocks.movePathToTrash
|
||||
.mockResolvedValueOnce("config.trashed")
|
||||
.mockResolvedValueOnce("credentials.trashed")
|
||||
.mockResolvedValueOnce("sessions.trashed")
|
||||
.mockRejectedValueOnce(new Error("trash unavailable"));
|
||||
|
||||
try {
|
||||
await withEnvAsync(
|
||||
@@ -216,53 +226,8 @@ describe("handleReset", () => {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const trashedPaths = mocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
|
||||
expect(trashedPaths).not.toContain(unownedAttestationTrashPath);
|
||||
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not abort full reset for an unreadable legacy attestation path",
|
||||
async () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-"));
|
||||
const profileStateDir = path.join(homeDir, ".openclaw-work");
|
||||
const profileConfigPath = path.join(profileStateDir, "openclaw.json");
|
||||
const profileCredentialsDir = path.join(profileStateDir, "credentials");
|
||||
const profileSessionsDir = path.join(profileStateDir, "agents", "main", "sessions");
|
||||
const workspaceDir = path.join(profileStateDir, "workspace");
|
||||
const workspaceAttestationPath = `${workspaceDir}.attested`;
|
||||
|
||||
fs.mkdirSync(profileCredentialsDir, { recursive: true });
|
||||
fs.mkdirSync(profileSessionsDir, { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.writeFileSync(profileConfigPath, "{}\n");
|
||||
fs.writeFileSync(workspaceAttestationPath, "external data\n", { mode: 0o000 });
|
||||
fs.chmodSync(workspaceAttestationPath, 0o000);
|
||||
|
||||
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
|
||||
const unreadableAttestationTrashPath = expectedTrashSourcePath(workspaceAttestationPath);
|
||||
|
||||
try {
|
||||
await withEnvAsync(
|
||||
{
|
||||
HOME: homeDir,
|
||||
OPENCLAW_HOME: homeDir,
|
||||
OPENCLAW_PROFILE: "work",
|
||||
OPENCLAW_STATE_DIR: profileStateDir,
|
||||
OPENCLAW_CONFIG_PATH: profileConfigPath,
|
||||
},
|
||||
async () => {
|
||||
await expect(handleReset("full", workspaceDir, runtime)).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
fs.chmodSync(workspaceAttestationPath, 0o600);
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const trashedPaths = mocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
|
||||
expect(trashedPaths).not.toContain(unreadableAttestationTrashPath);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("moveToTrash", () => {
|
||||
@@ -310,6 +275,24 @@ describe("moveToTrash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("moves a dangling symlink instead of treating it as already removed", async () => {
|
||||
const testRoot = tempDirs.make("openclaw-trash-dangling-link-");
|
||||
const targetPath = path.join(testRoot, "workspace-link");
|
||||
fs.symlinkSync(path.join(testRoot, "missing-target"), targetPath, "dir");
|
||||
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
|
||||
const sourcePath = expectedTrashSourcePath(targetPath);
|
||||
|
||||
try {
|
||||
await expect(moveToTrash(targetPath, runtime)).resolves.toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(testRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(mocks.movePathToTrash).toHaveBeenCalledWith(sourcePath, {
|
||||
allowedRoots: [path.dirname(sourcePath)],
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes a symlinked parent before calling fs-safe trash", async () => {
|
||||
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trash-parent-link-"));
|
||||
const lexicalParent = path.join(testRoot, "state-link");
|
||||
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js";
|
||||
import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
resolveWorkspaceAttestationPaths,
|
||||
shouldRemoveWorkspaceAttestation,
|
||||
} from "../agents/workspace.js";
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
} from "../agents/workspace-legacy-state.js";
|
||||
import {
|
||||
deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion,
|
||||
} from "../agents/workspace-state-store.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import { printClawBanner } from "../cli/claw-banner.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||
import { resolveConfigPath } from "../config/paths.js";
|
||||
@@ -248,14 +251,14 @@ export async function ensureWorkspaceAndSessions(
|
||||
}
|
||||
|
||||
/** Moves a path to Trash when it exists, logging a manual-delete fallback on failure. */
|
||||
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<void> {
|
||||
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<boolean> {
|
||||
if (!pathname) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await fs.access(pathname);
|
||||
} catch {
|
||||
return;
|
||||
await fs.lstat(pathname);
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
}
|
||||
try {
|
||||
const targetPath = path.resolve(pathname);
|
||||
@@ -264,8 +267,10 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
|
||||
allowedRoots: await resolveMoveToTrashAllowedRoots(sourcePath),
|
||||
});
|
||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
||||
return true;
|
||||
} catch {
|
||||
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,13 +302,15 @@ export async function handleReset(scope: ResetScope, workspaceDir: string, runti
|
||||
await moveToTrash(path.join(resolveConfigDir(), "credentials"), runtime);
|
||||
await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime);
|
||||
if (scope === "full") {
|
||||
await moveToTrash(workspaceDir, runtime);
|
||||
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
|
||||
workspaceDir,
|
||||
).entries()) {
|
||||
if (await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })) {
|
||||
await moveToTrash(attestationPath, runtime);
|
||||
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
|
||||
const statePlan = prepareWorkspaceStateDeletion(workspaceDir);
|
||||
const workspaceRemoved = await moveToTrash(workspaceDir, runtime);
|
||||
if (workspaceRemoved) {
|
||||
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan);
|
||||
for (const warning of legacyCleanup.warnings) {
|
||||
runtime.log(warning);
|
||||
}
|
||||
deleteWorkspaceState(statePlan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Reset command tests cover cleanup runtime behavior, workspace attestations, and reset prompts.
|
||||
// Reset command tests cover cleanup runtime behavior, workspace state, and reset prompts.
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupCommandLogMessages,
|
||||
createCleanupCommandRuntime,
|
||||
removeWorkspaceAttestationPaths,
|
||||
removeStateAndLinkedPaths,
|
||||
removeWorkspaceDirs,
|
||||
resetCleanupCommandMocks,
|
||||
silenceCleanupCommandRuntime,
|
||||
} from "./cleanup-command.test-support.js";
|
||||
@@ -51,7 +52,7 @@ describe("resetCommand", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("removes workspace attestations during full reset", async () => {
|
||||
it("does not reopen workspace state after full state removal", async () => {
|
||||
await resetCommand(runtime, {
|
||||
scope: "full",
|
||||
yes: true,
|
||||
@@ -59,10 +60,24 @@ describe("resetCommand", () => {
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(removeWorkspaceAttestationPaths).toHaveBeenCalledWith(
|
||||
["/tmp/.openclaw/workspace"],
|
||||
runtime,
|
||||
{ dryRun: true },
|
||||
);
|
||||
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
|
||||
dryRun: true,
|
||||
removeStateRows: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes workspace rows when full state removal fails", async () => {
|
||||
removeStateAndLinkedPaths.mockResolvedValueOnce(false);
|
||||
|
||||
await resetCommand(runtime, {
|
||||
scope: "full",
|
||||
yes: true,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
|
||||
dryRun: false,
|
||||
removeStateRows: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
listAgentSessionDirs,
|
||||
removePath,
|
||||
removeStateAndLinkedPaths,
|
||||
removeWorkspaceAttestationPaths,
|
||||
removeWorkspaceDirs,
|
||||
} from "./cleanup-utils.js";
|
||||
|
||||
@@ -155,15 +154,15 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) {
|
||||
}
|
||||
|
||||
if (scope === "full") {
|
||||
await removeStateAndLinkedPaths(
|
||||
const stateRemoved = await removeStateAndLinkedPaths(
|
||||
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
|
||||
runtime,
|
||||
{ dryRun },
|
||||
);
|
||||
await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun });
|
||||
// Workspace attestations live beside workspace dirs and can outlive the
|
||||
// workspace itself, so full reset cleans both surfaces.
|
||||
await removeWorkspaceAttestationPaths(workspaceDirs, runtime, { dryRun });
|
||||
await removeWorkspaceDirs(workspaceDirs, runtime, {
|
||||
dryRun,
|
||||
removeStateRows: !stateRemoved,
|
||||
});
|
||||
runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupCommandLogMessages,
|
||||
createCleanupCommandRuntime,
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
removeStateAndLinkedPaths,
|
||||
removeWorkspaceAttestationPaths,
|
||||
removeWorkspaceDirs,
|
||||
resetCleanupCommandMocks,
|
||||
silenceCleanupCommandRuntime,
|
||||
} from "./cleanup-command.test-support.js";
|
||||
@@ -67,6 +69,29 @@ describe("uninstallCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("previews retired workspace files during state-only uninstall", async () => {
|
||||
removeLegacyWorkspaceStateForReset.mockResolvedValueOnce({
|
||||
removedPaths: ["/tmp/.openclaw/workspace/openclaw-workspace-state.json"],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await uninstallCommand(runtime, {
|
||||
state: true,
|
||||
yes: true,
|
||||
nonInteractive: true,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(prepareLegacyWorkspaceStateReset).toHaveBeenCalledWith("/tmp/.openclaw/workspace");
|
||||
expect(removeLegacyWorkspaceStateForReset).toHaveBeenCalledWith(
|
||||
{ workspaceDir: "/tmp/.openclaw/workspace" },
|
||||
{ dryRun: true },
|
||||
);
|
||||
expect(cleanupCommandLogMessages(runtime)).toContain(
|
||||
"[dry-run] remove /tmp/.openclaw/workspace/openclaw-workspace-state.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not preserve workspace dirs when workspace removal is selected", async () => {
|
||||
await uninstallCommand(runtime, {
|
||||
state: true,
|
||||
@@ -86,7 +111,7 @@ describe("uninstallCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes workspace attestations when workspace removal is selected", async () => {
|
||||
it("removes workspace state rows during workspace-only uninstall", async () => {
|
||||
await uninstallCommand(runtime, {
|
||||
workspace: true,
|
||||
yes: true,
|
||||
@@ -94,10 +119,40 @@ describe("uninstallCommand", () => {
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(removeWorkspaceAttestationPaths).toHaveBeenCalledWith(
|
||||
["/tmp/.openclaw/workspace"],
|
||||
runtime,
|
||||
{ dryRun: true },
|
||||
);
|
||||
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
|
||||
dryRun: true,
|
||||
removeStateRows: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reopen workspace state after state and workspace uninstall", async () => {
|
||||
await uninstallCommand(runtime, {
|
||||
state: true,
|
||||
workspace: true,
|
||||
yes: true,
|
||||
nonInteractive: true,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
|
||||
dryRun: true,
|
||||
removeStateRows: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes workspace rows when combined state removal fails", async () => {
|
||||
removeStateAndLinkedPaths.mockResolvedValueOnce(false);
|
||||
|
||||
await uninstallCommand(runtime, {
|
||||
state: true,
|
||||
workspace: true,
|
||||
yes: true,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
|
||||
dryRun: false,
|
||||
removeStateRows: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+26
-10
@@ -8,19 +8,18 @@ import {
|
||||
stylePromptMessage,
|
||||
stylePromptTitle,
|
||||
} from "../../packages/terminal-core/src/prompt-style.js";
|
||||
import {
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
} from "../agents/workspace-legacy-state.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { isNixMode } from "../config/config.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
import { resolveHomeDir, shortenHomeInString } from "../utils.js";
|
||||
import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js";
|
||||
import {
|
||||
removePath,
|
||||
removeStateAndLinkedPaths,
|
||||
removeWorkspaceAttestationPaths,
|
||||
removeWorkspaceDirs,
|
||||
} from "./cleanup-utils.js";
|
||||
import { removePath, removeStateAndLinkedPaths, removeWorkspaceDirs } from "./cleanup-utils.js";
|
||||
|
||||
type UninstallScope = "service" | "state" | "workspace" | "app";
|
||||
|
||||
@@ -178,6 +177,7 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
|
||||
}
|
||||
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
let stateRemoved = false;
|
||||
const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } =
|
||||
resolveCleanupPlanFromDisk();
|
||||
|
||||
@@ -194,8 +194,22 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
|
||||
}
|
||||
|
||||
if (scopes.has("state")) {
|
||||
if (!scopes.has("workspace")) {
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
|
||||
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan, { dryRun });
|
||||
for (const removedPath of legacyCleanup.removedPaths) {
|
||||
if (dryRun) {
|
||||
runtime.log(`[dry-run] remove ${shortenHomeInString(removedPath)}`);
|
||||
}
|
||||
}
|
||||
for (const warning of legacyCleanup.warnings) {
|
||||
runtime.error(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preserve workspaces when state-only uninstall is requested; workspace scope removes them explicitly.
|
||||
await removeStateAndLinkedPaths(
|
||||
stateRemoved = await removeStateAndLinkedPaths(
|
||||
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
|
||||
runtime,
|
||||
{ dryRun, preservePaths: scopes.has("workspace") ? [] : workspaceDirs },
|
||||
@@ -203,8 +217,10 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
|
||||
}
|
||||
|
||||
if (scopes.has("workspace")) {
|
||||
await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun });
|
||||
await removeWorkspaceAttestationPaths(workspaceDirs, runtime, { dryRun });
|
||||
await removeWorkspaceDirs(workspaceDirs, runtime, {
|
||||
dryRun,
|
||||
removeStateRows: !scopes.has("state") || !stateRemoved,
|
||||
});
|
||||
}
|
||||
|
||||
if (scopes.has("app")) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Agent mutation tests cover create/update/delete handlers, safe workspace file
|
||||
// access, config preconditions, trash cleanup, and attestation handling.
|
||||
// access, config preconditions, trash cleanup, and workspace-state handling.
|
||||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
@@ -24,10 +24,8 @@ const mocks = vi.hoisted(() => ({
|
||||
}),
|
||||
),
|
||||
isWorkspaceSetupCompleted: vi.fn(async () => false),
|
||||
resolveWorkspaceAttestationPaths: vi.fn((_workspaceDir: string) => [
|
||||
"/state/workspace-attestations/test-agent.attested",
|
||||
]),
|
||||
shouldRemoveWorkspaceAttestation: vi.fn(async () => true),
|
||||
deleteWorkspaceState: vi.fn(),
|
||||
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
|
||||
resolveAgentDir: vi.fn((_cfg?: unknown, _agentId?: string) => "/agents/test-agent"),
|
||||
resolveAgentWorkspaceDir: vi.fn((_cfg?: unknown, _agentId?: string) => "/workspace/test-agent"),
|
||||
resolveSessionTranscriptsDirForAgent: vi.fn((_agentId?: string) => "/transcripts/test-agent"),
|
||||
@@ -127,11 +125,17 @@ vi.mock("../../agents/workspace.js", async () => {
|
||||
...actual,
|
||||
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
|
||||
isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted,
|
||||
resolveWorkspaceAttestationPaths: mocks.resolveWorkspaceAttestationPaths,
|
||||
shouldRemoveWorkspaceAttestation: mocks.shouldRemoveWorkspaceAttestation,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/workspace-state-store.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../../agents/workspace-state-store.js")>(
|
||||
"../../agents/workspace-state-store.js",
|
||||
)),
|
||||
deleteWorkspaceState: mocks.deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion: mocks.prepareWorkspaceStateDeletion,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions/paths.js", () => ({
|
||||
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
|
||||
}));
|
||||
@@ -222,10 +226,6 @@ beforeEach(() => {
|
||||
mocks.resolveAgentWorkspaceDir.mockImplementation((cfg: unknown, agentId?: string) =>
|
||||
resolveMockWorkspaceDir(cfg, agentId),
|
||||
);
|
||||
mocks.resolveWorkspaceAttestationPaths.mockImplementation((_workspaceDir: string) => [
|
||||
"/state/workspace-attestations/test-agent.attested",
|
||||
]);
|
||||
mocks.shouldRemoveWorkspaceAttestation.mockResolvedValue(true);
|
||||
mocks.rootOpen.mockResolvedValue({
|
||||
handle: { close: vi.fn(async () => {}) },
|
||||
realPath: "/workspace/test-agent/AGENTS.md",
|
||||
@@ -1155,6 +1155,7 @@ describe("agents.update", () => {
|
||||
describe("agents.delete", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.fsLstat.mockResolvedValue(null as unknown as import("node:fs").Stats);
|
||||
mocks.loadConfigReturn = {};
|
||||
mocks.findAgentEntryIndex.mockReturnValue(0);
|
||||
mocks.pruneAgentConfig.mockReturnValue({ config: {}, removedBindings: 2 });
|
||||
@@ -1172,28 +1173,39 @@ describe("agents.delete", () => {
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalled();
|
||||
// moveToTrashBestEffort calls fs.access then movePathToTrash for each dir
|
||||
// moveToTrashBestEffort calls fs.lstat then movePathToTrash for each dir
|
||||
expect(mocks.movePathToTrash).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trashes workspace attestations when deleting the last workspace owner", async () => {
|
||||
it("deletes workspace state after removing the last owner's workspace", async () => {
|
||||
const { respond, promise } = makeCall("agents.delete", {
|
||||
agentId: "test-agent",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectRespondOk(respond, { ok: true });
|
||||
expect(mocks.resolveWorkspaceAttestationPaths).toHaveBeenCalledWith("/workspace/test-agent");
|
||||
expect(mocks.shouldRemoveWorkspaceAttestation).toHaveBeenCalledWith(
|
||||
"/state/workspace-attestations/test-agent.attested",
|
||||
{ trustUnknown: true },
|
||||
);
|
||||
expect(mocks.movePathToTrash).toHaveBeenCalledWith(
|
||||
"/state/workspace-attestations/test-agent.attested",
|
||||
);
|
||||
expect(mocks.movePathToTrash).toHaveBeenCalledWith("/workspace/test-agent");
|
||||
expect(mocks.deleteWorkspaceState).toHaveBeenCalledWith({
|
||||
workspaceDir: "/workspace/test-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps workspace attestations when another agent still owns the workspace", async () => {
|
||||
it("trashes a dangling workspace symlink before deleting its state", async () => {
|
||||
mocks.fsAccess.mockRejectedValueOnce(
|
||||
Object.assign(new Error("missing target"), { code: "ENOENT" }),
|
||||
);
|
||||
|
||||
const { respond, promise } = makeCall("agents.delete", {
|
||||
agentId: "test-agent",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectRespondOk(respond, { ok: true });
|
||||
expect(mocks.movePathToTrash).toHaveBeenCalledWith("/workspace/test-agent");
|
||||
expect(mocks.deleteWorkspaceState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps workspace state when another agent still owns the workspace", async () => {
|
||||
mocks.pruneAgentConfig.mockReturnValue({
|
||||
config: { agents: { list: [{ id: "other", workspace: "/workspace/test-agent" }] } },
|
||||
removedBindings: 2,
|
||||
@@ -1205,12 +1217,38 @@ describe("agents.delete", () => {
|
||||
await promise;
|
||||
|
||||
expectRespondOk(respond, { ok: true });
|
||||
expect(mocks.resolveWorkspaceAttestationPaths).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/workspace/test-agent");
|
||||
});
|
||||
|
||||
it("retains workspace state when best-effort workspace trash fails", async () => {
|
||||
mocks.movePathToTrash.mockRejectedValueOnce(new Error("trash unavailable"));
|
||||
|
||||
const { respond, promise } = makeCall("agents.delete", {
|
||||
agentId: "test-agent",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectRespondOk(respond, { ok: true });
|
||||
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains workspace state when workspace presence cannot be checked", async () => {
|
||||
mocks.fsLstat.mockRejectedValueOnce(
|
||||
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
||||
);
|
||||
|
||||
const { respond, promise } = makeCall("agents.delete", {
|
||||
agentId: "test-agent",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectRespondOk(respond, { ok: true });
|
||||
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips file deletion when deleteFiles is false", async () => {
|
||||
mocks.fsAccess.mockClear();
|
||||
mocks.fsLstat.mockClear();
|
||||
|
||||
const { respond, promise } = makeCall("agents.delete", {
|
||||
agentId: "test-agent",
|
||||
@@ -1220,7 +1258,8 @@ describe("agents.delete", () => {
|
||||
|
||||
expectRespondOk(respond, { ok: true });
|
||||
// moveToTrashBestEffort should not be called at all
|
||||
expect(mocks.fsAccess).not.toHaveBeenCalled();
|
||||
expect(mocks.fsLstat).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects deleting the main agent", async () => {
|
||||
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { mergeIdentityMarkdownContent } from "../../agents/identity-file.js";
|
||||
import { resolveAgentIdentity } from "../../agents/identity.js";
|
||||
import {
|
||||
prepareLegacyWorkspaceStateReset,
|
||||
removeLegacyWorkspaceStateForReset,
|
||||
} from "../../agents/workspace-legacy-state.js";
|
||||
import {
|
||||
deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion,
|
||||
} from "../../agents/workspace-state-store.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
@@ -34,8 +42,6 @@ import {
|
||||
DEFAULT_USER_FILENAME,
|
||||
ensureAgentWorkspace,
|
||||
isWorkspaceSetupCompleted,
|
||||
resolveWorkspaceAttestationPaths,
|
||||
shouldRemoveWorkspaceAttestation,
|
||||
} from "../../agents/workspace.js";
|
||||
import { applyAgentConfig } from "../../commands/agents.config.js";
|
||||
import {
|
||||
@@ -313,19 +319,21 @@ function respondAgentConfigPreconditionError(
|
||||
);
|
||||
}
|
||||
|
||||
async function moveToTrashBestEffort(pathname: string): Promise<void> {
|
||||
async function moveToTrashBestEffort(pathname: string): Promise<boolean> {
|
||||
if (!pathname) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await fs.access(pathname);
|
||||
} catch {
|
||||
return;
|
||||
await fs.lstat(pathname);
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
}
|
||||
try {
|
||||
await movePathToTrash(pathname);
|
||||
return true;
|
||||
} catch {
|
||||
// Best-effort: path may already be gone or trash unavailable.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,14 +751,15 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
const deleteWorkspace = workspaceSharedWith.length === 0;
|
||||
const pathsToTrash = [deleteResult.agentDir, deleteResult.sessionsDir];
|
||||
if (deleteWorkspace) {
|
||||
pathsToTrash.unshift(deleteResult.workspaceDir);
|
||||
for (const [index, attestationPath] of resolveWorkspaceAttestationPaths(
|
||||
deleteResult.workspaceDir,
|
||||
).entries()) {
|
||||
if (
|
||||
await shouldRemoveWorkspaceAttestation(attestationPath, { trustUnknown: index === 0 })
|
||||
) {
|
||||
pathsToTrash.push(attestationPath);
|
||||
const legacyPlan = prepareLegacyWorkspaceStateReset(deleteResult.workspaceDir);
|
||||
const statePlan = prepareWorkspaceStateDeletion(deleteResult.workspaceDir);
|
||||
const workspaceRemoved = await moveToTrashBestEffort(deleteResult.workspaceDir);
|
||||
if (workspaceRemoved) {
|
||||
try {
|
||||
await removeLegacyWorkspaceStateForReset(legacyPlan);
|
||||
deleteWorkspaceState(statePlan);
|
||||
} catch {
|
||||
// Best-effort cleanup. A later explicit reset can remove stale rows.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,10 @@ import {
|
||||
resolveLegacyUpdateCheckPath,
|
||||
} from "./state-migrations.update-check.js";
|
||||
import { detectLegacyWebPush, migrateLegacyWebPush } from "./state-migrations.web-push.js";
|
||||
import {
|
||||
detectLegacyWorkspaceState,
|
||||
migrateLegacyWorkspaceState,
|
||||
} from "./state-migrations.workspace-setup.js";
|
||||
|
||||
function describeStateSchemaMigration(migration: OpenClawStateDatabaseSchemaMigration): string {
|
||||
switch (migration.kind) {
|
||||
@@ -396,6 +400,13 @@ export async function detectLegacyStateMigrations(params: {
|
||||
stateDir,
|
||||
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
|
||||
});
|
||||
const workspace = detectLegacyWorkspaceState({
|
||||
cfg: params.cfg,
|
||||
stateDir,
|
||||
env,
|
||||
homedir,
|
||||
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
|
||||
});
|
||||
const webPush = detectLegacyWebPush({
|
||||
stateDir,
|
||||
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
|
||||
@@ -565,6 +576,9 @@ export async function detectLegacyStateMigrations(params: {
|
||||
if (apns.hasLegacy) {
|
||||
preview.push("- APNs registrations: legacy JSON → shared SQLite state");
|
||||
}
|
||||
if (workspace.hasLegacy) {
|
||||
preview.push("- Workspace setup and attestations: legacy files → shared SQLite state");
|
||||
}
|
||||
if (webPush.hasLegacy) {
|
||||
preview.push("- Web Push subscriptions and VAPID identity: legacy JSON → shared SQLite state");
|
||||
}
|
||||
@@ -663,6 +677,7 @@ export async function detectLegacyStateMigrations(params: {
|
||||
commitments,
|
||||
managedOutgoingImages,
|
||||
apns,
|
||||
workspace,
|
||||
webPush,
|
||||
nodeHost,
|
||||
subagentRegistry,
|
||||
@@ -861,6 +876,11 @@ export async function runLegacyStateMigrations(params: {
|
||||
env,
|
||||
stateDir: detected.stateDir,
|
||||
});
|
||||
const workspace = await migrateLegacyWorkspaceState({
|
||||
detected: detected.workspace,
|
||||
env,
|
||||
stateDir: detected.stateDir,
|
||||
});
|
||||
const webPush = await migrateLegacyWebPush({
|
||||
detected: detected.webPush,
|
||||
env,
|
||||
@@ -913,6 +933,7 @@ export async function runLegacyStateMigrations(params: {
|
||||
commitments,
|
||||
managedOutgoingImages,
|
||||
apns,
|
||||
workspace,
|
||||
webPush,
|
||||
nodeHost,
|
||||
subagentRegistry,
|
||||
@@ -935,6 +956,7 @@ export async function runLegacyStateMigrations(params: {
|
||||
...commitments.changes,
|
||||
...managedOutgoingImages.changes,
|
||||
...apns.changes,
|
||||
...workspace.changes,
|
||||
...webPush.changes,
|
||||
...nodeHost.changes,
|
||||
...subagentRegistry.changes,
|
||||
@@ -964,6 +986,7 @@ export async function runLegacyStateMigrations(params: {
|
||||
...commitments.warnings,
|
||||
...managedOutgoingImages.warnings,
|
||||
...apns.warnings,
|
||||
...workspace.warnings,
|
||||
...webPush.warnings,
|
||||
...nodeHost.warnings,
|
||||
...subagentRegistry.warnings,
|
||||
@@ -1220,6 +1243,7 @@ export async function autoMigrateLegacyState(params: {
|
||||
!detected.configHealth.hasLegacy &&
|
||||
!detected.pluginBindingApprovals.hasLegacy &&
|
||||
!detected.currentConversationBindings.hasLegacy &&
|
||||
!detected.workspace.hasLegacy &&
|
||||
!detected.channelPairing.hasLegacy
|
||||
) {
|
||||
const changes = [
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.
|
||||
import type { SessionScope } from "../config/sessions/types.js";
|
||||
import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js";
|
||||
import type { LegacyChannelPairingStateDetection } from "./state-migrations.channel-pairing.js";
|
||||
import type { LegacyWorkspaceStateDetection } from "./state-migrations.workspace-setup.types.js";
|
||||
|
||||
export type LegacyRescuePendingDetection = {
|
||||
sourcePaths: string[];
|
||||
@@ -108,6 +109,7 @@ export type LegacyStateDetection = {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
workspace: LegacyWorkspaceStateDetection;
|
||||
webPush: {
|
||||
subscriptionsPath: string;
|
||||
vapidKeysPath: string;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Receipt lookup and source-removal bookkeeping for legacy workspace migration.
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "./kysely-sync.js";
|
||||
import type { LegacyWorkspaceStateSource } from "./state-migrations.workspace-setup.types.js";
|
||||
|
||||
type WorkspaceReceiptDatabase = Pick<OpenClawStateKyselyDatabase, "migration_sources">;
|
||||
|
||||
export type MigrationReceipt = {
|
||||
sourceKey: string;
|
||||
sha256: string | null;
|
||||
removedSource: boolean;
|
||||
};
|
||||
|
||||
export function resolveWorkspaceMigrationSourceKey(source: LegacyWorkspaceStateSource): string {
|
||||
return `workspace-${source.kind}:${createHash("sha256")
|
||||
.update(source.workspaceKey)
|
||||
.update("\0")
|
||||
.update(path.resolve(source.sourcePath))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function readReceipt(
|
||||
source: LegacyWorkspaceStateSource,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): MigrationReceipt | null {
|
||||
const key = resolveWorkspaceMigrationSourceKey(source);
|
||||
const { db } = openOpenClawStateDatabase({ env });
|
||||
const row = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
getNodeSqliteKysely<WorkspaceReceiptDatabase>(db)
|
||||
.selectFrom("migration_sources")
|
||||
.select(["source_sha256", "removed_source"])
|
||||
.where("source_key", "=", key),
|
||||
);
|
||||
return row
|
||||
? { sourceKey: key, sha256: row.source_sha256, removedSource: row.removed_source === 1 }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function markSourceRemoved(sourceKey: string, env: NodeJS.ProcessEnv): void {
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
getNodeSqliteKysely<WorkspaceReceiptDatabase>(db)
|
||||
.updateTable("migration_sources")
|
||||
.set({ removed_source: 1 })
|
||||
.where("source_key", "=", sourceKey),
|
||||
);
|
||||
},
|
||||
{ env },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
// SQLite import and receipt semantics for retired workspace state.
|
||||
import { createHash } from "node:crypto";
|
||||
import { LEGACY_WORKSPACE_ATTESTATION_HEADER } from "../agents/workspace-legacy-state.js";
|
||||
import {
|
||||
WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES,
|
||||
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
|
||||
WORKSPACE_SETUP_STATE_VERSION,
|
||||
registerWorkspaceStateAliasesInTransaction,
|
||||
} from "../agents/workspace-state-store.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "./kysely-sync.js";
|
||||
import { runSqliteDeferredTransactionSync } from "./sqlite-transaction.js";
|
||||
import { resolveWorkspaceMigrationSourceKey } from "./state-migrations.workspace-setup-receipts.js";
|
||||
import type { LegacyWorkspaceStateSource } from "./state-migrations.workspace-setup.types.js";
|
||||
|
||||
const MIGRATION_KIND = WORKSPACE_LEGACY_STATE_MIGRATION_KIND;
|
||||
|
||||
type WorkspaceMigrationDatabase = Pick<
|
||||
OpenClawStateKyselyDatabase,
|
||||
| "workspace_setup_state"
|
||||
| "workspace_path_aliases"
|
||||
| "workspace_attestations"
|
||||
| "workspace_generated_bootstrap_hashes"
|
||||
| "migration_runs"
|
||||
| "migration_sources"
|
||||
>;
|
||||
|
||||
export type SourceSnapshot = {
|
||||
sourcePath: string;
|
||||
dev: number;
|
||||
ino: number;
|
||||
mtimeMs: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
type ParsedSetup = {
|
||||
bootstrapSeededAt?: string;
|
||||
setupCompletedAt?: string;
|
||||
};
|
||||
|
||||
type ParsedAttestation = {
|
||||
attestedAtMs: number;
|
||||
generatedHashes: Map<string, string>;
|
||||
};
|
||||
|
||||
export type ParsedSource =
|
||||
| { kind: "setup"; value: ParsedSetup; recordCount: number }
|
||||
| { kind: "attestation"; value: ParsedAttestation; recordCount: number };
|
||||
|
||||
function parseIsoTimestamp(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`legacy workspace setup ${field} is invalid`);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) {
|
||||
throw new Error(`legacy workspace setup ${field} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSetup(raw: string): ParsedSource {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("legacy workspace setup contains invalid JSON");
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("legacy workspace setup is not an object");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const allowed = new Set([
|
||||
"version",
|
||||
"bootstrapSeededAt",
|
||||
"setupCompletedAt",
|
||||
"onboardingCompletedAt",
|
||||
]);
|
||||
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
||||
throw new Error("legacy workspace setup has an unexpected field");
|
||||
}
|
||||
if (record.version !== undefined && record.version !== WORKSPACE_SETUP_STATE_VERSION) {
|
||||
throw new Error("legacy workspace setup has an unsupported version");
|
||||
}
|
||||
const bootstrapSeededAt = parseIsoTimestamp(record.bootstrapSeededAt, "bootstrap timestamp");
|
||||
const setupCompletedAt = parseIsoTimestamp(record.setupCompletedAt, "completion timestamp");
|
||||
const onboardingCompletedAt = parseIsoTimestamp(
|
||||
record.onboardingCompletedAt,
|
||||
"legacy completion timestamp",
|
||||
);
|
||||
if (setupCompletedAt && onboardingCompletedAt && setupCompletedAt !== onboardingCompletedAt) {
|
||||
throw new Error("legacy workspace setup has conflicting completion timestamps");
|
||||
}
|
||||
const parsed = {
|
||||
...(bootstrapSeededAt ? { bootstrapSeededAt } : {}),
|
||||
...((setupCompletedAt ?? onboardingCompletedAt)
|
||||
? { setupCompletedAt: setupCompletedAt ?? onboardingCompletedAt }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
kind: "setup",
|
||||
value: parsed,
|
||||
recordCount:
|
||||
Number(Boolean(parsed.bootstrapSeededAt)) + Number(Boolean(parsed.setupCompletedAt)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttestation(snapshot: SourceSnapshot): ParsedSource {
|
||||
const lines = snapshot.raw.split(/\r?\n/);
|
||||
if (lines.at(-1) === "") {
|
||||
lines.pop();
|
||||
}
|
||||
if (lines[0] !== LEGACY_WORKSPACE_ATTESTATION_HEADER || lines.length < 2) {
|
||||
throw new Error("legacy workspace attestation has an invalid header");
|
||||
}
|
||||
parseIsoTimestamp(lines[1], "attestation timestamp");
|
||||
const generatedHashes = new Map<string, string>();
|
||||
for (const line of lines.slice(2)) {
|
||||
const match = /^generated:([^:]+):([a-f0-9]{64})$/.exec(line);
|
||||
if (!match?.[1] || !match[2] || !WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(match[1])) {
|
||||
throw new Error("legacy workspace attestation has an invalid generated hash");
|
||||
}
|
||||
if (generatedHashes.has(match[1])) {
|
||||
throw new Error("legacy workspace attestation has a duplicate generated hash");
|
||||
}
|
||||
generatedHashes.set(match[1], match[2]);
|
||||
}
|
||||
const attestedAtMs = Math.trunc(snapshot.mtimeMs);
|
||||
if (!Number.isSafeInteger(attestedAtMs) || attestedAtMs < 0) {
|
||||
throw new Error("legacy workspace attestation has an invalid modification time");
|
||||
}
|
||||
return {
|
||||
kind: "attestation",
|
||||
value: { attestedAtMs, generatedHashes },
|
||||
recordCount: 1 + generatedHashes.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSource(
|
||||
source: LegacyWorkspaceStateSource,
|
||||
snapshot: SourceSnapshot,
|
||||
): ParsedSource {
|
||||
return source.kind === "setup" ? parseSetup(snapshot.raw) : parseAttestation(snapshot);
|
||||
}
|
||||
|
||||
function mapsEqual(left: ReadonlyMap<string, string>, right: ReadonlyMap<string, string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
for (const [key, value] of left) {
|
||||
if (right.get(key) !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function canonicalFingerprint(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function setupFingerprint(params: {
|
||||
workspacePath: string;
|
||||
bootstrapSeededAt: string | null;
|
||||
setupCompletedAt: string | null;
|
||||
}): string {
|
||||
return canonicalFingerprint({
|
||||
kind: "setup",
|
||||
workspacePath: params.workspacePath,
|
||||
version: WORKSPACE_SETUP_STATE_VERSION,
|
||||
bootstrapSeededAt: params.bootstrapSeededAt,
|
||||
setupCompletedAt: params.setupCompletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function attestationFingerprint(params: {
|
||||
attestedAtMs: number;
|
||||
generatedHashes: ReadonlyMap<string, string>;
|
||||
}): string {
|
||||
return canonicalFingerprint({
|
||||
kind: "attestation",
|
||||
attestedAtMs: params.attestedAtMs,
|
||||
generatedHashes: [...params.generatedHashes.entries()].toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function findMigrationAuthority(params: {
|
||||
db: ReturnType<typeof openOpenClawStateDatabase>["db"];
|
||||
kysely: ReturnType<typeof getNodeSqliteKysely<WorkspaceMigrationDatabase>>;
|
||||
source: LegacyWorkspaceStateSource;
|
||||
fingerprint: string;
|
||||
}): { priority: number } | null {
|
||||
const rows = executeSqliteQuerySync(
|
||||
params.db,
|
||||
params.kysely
|
||||
.selectFrom("migration_sources")
|
||||
.select("report_json")
|
||||
.where("migration_kind", "=", MIGRATION_KIND)
|
||||
.where(
|
||||
"target_table",
|
||||
"=",
|
||||
params.source.kind === "setup" ? "workspace_setup_state" : "workspace_attestations",
|
||||
),
|
||||
).rows;
|
||||
let bestPriority: number | null = null;
|
||||
for (const row of rows) {
|
||||
if (!row.report_json) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const report = JSON.parse(row.report_json) as Record<string, unknown>;
|
||||
if (
|
||||
report.workspaceKey !== params.source.workspaceKey ||
|
||||
report.sourceKind !== params.source.kind ||
|
||||
report.canonicalFingerprint !== params.fingerprint ||
|
||||
report.authoritative !== true ||
|
||||
typeof report.sourcePriority !== "number" ||
|
||||
!Number.isSafeInteger(report.sourcePriority) ||
|
||||
report.sourcePriority < 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
bestPriority =
|
||||
bestPriority === null
|
||||
? report.sourcePriority
|
||||
: Math.min(bestPriority, report.sourcePriority);
|
||||
} catch {
|
||||
// Ignore unrelated or older migration reports without authority metadata.
|
||||
}
|
||||
}
|
||||
return bestPriority === null ? null : { priority: bestPriority };
|
||||
}
|
||||
|
||||
export function canonicalCoversParsedSource(params: {
|
||||
source: LegacyWorkspaceStateSource;
|
||||
parsed: ParsedSource;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const { db } = openOpenClawStateDatabase({ env: params.env });
|
||||
return runSqliteDeferredTransactionSync(db, () => {
|
||||
const kysely = getNodeSqliteKysely<WorkspaceMigrationDatabase>(db);
|
||||
if (params.source.kind === "setup" && params.parsed.kind === "setup") {
|
||||
if (!params.source.workspaceDir) {
|
||||
return false;
|
||||
}
|
||||
const row = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_setup_state")
|
||||
.selectAll()
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
if (
|
||||
!row ||
|
||||
row.workspace_path !== params.source.workspaceDir ||
|
||||
row.version !== WORKSPACE_SETUP_STATE_VERSION
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const fingerprint = setupFingerprint({
|
||||
workspacePath: row.workspace_path,
|
||||
bootstrapSeededAt: row.bootstrap_seeded_at,
|
||||
setupCompletedAt: row.setup_completed_at,
|
||||
});
|
||||
const sourceBootstrapSeededAt = params.parsed.value.bootstrapSeededAt ?? null;
|
||||
const sourceSetupCompletedAt = params.parsed.value.setupCompletedAt ?? null;
|
||||
const coversSource =
|
||||
(sourceBootstrapSeededAt === null || row.bootstrap_seeded_at === sourceBootstrapSeededAt) &&
|
||||
(sourceSetupCompletedAt === null || row.setup_completed_at === sourceSetupCompletedAt);
|
||||
const authority = findMigrationAuthority({ db, kysely, source: params.source, fingerprint });
|
||||
return coversSource || Boolean(authority && authority.priority <= params.source.priority);
|
||||
}
|
||||
if (params.source.kind !== "attestation" || params.parsed.kind !== "attestation") {
|
||||
return false;
|
||||
}
|
||||
const row = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_attestations")
|
||||
.select("attested_at_ms")
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
if (!row) {
|
||||
return false;
|
||||
}
|
||||
if (row.attested_at_ms > params.parsed.value.attestedAtMs) {
|
||||
return true;
|
||||
}
|
||||
if (row.attested_at_ms < params.parsed.value.attestedAtMs) {
|
||||
return false;
|
||||
}
|
||||
const hashes = new Map(
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_generated_bootstrap_hashes")
|
||||
.select(["filename", "sha256"])
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
).rows.map((hashRow) => [hashRow.filename, hashRow.sha256]),
|
||||
);
|
||||
if (mapsEqual(hashes, params.parsed.value.generatedHashes)) {
|
||||
return true;
|
||||
}
|
||||
const fingerprint = attestationFingerprint({
|
||||
attestedAtMs: row.attested_at_ms,
|
||||
generatedHashes: hashes,
|
||||
});
|
||||
const authority = findMigrationAuthority({ db, kysely, source: params.source, fingerprint });
|
||||
return Boolean(authority && authority.priority <= params.source.priority);
|
||||
});
|
||||
}
|
||||
|
||||
export function importAndRecordReceipt(params: {
|
||||
source: LegacyWorkspaceStateSource;
|
||||
snapshot: SourceSnapshot;
|
||||
parsed: ParsedSource;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): { sourceKey: string; imported: boolean } {
|
||||
const key = resolveWorkspaceMigrationSourceKey(params.source);
|
||||
const runId = `${key}:${params.snapshot.sha256.slice(0, 16)}`;
|
||||
const now = Date.now();
|
||||
return runOpenClawStateWriteTransaction(
|
||||
(database) => {
|
||||
const { db } = database;
|
||||
const kysely = getNodeSqliteKysely<WorkspaceMigrationDatabase>(db);
|
||||
const existingReceipt = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely.selectFrom("migration_sources").select("source_key").where("source_key", "=", key),
|
||||
);
|
||||
if (existingReceipt) {
|
||||
throw new Error("workspace migration receipt appeared concurrently; retry Doctor");
|
||||
}
|
||||
|
||||
let imported = false;
|
||||
let resolution: "inserted" | "verified" | "merged" | "replaced" | "superseded";
|
||||
let verifiedFingerprint: string;
|
||||
if (params.parsed.kind === "setup") {
|
||||
if (!params.source.workspaceDir) {
|
||||
throw new Error("legacy workspace setup has no workspace path");
|
||||
}
|
||||
const incomingFingerprint = setupFingerprint({
|
||||
workspacePath: params.source.workspaceDir,
|
||||
bootstrapSeededAt: params.parsed.value.bootstrapSeededAt ?? null,
|
||||
setupCompletedAt: params.parsed.value.setupCompletedAt ?? null,
|
||||
});
|
||||
const existing = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_setup_state")
|
||||
.selectAll()
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.workspace_path !== params.source.workspaceDir ||
|
||||
existing.version !== WORKSPACE_SETUP_STATE_VERSION
|
||||
) {
|
||||
throw new Error("legacy workspace setup conflicts with canonical SQLite state");
|
||||
}
|
||||
const existingFingerprint = setupFingerprint({
|
||||
workspacePath: existing.workspace_path,
|
||||
bootstrapSeededAt: existing.bootstrap_seeded_at,
|
||||
setupCompletedAt: existing.setup_completed_at,
|
||||
});
|
||||
const sourceBootstrapSeededAt = params.parsed.value.bootstrapSeededAt ?? null;
|
||||
const sourceSetupCompletedAt = params.parsed.value.setupCompletedAt ?? null;
|
||||
const coversSource =
|
||||
(sourceBootstrapSeededAt === null ||
|
||||
existing.bootstrap_seeded_at === sourceBootstrapSeededAt) &&
|
||||
(sourceSetupCompletedAt === null ||
|
||||
existing.setup_completed_at === sourceSetupCompletedAt);
|
||||
const authority = findMigrationAuthority({
|
||||
db,
|
||||
kysely,
|
||||
source: params.source,
|
||||
fingerprint: existingFingerprint,
|
||||
});
|
||||
if (authority && params.source.priority < authority.priority) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.updateTable("workspace_setup_state")
|
||||
.set({
|
||||
bootstrap_seeded_at: sourceBootstrapSeededAt,
|
||||
setup_completed_at: sourceSetupCompletedAt,
|
||||
updated_at: now,
|
||||
})
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
imported = true;
|
||||
resolution = "replaced";
|
||||
verifiedFingerprint = incomingFingerprint;
|
||||
} else if (coversSource) {
|
||||
resolution = "verified";
|
||||
verifiedFingerprint = existingFingerprint;
|
||||
} else if (!authority) {
|
||||
const mergedBootstrapSeededAt = existing.bootstrap_seeded_at ?? sourceBootstrapSeededAt;
|
||||
const mergedSetupCompletedAt = existing.setup_completed_at ?? sourceSetupCompletedAt;
|
||||
const hasConflictingMilestone =
|
||||
(sourceBootstrapSeededAt !== null &&
|
||||
existing.bootstrap_seeded_at !== null &&
|
||||
sourceBootstrapSeededAt !== existing.bootstrap_seeded_at) ||
|
||||
(sourceSetupCompletedAt !== null &&
|
||||
existing.setup_completed_at !== null &&
|
||||
sourceSetupCompletedAt !== existing.setup_completed_at);
|
||||
if (hasConflictingMilestone) {
|
||||
throw new Error("legacy workspace setup conflicts with canonical SQLite state");
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.updateTable("workspace_setup_state")
|
||||
.set({
|
||||
bootstrap_seeded_at: mergedBootstrapSeededAt,
|
||||
setup_completed_at: mergedSetupCompletedAt,
|
||||
updated_at: now,
|
||||
})
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
imported = true;
|
||||
resolution = "merged";
|
||||
verifiedFingerprint = setupFingerprint({
|
||||
workspacePath: existing.workspace_path,
|
||||
bootstrapSeededAt: mergedBootstrapSeededAt,
|
||||
setupCompletedAt: mergedSetupCompletedAt,
|
||||
});
|
||||
} else {
|
||||
resolution = "superseded";
|
||||
verifiedFingerprint = existingFingerprint;
|
||||
}
|
||||
} else {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("workspace_setup_state").values({
|
||||
workspace_key: params.source.workspaceKey,
|
||||
workspace_path: params.source.workspaceDir,
|
||||
version: WORKSPACE_SETUP_STATE_VERSION,
|
||||
bootstrap_seeded_at: params.parsed.value.bootstrapSeededAt ?? null,
|
||||
setup_completed_at: params.parsed.value.setupCompletedAt ?? null,
|
||||
updated_at: now,
|
||||
}),
|
||||
);
|
||||
imported = true;
|
||||
resolution = "inserted";
|
||||
verifiedFingerprint = incomingFingerprint;
|
||||
}
|
||||
const verified = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_setup_state")
|
||||
.selectAll()
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
const actualFingerprint = verified
|
||||
? setupFingerprint({
|
||||
workspacePath: verified.workspace_path,
|
||||
bootstrapSeededAt: verified.bootstrap_seeded_at,
|
||||
setupCompletedAt: verified.setup_completed_at,
|
||||
})
|
||||
: null;
|
||||
if (!verified || actualFingerprint !== verifiedFingerprint) {
|
||||
throw new Error("SQLite verification failed for workspace setup state");
|
||||
}
|
||||
} else {
|
||||
const parsedAttestation = params.parsed.value;
|
||||
const incomingFingerprint = attestationFingerprint({
|
||||
attestedAtMs: parsedAttestation.attestedAtMs,
|
||||
generatedHashes: parsedAttestation.generatedHashes,
|
||||
});
|
||||
const existing = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_attestations")
|
||||
.selectAll()
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
if (existing) {
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_generated_bootstrap_hashes")
|
||||
.select(["filename", "sha256"])
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
).rows;
|
||||
const existingHashes = new Map(rows.map((row) => [row.filename, row.sha256]));
|
||||
const existingFingerprint = attestationFingerprint({
|
||||
attestedAtMs: existing.attested_at_ms,
|
||||
generatedHashes: existingHashes,
|
||||
});
|
||||
const replaceExistingAttestation = () => {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.updateTable("workspace_attestations")
|
||||
.set({
|
||||
attested_at_ms: parsedAttestation.attestedAtMs,
|
||||
updated_at_ms: now,
|
||||
})
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.deleteFrom("workspace_generated_bootstrap_hashes")
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
const replacementHashes = [...parsedAttestation.generatedHashes.entries()].toSorted(
|
||||
([left], [right]) => left.localeCompare(right),
|
||||
);
|
||||
if (replacementHashes.length > 0) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("workspace_generated_bootstrap_hashes").values(
|
||||
replacementHashes.map(([filename, sha256]) => ({
|
||||
workspace_key: params.source.workspaceKey,
|
||||
filename,
|
||||
sha256,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
const equivalent =
|
||||
existing.attested_at_ms === parsedAttestation.attestedAtMs &&
|
||||
mapsEqual(existingHashes, parsedAttestation.generatedHashes);
|
||||
if (equivalent) {
|
||||
resolution = "verified";
|
||||
verifiedFingerprint = existingFingerprint;
|
||||
} else if (existing.attested_at_ms > parsedAttestation.attestedAtMs) {
|
||||
resolution = "superseded";
|
||||
verifiedFingerprint = existingFingerprint;
|
||||
} else if (existing.attested_at_ms === parsedAttestation.attestedAtMs) {
|
||||
const authority = findMigrationAuthority({
|
||||
db,
|
||||
kysely,
|
||||
source: params.source,
|
||||
fingerprint: existingFingerprint,
|
||||
});
|
||||
if (!authority) {
|
||||
throw new Error("legacy workspace attestation conflicts with canonical SQLite state");
|
||||
}
|
||||
if (params.source.priority < authority.priority) {
|
||||
// Equal-time markers use source priority only when migration receipts
|
||||
// prove which whole snapshot won; hashes are never merged.
|
||||
replaceExistingAttestation();
|
||||
imported = true;
|
||||
resolution = "replaced";
|
||||
verifiedFingerprint = incomingFingerprint;
|
||||
} else {
|
||||
resolution = "superseded";
|
||||
verifiedFingerprint = existingFingerprint;
|
||||
}
|
||||
} else {
|
||||
replaceExistingAttestation();
|
||||
imported = true;
|
||||
resolution = "replaced";
|
||||
verifiedFingerprint = incomingFingerprint;
|
||||
}
|
||||
} else {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("workspace_attestations").values({
|
||||
workspace_key: params.source.workspaceKey,
|
||||
attested_at_ms: parsedAttestation.attestedAtMs,
|
||||
updated_at_ms: now,
|
||||
}),
|
||||
);
|
||||
const hashes = [...parsedAttestation.generatedHashes.entries()].toSorted(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
if (hashes.length > 0) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("workspace_generated_bootstrap_hashes").values(
|
||||
hashes.map(([filename, sha256]) => ({
|
||||
workspace_key: params.source.workspaceKey,
|
||||
filename,
|
||||
sha256,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
imported = true;
|
||||
resolution = "inserted";
|
||||
verifiedFingerprint = incomingFingerprint;
|
||||
}
|
||||
const verified = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_attestations")
|
||||
.select("attested_at_ms")
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
);
|
||||
const verifiedHashes = new Map(
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("workspace_generated_bootstrap_hashes")
|
||||
.select(["filename", "sha256"])
|
||||
.where("workspace_key", "=", params.source.workspaceKey),
|
||||
).rows.map((row) => [row.filename, row.sha256]),
|
||||
);
|
||||
const actualFingerprint = verified
|
||||
? attestationFingerprint({
|
||||
attestedAtMs: verified.attested_at_ms,
|
||||
generatedHashes: verifiedHashes,
|
||||
})
|
||||
: null;
|
||||
if (!verified || actualFingerprint !== verifiedFingerprint) {
|
||||
throw new Error("SQLite verification failed for workspace attestation state");
|
||||
}
|
||||
}
|
||||
|
||||
if (params.source.workspaceDir) {
|
||||
registerWorkspaceStateAliasesInTransaction({
|
||||
database,
|
||||
workspaceDirs: [
|
||||
params.source.workspaceDir,
|
||||
params.source.workspaceAliasPath ?? params.source.workspaceDir,
|
||||
],
|
||||
identity: {
|
||||
workspaceKey: params.source.workspaceKey,
|
||||
workspacePath: params.source.workspaceDir,
|
||||
},
|
||||
updatedAtMs: now,
|
||||
});
|
||||
}
|
||||
|
||||
const targetTable =
|
||||
params.parsed.kind === "setup" ? "workspace_setup_state" : "workspace_attestations";
|
||||
const reportJson = JSON.stringify({
|
||||
source: MIGRATION_KIND,
|
||||
sourceKind: params.parsed.kind,
|
||||
target: targetTable,
|
||||
workspaceKey: params.source.workspaceKey,
|
||||
sourceSha256: params.snapshot.sha256,
|
||||
sourceRecordCount: params.parsed.recordCount,
|
||||
sourcePriority: params.source.priority,
|
||||
canonicalFingerprint: verifiedFingerprint,
|
||||
// Only a whole-source insert or precedence replacement can establish
|
||||
// authority. Verification and complementary merges may cover cleanup,
|
||||
// but must not let a legacy source overwrite unrelated canonical data.
|
||||
authoritative: resolution === "inserted" || resolution === "replaced",
|
||||
resolution,
|
||||
imported,
|
||||
});
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("migration_runs").values({
|
||||
id: runId,
|
||||
started_at: now,
|
||||
finished_at: now,
|
||||
status: "completed",
|
||||
report_json: reportJson,
|
||||
}),
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("migration_sources").values({
|
||||
source_key: key,
|
||||
migration_kind: MIGRATION_KIND,
|
||||
source_path: params.source.sourcePath,
|
||||
target_table: targetTable,
|
||||
source_sha256: params.snapshot.sha256,
|
||||
source_size_bytes: params.snapshot.size,
|
||||
source_record_count: params.parsed.recordCount,
|
||||
last_run_id: runId,
|
||||
status: "completed",
|
||||
imported_at: now,
|
||||
removed_source: 0,
|
||||
report_json: reportJson,
|
||||
}),
|
||||
);
|
||||
return { sourceKey: key, imported };
|
||||
},
|
||||
{ env: params.env },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,964 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import {
|
||||
deleteWorkspaceState,
|
||||
prepareWorkspaceStateDeletion,
|
||||
readWorkspaceStateSnapshot,
|
||||
resolveWorkspaceStateIdentity,
|
||||
} from "../agents/workspace-state-store.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
||||
import {
|
||||
detectLegacyWorkspaceState,
|
||||
migrateLegacyWorkspaceState,
|
||||
} from "./state-migrations.workspace-setup.js";
|
||||
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
describe("legacy workspace Doctor migration", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
|
||||
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
envSnapshot?.restore();
|
||||
envSnapshot = undefined;
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const homeDir = tempDirs.make("openclaw-workspace-migration-home-");
|
||||
const stateDir = path.join(homeDir, ".openclaw");
|
||||
const workspaceDir = path.join(homeDir, "workspace");
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
envSnapshot ??= captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
setTestEnvValue("HOME", homeDir);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: workspaceDir } },
|
||||
} satisfies OpenClawConfig;
|
||||
return {
|
||||
cfg,
|
||||
env: { ...process.env, HOME: homeDir, OPENCLAW_STATE_DIR: stateDir },
|
||||
homeDir,
|
||||
stateDir,
|
||||
workspaceDir,
|
||||
};
|
||||
}
|
||||
|
||||
function detect(context: ReturnType<typeof setup>) {
|
||||
return detectLegacyWorkspaceState({
|
||||
cfg: context.cfg,
|
||||
stateDir: context.stateDir,
|
||||
env: context.env,
|
||||
homedir: () => context.homeDir,
|
||||
doctorOnlyStateMigrations: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate(context: ReturnType<typeof setup>) {
|
||||
return await migrateLegacyWorkspaceState({
|
||||
detected: detect(context),
|
||||
env: context.env,
|
||||
stateDir: context.stateDir,
|
||||
});
|
||||
}
|
||||
|
||||
it("detects configured and orphan sources only for explicit Doctor repair", async () => {
|
||||
const context = setup();
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
const canonicalSetupPath = path.join(
|
||||
resolveWorkspaceStateIdentity(context.workspaceDir).workspacePath,
|
||||
"openclaw-workspace-state.json",
|
||||
);
|
||||
await fsp.writeFile(setupPath, JSON.stringify({ version: 1 }), "utf8");
|
||||
const orphanKey = "b".repeat(64);
|
||||
const orphanPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${orphanKey}.attested`,
|
||||
);
|
||||
await fsp.mkdir(path.dirname(orphanPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
orphanPath,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-16T00:00:00.000Z\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(
|
||||
detectLegacyWorkspaceState({
|
||||
cfg: context.cfg,
|
||||
stateDir: context.stateDir,
|
||||
env: context.env,
|
||||
homedir: () => context.homeDir,
|
||||
}),
|
||||
).toEqual({ sources: [], hasLegacy: false });
|
||||
expect(detect(context)).toMatchObject({
|
||||
hasLegacy: true,
|
||||
sources: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "setup", sourcePath: canonicalSetupPath }),
|
||||
expect.objectContaining({ kind: "attestation", workspaceKey: orphanKey }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("imports setup and attestation state, records receipts, and removes files", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const seededAt = "2026-07-15T10:00:00.000Z";
|
||||
const completedAt = "2026-07-15T10:01:00.000Z";
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
await fsp.writeFile(
|
||||
setupPath,
|
||||
JSON.stringify({ version: 1, bootstrapSeededAt: seededAt, setupCompletedAt: completedAt }),
|
||||
"utf8",
|
||||
);
|
||||
const attestationPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
attestationPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const mtime = new Date("2026-07-15T11:02:03.456Z");
|
||||
await fsp.utimes(attestationPath, mtime, mtime);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toHaveLength(2);
|
||||
expect(fs.existsSync(setupPath)).toBe(false);
|
||||
expect(fs.existsSync(attestationPath)).toBe(false);
|
||||
const db = openOpenClawStateDatabase({ env: context.env }).db;
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT workspace_path, bootstrap_seeded_at, setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?",
|
||||
)
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({
|
||||
workspace_path: identity.workspacePath,
|
||||
bootstrap_seeded_at: seededAt,
|
||||
setup_completed_at: completedAt,
|
||||
});
|
||||
expect(
|
||||
db
|
||||
.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ attested_at_ms: mtime.getTime() });
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
|
||||
)
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ filename: "AGENTS.md", sha256: HASH });
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS count, SUM(removed_source) AS removed FROM migration_sources WHERE migration_kind = ?",
|
||||
)
|
||||
.get("legacy-workspace-setup-files"),
|
||||
).toEqual({ count: 2, removed: 2 });
|
||||
});
|
||||
|
||||
it("imports the legacy onboarding completion alias", async () => {
|
||||
const context = setup();
|
||||
const completedAt = "2026-07-15T10:01:00.000Z";
|
||||
const setupPath = path.join(context.workspaceDir, ".openclaw", "workspace-state.json");
|
||||
await fsp.mkdir(path.dirname(setupPath), { recursive: true });
|
||||
await fsp.writeFile(setupPath, JSON.stringify({ onboardingCompletedAt: completedAt }), "utf8");
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare("SELECT setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ setup_completed_at: completedAt });
|
||||
});
|
||||
|
||||
it("persists the configured symlink alias during Doctor import", async () => {
|
||||
const context = setup();
|
||||
const workspaceAlias = path.join(context.homeDir, "workspace-link");
|
||||
fs.symlinkSync(
|
||||
context.workspaceDir,
|
||||
workspaceAlias,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const aliasContext = {
|
||||
...context,
|
||||
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
|
||||
workspaceDir: workspaceAlias,
|
||||
};
|
||||
const completedAt = "2026-07-15T10:01:00.000Z";
|
||||
await fsp.writeFile(
|
||||
path.join(workspaceAlias, "openclaw-workspace-state.json"),
|
||||
JSON.stringify({ setupCompletedAt: completedAt }),
|
||||
"utf8",
|
||||
);
|
||||
const canonicalSiblingPath = `${resolveWorkspaceStateIdentity(context.workspaceDir).workspacePath}.attested`;
|
||||
await fsp.writeFile(
|
||||
canonicalSiblingPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect((await migrate(aliasContext)).warnings).toEqual([]);
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
expect(fs.existsSync(canonicalSiblingPath)).toBe(false);
|
||||
fs.unlinkSync(workspaceAlias);
|
||||
|
||||
expect(readWorkspaceStateSnapshot(workspaceAlias)).toMatchObject({
|
||||
identity,
|
||||
setup: { setupCompletedAt: completedAt },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves configured metadata when orphan discovery finds the same marker", async () => {
|
||||
const context = setup();
|
||||
const workspaceAlias = path.join(context.homeDir, "workspace-link");
|
||||
fs.symlinkSync(
|
||||
context.workspaceDir,
|
||||
workspaceAlias,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const aliasContext = {
|
||||
...context,
|
||||
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
|
||||
workspaceDir: workspaceAlias,
|
||||
};
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const attestationPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
attestationPath,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
"utf8",
|
||||
);
|
||||
const attestedAt = new Date("2026-07-15T11:00:00.000Z");
|
||||
await fsp.utimes(attestationPath, attestedAt, attestedAt);
|
||||
|
||||
const detected = detect(aliasContext);
|
||||
expect(detected.sources.find((source) => source.sourcePath === attestationPath)).toMatchObject({
|
||||
workspaceDir: identity.workspacePath,
|
||||
workspaceAliasPath: path.resolve(workspaceAlias),
|
||||
priority: 1,
|
||||
});
|
||||
expect((await migrate(aliasContext)).warnings).toEqual([]);
|
||||
fs.unlinkSync(workspaceAlias);
|
||||
|
||||
expect(readWorkspaceStateSnapshot(workspaceAlias)).toMatchObject({
|
||||
identity,
|
||||
attestation: { attestedAtMs: attestedAt.getTime() },
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates receipts when a configured alias is repointed", async () => {
|
||||
const context = setup();
|
||||
const targetB = path.join(context.homeDir, "workspace-b");
|
||||
const workspaceAlias = path.join(context.homeDir, "workspace-link");
|
||||
fs.mkdirSync(targetB, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
context.workspaceDir,
|
||||
workspaceAlias,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const aliasContext = {
|
||||
...context,
|
||||
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
|
||||
workspaceDir: workspaceAlias,
|
||||
};
|
||||
const sourcePath = `${workspaceAlias}.attested`;
|
||||
const identityA = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
await fsp.writeFile(
|
||||
sourcePath,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
"utf8",
|
||||
);
|
||||
const attestedAtA = new Date("2026-07-15T11:00:00.000Z");
|
||||
await fsp.utimes(sourcePath, attestedAtA, attestedAtA);
|
||||
expect((await migrate(aliasContext)).warnings).toEqual([]);
|
||||
|
||||
fs.unlinkSync(workspaceAlias);
|
||||
fs.symlinkSync(targetB, workspaceAlias, process.platform === "win32" ? "junction" : "dir");
|
||||
deleteWorkspaceState(prepareWorkspaceStateDeletion(workspaceAlias));
|
||||
const identityB = resolveWorkspaceStateIdentity(targetB);
|
||||
await fsp.writeFile(
|
||||
sourcePath,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T12:00:00.000Z\n",
|
||||
"utf8",
|
||||
);
|
||||
const attestedAtB = new Date("2026-07-15T12:00:00.000Z");
|
||||
await fsp.utimes(sourcePath, attestedAtB, attestedAtB);
|
||||
|
||||
expect((await migrate(aliasContext)).warnings).toEqual([]);
|
||||
const db = openOpenClawStateDatabase({ env: context.env }).db;
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT workspace_key, attested_at_ms FROM workspace_attestations ORDER BY workspace_key",
|
||||
)
|
||||
.all(),
|
||||
).toEqual(
|
||||
[
|
||||
{
|
||||
workspace_key: identityA.workspaceKey,
|
||||
attested_at_ms: attestedAtA.getTime(),
|
||||
},
|
||||
{
|
||||
workspace_key: identityB.workspaceKey,
|
||||
attested_at_ms: attestedAtB.getTime(),
|
||||
},
|
||||
].toSorted((left, right) => left.workspace_key.localeCompare(right.workspace_key)),
|
||||
);
|
||||
expect(
|
||||
db
|
||||
.prepare("SELECT COUNT(*) AS count FROM migration_sources WHERE source_path = ?")
|
||||
.get(sourcePath),
|
||||
).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it("rejects a configured workspace identity change before claiming a source", async () => {
|
||||
const context = setup();
|
||||
const targetB = path.join(context.homeDir, "workspace-b");
|
||||
const workspaceAlias = path.join(context.homeDir, "workspace-link");
|
||||
fs.mkdirSync(targetB, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
context.workspaceDir,
|
||||
workspaceAlias,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const aliasContext = {
|
||||
...context,
|
||||
cfg: { agents: { defaults: { workspace: workspaceAlias } } } satisfies OpenClawConfig,
|
||||
workspaceDir: workspaceAlias,
|
||||
};
|
||||
const identityA = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const attestationPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identityA.workspaceKey}.attested`,
|
||||
);
|
||||
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
attestationPath,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
"utf8",
|
||||
);
|
||||
const detected = detect(aliasContext);
|
||||
|
||||
const result = await migrateLegacyWorkspaceState({
|
||||
detected,
|
||||
env: context.env,
|
||||
stateDir: context.stateDir,
|
||||
beforeClaim: () => {
|
||||
fs.unlinkSync(workspaceAlias);
|
||||
fs.symlinkSync(targetB, workspaceAlias, process.platform === "win32" ? "junction" : "dir");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.warnings[0]).toContain("configured workspace identity changed");
|
||||
expect(fs.existsSync(attestationPath)).toBe(true);
|
||||
expect(fs.existsSync(`${attestationPath}.doctor-importing`)).toBe(false);
|
||||
const db = openOpenClawStateDatabase({ env: context.env }).db;
|
||||
expect(db.prepare("SELECT COUNT(*) AS count FROM workspace_attestations").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(db.prepare("SELECT COUNT(*) AS count FROM migration_sources").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(db.prepare("SELECT COUNT(*) AS count FROM workspace_path_aliases").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a stale nested setup marker after the root marker wins", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const rootPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
const nestedPath = path.join(context.workspaceDir, ".openclaw", "workspace-state.json");
|
||||
const rootSeededAt = "2026-07-15T10:00:00.000Z";
|
||||
const completedAt = "2026-07-15T10:01:00.000Z";
|
||||
await fsp.mkdir(path.dirname(nestedPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
rootPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
bootstrapSeededAt: rootSeededAt,
|
||||
setupCompletedAt: completedAt,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fsp.writeFile(
|
||||
nestedPath,
|
||||
JSON.stringify({ version: 1, bootstrapSeededAt: "2026-07-14T09:00:00.000Z" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(rootPath)).toBe(false);
|
||||
expect(fs.existsSync(nestedPath)).toBe(false);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare(
|
||||
"SELECT bootstrap_seeded_at, setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?",
|
||||
)
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ bootstrap_seeded_at: rootSeededAt, setup_completed_at: completedAt });
|
||||
});
|
||||
|
||||
it("imports an orphan state-directory attestation by its hashed workspace key", async () => {
|
||||
const context = setup();
|
||||
const orphanKey = "c".repeat(64);
|
||||
const attestationPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${orphanKey}.attested`,
|
||||
);
|
||||
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
attestationPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:TOOLS.md:${HASH}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare(
|
||||
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
|
||||
)
|
||||
.get(orphanKey),
|
||||
).toEqual({ filename: "TOOLS.md", sha256: HASH });
|
||||
expect(fs.existsSync(attestationPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("imports an owned sibling attestation", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const attestationPath = `${context.workspaceDir}.attested`;
|
||||
await fsp.writeFile(
|
||||
attestationPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:USER.md:${HASH}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare(
|
||||
"SELECT filename FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
|
||||
)
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ filename: "USER.md" });
|
||||
expect(fs.existsSync(attestationPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("consolidates attestation paths using newest modification time", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const currentPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
const siblingPath = `${context.workspaceDir}.attested`;
|
||||
await fsp.mkdir(path.dirname(currentPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
currentPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fsp.writeFile(
|
||||
siblingPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T12:00:00.000Z\ngenerated:USER.md:${"b".repeat(64)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const currentMtime = new Date("2026-07-15T11:00:00.000Z");
|
||||
const siblingMtime = new Date("2026-07-15T12:00:00.000Z");
|
||||
await fsp.utimes(currentPath, currentMtime, currentMtime);
|
||||
await fsp.utimes(siblingPath, siblingMtime, siblingMtime);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(currentPath)).toBe(false);
|
||||
expect(fs.existsSync(siblingPath)).toBe(false);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare(
|
||||
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
|
||||
)
|
||||
.all(identity.workspaceKey),
|
||||
).toEqual([{ filename: "USER.md", sha256: "b".repeat(64) }]);
|
||||
});
|
||||
|
||||
it("uses source priority for equal-time attestation snapshots", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const currentPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
const siblingPath = `${context.workspaceDir}.attested`;
|
||||
await fsp.mkdir(path.dirname(currentPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
currentPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fsp.writeFile(
|
||||
siblingPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:USER.md:${"b".repeat(64)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const sameMtime = new Date("2026-07-15T11:00:00.000Z");
|
||||
await fsp.utimes(currentPath, sameMtime, sameMtime);
|
||||
await fsp.utimes(siblingPath, sameMtime, sameMtime);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare(
|
||||
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
|
||||
)
|
||||
.all(identity.workspaceKey),
|
||||
).toEqual([{ filename: "AGENTS.md", sha256: HASH }]);
|
||||
});
|
||||
|
||||
it("lets a later higher-priority marker replace an equal-time attestation", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const currentPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
const siblingPath = `${context.workspaceDir}.attested`;
|
||||
const sameMtime = new Date("2026-07-15T11:00:00.000Z");
|
||||
await fsp.writeFile(
|
||||
siblingPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:USER.md:${"b".repeat(64)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fsp.utimes(siblingPath, sameMtime, sameMtime);
|
||||
expect((await migrate(context)).warnings).toEqual([]);
|
||||
|
||||
await fsp.mkdir(path.dirname(currentPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
currentPath,
|
||||
`openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\ngenerated:AGENTS.md:${HASH}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fsp.utimes(currentPath, sameMtime, sameMtime);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(currentPath)).toBe(false);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare(
|
||||
"SELECT filename, sha256 FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?",
|
||||
)
|
||||
.all(identity.workspaceKey),
|
||||
).toEqual([{ filename: "AGENTS.md", sha256: HASH }]);
|
||||
});
|
||||
|
||||
it("resumes an interrupted unreceipted claim", async () => {
|
||||
const context = setup();
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
const claimPath = `${setupPath}.doctor-importing`;
|
||||
await fsp.writeFile(
|
||||
setupPath,
|
||||
JSON.stringify({ version: 1, setupCompletedAt: "2026-07-15T10:01:00.000Z" }),
|
||||
"utf8",
|
||||
);
|
||||
await fsp.rename(setupPath, claimPath);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(claimPath)).toBe(false);
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare("SELECT setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ setup_completed_at: "2026-07-15T10:01:00.000Z" });
|
||||
});
|
||||
|
||||
it.each(["hardlink", "oversized"] as const)(
|
||||
"detects but retains an owned %s sibling attestation",
|
||||
async (kind) => {
|
||||
const context = setup();
|
||||
const attestationPath = `${context.workspaceDir}.attested`;
|
||||
const content = `openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n${kind === "oversized" ? "x".repeat(3_000) : ""}`;
|
||||
if (kind === "hardlink") {
|
||||
const targetPath = path.join(context.homeDir, "attestation-target");
|
||||
await fsp.writeFile(targetPath, content, "utf8");
|
||||
await fsp.link(targetPath, attestationPath);
|
||||
} else {
|
||||
await fsp.writeFile(attestationPath, content, "utf8");
|
||||
}
|
||||
|
||||
expect(detect(context).hasLegacy).toBe(true);
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings[0]).toMatch(/legacy workspace/i);
|
||||
expect(fs.existsSync(attestationPath)).toBe(true);
|
||||
expect(fs.existsSync(`${attestationPath}.doctor-importing`)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"symlink",
|
||||
"hardlink",
|
||||
"invalid-json",
|
||||
"invalid-attestation",
|
||||
"oversized-attestation",
|
||||
] as const)("rejects %s without changing canonical state", async (kind) => {
|
||||
const context = setup();
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
let sourcePath = setupPath;
|
||||
if (kind === "invalid-attestation" || kind === "oversized-attestation") {
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const attestationPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
sourcePath = attestationPath;
|
||||
await fsp.writeFile(
|
||||
attestationPath,
|
||||
kind === "invalid-attestation"
|
||||
? "openclaw-workspace-attestation:v1\nnot-a-date\n"
|
||||
: `openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n${"x".repeat(3_000)}`,
|
||||
"utf8",
|
||||
);
|
||||
} else {
|
||||
const targetPath = path.join(context.workspaceDir, "target.json");
|
||||
await fsp.writeFile(targetPath, JSON.stringify({ version: 1 }), "utf8");
|
||||
if (kind === "symlink") {
|
||||
await fsp.symlink(targetPath, setupPath);
|
||||
} else if (kind === "hardlink") {
|
||||
await fsp.link(targetPath, setupPath);
|
||||
} else {
|
||||
await fsp.writeFile(setupPath, "{invalid", "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings[0]).toMatch(/legacy workspace/i);
|
||||
expect(fs.existsSync(sourcePath)).toBe(true);
|
||||
expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(false);
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a setup source beneath a symlinked workspace subdirectory", async () => {
|
||||
const context = setup();
|
||||
const externalDir = path.join(context.homeDir, "external-workspace-state");
|
||||
const externalSource = path.join(externalDir, "workspace-state.json");
|
||||
await fsp.mkdir(externalDir, { recursive: true });
|
||||
await fsp.writeFile(externalSource, JSON.stringify({ version: 1 }), "utf8");
|
||||
await fsp.symlink(externalDir, path.join(context.workspaceDir, ".openclaw"));
|
||||
|
||||
expect(detect(context).hasLegacy).toBe(true);
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings[0]).toMatch(/legacy workspace/i);
|
||||
await expect(fsp.readFile(externalSource, "utf8")).resolves.toBe(
|
||||
JSON.stringify({ version: 1 }),
|
||||
);
|
||||
expect(fs.existsSync(`${externalSource}.doctor-importing`)).toBe(false);
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects attestations beneath a symlinked state subdirectory", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const externalDir = path.join(context.homeDir, "external-attestations");
|
||||
const externalSource = path.join(externalDir, `${identity.workspaceKey}.attested`);
|
||||
await fsp.mkdir(externalDir, { recursive: true });
|
||||
await fsp.writeFile(
|
||||
externalSource,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
"utf8",
|
||||
);
|
||||
await fsp.mkdir(context.stateDir, { recursive: true });
|
||||
await fsp.symlink(externalDir, path.join(context.stateDir, "workspace-attestations"));
|
||||
|
||||
expect(detect(context).hasLegacy).toBe(true);
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings[0]).toMatch(/legacy workspace/i);
|
||||
await expect(fsp.readFile(externalSource, "utf8")).resolves.toContain(
|
||||
"openclaw-workspace-attestation:v1",
|
||||
);
|
||||
expect(fs.existsSync(`${externalSource}.doctor-importing`)).toBe(false);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare("SELECT workspace_key FROM workspace_attestations WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains a setup source that changes before Doctor claims it", async () => {
|
||||
const context = setup();
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
await fsp.writeFile(setupPath, JSON.stringify({ version: 1 }), "utf8");
|
||||
|
||||
const result = await migrateLegacyWorkspaceState({
|
||||
detected: detect(context),
|
||||
env: context.env,
|
||||
stateDir: context.stateDir,
|
||||
beforeClaim: () => {
|
||||
fs.writeFileSync(
|
||||
setupPath,
|
||||
JSON.stringify({ version: 1, setupCompletedAt: "2026-07-16T00:00:00.000Z" }),
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.warnings[0]).toContain("changed before Doctor could claim it");
|
||||
expect(fs.existsSync(setupPath)).toBe(true);
|
||||
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(false);
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a conflicting source and preserves canonical SQLite state", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const db = openOpenClawStateDatabase({ env: context.env }).db;
|
||||
db.prepare(
|
||||
`INSERT INTO workspace_setup_state (
|
||||
workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at
|
||||
) VALUES (?, ?, 1, ?, NULL, 1)`,
|
||||
).run(identity.workspaceKey, identity.workspacePath, "2026-07-15T00:00:00.000Z");
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
await fsp.writeFile(
|
||||
setupPath,
|
||||
JSON.stringify({ version: 1, bootstrapSeededAt: "2026-07-16T00:00:00.000Z" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings[0]).toContain("conflicts with canonical SQLite state");
|
||||
expect(fs.existsSync(setupPath)).toBe(true);
|
||||
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(false);
|
||||
expect(
|
||||
db
|
||||
.prepare("SELECT bootstrap_seeded_at FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ bootstrap_seeded_at: "2026-07-15T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("merges complementary legacy milestones into unowned SQLite setup state", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const seededAt = "2026-07-15T00:00:00.000Z";
|
||||
const completedAt = "2026-07-15T00:01:00.000Z";
|
||||
const db = openOpenClawStateDatabase({ env: context.env }).db;
|
||||
db.prepare(
|
||||
`INSERT INTO workspace_setup_state (
|
||||
workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at
|
||||
) VALUES (?, ?, 1, ?, NULL, 1)`,
|
||||
).run(identity.workspaceKey, identity.workspacePath, seededAt);
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
await fsp.writeFile(
|
||||
setupPath,
|
||||
JSON.stringify({ version: 1, setupCompletedAt: completedAt }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await migrate(context);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(setupPath)).toBe(false);
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT bootstrap_seeded_at, setup_completed_at FROM workspace_setup_state WHERE workspace_key = ?",
|
||||
)
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ bootstrap_seeded_at: seededAt, setup_completed_at: completedAt });
|
||||
const receipt = db
|
||||
.prepare("SELECT report_json FROM migration_sources WHERE source_path = ?")
|
||||
.get(path.join(identity.workspacePath, "openclaw-workspace-state.json")) as {
|
||||
report_json: string;
|
||||
};
|
||||
expect(JSON.parse(receipt.report_json)).toMatchObject({
|
||||
authoritative: false,
|
||||
imported: true,
|
||||
resolution: "merged",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses receipts for idempotent cleanup-only retries", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const setupPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
const seededAt = "2026-07-15T00:00:00.000Z";
|
||||
await fsp.writeFile(
|
||||
setupPath,
|
||||
JSON.stringify({ version: 1, bootstrapSeededAt: seededAt }),
|
||||
"utf8",
|
||||
);
|
||||
const first = await migrateLegacyWorkspaceState({
|
||||
detected: detect(context),
|
||||
env: context.env,
|
||||
stateDir: context.stateDir,
|
||||
removeSource: () => {
|
||||
throw new Error("simulated unlink failure");
|
||||
},
|
||||
});
|
||||
expect(first.warnings[0]).toContain("legacy cleanup failed");
|
||||
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(true);
|
||||
const db = openOpenClawStateDatabase({ env: context.env }).db;
|
||||
|
||||
const retry = await migrate(context);
|
||||
|
||||
expect(retry.warnings).toEqual([]);
|
||||
expect(fs.existsSync(`${setupPath}.doctor-importing`)).toBe(false);
|
||||
expect(
|
||||
db
|
||||
.prepare("SELECT bootstrap_seeded_at FROM workspace_setup_state WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ bootstrap_seeded_at: seededAt });
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT source_sha256, removed_source FROM migration_sources WHERE source_path = ?",
|
||||
)
|
||||
.get(path.join(identity.workspacePath, "openclaw-workspace-state.json")),
|
||||
).toEqual({
|
||||
source_sha256: createHash("sha256")
|
||||
.update(JSON.stringify({ version: 1, bootstrapSeededAt: seededAt }))
|
||||
.digest("hex"),
|
||||
removed_source: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans receipt-covered superseded setup markers after an interrupted delete", async () => {
|
||||
const context = setup();
|
||||
const rootPath = path.join(context.workspaceDir, "openclaw-workspace-state.json");
|
||||
const nestedPath = path.join(context.workspaceDir, ".openclaw", "workspace-state.json");
|
||||
await fsp.mkdir(path.dirname(nestedPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
rootPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-07-15T10:00:00.000Z",
|
||||
setupCompletedAt: "2026-07-15T10:01:00.000Z",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fsp.writeFile(
|
||||
nestedPath,
|
||||
JSON.stringify({ version: 1, bootstrapSeededAt: "2026-07-14T09:00:00.000Z" }),
|
||||
"utf8",
|
||||
);
|
||||
const first = await migrateLegacyWorkspaceState({
|
||||
detected: detect(context),
|
||||
env: context.env,
|
||||
stateDir: context.stateDir,
|
||||
removeSource: () => {
|
||||
throw new Error("simulated unlink failure");
|
||||
},
|
||||
});
|
||||
expect(first.warnings).toHaveLength(2);
|
||||
|
||||
const retry = await migrate(context);
|
||||
|
||||
expect(retry.warnings).toEqual([]);
|
||||
expect(fs.existsSync(`${rootPath}.doctor-importing`)).toBe(false);
|
||||
expect(fs.existsSync(`${nestedPath}.doctor-importing`)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains a receipt-covered attestation when only its modification time changed", async () => {
|
||||
const context = setup();
|
||||
const identity = resolveWorkspaceStateIdentity(context.workspaceDir);
|
||||
const attestationPath = path.join(
|
||||
context.stateDir,
|
||||
"workspace-attestations",
|
||||
`${identity.workspaceKey}.attested`,
|
||||
);
|
||||
await fsp.mkdir(path.dirname(attestationPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
attestationPath,
|
||||
"openclaw-workspace-attestation:v1\n2026-07-15T11:00:00.000Z\n",
|
||||
"utf8",
|
||||
);
|
||||
const originalMtime = new Date("2026-07-15T11:01:00.000Z");
|
||||
await fsp.utimes(attestationPath, originalMtime, originalMtime);
|
||||
const first = await migrateLegacyWorkspaceState({
|
||||
detected: detect(context),
|
||||
env: context.env,
|
||||
stateDir: context.stateDir,
|
||||
removeSource: () => {
|
||||
throw new Error("simulated unlink failure");
|
||||
},
|
||||
});
|
||||
expect(first.warnings[0]).toContain("legacy cleanup failed");
|
||||
const claimPath = `${attestationPath}.doctor-importing`;
|
||||
const changedMtime = new Date("2026-07-15T11:02:00.000Z");
|
||||
await fsp.utimes(claimPath, changedMtime, changedMtime);
|
||||
|
||||
const retry = await migrate(context);
|
||||
|
||||
expect(retry.warnings[0]).toContain("retired source now conflicts");
|
||||
expect(fs.existsSync(claimPath)).toBe(true);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env: context.env })
|
||||
.db.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
|
||||
.get(identity.workspaceKey),
|
||||
).toEqual({ attested_at_ms: originalMtime.getTime() });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,710 @@
|
||||
// Doctor-only import for retired workspace setup and attestation files.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { root, type Root } from "@openclaw/fs-safe";
|
||||
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
|
||||
import {
|
||||
LEGACY_WORKSPACE_ATTESTATION_DIRNAME,
|
||||
LEGACY_WORKSPACE_ATTESTATION_HEADER,
|
||||
LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
LEGACY_WORKSPACE_STATE_CURRENT_FILENAME,
|
||||
WORKSPACE_DOCTOR_CLAIM_SUFFIX,
|
||||
resolveLegacyWorkspaceSourcePaths,
|
||||
} from "../agents/workspace-legacy-state.js";
|
||||
import { resolveWorkspaceStateIdentity } from "../agents/workspace-state-store.js";
|
||||
import { resolveLegacyStateDirs } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
|
||||
import type { MigrationMessages } from "./state-migrations.types.js";
|
||||
import {
|
||||
markSourceRemoved,
|
||||
readReceipt,
|
||||
type MigrationReceipt,
|
||||
} from "./state-migrations.workspace-setup-receipts.js";
|
||||
import {
|
||||
canonicalCoversParsedSource,
|
||||
importAndRecordReceipt,
|
||||
parseSource,
|
||||
type ParsedSource,
|
||||
type SourceSnapshot,
|
||||
} from "./state-migrations.workspace-setup-store.js";
|
||||
import type {
|
||||
LegacyWorkspaceStateDetection,
|
||||
LegacyWorkspaceStateSource,
|
||||
} from "./state-migrations.workspace-setup.types.js";
|
||||
|
||||
const SETUP_MAX_BYTES = 64 * 1024;
|
||||
const CLAIM_SUFFIX = WORKSPACE_DOCTOR_CLAIM_SUFFIX;
|
||||
const MIGRATION_LOCK_TIMEOUT_MS = 250;
|
||||
const MIGRATION_LOCK_POLL_INTERVAL_MS = 25;
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
function pathMayExist(filePath: string): boolean {
|
||||
try {
|
||||
fs.lstatSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
function sourceOrClaimMayExist(sourcePath: string): boolean {
|
||||
return pathMayExist(sourcePath) || pathMayExist(`${sourcePath}${CLAIM_SUFFIX}`);
|
||||
}
|
||||
|
||||
async function readBoundedRegularFile(params: {
|
||||
sourceRoot: Root;
|
||||
relativePath: string;
|
||||
sourcePath: string;
|
||||
maxBytes: number;
|
||||
}): Promise<SourceSnapshot> {
|
||||
const opened = await params.sourceRoot.open(params.relativePath, {
|
||||
hardlinks: "reject",
|
||||
symlinks: "reject",
|
||||
});
|
||||
try {
|
||||
const before = opened.stat;
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
!Number.isSafeInteger(before.size) ||
|
||||
before.size < 0 ||
|
||||
before.size > params.maxBytes
|
||||
) {
|
||||
throw new Error("legacy workspace source is not a safe regular file");
|
||||
}
|
||||
const buffer = Buffer.alloc(before.size);
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const { bytesRead } = await opened.handle.read(
|
||||
buffer,
|
||||
offset,
|
||||
buffer.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (bytesRead === 0) {
|
||||
throw new Error("legacy workspace source ended unexpectedly");
|
||||
}
|
||||
offset += bytesRead;
|
||||
}
|
||||
const after = await opened.handle.stat();
|
||||
if (
|
||||
!after.isFile() ||
|
||||
after.nlink !== 1 ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs ||
|
||||
after.ctimeMs !== before.ctimeMs ||
|
||||
offset !== after.size
|
||||
) {
|
||||
throw new Error("legacy workspace source changed while reading");
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = utf8Decoder.decode(buffer);
|
||||
} catch {
|
||||
throw new Error("legacy workspace source is not valid UTF-8");
|
||||
}
|
||||
return {
|
||||
sourcePath: params.sourcePath,
|
||||
dev: after.dev,
|
||||
ino: after.ino,
|
||||
mtimeMs: after.mtimeMs,
|
||||
sha256: createHash("sha256").update(buffer).digest("hex"),
|
||||
size: after.size,
|
||||
raw,
|
||||
};
|
||||
} finally {
|
||||
await opened[Symbol.asyncDispose]();
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacySource(
|
||||
params: Omit<LegacyWorkspaceStateSource, "relativePath" | "rootDir"> & { rootDir: string },
|
||||
): LegacyWorkspaceStateSource {
|
||||
const rootDir = path.resolve(params.rootDir);
|
||||
const sourcePath = path.resolve(params.sourcePath);
|
||||
const relativePath = path.relative(rootDir, sourcePath);
|
||||
if (
|
||||
!relativePath ||
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error("legacy workspace source is outside its migration root");
|
||||
}
|
||||
return { ...params, rootDir, relativePath, sourcePath };
|
||||
}
|
||||
|
||||
function snapshotsMatch(left: SourceSnapshot, right: SourceSnapshot): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.sha256 === right.sha256 &&
|
||||
left.size === right.size
|
||||
);
|
||||
}
|
||||
|
||||
function siblingAttestationNeedsDoctor(filePath: string): boolean {
|
||||
try {
|
||||
const before = fs.lstatSync(filePath);
|
||||
if (!before.isFile()) {
|
||||
return false;
|
||||
}
|
||||
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
||||
let fd: number;
|
||||
try {
|
||||
fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
|
||||
} catch {
|
||||
// An unreadable regular file could be an owned marker. Doctor must surface it.
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const opened = fs.fstatSync(fd);
|
||||
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
|
||||
return true;
|
||||
}
|
||||
const expected = Buffer.from(`${LEGACY_WORKSPACE_ATTESTATION_HEADER}\n`, "utf8");
|
||||
const bytes = Buffer.alloc(expected.length);
|
||||
const read = fs.readSync(fd, bytes, 0, bytes.length, 0);
|
||||
return read === expected.length && bytes.equals(expected);
|
||||
} catch {
|
||||
return true;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function listOrphanAttestationSources(params: {
|
||||
stateDir: string;
|
||||
homedir: () => string;
|
||||
}): LegacyWorkspaceStateSource[] {
|
||||
const sources: LegacyWorkspaceStateSource[] = [];
|
||||
const stateDirs = [...new Set([params.stateDir, ...resolveLegacyStateDirs(params.homedir)])];
|
||||
for (const [priority, stateDir] of stateDirs.entries()) {
|
||||
const attestationDir = path.join(stateDir, LEGACY_WORKSPACE_ATTESTATION_DIRNAME);
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(attestationDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
// Preserve a path-shaped detection so Doctor reports the unsafe directory.
|
||||
sources.push({
|
||||
...createLegacySource({
|
||||
kind: "attestation",
|
||||
rootDir: stateDir,
|
||||
sourcePath: attestationDir,
|
||||
workspaceKey: "unreadable-attestation-directory",
|
||||
priority,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const match = /^([a-f0-9]{64})\.attested(?:\.doctor-importing)?$/.exec(entry.name);
|
||||
if (!match?.[1]) {
|
||||
continue;
|
||||
}
|
||||
const sourceName = entry.name.endsWith(CLAIM_SUFFIX)
|
||||
? entry.name.slice(0, -CLAIM_SUFFIX.length)
|
||||
: entry.name;
|
||||
sources.push(
|
||||
createLegacySource({
|
||||
kind: "attestation",
|
||||
rootDir: stateDir,
|
||||
sourcePath: path.join(attestationDir, sourceName),
|
||||
workspaceKey: match[1],
|
||||
priority,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** Detect retired workspace files only when an explicit Doctor flow opts in. */
|
||||
export function detectLegacyWorkspaceState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
stateDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
doctorOnlyStateMigrations?: boolean;
|
||||
}): LegacyWorkspaceStateDetection {
|
||||
if (params.doctorOnlyStateMigrations !== true) {
|
||||
return { sources: [], hasLegacy: false };
|
||||
}
|
||||
const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir };
|
||||
const homedir = params.homedir ?? os.homedir;
|
||||
const byPath = new Map<string, LegacyWorkspaceStateSource>();
|
||||
const add = (source: LegacyWorkspaceStateSource) => {
|
||||
const key = `${source.kind}:${path.resolve(source.sourcePath)}`;
|
||||
const existing = byPath.get(key);
|
||||
const sourceIsConfigured = source.workspaceDir !== undefined;
|
||||
const existingIsConfigured = existing?.workspaceDir !== undefined;
|
||||
if (
|
||||
!existing ||
|
||||
(sourceIsConfigured && !existingIsConfigured) ||
|
||||
(sourceIsConfigured === existingIsConfigured && source.priority < existing.priority)
|
||||
) {
|
||||
byPath.set(key, source);
|
||||
}
|
||||
};
|
||||
|
||||
for (const workspaceDir of listAgentWorkspaceDirs(params.cfg)) {
|
||||
const identity = resolveWorkspaceStateIdentity(workspaceDir);
|
||||
const paths = resolveLegacyWorkspaceSourcePaths(workspaceDir, { env, homedir });
|
||||
for (const [priority, sourcePath] of paths.setupStatePaths.entries()) {
|
||||
if (sourceOrClaimMayExist(sourcePath)) {
|
||||
add(
|
||||
createLegacySource({
|
||||
kind: "setup",
|
||||
rootDir: sourcePath.endsWith(LEGACY_WORKSPACE_STATE_CURRENT_FILENAME)
|
||||
? path.dirname(sourcePath)
|
||||
: path.dirname(path.dirname(sourcePath)),
|
||||
sourcePath,
|
||||
workspaceKey: identity.workspaceKey,
|
||||
workspaceDir: identity.workspacePath,
|
||||
workspaceAliasPath: paths.workspacePath,
|
||||
priority,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [priority, sourcePath] of paths.stateDirAttestationPaths.entries()) {
|
||||
if (sourceOrClaimMayExist(sourcePath)) {
|
||||
add(
|
||||
createLegacySource({
|
||||
kind: "attestation",
|
||||
rootDir: path.dirname(path.dirname(sourcePath)),
|
||||
sourcePath,
|
||||
workspaceKey: identity.workspaceKey,
|
||||
workspaceDir: identity.workspacePath,
|
||||
workspaceAliasPath: paths.workspacePath,
|
||||
priority,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [index, sourcePath] of paths.siblingAttestationPaths.entries()) {
|
||||
if (
|
||||
!pathMayExist(`${sourcePath}${CLAIM_SUFFIX}`) &&
|
||||
!siblingAttestationNeedsDoctor(sourcePath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
add(
|
||||
createLegacySource({
|
||||
kind: "attestation",
|
||||
rootDir: path.dirname(sourcePath),
|
||||
sourcePath,
|
||||
workspaceKey: identity.workspaceKey,
|
||||
workspaceDir: identity.workspacePath,
|
||||
workspaceAliasPath: paths.workspacePath,
|
||||
priority: paths.stateDirAttestationPaths.length + index,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of listOrphanAttestationSources({ stateDir: params.stateDir, homedir })) {
|
||||
add(source);
|
||||
}
|
||||
const sources = [...byPath.values()].toSorted(
|
||||
(left, right) =>
|
||||
left.priority - right.priority ||
|
||||
left.workspaceKey.localeCompare(right.workspaceKey) ||
|
||||
left.sourcePath.localeCompare(right.sourcePath),
|
||||
);
|
||||
return { sources, hasLegacy: sources.length > 0 };
|
||||
}
|
||||
|
||||
function assertConfiguredWorkspaceIdentity(source: LegacyWorkspaceStateSource): void {
|
||||
if (!source.workspaceAliasPath) {
|
||||
return;
|
||||
}
|
||||
if (!source.workspaceDir) {
|
||||
throw new Error("configured legacy workspace source has no canonical path");
|
||||
}
|
||||
const current = resolveWorkspaceStateIdentity(source.workspaceAliasPath);
|
||||
if (
|
||||
current.workspaceKey !== source.workspaceKey ||
|
||||
current.workspacePath !== source.workspaceDir
|
||||
) {
|
||||
throw new Error("configured workspace identity changed during Doctor migration");
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreClaim(params: {
|
||||
sourceRoot: Root;
|
||||
source: LegacyWorkspaceStateSource;
|
||||
}): Promise<string | null> {
|
||||
const claimRelativePath = `${params.source.relativePath}${CLAIM_SUFFIX}`;
|
||||
try {
|
||||
if (!(await params.sourceRoot.exists(claimRelativePath))) {
|
||||
return null;
|
||||
}
|
||||
if (await params.sourceRoot.exists(params.source.relativePath)) {
|
||||
return `source path already exists: ${params.source.sourcePath}`;
|
||||
}
|
||||
await params.sourceRoot.move(claimRelativePath, params.source.relativePath);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return formatErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupReceiptSource(params: {
|
||||
sourceRoot: Root;
|
||||
source: LegacyWorkspaceStateSource;
|
||||
receipt: MigrationReceipt;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<MigrationMessages> {
|
||||
try {
|
||||
assertConfiguredWorkspaceIdentity(params.source);
|
||||
const candidates = [
|
||||
{
|
||||
relativePath: params.source.relativePath,
|
||||
sourcePath: params.source.sourcePath,
|
||||
},
|
||||
{
|
||||
relativePath: `${params.source.relativePath}${CLAIM_SUFFIX}`,
|
||||
sourcePath: `${params.source.sourcePath}${CLAIM_SUFFIX}`,
|
||||
},
|
||||
];
|
||||
const existing = [];
|
||||
for (const candidate of candidates) {
|
||||
if (await params.sourceRoot.exists(candidate.relativePath)) {
|
||||
existing.push(candidate);
|
||||
}
|
||||
}
|
||||
if (existing.length === 0) {
|
||||
if (!params.receipt.removedSource) {
|
||||
markSourceRemoved(params.receipt.sourceKey, params.env);
|
||||
}
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
if (existing.length > 1) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: ["Workspace state is in SQLite, but source and interrupted claim both exist."],
|
||||
};
|
||||
}
|
||||
let active = existing[0]!;
|
||||
let snapshot = await readBoundedRegularFile({
|
||||
sourceRoot: params.sourceRoot,
|
||||
relativePath: active.relativePath,
|
||||
sourcePath: active.sourcePath,
|
||||
maxBytes:
|
||||
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
});
|
||||
let claimedByThisRun = false;
|
||||
if (active.relativePath === params.source.relativePath) {
|
||||
const claim = candidates[1]!;
|
||||
await params.sourceRoot.move(active.relativePath, claim.relativePath);
|
||||
const claimed = await readBoundedRegularFile({
|
||||
sourceRoot: params.sourceRoot,
|
||||
relativePath: claim.relativePath,
|
||||
sourcePath: claim.sourcePath,
|
||||
maxBytes:
|
||||
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
});
|
||||
if (!snapshotsMatch(snapshot, claimed)) {
|
||||
await restoreClaim({ sourceRoot: params.sourceRoot, source: params.source });
|
||||
throw new Error("legacy workspace source changed before Doctor could claim it");
|
||||
}
|
||||
active = claim;
|
||||
snapshot = claimed;
|
||||
claimedByThisRun = true;
|
||||
}
|
||||
const parsed = parseSource(params.source, snapshot);
|
||||
if (
|
||||
!params.receipt.sha256 ||
|
||||
snapshot.sha256 !== params.receipt.sha256 ||
|
||||
!canonicalCoversParsedSource({ source: params.source, parsed, env: params.env })
|
||||
) {
|
||||
if (claimedByThisRun) {
|
||||
await restoreClaim({ sourceRoot: params.sourceRoot, source: params.source });
|
||||
}
|
||||
return {
|
||||
changes: [],
|
||||
warnings: ["Workspace state is in SQLite, but the retired source now conflicts."],
|
||||
};
|
||||
}
|
||||
const unchanged = await readBoundedRegularFile({
|
||||
sourceRoot: params.sourceRoot,
|
||||
relativePath: active.relativePath,
|
||||
sourcePath: active.sourcePath,
|
||||
maxBytes:
|
||||
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
});
|
||||
if (!snapshotsMatch(snapshot, unchanged)) {
|
||||
if (claimedByThisRun) {
|
||||
await restoreClaim({ sourceRoot: params.sourceRoot, source: params.source });
|
||||
}
|
||||
throw new Error("legacy workspace claim changed before cleanup");
|
||||
}
|
||||
assertConfiguredWorkspaceIdentity(params.source);
|
||||
await params.sourceRoot.remove(active.relativePath);
|
||||
markSourceRemoved(params.receipt.sourceKey, params.env);
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [],
|
||||
notices: ["Discarded retired workspace state already covered by its SQLite receipt."],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [
|
||||
`Workspace state is in SQLite, but legacy cleanup failed: ${formatErrorMessage(error)}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateOneSource(params: {
|
||||
source: LegacyWorkspaceStateSource;
|
||||
env: NodeJS.ProcessEnv;
|
||||
beforeClaim?: (source: LegacyWorkspaceStateSource) => void;
|
||||
removeSource?: (sourcePath: string) => Promise<void> | void;
|
||||
}): Promise<MigrationMessages> {
|
||||
let sourceRoot: Root;
|
||||
try {
|
||||
assertConfiguredWorkspaceIdentity(params.source);
|
||||
sourceRoot = await root(params.source.rootDir, {
|
||||
hardlinks: "reject",
|
||||
symlinks: "reject",
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [`Failed reading legacy workspace state: ${formatErrorMessage(error)}`],
|
||||
};
|
||||
}
|
||||
const receipt = readReceipt(params.source, params.env);
|
||||
if (receipt) {
|
||||
return cleanupReceiptSource({
|
||||
sourceRoot,
|
||||
source: params.source,
|
||||
receipt,
|
||||
env: params.env,
|
||||
});
|
||||
}
|
||||
|
||||
const sourcePath = params.source.sourcePath;
|
||||
const claimPath = `${sourcePath}${CLAIM_SUFFIX}`;
|
||||
const claimRelativePath = `${params.source.relativePath}${CLAIM_SUFFIX}`;
|
||||
let hasSource: boolean;
|
||||
let hasClaim: boolean;
|
||||
try {
|
||||
hasSource = await sourceRoot.exists(params.source.relativePath);
|
||||
hasClaim = await sourceRoot.exists(claimRelativePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [`Failed reading legacy workspace state: ${formatErrorMessage(error)}`],
|
||||
};
|
||||
}
|
||||
if (hasSource && hasClaim) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [
|
||||
"Failed migrating legacy workspace state: source and interrupted claim both exist.",
|
||||
],
|
||||
};
|
||||
}
|
||||
const activePath = hasSource ? sourcePath : hasClaim ? claimPath : null;
|
||||
const activeRelativePath = hasSource
|
||||
? params.source.relativePath
|
||||
: hasClaim
|
||||
? claimRelativePath
|
||||
: null;
|
||||
if (!activePath || !activeRelativePath) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
|
||||
let snapshot: SourceSnapshot;
|
||||
let parsed: ParsedSource;
|
||||
let claimedByThisRun = false;
|
||||
try {
|
||||
snapshot = await readBoundedRegularFile({
|
||||
sourceRoot,
|
||||
relativePath: activeRelativePath,
|
||||
sourcePath: activePath,
|
||||
maxBytes:
|
||||
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
});
|
||||
parsed = parseSource(params.source, snapshot);
|
||||
} catch (error) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [`Failed reading legacy workspace state: ${formatErrorMessage(error)}`],
|
||||
};
|
||||
}
|
||||
|
||||
if (activePath === sourcePath) {
|
||||
try {
|
||||
params.beforeClaim?.(params.source);
|
||||
assertConfiguredWorkspaceIdentity(params.source);
|
||||
await sourceRoot.move(params.source.relativePath, claimRelativePath);
|
||||
const claimed = await readBoundedRegularFile({
|
||||
sourceRoot,
|
||||
relativePath: claimRelativePath,
|
||||
sourcePath: claimPath,
|
||||
maxBytes:
|
||||
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
});
|
||||
if (!snapshotsMatch(snapshot, claimed)) {
|
||||
throw new Error("legacy workspace source changed before Doctor could claim it");
|
||||
}
|
||||
snapshot = claimed;
|
||||
claimedByThisRun = true;
|
||||
} catch (error) {
|
||||
const restoreError = await restoreClaim({ sourceRoot, source: params.source });
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [
|
||||
`Failed migrating legacy workspace state: ${formatErrorMessage(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result: ReturnType<typeof importAndRecordReceipt>;
|
||||
try {
|
||||
assertConfiguredWorkspaceIdentity(params.source);
|
||||
result = importAndRecordReceipt({
|
||||
source: params.source,
|
||||
snapshot,
|
||||
parsed,
|
||||
env: params.env,
|
||||
});
|
||||
} catch (error) {
|
||||
const restoreError = claimedByThisRun
|
||||
? await restoreClaim({ sourceRoot, source: params.source })
|
||||
: null;
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [
|
||||
`Failed migrating legacy workspace state: ${formatErrorMessage(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (await sourceRoot.exists(params.source.relativePath)) {
|
||||
throw new Error("legacy workspace source reappeared during import");
|
||||
}
|
||||
const unchanged = await readBoundedRegularFile({
|
||||
sourceRoot,
|
||||
relativePath: claimRelativePath,
|
||||
sourcePath: claimPath,
|
||||
maxBytes:
|
||||
params.source.kind === "setup" ? SETUP_MAX_BYTES : LEGACY_WORKSPACE_ATTESTATION_MAX_BYTES,
|
||||
});
|
||||
if (!snapshotsMatch(snapshot, unchanged)) {
|
||||
throw new Error("legacy workspace claim changed after import");
|
||||
}
|
||||
if (params.removeSource) {
|
||||
await params.removeSource(claimPath);
|
||||
} else {
|
||||
await sourceRoot.remove(claimRelativePath);
|
||||
}
|
||||
markSourceRemoved(result.sourceKey, params.env);
|
||||
} catch (error) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [
|
||||
`Workspace state is in SQLite, but legacy cleanup failed: ${formatErrorMessage(error)}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const label = parsed.kind === "setup" ? "workspace setup state" : "workspace attestation";
|
||||
return {
|
||||
changes: [
|
||||
result.imported ? `Migrated ${label} to SQLite.` : `Verified canonical SQLite ${label}.`,
|
||||
],
|
||||
warnings: [],
|
||||
notices: ["Removed retired workspace state after verified SQLite import."],
|
||||
};
|
||||
}
|
||||
|
||||
/** Import retired workspace files while excluding Gateways that can recreate them. */
|
||||
export async function migrateLegacyWorkspaceState(params: {
|
||||
detected?: LegacyWorkspaceStateDetection;
|
||||
stateDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
beforeClaim?: (source: LegacyWorkspaceStateSource) => void;
|
||||
removeSource?: (sourcePath: string) => Promise<void> | void;
|
||||
}): Promise<MigrationMessages> {
|
||||
if (!params.detected?.hasLegacy) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir };
|
||||
let lock: Awaited<ReturnType<typeof acquireGatewayLock>>;
|
||||
try {
|
||||
lock = await acquireGatewayLock({
|
||||
allowInTests: true,
|
||||
env,
|
||||
pollIntervalMs: MIGRATION_LOCK_POLL_INTERVAL_MS,
|
||||
role: "sqlite-maintenance",
|
||||
timeoutMs: MIGRATION_LOCK_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof GatewayLockError
|
||||
? "the Gateway or another SQLite maintenance command owns this state directory"
|
||||
: formatErrorMessage(error);
|
||||
return {
|
||||
changes: [],
|
||||
warnings: [
|
||||
`Failed migrating legacy workspace state: ${detail}. Stop the Gateway and run \`openclaw doctor --fix\` again.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!lock) {
|
||||
return {
|
||||
changes: [],
|
||||
warnings: ["Failed migrating legacy workspace state: exclusive state ownership unavailable."],
|
||||
};
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const notices: string[] = [];
|
||||
let releaseError: unknown;
|
||||
try {
|
||||
for (const source of params.detected.sources) {
|
||||
const result = await migrateOneSource({
|
||||
source,
|
||||
env,
|
||||
...(params.beforeClaim ? { beforeClaim: params.beforeClaim } : {}),
|
||||
...(params.removeSource ? { removeSource: params.removeSource } : {}),
|
||||
});
|
||||
changes.push(...result.changes);
|
||||
warnings.push(...result.warnings);
|
||||
notices.push(...(result.notices ?? []));
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await lock.release();
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
}
|
||||
}
|
||||
if (releaseError) {
|
||||
warnings.push(`Workspace migration lock release failed: ${formatErrorMessage(releaseError)}`);
|
||||
}
|
||||
return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type LegacyWorkspaceStateSource = {
|
||||
kind: "setup" | "attestation";
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
sourcePath: string;
|
||||
workspaceKey: string;
|
||||
workspaceDir?: string;
|
||||
workspaceAliasPath?: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export type LegacyWorkspaceStateDetection = {
|
||||
sources: LegacyWorkspaceStateSource[];
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
+23
@@ -1224,6 +1224,26 @@ export interface WorkerWorkspaceReconciliations {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceAttestations {
|
||||
attested_at_ms: number;
|
||||
updated_at_ms: number;
|
||||
workspace_key: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceGeneratedBootstrapHashes {
|
||||
filename: string;
|
||||
sha256: string;
|
||||
workspace_key: string;
|
||||
}
|
||||
|
||||
export interface WorkspacePathAliases {
|
||||
alias_key: string;
|
||||
alias_path: string;
|
||||
updated_at_ms: number;
|
||||
workspace_key: string;
|
||||
workspace_path: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceSetupState {
|
||||
bootstrap_seeded_at: string | null;
|
||||
setup_completed_at: string | null;
|
||||
@@ -1343,6 +1363,9 @@ export interface DB {
|
||||
worker_transcript_commits: WorkerTranscriptCommits;
|
||||
worker_workspace_pending_results: WorkerWorkspacePendingResults;
|
||||
worker_workspace_reconciliations: WorkerWorkspaceReconciliations;
|
||||
workspace_attestations: WorkspaceAttestations;
|
||||
workspace_generated_bootstrap_hashes: WorkspaceGeneratedBootstrapHashes;
|
||||
workspace_path_aliases: WorkspacePathAliases;
|
||||
workspace_setup_state: WorkspaceSetupState;
|
||||
worktree_provisioned_file_chunks: WorktreeProvisionedFileChunks;
|
||||
worktrees: Worktrees;
|
||||
|
||||
@@ -495,6 +495,34 @@ CREATE TABLE IF NOT EXISTS workspace_setup_state (
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_setup_state_path
|
||||
ON workspace_setup_state(workspace_path);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_path_aliases (
|
||||
alias_key TEXT NOT NULL PRIMARY KEY,
|
||||
alias_path TEXT NOT NULL,
|
||||
workspace_key TEXT NOT NULL,
|
||||
workspace_path TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_path_aliases_workspace
|
||||
ON workspace_path_aliases(workspace_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_attestations (
|
||||
workspace_key TEXT NOT NULL PRIMARY KEY,
|
||||
attested_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_attestations_attested
|
||||
ON workspace_attestations(attested_at_ms DESC, workspace_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_generated_bootstrap_hashes (
|
||||
workspace_key TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_key, filename),
|
||||
FOREIGN KEY (workspace_key) REFERENCES workspace_attestations(workspace_key) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS native_hook_relay_bridges (
|
||||
relay_id TEXT NOT NULL PRIMARY KEY,
|
||||
pid INTEGER NOT NULL,
|
||||
|
||||
@@ -490,6 +490,34 @@ CREATE TABLE IF NOT EXISTS workspace_setup_state (
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_setup_state_path
|
||||
ON workspace_setup_state(workspace_path);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_path_aliases (
|
||||
alias_key TEXT NOT NULL PRIMARY KEY,
|
||||
alias_path TEXT NOT NULL,
|
||||
workspace_key TEXT NOT NULL,
|
||||
workspace_path TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_path_aliases_workspace
|
||||
ON workspace_path_aliases(workspace_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_attestations (
|
||||
workspace_key TEXT NOT NULL PRIMARY KEY,
|
||||
attested_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_attestations_attested
|
||||
ON workspace_attestations(attested_at_ms DESC, workspace_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_generated_bootstrap_hashes (
|
||||
workspace_key TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_key, filename),
|
||||
FOREIGN KEY (workspace_key) REFERENCES workspace_attestations(workspace_key) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS native_hook_relay_bridges (
|
||||
relay_id TEXT NOT NULL PRIMARY KEY,
|
||||
pid INTEGER NOT NULL,
|
||||
|
||||
@@ -257,6 +257,27 @@ describe("check-database-first-legacy-stores", () => {
|
||||
expect(violations).toEqual([{ kind: "legacy store filesystem write", line: 4 }]);
|
||||
});
|
||||
|
||||
it("flags runtime writes to retired workspace setup and attestation sidecars", () => {
|
||||
const violations = collectDatabaseFirstLegacyStoreViolations(
|
||||
`
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
await fs.writeFile(path.join(workspaceDir, "openclaw-workspace-state.json"), "{}\\n");
|
||||
await fs.writeFile(path.join(workspaceDir, ".openclaw", "workspace-state.json"), "{}\\n");
|
||||
await fs.writeFile(path.join(stateDir, "workspace-attestations", \`\${workspaceKey}.attested\`), "ok\\n");
|
||||
await fs.writeFile(\`\${workspaceDir}.attested\`, "ok\\n");
|
||||
`,
|
||||
"src/agents/workspace-sidecar-store.ts",
|
||||
);
|
||||
|
||||
expect(violations).toEqual([
|
||||
{ kind: "legacy store filesystem write", line: 4 },
|
||||
{ kind: "legacy store filesystem write", line: 5 },
|
||||
{ kind: "legacy store filesystem write", line: 6 },
|
||||
{ kind: "legacy store filesystem write", line: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags runtime writes to the retired subagent JSON registry", () => {
|
||||
const violations = collectDatabaseFirstLegacyStoreViolations(
|
||||
`
|
||||
@@ -8569,6 +8590,19 @@ describe("check-database-first-legacy-stores", () => {
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows the workspace Doctor migration owner to claim legacy sidecars", () => {
|
||||
const violations = collectDatabaseFirstLegacyStoreViolations(
|
||||
`
|
||||
import { promises as fs } from "node:fs";
|
||||
await fs.rename("openclaw-workspace-state.json", "openclaw-workspace-state.json.doctor-importing");
|
||||
await fs.rename("workspace.attested", "workspace.attested.doctor-importing");
|
||||
`,
|
||||
"src/infra/state-migrations.workspace-setup.ts",
|
||||
);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows plugin doctor migration owners to archive legacy files", () => {
|
||||
const violations = collectDatabaseFirstLegacyStoreViolations(
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user