mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 22:01:24 +00:00
fix(workspace): persist bootstrap onboarding state
This commit is contained in:
@@ -29,6 +29,9 @@ export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
|
||||
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
|
||||
export const DEFAULT_MEMORY_FILENAME = "MEMORY.md";
|
||||
export const DEFAULT_MEMORY_ALT_FILENAME = "memory.md";
|
||||
const WORKSPACE_STATE_DIRNAME = ".openclaw";
|
||||
const WORKSPACE_STATE_FILENAME = "workspace-state.json";
|
||||
const WORKSPACE_STATE_VERSION = 1;
|
||||
|
||||
const workspaceTemplateCache = new Map<string, Promise<string>>();
|
||||
let gitAvailabilityPromise: Promise<boolean> | null = null;
|
||||
@@ -93,6 +96,12 @@ export type WorkspaceBootstrapFile = {
|
||||
missing: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceOnboardingState = {
|
||||
version: typeof WORKSPACE_STATE_VERSION;
|
||||
bootstrapSeededAt?: string;
|
||||
onboardingCompletedAt?: string;
|
||||
};
|
||||
|
||||
/** Set of recognized bootstrap filenames for runtime validation */
|
||||
const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -122,6 +131,75 @@ async function writeFileIfMissing(filePath: string, content: string): Promise<bo
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceStatePath(dir: string): string {
|
||||
return path.join(dir, WORKSPACE_STATE_DIRNAME, WORKSPACE_STATE_FILENAME);
|
||||
}
|
||||
|
||||
function parseWorkspaceOnboardingState(raw: string): WorkspaceOnboardingState | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
bootstrapSeededAt?: unknown;
|
||||
onboardingCompletedAt?: unknown;
|
||||
};
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: WORKSPACE_STATE_VERSION,
|
||||
bootstrapSeededAt:
|
||||
typeof parsed.bootstrapSeededAt === "string" ? parsed.bootstrapSeededAt : undefined,
|
||||
onboardingCompletedAt:
|
||||
typeof parsed.onboardingCompletedAt === "string" ? parsed.onboardingCompletedAt : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readWorkspaceOnboardingState(statePath: string): Promise<WorkspaceOnboardingState> {
|
||||
try {
|
||||
const raw = await fs.readFile(statePath, "utf-8");
|
||||
return (
|
||||
parseWorkspaceOnboardingState(raw) ?? {
|
||||
version: WORKSPACE_STATE_VERSION,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
const anyErr = err as { code?: string };
|
||||
if (anyErr.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
version: WORKSPACE_STATE_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWorkspaceOnboardingState(
|
||||
statePath: string,
|
||||
state: WorkspaceOnboardingState,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
||||
const payload = `${JSON.stringify(state, null, 2)}\n`;
|
||||
const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, payload, { encoding: "utf-8" });
|
||||
await fs.rename(tmpPath, statePath);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasGitRepo(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path.join(dir, ".git"));
|
||||
@@ -193,6 +271,7 @@ export async function ensureAgentWorkspace(params?: {
|
||||
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 paths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
|
||||
@@ -215,17 +294,57 @@ 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);
|
||||
const wroteAgents = await writeFileIfMissing(agentsPath, agentsTemplate);
|
||||
const wroteSoul = await writeFileIfMissing(soulPath, soulTemplate);
|
||||
const wroteTools = await writeFileIfMissing(toolsPath, toolsTemplate);
|
||||
const wroteIdentity = await writeFileIfMissing(identityPath, identityTemplate);
|
||||
const wroteUser = await writeFileIfMissing(userPath, userTemplate);
|
||||
const wroteHeartbeat = await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
|
||||
const wroteAnyCoreBootstrapFile =
|
||||
wroteAgents || wroteSoul || wroteTools || wroteIdentity || wroteUser || wroteHeartbeat;
|
||||
if (isBrandNewWorkspace || wroteAnyCoreBootstrapFile) {
|
||||
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
|
||||
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
|
||||
await writeFileIfMissing(agentsPath, agentsTemplate);
|
||||
await writeFileIfMissing(soulPath, soulTemplate);
|
||||
await writeFileIfMissing(toolsPath, toolsTemplate);
|
||||
await writeFileIfMissing(identityPath, identityTemplate);
|
||||
await writeFileIfMissing(userPath, userTemplate);
|
||||
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
|
||||
|
||||
let state = await readWorkspaceOnboardingState(statePath);
|
||||
let stateDirty = false;
|
||||
const markState = (next: Partial<WorkspaceOnboardingState>) => {
|
||||
state = { ...state, ...next };
|
||||
stateDirty = true;
|
||||
};
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
let bootstrapExists = await fileExists(bootstrapPath);
|
||||
if (!state.bootstrapSeededAt && bootstrapExists) {
|
||||
markState({ bootstrapSeededAt: nowIso() });
|
||||
}
|
||||
|
||||
if (!state.onboardingCompletedAt && state.bootstrapSeededAt && !bootstrapExists) {
|
||||
markState({ onboardingCompletedAt: nowIso() });
|
||||
}
|
||||
|
||||
if (!state.bootstrapSeededAt && !state.onboardingCompletedAt && !bootstrapExists) {
|
||||
// Legacy migration path: if USER/IDENTITY diverged from templates, treat onboarding as complete
|
||||
// and avoid recreating BOOTSTRAP for already-onboarded workspaces.
|
||||
const [identityContent, userContent] = await Promise.all([
|
||||
fs.readFile(identityPath, "utf-8"),
|
||||
fs.readFile(userPath, "utf-8"),
|
||||
]);
|
||||
const legacyOnboardingCompleted =
|
||||
identityContent !== identityTemplate || userContent !== userTemplate;
|
||||
if (legacyOnboardingCompleted) {
|
||||
markState({ onboardingCompletedAt: nowIso() });
|
||||
} else {
|
||||
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
|
||||
const wroteBootstrap = await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
|
||||
if (!wroteBootstrap) {
|
||||
bootstrapExists = await fileExists(bootstrapPath);
|
||||
} else {
|
||||
bootstrapExists = true;
|
||||
}
|
||||
if (bootstrapExists && !state.bootstrapSeededAt) {
|
||||
markState({ bootstrapSeededAt: nowIso() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stateDirty) {
|
||||
await writeWorkspaceOnboardingState(statePath, state);
|
||||
}
|
||||
await ensureGitRepo(dir, isBrandNewWorkspace);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user