Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync } from 'fs';
|
||||
/**
|
||||
* OpenClaw workspace context utilities.
|
||||
*
|
||||
* All file I/O is async (fs/promises) to avoid blocking the Electron
|
||||
* main thread.
|
||||
*/
|
||||
import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises';
|
||||
import { constants, Dirent } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { logger } from './logger';
|
||||
@@ -7,6 +14,20 @@ import { getResourcesDir } from './paths';
|
||||
const CLAWX_BEGIN = '<!-- clawx:begin -->';
|
||||
const CLAWX_END = '<!-- clawx:end -->';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (no I/O) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge a ClawX context section into an existing file's content.
|
||||
* If markers already exist, replaces the section in-place.
|
||||
@@ -22,62 +43,21 @@ export function mergeClawXSection(existing: string, section: string): string {
|
||||
return existing.trimEnd() + '\n\n' + wrapped + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and remove bootstrap .md files that contain only ClawX markers
|
||||
* with no meaningful OpenClaw content outside them. This repairs a race
|
||||
* condition where ensureClawXContext() created the file before the gateway
|
||||
* could seed the full template. Deleting the hollow file lets the gateway
|
||||
* re-seed the complete template on next start.
|
||||
*/
|
||||
export function repairClawXOnlyBootstrapFiles(): void {
|
||||
const workspaceDirs = resolveAllWorkspaceDirs();
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
if (!existsSync(workspaceDir)) continue;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(workspaceDir).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of entries) {
|
||||
const filePath = join(workspaceDir, file);
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const beginIdx = content.indexOf(CLAWX_BEGIN);
|
||||
const endIdx = content.indexOf(CLAWX_END);
|
||||
if (beginIdx === -1 || endIdx === -1) continue;
|
||||
|
||||
const before = content.slice(0, beginIdx).trim();
|
||||
const after = content.slice(endIdx + CLAWX_END.length).trim();
|
||||
if (before === '' && after === '') {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
|
||||
} catch {
|
||||
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Workspace directory resolution ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect all unique workspace directories from the openclaw config:
|
||||
* the defaults workspace, each agent's workspace, and any workspace-*
|
||||
* directories that already exist under ~/.openclaw/.
|
||||
*/
|
||||
function resolveAllWorkspaceDirs(): string[] {
|
||||
async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
||||
const openclawDir = join(homedir(), '.openclaw');
|
||||
const dirs = new Set<string>();
|
||||
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
if (await fileExists(configPath)) {
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8'));
|
||||
|
||||
const defaultWs = config?.agents?.defaults?.workspace;
|
||||
if (typeof defaultWs === 'string' && defaultWs.trim()) {
|
||||
@@ -99,7 +79,8 @@ function resolveAllWorkspaceDirs(): string[] {
|
||||
}
|
||||
|
||||
try {
|
||||
for (const entry of readdirSync(openclawDir, { withFileTypes: true })) {
|
||||
const entries: Dirent[] = await readdir(openclawDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('workspace')) {
|
||||
dirs.add(join(openclawDir, entry.name));
|
||||
}
|
||||
@@ -115,49 +96,93 @@ function resolveAllWorkspaceDirs(): string[] {
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
// ── Bootstrap file repair ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synchronously merge ClawX context snippets into workspace bootstrap
|
||||
* files that already exist on disk. Returns the number of target files
|
||||
* that were skipped because they don't exist yet.
|
||||
* Detect and remove bootstrap .md files that contain only ClawX markers
|
||||
* with no meaningful OpenClaw content outside them.
|
||||
*/
|
||||
function mergeClawXContextOnce(): number {
|
||||
export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
|
||||
const workspaceDirs = await resolveAllWorkspaceDirs();
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
if (!(await fileExists(workspaceDir))) continue;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = (await readdir(workspaceDir)).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of entries) {
|
||||
const filePath = join(workspaceDir, file);
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const beginIdx = content.indexOf(CLAWX_BEGIN);
|
||||
const endIdx = content.indexOf(CLAWX_END);
|
||||
if (beginIdx === -1 || endIdx === -1) continue;
|
||||
|
||||
const before = content.slice(0, beginIdx).trim();
|
||||
const after = content.slice(endIdx + CLAWX_END.length).trim();
|
||||
if (before === '' && after === '') {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
|
||||
} catch {
|
||||
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context merging ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge ClawX context snippets into workspace bootstrap files that
|
||||
* already exist on disk. Returns the number of target files that were
|
||||
* skipped because they don't exist yet.
|
||||
*/
|
||||
async function mergeClawXContextOnce(): Promise<number> {
|
||||
const contextDir = join(getResourcesDir(), 'context');
|
||||
if (!existsSync(contextDir)) {
|
||||
if (!(await fileExists(contextDir))) {
|
||||
logger.debug('ClawX context directory not found, skipping context merge');
|
||||
return 0;
|
||||
}
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(contextDir).filter((f) => f.endsWith('.clawx.md'));
|
||||
files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md'));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const workspaceDirs = resolveAllWorkspaceDirs();
|
||||
const workspaceDirs = await resolveAllWorkspaceDirs();
|
||||
let skipped = 0;
|
||||
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
if (!existsSync(workspaceDir)) {
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
}
|
||||
await ensureDir(workspaceDir);
|
||||
|
||||
for (const file of files) {
|
||||
const targetName = file.replace('.clawx.md', '.md');
|
||||
const targetPath = join(workspaceDir, targetName);
|
||||
|
||||
if (!existsSync(targetPath)) {
|
||||
if (!(await fileExists(targetPath))) {
|
||||
logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = readFileSync(join(contextDir, file), 'utf-8');
|
||||
const existing = readFileSync(targetPath, 'utf-8');
|
||||
const section = await readFile(join(contextDir, file), 'utf-8');
|
||||
const existing = await readFile(targetPath, 'utf-8');
|
||||
|
||||
const merged = mergeClawXSection(existing, section);
|
||||
if (merged !== existing) {
|
||||
writeFileSync(targetPath, merged, 'utf-8');
|
||||
await writeFile(targetPath, merged, 'utf-8');
|
||||
logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`);
|
||||
}
|
||||
}
|
||||
@@ -171,22 +196,15 @@ const MAX_RETRIES = 15;
|
||||
|
||||
/**
|
||||
* Ensure ClawX context snippets are merged into the openclaw workspace
|
||||
* bootstrap files. Reads `*.clawx.md` templates from resources/context/
|
||||
* and injects them as marker-delimited sections into the corresponding
|
||||
* workspace `.md` files (e.g. AGENTS.clawx.md -> AGENTS.md).
|
||||
*
|
||||
* The gateway seeds workspace files asynchronously after its HTTP server
|
||||
* starts, so the target files may not exist yet when this is first called.
|
||||
* When files are missing, retries with a delay until all targets are merged
|
||||
* or the retry budget is exhausted.
|
||||
* bootstrap files.
|
||||
*/
|
||||
export async function ensureClawXContext(): Promise<void> {
|
||||
let skipped = mergeClawXContextOnce();
|
||||
let skipped = await mergeClawXContextOnce();
|
||||
if (skipped === 0) return;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
|
||||
skipped = mergeClawXContextOnce();
|
||||
skipped = await mergeClawXContextOnce();
|
||||
if (skipped === 0) {
|
||||
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user