Files
openclaw/src/commands/doctor-state-integrity.ts
2026-03-02 01:48:14 +00:00

826 lines
26 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import {
formatSessionArchiveTimestamp,
isPrimarySessionTranscriptFileName,
loadSessionStore,
resolveMainSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptsDirForAgent,
resolveStorePath,
} from "../config/sessions.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
type DoctorPrompterLike = {
confirmSkipInNonInteractive: (params: {
message: string;
initialValue?: boolean;
}) => Promise<boolean>;
};
function existsDir(dir: string): boolean {
try {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
function existsFile(filePath: string): boolean {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function canWriteDir(dir: string): boolean {
try {
fs.accessSync(dir, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function ensureDir(dir: string): { ok: boolean; error?: string } {
try {
fs.mkdirSync(dir, { recursive: true });
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
}
}
function dirPermissionHint(dir: string): string | null {
const uid = typeof process.getuid === "function" ? process.getuid() : null;
const gid = typeof process.getgid === "function" ? process.getgid() : null;
try {
const stat = fs.statSync(dir);
if (uid !== null && stat.uid !== uid) {
return `Owner mismatch (uid ${stat.uid}). Run: sudo chown -R $USER "${dir}"`;
}
if (gid !== null && stat.gid !== gid) {
return `Group mismatch (gid ${stat.gid}). If access fails, run: sudo chown -R $USER "${dir}"`;
}
} catch {
return null;
}
return null;
}
function addUserRwx(mode: number): number {
const perms = mode & 0o777;
return perms | 0o700;
}
function countJsonlLines(filePath: string): number {
try {
const raw = fs.readFileSync(filePath, "utf-8");
if (!raw) {
return 0;
}
let count = 0;
for (let i = 0; i < raw.length; i += 1) {
if (raw[i] === "\n") {
count += 1;
}
}
if (!raw.endsWith("\n")) {
count += 1;
}
return count;
} catch {
return 0;
}
}
function findOtherStateDirs(stateDir: string): string[] {
const resolvedState = path.resolve(stateDir);
const roots =
process.platform === "darwin" ? ["/Users"] : process.platform === "linux" ? ["/home"] : [];
const found: string[] = [];
for (const root of roots) {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (entry.name.startsWith(".")) {
continue;
}
const candidates = [".openclaw"].map((dir) => path.resolve(root, entry.name, dir));
for (const candidate of candidates) {
if (candidate === resolvedState) {
continue;
}
if (existsDir(candidate)) {
found.push(candidate);
}
}
}
}
return found;
}
function isPathUnderRoot(targetPath: string, rootPath: string): boolean {
const normalizedTarget = path.resolve(targetPath);
const normalizedRoot = path.resolve(rootPath);
const rootToken = path.parse(normalizedRoot).root;
if (normalizedRoot === rootToken) {
return normalizedTarget.startsWith(rootToken);
}
return (
normalizedTarget === normalizedRoot ||
normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)
);
}
function tryResolveRealPath(targetPath: string): string | null {
try {
return fs.realpathSync(targetPath);
} catch {
return null;
}
}
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
function escapeControlCharsForTerminal(value: string): string {
let escaped = "";
for (const char of value) {
if (char === "\u001b") {
escaped += "\\x1b";
continue;
}
if (char === "\r") {
escaped += "\\r";
continue;
}
if (char === "\n") {
escaped += "\\n";
continue;
}
if (char === "\t") {
escaped += "\\t";
continue;
}
const code = char.charCodeAt(0);
if ((code >= 0 && code <= 8) || code === 11 || code === 12 || (code >= 14 && code <= 31)) {
escaped += `\\x${code.toString(16).padStart(2, "0")}`;
continue;
}
if (code === 127) {
escaped += "\\x7f";
continue;
}
escaped += char;
}
return escaped;
}
type LinuxMountInfoEntry = {
mountPoint: string;
fsType: string;
source: string;
};
export type LinuxSdBackedStateDir = {
path: string;
mountPoint: string;
fsType: string;
source: string;
};
function parseLinuxMountInfo(rawMountInfo: string): LinuxMountInfoEntry[] {
const entries: LinuxMountInfoEntry[] = [];
for (const line of rawMountInfo.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const separatorIndex = trimmed.indexOf(" - ");
if (separatorIndex === -1) {
continue;
}
const left = trimmed.slice(0, separatorIndex);
const right = trimmed.slice(separatorIndex + 3);
const leftFields = left.split(" ");
const rightFields = right.split(" ");
if (leftFields.length < 5 || rightFields.length < 2) {
continue;
}
entries.push({
mountPoint: decodeMountInfoPath(leftFields[4]),
fsType: rightFields[0],
source: decodeMountInfoPath(rightFields[1]),
});
}
return entries;
}
function isPathUnderRootWithPathOps(
targetPath: string,
rootPath: string,
pathOps: Pick<typeof path, "resolve" | "sep" | "parse">,
): boolean {
const normalizedTarget = pathOps.resolve(targetPath);
const normalizedRoot = pathOps.resolve(rootPath);
const rootToken = pathOps.parse(normalizedRoot).root;
if (normalizedRoot === rootToken) {
return normalizedTarget.startsWith(rootToken);
}
return (
normalizedTarget === normalizedRoot ||
normalizedTarget.startsWith(`${normalizedRoot}${pathOps.sep}`)
);
}
function findLinuxMountInfoEntryForPath(
targetPath: string,
entries: LinuxMountInfoEntry[],
pathOps: Pick<typeof path, "resolve" | "sep" | "parse">,
): LinuxMountInfoEntry | null {
const normalizedTarget = pathOps.resolve(targetPath);
let bestMatch: LinuxMountInfoEntry | null = null;
for (const entry of entries) {
if (!isPathUnderRootWithPathOps(normalizedTarget, entry.mountPoint, pathOps)) {
continue;
}
if (
!bestMatch ||
pathOps.resolve(entry.mountPoint).length > pathOps.resolve(bestMatch.mountPoint).length
) {
bestMatch = entry;
}
}
return bestMatch;
}
function isMmcDevicePath(devicePath: string, pathOps: Pick<typeof path, "basename">): boolean {
const name = pathOps.basename(devicePath);
return /^mmcblk\d+(?:p\d+)?$/.test(name);
}
function tryReadLinuxMountInfo(): string | null {
try {
return fs.readFileSync("/proc/self/mountinfo", "utf8");
} catch {
return null;
}
}
export function detectLinuxSdBackedStateDir(
stateDir: string,
deps?: {
platform?: NodeJS.Platform;
mountInfo?: string;
resolveRealPath?: (targetPath: string) => string | null;
resolveDeviceRealPath?: (targetPath: string) => string | null;
},
): LinuxSdBackedStateDir | null {
const platform = deps?.platform ?? process.platform;
if (platform !== "linux") {
return null;
}
const linuxPath = path.posix;
const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath;
const resolvedStatePath = resolveRealPath(stateDir) ?? linuxPath.resolve(stateDir);
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
if (!mountInfo) {
return null;
}
const mountEntry = findLinuxMountInfoEntryForPath(
resolvedStatePath,
parseLinuxMountInfo(mountInfo),
linuxPath,
);
if (!mountEntry) {
return null;
}
const sourceCandidates = [mountEntry.source];
if (mountEntry.source.startsWith("/dev/")) {
const resolvedDevicePath = (deps?.resolveDeviceRealPath ?? tryResolveRealPath)(
mountEntry.source,
);
if (resolvedDevicePath) {
sourceCandidates.push(linuxPath.resolve(resolvedDevicePath));
}
}
if (!sourceCandidates.some((candidate) => isMmcDevicePath(candidate, linuxPath))) {
return null;
}
return {
path: linuxPath.resolve(resolvedStatePath),
mountPoint: linuxPath.resolve(mountEntry.mountPoint),
fsType: mountEntry.fsType,
source: mountEntry.source,
};
}
export function formatLinuxSdBackedStateDirWarning(
displayStateDir: string,
linuxSdBackedStateDir: LinuxSdBackedStateDir,
): string {
const displayMountPoint =
linuxSdBackedStateDir.mountPoint === "/"
? "/"
: shortenHomePath(linuxSdBackedStateDir.mountPoint);
const safeSource = escapeControlCharsForTerminal(linuxSdBackedStateDir.source);
const safeFsType = escapeControlCharsForTerminal(linuxSdBackedStateDir.fsType);
const safeMountPoint = escapeControlCharsForTerminal(displayMountPoint);
return [
`- State directory appears to be on SD/eMMC storage (${displayStateDir}; device ${safeSource}, fs ${safeFsType}, mount ${safeMountPoint}).`,
"- SD/eMMC media can be slower for random I/O and wear faster under session/log churn.",
"- For better startup and state durability, prefer SSD/NVMe (or USB SSD on Raspberry Pi) for OPENCLAW_STATE_DIR.",
].join("\n");
}
export function detectMacCloudSyncedStateDir(
stateDir: string,
deps?: {
platform?: NodeJS.Platform;
homedir?: string;
resolveRealPath?: (targetPath: string) => string | null;
},
): {
path: string;
storage: "iCloud Drive" | "CloudStorage provider";
} | null {
const platform = deps?.platform ?? process.platform;
if (platform !== "darwin") {
return null;
}
// Cloud-sync roots should always be anchored to the OS account home on macOS.
// OPENCLAW_HOME can relocate app data defaults, but iCloud/CloudStorage remain under the OS home.
const homedir = deps?.homedir ?? os.homedir();
const roots = [
{
storage: "iCloud Drive" as const,
root: path.join(homedir, "Library", "Mobile Documents", "com~apple~CloudDocs"),
},
{
storage: "CloudStorage provider" as const,
root: path.join(homedir, "Library", "CloudStorage"),
},
];
const realPath = (deps?.resolveRealPath ?? tryResolveRealPath)(stateDir);
// Prefer the resolved target path when available so symlink prefixes do not
// misclassify local state dirs as cloud-synced.
const candidates = realPath ? [path.resolve(realPath)] : [path.resolve(stateDir)];
for (const candidate of candidates) {
for (const { storage, root } of roots) {
if (isPathUnderRoot(candidate, root)) {
return { path: candidate, storage };
}
}
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isPairingPolicy(value: unknown): boolean {
return typeof value === "string" && value.trim().toLowerCase() === "pairing";
}
function hasPairingPolicy(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
if (isPairingPolicy(value.dmPolicy)) {
return true;
}
if (isRecord(value.dm) && isPairingPolicy(value.dm.policy)) {
return true;
}
if (!isRecord(value.accounts)) {
return false;
}
for (const accountCfg of Object.values(value.accounts)) {
if (hasPairingPolicy(accountCfg)) {
return true;
}
}
return false;
}
function isSlashRoutingSessionKey(sessionKey: string): boolean {
const raw = sessionKey.trim().toLowerCase();
if (!raw) {
return false;
}
const scoped = parseAgentSessionKey(raw)?.rest ?? raw;
return /^[^:]+:slash:[^:]+(?:$|:)/.test(scoped);
}
function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (env.OPENCLAW_OAUTH_DIR?.trim()) {
return true;
}
const channels = cfg.channels;
if (!isRecord(channels)) {
return false;
}
// WhatsApp auth always uses the credentials tree.
if (isRecord(channels.whatsapp)) {
return true;
}
// Pairing allowlists are persisted under credentials/<channel>-allowFrom.json.
for (const [channelId, channelCfg] of Object.entries(channels)) {
if (channelId === "defaults" || channelId === "modelByChannel") {
continue;
}
if (hasPairingPolicy(channelCfg)) {
return true;
}
}
return false;
}
export async function noteStateIntegrity(
cfg: OpenClawConfig,
prompter: DoctorPrompterLike,
configPath?: string,
) {
const warnings: string[] = [];
const changes: string[] = [];
const env = process.env;
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
const stateDir = resolveStateDir(env, homedir);
const defaultStateDir = path.join(homedir(), ".openclaw");
const oauthDir = resolveOAuthDir(env, stateDir);
const agentId = resolveDefaultAgentId(cfg);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const storeDir = path.dirname(storePath);
const absoluteStorePath = path.resolve(storePath);
const displayStateDir = shortenHomePath(stateDir);
const displayOauthDir = shortenHomePath(oauthDir);
const displaySessionsDir = shortenHomePath(sessionsDir);
const displayStoreDir = shortenHomePath(storeDir);
const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined;
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
if (cloudSyncedStateDir) {
warnings.push(
[
`- State directory is under macOS cloud-synced storage (${displayStateDir}; ${cloudSyncedStateDir.storage}).`,
"- This can cause slow I/O and sync/lock races for sessions and credentials.",
"- Prefer a local non-synced state dir (for example: ~/.openclaw).",
` Set locally: OPENCLAW_STATE_DIR=~/.openclaw ${formatCliCommand("openclaw doctor")}`,
].join("\n"),
);
}
if (linuxSdBackedStateDir) {
warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir));
}
let stateDirExists = existsDir(stateDir);
if (!stateDirExists) {
warnings.push(
`- CRITICAL: state directory missing (${displayStateDir}). Sessions, credentials, logs, and config are stored there.`,
);
if (cfg.gateway?.mode === "remote") {
warnings.push(
"- Gateway is in remote mode; run doctor on the remote host where the gateway runs.",
);
}
const create = await prompter.confirmSkipInNonInteractive({
message: `Create ${displayStateDir} now?`,
initialValue: false,
});
if (create) {
const created = ensureDir(stateDir);
if (created.ok) {
changes.push(`- Created ${displayStateDir}`);
stateDirExists = true;
} else {
warnings.push(`- Failed to create ${displayStateDir}: ${created.error}`);
}
}
}
if (stateDirExists && !canWriteDir(stateDir)) {
warnings.push(`- State directory not writable (${displayStateDir}).`);
const hint = dirPermissionHint(stateDir);
if (hint) {
warnings.push(` ${hint}`);
}
const repair = await prompter.confirmSkipInNonInteractive({
message: `Repair permissions on ${displayStateDir}?`,
initialValue: true,
});
if (repair) {
try {
const stat = fs.statSync(stateDir);
const target = addUserRwx(stat.mode);
fs.chmodSync(stateDir, target);
changes.push(`- Repaired permissions on ${displayStateDir}`);
} catch (err) {
warnings.push(`- Failed to repair ${displayStateDir}: ${String(err)}`);
}
}
}
if (stateDirExists && process.platform !== "win32") {
try {
const dirLstat = fs.lstatSync(stateDir);
const isDirSymlink = dirLstat.isSymbolicLink();
// For symlinks, check the resolved target permissions instead of the
// symlink itself (which always reports 777). Skip the warning only when
// the target lives in a known immutable store (e.g. /nix/store/).
const stat = isDirSymlink ? fs.statSync(stateDir) : dirLstat;
const resolvedDir = isDirSymlink ? fs.realpathSync(stateDir) : stateDir;
const isImmutableStore = resolvedDir.startsWith("/nix/store/");
if (!isImmutableStore && (stat.mode & 0o077) !== 0) {
warnings.push(
`- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${displayStateDir} to 700?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(stateDir, 0o700);
changes.push(`- Tightened permissions on ${displayStateDir} to 700`);
}
}
} catch (err) {
warnings.push(`- Failed to read ${displayStateDir} permissions: ${String(err)}`);
}
}
if (configPath && existsFile(configPath) && process.platform !== "win32") {
try {
const configLstat = fs.lstatSync(configPath);
const isSymlink = configLstat.isSymbolicLink();
// For symlinks, check the resolved target permissions. Skip the warning
// only when the target lives in an immutable store (e.g. /nix/store/).
const stat = isSymlink ? fs.statSync(configPath) : configLstat;
const resolvedConfig = isSymlink ? fs.realpathSync(configPath) : configPath;
const isImmutableConfig = resolvedConfig.startsWith("/nix/store/");
if (!isImmutableConfig && (stat.mode & 0o077) !== 0) {
warnings.push(
`- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${displayConfigPath ?? configPath} to 600?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(configPath, 0o600);
changes.push(`- Tightened permissions on ${displayConfigPath ?? configPath} to 600`);
}
}
} catch (err) {
warnings.push(
`- Failed to read config permissions (${displayConfigPath ?? configPath}): ${String(err)}`,
);
}
}
if (stateDirExists) {
const dirCandidates = new Map<string, string>();
dirCandidates.set(sessionsDir, "Sessions dir");
dirCandidates.set(storeDir, "Session store dir");
if (requireOAuthDir) {
dirCandidates.set(oauthDir, "OAuth dir");
} else if (!existsDir(oauthDir)) {
warnings.push(
`- OAuth dir not present (${displayOauthDir}). Skipping create because no WhatsApp/pairing channel config is active.`,
);
}
const displayDirFor = (dir: string) => {
if (dir === sessionsDir) {
return displaySessionsDir;
}
if (dir === storeDir) {
return displayStoreDir;
}
if (dir === oauthDir) {
return displayOauthDir;
}
return shortenHomePath(dir);
};
for (const [dir, label] of dirCandidates) {
const displayDir = displayDirFor(dir);
if (!existsDir(dir)) {
warnings.push(`- CRITICAL: ${label} missing (${displayDir}).`);
const create = await prompter.confirmSkipInNonInteractive({
message: `Create ${label} at ${displayDir}?`,
initialValue: true,
});
if (create) {
const created = ensureDir(dir);
if (created.ok) {
changes.push(`- Created ${label}: ${displayDir}`);
} else {
warnings.push(`- Failed to create ${displayDir}: ${created.error}`);
}
}
continue;
}
if (!canWriteDir(dir)) {
warnings.push(`- ${label} not writable (${displayDir}).`);
const hint = dirPermissionHint(dir);
if (hint) {
warnings.push(` ${hint}`);
}
const repair = await prompter.confirmSkipInNonInteractive({
message: `Repair permissions on ${label}?`,
initialValue: true,
});
if (repair) {
try {
const stat = fs.statSync(dir);
const target = addUserRwx(stat.mode);
fs.chmodSync(dir, target);
changes.push(`- Repaired permissions on ${label}: ${displayDir}`);
} catch (err) {
warnings.push(`- Failed to repair ${displayDir}: ${String(err)}`);
}
}
}
}
}
const extraStateDirs = new Set<string>();
if (path.resolve(stateDir) !== path.resolve(defaultStateDir)) {
if (existsDir(defaultStateDir)) {
extraStateDirs.add(defaultStateDir);
}
}
for (const other of findOtherStateDirs(stateDir)) {
extraStateDirs.add(other);
}
if (extraStateDirs.size > 0) {
warnings.push(
[
"- Multiple state directories detected. This can split session history.",
...Array.from(extraStateDirs).map((dir) => ` - ${shortenHomePath(dir)}`),
` Active state dir: ${displayStateDir}`,
].join("\n"),
);
}
const store = loadSessionStore(storePath);
const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath });
const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object");
if (entries.length > 0) {
const recent = entries
.slice()
.toSorted((a, b) => {
const aUpdated = typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
const bUpdated = typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
return bUpdated - aUpdated;
})
.slice(0, 5);
const recentTranscriptCandidates = recent.filter(([key]) => !isSlashRoutingSessionKey(key));
const missing = recentTranscriptCandidates.filter(([, entry]) => {
const sessionId = entry.sessionId;
if (!sessionId) {
return false;
}
const transcriptPath = resolveSessionFilePath(sessionId, entry, sessionPathOpts);
return !existsFile(transcriptPath);
});
if (missing.length > 0) {
warnings.push(
[
`- ${missing.length}/${recentTranscriptCandidates.length} recent sessions are missing transcripts.`,
` Verify sessions in store: ${formatCliCommand(`openclaw sessions --store "${absoluteStorePath}"`)}`,
` Preview cleanup impact: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --dry-run`)}`,
` Prune missing entries: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --enforce --fix-missing`)}`,
].join("\n"),
);
}
const mainKey = resolveMainSessionKey(cfg);
const mainEntry = store[mainKey];
if (mainEntry?.sessionId) {
const transcriptPath = resolveSessionFilePath(
mainEntry.sessionId,
mainEntry,
sessionPathOpts,
);
if (!existsFile(transcriptPath)) {
warnings.push(
`- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`,
);
} else {
const lineCount = countJsonlLines(transcriptPath);
if (lineCount <= 1) {
warnings.push(
`- Main session transcript has only ${lineCount} line. Session history may not be appending.`,
);
}
}
}
}
if (existsDir(sessionsDir)) {
const referencedTranscriptPaths = new Set<string>();
for (const [, entry] of entries) {
if (!entry?.sessionId) {
continue;
}
try {
referencedTranscriptPaths.add(
path.resolve(resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts)),
);
} catch {
// ignore invalid legacy paths
}
}
const sessionDirEntries = fs.readdirSync(sessionsDir, { withFileTypes: true });
const orphanTranscriptPaths = sessionDirEntries
.filter((entry) => entry.isFile() && isPrimarySessionTranscriptFileName(entry.name))
.map((entry) => path.resolve(path.join(sessionsDir, entry.name)))
.filter((filePath) => !referencedTranscriptPaths.has(filePath));
if (orphanTranscriptPaths.length > 0) {
warnings.push(
`- Found ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}. They are not referenced by sessions.json and can consume disk over time.`,
);
const archiveOrphans = await prompter.confirmSkipInNonInteractive({
message: `Archive ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}?`,
initialValue: false,
});
if (archiveOrphans) {
let archived = 0;
const archivedAt = formatSessionArchiveTimestamp();
for (const orphanPath of orphanTranscriptPaths) {
const archivedPath = `${orphanPath}.deleted.${archivedAt}`;
try {
fs.renameSync(orphanPath, archivedPath);
archived += 1;
} catch (err) {
warnings.push(
`- Failed to archive orphan transcript ${shortenHomePath(orphanPath)}: ${String(err)}`,
);
}
}
if (archived > 0) {
changes.push(`- Archived ${archived} orphan transcript file(s) in ${displaySessionsDir}`);
}
}
}
}
if (warnings.length > 0) {
note(warnings.join("\n"), "State integrity");
}
if (changes.length > 0) {
note(changes.join("\n"), "Doctor changes");
}
}
export function noteWorkspaceBackupTip(workspaceDir: string) {
if (!existsDir(workspaceDir)) {
return;
}
const gitMarker = path.join(workspaceDir, ".git");
if (fs.existsSync(gitMarker)) {
return;
}
note(
[
"- Tip: back up the workspace in a private git repo (GitHub or GitLab).",
"- Keep ~/.openclaw out of git; it contains credentials and session history.",
"- Details: /concepts/agent-workspace#git-backup-recommended",
].join("\n"),
"Workspace",
);
}