Skip to content

Commit db9d9d1

Browse files
committed
fix(workspace): preserve BOOTSTRAP.md in preseeded workspaces without user content
In preseeded/managed workspaces, profile file differences alone should not prove bootstrap completion when BOOTSTRAP.md still exists and setupCompletedAt is unset. The preseeded gate checks for actual user content (memory/, IDENTITY.md edits, SOUL.md edits) excluding skills, since platform-seeded skills are not user evidence of onboarding completion. - Add includeSkills option to hasWorkspaceUserContentEvidence (default true) - Add bootstrapExists/state params to workspaceHasBootstrapCompletionEvidence - Block stale bootstrap repair when BOOTSTRAP.md exists, no setupCompletedAt, and no user content evidence (excluding skills) - Add memory/ dir to 5 existing tests that need user content for repair - Add workspace-preseeded.test.ts with 3 preseeded-specific tests
1 parent 2232350 commit db9d9d1

3 files changed

Lines changed: 125 additions & 3 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
5+
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
6+
import {
7+
createOpenClawTestState,
8+
type OpenClawTestState,
9+
} from "../test-utils/openclaw-test-state.js";
10+
import { mergeWorkspaceSetupState, readWorkspaceStateSnapshot } from "./workspace-state-store.js";
11+
import {
12+
DEFAULT_BOOTSTRAP_FILENAME,
13+
DEFAULT_IDENTITY_FILENAME,
14+
ensureAgentWorkspace,
15+
isWorkspaceBootstrapPending,
16+
resolveWorkspaceBootstrapStatus,
17+
} from "./workspace.js";
18+
19+
let testState: OpenClawTestState | undefined;
20+
21+
beforeEach(async () => {
22+
testState = await createOpenClawTestState({
23+
layout: "state-only",
24+
prefix: "openclaw-preseeded-",
25+
});
26+
});
27+
28+
afterEach(async () => {
29+
closeOpenClawStateDatabaseForTest();
30+
await testState?.cleanup();
31+
testState = undefined;
32+
});
33+
34+
describe("preseeded workspace bootstrap", () => {
35+
it("preserves BOOTSTRAP.md in a preseeded workspace with a custom profile", async () => {
36+
const tempDir = await makeTempWorkspace("openclaw-preseeded-");
37+
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
38+
await writeWorkspaceFile({
39+
dir: tempDir,
40+
name: DEFAULT_IDENTITY_FILENAME,
41+
content: "# IDENTITY.md\n\n- **Name:** Preseeded\n",
42+
});
43+
mergeWorkspaceSetupState(tempDir, { bootstrapSeededAt: new Date().toISOString() }, 1_000);
44+
45+
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
46+
await expect(
47+
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
48+
).resolves.toBeUndefined();
49+
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
50+
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(true);
51+
});
52+
53+
it("keeps BOOTSTRAP.md pending across restart before first bootstrap run", async () => {
54+
const tempDir = await makeTempWorkspace("openclaw-preseeded-");
55+
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
56+
await writeWorkspaceFile({
57+
dir: tempDir,
58+
name: DEFAULT_IDENTITY_FILENAME,
59+
content: "# IDENTITY.md\n\n- **Name:** Restart\n",
60+
});
61+
mergeWorkspaceSetupState(tempDir, { bootstrapSeededAt: new Date().toISOString() }, 1_000);
62+
63+
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
64+
await expect(
65+
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
66+
).resolves.toBeUndefined();
67+
expect(readWorkspaceStateSnapshot(tempDir).setup.setupCompletedAt).toBeUndefined();
68+
69+
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
70+
await expect(
71+
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
72+
).resolves.toBeUndefined();
73+
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
74+
});
75+
76+
it("does not treat preseeded workspace skills as bootstrap completion evidence", async () => {
77+
const tempDir = await makeTempWorkspace("openclaw-preseeded-");
78+
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
79+
await writeWorkspaceFile({
80+
dir: tempDir,
81+
name: DEFAULT_IDENTITY_FILENAME,
82+
content: "# IDENTITY.md\n\n- **Name:** Skills\n",
83+
});
84+
await fs.mkdir(path.join(tempDir, "skills"), { recursive: true });
85+
await fs.writeFile(path.join(tempDir, "skills", "example.md"), "# Example Skill\n");
86+
mergeWorkspaceSetupState(tempDir, { bootstrapSeededAt: new Date().toISOString() }, 1_000);
87+
88+
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
89+
await expect(
90+
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
91+
).resolves.toBeUndefined();
92+
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
93+
expect(readWorkspaceStateSnapshot(tempDir).setup.setupCompletedAt).toBeUndefined();
94+
});
95+
});

