|
|
|
|
@@ -4,9 +4,9 @@
|
|
|
|
|
* 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 { access, readFile, writeFile, readdir, unlink } from 'fs/promises';
|
|
|
|
|
import { constants } from 'fs';
|
|
|
|
|
import { join } from 'path';
|
|
|
|
|
import { join, resolve, sep } from 'path';
|
|
|
|
|
import { homedir } from 'os';
|
|
|
|
|
import { logger } from './logger';
|
|
|
|
|
import { getResourcesDir } from './paths';
|
|
|
|
|
@@ -20,10 +20,10 @@ 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 });
|
|
|
|
|
}
|
|
|
|
|
function isCurrentOpenClawPath(p: string): boolean {
|
|
|
|
|
const openclawDir = resolve(join(homedir(), '.openclaw'));
|
|
|
|
|
const workspaceDir = resolve(p);
|
|
|
|
|
return workspaceDir === openclawDir || workspaceDir.startsWith(openclawDir + sep);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Pure helpers (no I/O) ────────────────────────────────────────
|
|
|
|
|
@@ -105,14 +105,29 @@ export function stripFirstRunSection(content: string): string {
|
|
|
|
|
|
|
|
|
|
// ── Workspace directory resolution ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
type WorkspaceDir = {
|
|
|
|
|
dir: string;
|
|
|
|
|
/**
|
|
|
|
|
* Only the default workspace is expected to be seeded during Gateway startup.
|
|
|
|
|
* Other agent workspaces may remain empty until that agent is actually used,
|
|
|
|
|
* so missing bootstrap files there should not keep a startup retry loop alive.
|
|
|
|
|
*/
|
|
|
|
|
waitForGatewaySeed: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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/.
|
|
|
|
|
* Collect all unique workspace directories from the openclaw config.
|
|
|
|
|
*/
|
|
|
|
|
async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
|
|
|
|
async function resolveAllWorkspaceDirs(): Promise<WorkspaceDir[]> {
|
|
|
|
|
const openclawDir = join(homedir(), '.openclaw');
|
|
|
|
|
const dirs = new Set<string>();
|
|
|
|
|
const dirs = new Map<string, WorkspaceDir>();
|
|
|
|
|
const addDir = (dir: string, waitForGatewaySeed: boolean) => {
|
|
|
|
|
const existing = dirs.get(dir);
|
|
|
|
|
dirs.set(dir, {
|
|
|
|
|
dir,
|
|
|
|
|
waitForGatewaySeed: waitForGatewaySeed || existing?.waitForGatewaySeed === true,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const configPath = join(openclawDir, 'openclaw.json');
|
|
|
|
|
try {
|
|
|
|
|
@@ -120,8 +135,10 @@ async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
|
|
|
|
const config = JSON.parse(await readFile(configPath, 'utf-8'));
|
|
|
|
|
|
|
|
|
|
const defaultWs = config?.agents?.defaults?.workspace;
|
|
|
|
|
let hasDefaultWorkspace = false;
|
|
|
|
|
if (typeof defaultWs === 'string' && defaultWs.trim()) {
|
|
|
|
|
dirs.add(defaultWs.replace(/^~/, homedir()));
|
|
|
|
|
addDir(defaultWs.replace(/^~/, homedir()), true);
|
|
|
|
|
hasDefaultWorkspace = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const agents = config?.agents?.list;
|
|
|
|
|
@@ -129,7 +146,9 @@ async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
|
|
|
|
for (const agent of agents) {
|
|
|
|
|
const ws = agent?.workspace;
|
|
|
|
|
if (typeof ws === 'string' && ws.trim()) {
|
|
|
|
|
dirs.add(ws.replace(/^~/, homedir()));
|
|
|
|
|
const isMainDefault =
|
|
|
|
|
agent?.default === true || (agent?.id === 'main' && !hasDefaultWorkspace);
|
|
|
|
|
addDir(ws.replace(/^~/, homedir()), isMainDefault);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -145,10 +164,10 @@ async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
|
|
|
|
// explicitly declared in openclaw.json should be seeded.
|
|
|
|
|
|
|
|
|
|
if (dirs.size === 0) {
|
|
|
|
|
dirs.add(join(openclawDir, 'workspace'));
|
|
|
|
|
addDir(join(openclawDir, 'workspace'), true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...dirs];
|
|
|
|
|
return [...dirs.values()];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Bootstrap file repair ────────────────────────────────────────
|
|
|
|
|
@@ -159,7 +178,7 @@ async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
|
|
|
|
*/
|
|
|
|
|
export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
|
|
|
|
|
const workspaceDirs = await resolveAllWorkspaceDirs();
|
|
|
|
|
for (const workspaceDir of workspaceDirs) {
|
|
|
|
|
for (const { dir: workspaceDir } of workspaceDirs) {
|
|
|
|
|
if (!(await fileExists(workspaceDir))) continue;
|
|
|
|
|
|
|
|
|
|
let entries: string[];
|
|
|
|
|
@@ -202,7 +221,7 @@ export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
|
|
|
|
|
*/
|
|
|
|
|
export async function removeChatFirstBootstrapFiles(): Promise<void> {
|
|
|
|
|
const workspaceDirs = await resolveAllWorkspaceDirs();
|
|
|
|
|
for (const workspaceDir of workspaceDirs) {
|
|
|
|
|
for (const { dir: workspaceDir } of workspaceDirs) {
|
|
|
|
|
const bootstrapPath = join(workspaceDir, 'BOOTSTRAP.md');
|
|
|
|
|
if (!(await fileExists(bootstrapPath))) continue;
|
|
|
|
|
|
|
|
|
|
@@ -218,37 +237,64 @@ export async function removeChatFirstBootstrapFiles(): Promise<void> {
|
|
|
|
|
// ── 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.
|
|
|
|
|
* Merge ClawX context snippets into workspace bootstrap files that already
|
|
|
|
|
* exist on disk. Missing files are only retryable for startup-owned workspaces.
|
|
|
|
|
*/
|
|
|
|
|
async function mergeClawXContextOnce(): Promise<number> {
|
|
|
|
|
type MergeResult = {
|
|
|
|
|
missing: number;
|
|
|
|
|
retryableMissing: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type EnsureClawXContextOptions = {
|
|
|
|
|
/**
|
|
|
|
|
* Startup should only wait for the default workspace. Explicit provisioning
|
|
|
|
|
* flows can opt in so a freshly-created agent workspace gets patched after
|
|
|
|
|
* the Gateway seeds it.
|
|
|
|
|
*/
|
|
|
|
|
waitForAllConfiguredWorkspaces?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function mergeClawXContextOnce(options: EnsureClawXContextOptions = {}): Promise<MergeResult> {
|
|
|
|
|
const contextDir = join(getResourcesDir(), 'context');
|
|
|
|
|
if (!(await fileExists(contextDir))) {
|
|
|
|
|
logger.debug('ClawX context directory not found, skipping context merge');
|
|
|
|
|
return 0;
|
|
|
|
|
return { missing: 0, retryableMissing: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let files: string[];
|
|
|
|
|
try {
|
|
|
|
|
files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md'));
|
|
|
|
|
} catch {
|
|
|
|
|
return 0;
|
|
|
|
|
return { missing: 0, retryableMissing: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workspaceDirs = await resolveAllWorkspaceDirs();
|
|
|
|
|
let skipped = 0;
|
|
|
|
|
let missing = 0;
|
|
|
|
|
let retryableMissing = 0;
|
|
|
|
|
|
|
|
|
|
for (const workspaceDir of workspaceDirs) {
|
|
|
|
|
await ensureDir(workspaceDir);
|
|
|
|
|
for (const { dir: workspaceDir, waitForGatewaySeed } of workspaceDirs) {
|
|
|
|
|
const workspaceExists = await fileExists(workspaceDir);
|
|
|
|
|
const shouldWaitForSeed =
|
|
|
|
|
(waitForGatewaySeed || options.waitForAllConfiguredWorkspaces === true)
|
|
|
|
|
&& (workspaceExists || isCurrentOpenClawPath(workspaceDir));
|
|
|
|
|
|
|
|
|
|
if (!workspaceExists) {
|
|
|
|
|
if (shouldWaitForSeed) {
|
|
|
|
|
retryableMissing += files.length;
|
|
|
|
|
}
|
|
|
|
|
missing += files.length;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
const targetName = file.replace('.clawx.md', '.md');
|
|
|
|
|
const targetPath = join(workspaceDir, targetName);
|
|
|
|
|
|
|
|
|
|
if (!(await fileExists(targetPath))) {
|
|
|
|
|
logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`);
|
|
|
|
|
skipped++;
|
|
|
|
|
missing++;
|
|
|
|
|
if (shouldWaitForSeed) {
|
|
|
|
|
retryableMissing++;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -275,34 +321,55 @@ async function mergeClawXContextOnce(): Promise<number> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return skipped;
|
|
|
|
|
return { missing, retryableMissing };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RETRY_INTERVAL_MS = 2000;
|
|
|
|
|
const MAX_RETRIES = 15;
|
|
|
|
|
const MAX_RETRIES = 5;
|
|
|
|
|
let ensureClawXContextPromise: Promise<void> | null = null;
|
|
|
|
|
let ensureClawXContextWaitsForAll = false;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure ClawX context snippets are merged into the openclaw workspace
|
|
|
|
|
* bootstrap files.
|
|
|
|
|
*/
|
|
|
|
|
export async function ensureClawXContext(): Promise<void> {
|
|
|
|
|
let skipped = await mergeClawXContextOnce();
|
|
|
|
|
if (skipped === 0) {
|
|
|
|
|
export async function ensureClawXContext(options: EnsureClawXContextOptions = {}): Promise<void> {
|
|
|
|
|
if (ensureClawXContextPromise) {
|
|
|
|
|
if (options.waitForAllConfiguredWorkspaces && !ensureClawXContextWaitsForAll) {
|
|
|
|
|
return ensureClawXContextPromise.then(() => ensureClawXContext(options));
|
|
|
|
|
}
|
|
|
|
|
return ensureClawXContextPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensureClawXContextWaitsForAll = options.waitForAllConfiguredWorkspaces === true;
|
|
|
|
|
ensureClawXContextPromise = runEnsureClawXContext(options).finally(() => {
|
|
|
|
|
ensureClawXContextPromise = null;
|
|
|
|
|
ensureClawXContextWaitsForAll = false;
|
|
|
|
|
});
|
|
|
|
|
return ensureClawXContextPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runEnsureClawXContext(options: EnsureClawXContextOptions): Promise<void> {
|
|
|
|
|
let result = await mergeClawXContextOnce(options);
|
|
|
|
|
if (result.retryableMissing === 0) {
|
|
|
|
|
await removeChatFirstBootstrapFiles();
|
|
|
|
|
if (result.missing > 0) {
|
|
|
|
|
logger.debug(`ClawX context merge skipped ${result.missing} non-ready file(s)`);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
|
|
|
|
|
skipped = await mergeClawXContextOnce();
|
|
|
|
|
if (skipped === 0) {
|
|
|
|
|
result = await mergeClawXContextOnce(options);
|
|
|
|
|
if (result.retryableMissing === 0) {
|
|
|
|
|
await removeChatFirstBootstrapFiles();
|
|
|
|
|
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
logger.debug(`ClawX context merge: ${skipped} file(s) still missing (retry ${attempt}/${MAX_RETRIES})`);
|
|
|
|
|
logger.debug(`ClawX context merge: ${result.retryableMissing} startup file(s) still missing (retry ${attempt}/${MAX_RETRIES})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.warn(`ClawX context merge: ${skipped} file(s) still missing after ${MAX_RETRIES} retries`);
|
|
|
|
|
logger.warn(`ClawX context merge: ${result.retryableMissing} startup file(s) still missing after ${MAX_RETRIES} retries`);
|
|
|
|
|
await removeChatFirstBootstrapFiles();
|
|
|
|
|
}
|
|
|
|
|
|