src/agents/workspace.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,7 @@ describe("ensureAgentWorkspace", () => {
693693
name: DEFAULT_IDENTITY_FILENAME,
694694
content: "# IDENTITY.md\n\n- **Name:** Example\n",
695695
});
696+
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
696697

697698
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
698699
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
@@ -711,6 +712,7 @@ describe("ensureAgentWorkspace", () => {
711712
name: DEFAULT_IDENTITY_FILENAME,
712713
content: "# IDENTITY.md\n\n- **Name:** Example\n",
713714
});
715+
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
714716

715717
const identityPath = path.join(tempDir, DEFAULT_IDENTITY_FILENAME);
716718
const originalReadFile = fs.readFile.bind(fs);
@@ -740,6 +742,7 @@ describe("ensureAgentWorkspace", () => {
740742
it("propagates a transient profile read after the retry budget is exhausted", async () => {
741743
const tempDir = await makeTempWorkspace("openclaw-workspace-");
742744
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
745+
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
743746
const identityPath = path.join(tempDir, DEFAULT_IDENTITY_FILENAME);
744747
const originalReadFile = fs.readFile.bind(fs);
745748
const readSpy = vi.spyOn(fs, "readFile").mockImplementation((async (filePath, options) => {
@@ -769,6 +772,7 @@ describe("ensureAgentWorkspace", () => {
769772
name: DEFAULT_IDENTITY_FILENAME,
770773
content: "# IDENTITY.md\n\n- **Name:** Example\n",
771774
});
775+
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
772776
const bootstrapPath = path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME);
773777
const rmSpy = vi
774778
.spyOn(fs, "rm")
@@ -796,6 +800,7 @@ describe("ensureAgentWorkspace", () => {
796800
name: DEFAULT_SOUL_FILENAME,
797801
content: "# SOUL.md\n\nUse a concise, practical voice.\n",
798802
});
803+
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
799804

800805
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
801806
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));

src/agents/workspace.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ async function fileContentDiffersFromTemplate(
295295

296296
async function hasWorkspaceUserContentEvidence(
297297
dir: string,
298-
opts?: { includeGit?: boolean },
298+
opts?: { includeGit?: boolean; includeSkills?: boolean },
299299
): Promise<boolean> {
300300
const indicators = [path.join(dir, "memory")];
301301
if (opts?.includeGit) {
@@ -312,7 +312,10 @@ async function hasWorkspaceUserContentEvidence(
312312
if (await exactWorkspaceEntryExists(dir, DEFAULT_MEMORY_FILENAME)) {
313313
return true;
314314
}
315-
return await hasWorkspaceSkillEvidence(dir);
315+
if (opts?.includeSkills ?? true) {
316+
return await hasWorkspaceSkillEvidence(dir);
317+
}
318+
return false;
316319
}
317320

318321
async function hasWorkspaceSkillEvidence(dir: string): Promise<boolean> {
@@ -433,7 +436,24 @@ async function workspaceAttestedGeneratedFilesIntact(
433436
return true;
434437
}
435438

436-
async function workspaceHasBootstrapCompletionEvidence(params: { dir: string }): Promise<boolean> {
439+
async function workspaceHasBootstrapCompletionEvidence(params: {
440+
dir: string;
441+
bootstrapExists: boolean;
442+
state?: { setupCompletedAt?: string };
443+
}): Promise<boolean> {
444+
// In preseeded/managed workspaces, profile file differences alone should not
445+
// prove bootstrap completion when BOOTSTRAP.md still exists and setupCompletedAt
446+
// is unset — including after restart once bootstrapSeededAt is persisted.
447+
// The presence of BOOTSTRAP.md indicates the onboarding flow has not
448+
// completed yet.
449+
// However, if setupCompletedAt is already set, profile differences are valid
450+
// evidence of user customization after onboarding.
451+
const hasSetupState = typeof params.state?.setupCompletedAt === "string";
452+
if (params.bootstrapExists && !hasSetupState) {
453+
if (!(await hasWorkspaceUserContentEvidence(params.dir, { includeSkills: false }))) {
454+
return false;
455+
}
456+
}
437457
return await workspaceProfileLooksConfigured(params);
438458
}
439459

@@ -470,6 +490,8 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
470490
!bootstrapExists ||
471491
!(await workspaceHasBootstrapCompletionEvidence({
472492
dir: params.dir,
493+
bootstrapExists,
494+
state: params.state,
473495
}))
474496
) {
475497
return { repaired: false, bootstrapExists, state: params.state };

0 commit comments

Comments
 (0)