refactor: extract doctor state integrity
This commit is contained in:
322
src/commands/doctor-state-integrity.ts
Normal file
322
src/commands/doctor-state-integrity.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { note } from "@clack/prompts";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||||
|
import {
|
||||||
|
loadSessionStore,
|
||||||
|
resolveMainSessionKey,
|
||||||
|
resolveSessionFilePath,
|
||||||
|
resolveSessionTranscriptsDirForAgent,
|
||||||
|
resolveStorePath,
|
||||||
|
} from "../config/sessions.js";
|
||||||
|
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.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 candidate = path.resolve(root, entry.name, ".clawdbot");
|
||||||
|
if (candidate === resolvedState) continue;
|
||||||
|
if (existsDir(candidate)) found.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function noteStateIntegrity(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
prompter: DoctorPrompterLike,
|
||||||
|
) {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const changes: string[] = [];
|
||||||
|
const env = process.env;
|
||||||
|
const homedir = os.homedir;
|
||||||
|
const stateDir = resolveStateDir(env, homedir);
|
||||||
|
const defaultStateDir = path.join(homedir(), ".clawdbot");
|
||||||
|
const oauthDir = resolveOAuthDir(env, stateDir);
|
||||||
|
const agentId = normalizeAgentId(
|
||||||
|
cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
|
||||||
|
);
|
||||||
|
const sessionsDir = resolveSessionTranscriptsDirForAgent(
|
||||||
|
agentId,
|
||||||
|
env,
|
||||||
|
homedir,
|
||||||
|
);
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||||
|
const storeDir = path.dirname(storePath);
|
||||||
|
|
||||||
|
let stateDirExists = existsDir(stateDir);
|
||||||
|
if (!stateDirExists) {
|
||||||
|
warnings.push(
|
||||||
|
`- CRITICAL: state directory missing (${stateDir}). 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 ${stateDir} now?`,
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (create) {
|
||||||
|
const created = ensureDir(stateDir);
|
||||||
|
if (created.ok) {
|
||||||
|
changes.push(`- Created ${stateDir}`);
|
||||||
|
stateDirExists = true;
|
||||||
|
} else {
|
||||||
|
warnings.push(`- Failed to create ${stateDir}: ${created.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateDirExists && !canWriteDir(stateDir)) {
|
||||||
|
warnings.push(`- State directory not writable (${stateDir}).`);
|
||||||
|
const hint = dirPermissionHint(stateDir);
|
||||||
|
if (hint) warnings.push(` ${hint}`);
|
||||||
|
const repair = await prompter.confirmSkipInNonInteractive({
|
||||||
|
message: `Repair permissions on ${stateDir}?`,
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (repair) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(stateDir);
|
||||||
|
const target = addUserRwx(stat.mode);
|
||||||
|
fs.chmodSync(stateDir, target);
|
||||||
|
changes.push(`- Repaired permissions on ${stateDir}`);
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(`- Failed to repair ${stateDir}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateDirExists) {
|
||||||
|
const dirCandidates = new Map<string, string>();
|
||||||
|
dirCandidates.set(sessionsDir, "Sessions dir");
|
||||||
|
dirCandidates.set(storeDir, "Session store dir");
|
||||||
|
dirCandidates.set(oauthDir, "OAuth dir");
|
||||||
|
|
||||||
|
for (const [dir, label] of dirCandidates) {
|
||||||
|
if (!existsDir(dir)) {
|
||||||
|
warnings.push(`- ${label} missing (${dir}).`);
|
||||||
|
const create = await prompter.confirmSkipInNonInteractive({
|
||||||
|
message: `Create ${label} at ${dir}?`,
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (create) {
|
||||||
|
const created = ensureDir(dir);
|
||||||
|
if (created.ok) {
|
||||||
|
changes.push(`- Created ${label}: ${dir}`);
|
||||||
|
} else {
|
||||||
|
warnings.push(`- Failed to create ${dir}: ${created.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!canWriteDir(dir)) {
|
||||||
|
warnings.push(`- ${label} not writable (${dir}).`);
|
||||||
|
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}: ${dir}`);
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(`- Failed to repair ${dir}: ${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) => ` - ${dir}`),
|
||||||
|
` Active state dir: ${stateDir}`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entries = Object.entries(store).filter(
|
||||||
|
([, entry]) => entry && typeof entry === "object",
|
||||||
|
);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const recent = entries
|
||||||
|
.slice()
|
||||||
|
.sort((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 missing = recent.filter(([, entry]) => {
|
||||||
|
const sessionId = entry.sessionId;
|
||||||
|
if (!sessionId) return false;
|
||||||
|
const transcriptPath = resolveSessionFilePath(sessionId, entry, {
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
return !existsFile(transcriptPath);
|
||||||
|
});
|
||||||
|
if (missing.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`- ${missing.length}/${recent.length} recent sessions are missing transcripts. Check for deleted session files or split state dirs.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainKey = resolveMainSessionKey(cfg);
|
||||||
|
const mainEntry = store[mainKey];
|
||||||
|
if (mainEntry?.sessionId) {
|
||||||
|
const transcriptPath = resolveSessionFilePath(
|
||||||
|
mainEntry.sessionId,
|
||||||
|
mainEntry,
|
||||||
|
{ agentId },
|
||||||
|
);
|
||||||
|
if (!existsFile(transcriptPath)) {
|
||||||
|
warnings.push(
|
||||||
|
`- Main session transcript missing (${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 (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 ~/.clawdbot out of git; it contains credentials and session history.",
|
||||||
|
"- Details: /concepts/agent-workspace#git-backup-recommended",
|
||||||
|
].join("\n"),
|
||||||
|
"Workspace",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,19 +23,7 @@ import {
|
|||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import {
|
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
|
||||||
resolveGatewayPort,
|
|
||||||
resolveIsNixMode,
|
|
||||||
resolveOAuthDir,
|
|
||||||
resolveStateDir,
|
|
||||||
} from "../config/paths.js";
|
|
||||||
import {
|
|
||||||
loadSessionStore,
|
|
||||||
resolveMainSessionKey,
|
|
||||||
resolveSessionFilePath,
|
|
||||||
resolveSessionTranscriptsDirForAgent,
|
|
||||||
resolveStorePath,
|
|
||||||
} from "../config/sessions.js";
|
|
||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import {
|
import {
|
||||||
findExtraGatewayServices,
|
findExtraGatewayServices,
|
||||||
@@ -51,7 +39,6 @@ import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
|
|||||||
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
|
||||||
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
||||||
import { resolveTelegramToken } from "../telegram/token.js";
|
import { resolveTelegramToken } from "../telegram/token.js";
|
||||||
import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
|
import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
|
||||||
@@ -64,6 +51,10 @@ import {
|
|||||||
detectLegacyStateMigrations,
|
detectLegacyStateMigrations,
|
||||||
runLegacyStateMigrations,
|
runLegacyStateMigrations,
|
||||||
} from "./doctor-state-migrations.js";
|
} from "./doctor-state-migrations.js";
|
||||||
|
import {
|
||||||
|
noteStateIntegrity,
|
||||||
|
noteWorkspaceBackupTip,
|
||||||
|
} from "./doctor-state-integrity.js";
|
||||||
import { healthCommand } from "./health.js";
|
import { healthCommand } from "./health.js";
|
||||||
import {
|
import {
|
||||||
applyWizardMetadata,
|
applyWizardMetadata,
|
||||||
@@ -444,305 +435,6 @@ function createDoctorPrompter(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 candidate = path.resolve(root, entry.name, ".clawdbot");
|
|
||||||
if (candidate === resolvedState) continue;
|
|
||||||
if (existsDir(candidate)) found.push(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function noteStateIntegrity(
|
|
||||||
cfg: ClawdbotConfig,
|
|
||||||
prompter: DoctorPrompter,
|
|
||||||
) {
|
|
||||||
const warnings: string[] = [];
|
|
||||||
const changes: string[] = [];
|
|
||||||
const env = process.env;
|
|
||||||
const homedir = os.homedir;
|
|
||||||
const stateDir = resolveStateDir(env, homedir);
|
|
||||||
const defaultStateDir = path.join(homedir(), ".clawdbot");
|
|
||||||
const oauthDir = resolveOAuthDir(env, stateDir);
|
|
||||||
const agentId = normalizeAgentId(
|
|
||||||
cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
|
|
||||||
);
|
|
||||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(
|
|
||||||
agentId,
|
|
||||||
env,
|
|
||||||
homedir,
|
|
||||||
);
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
|
||||||
const storeDir = path.dirname(storePath);
|
|
||||||
|
|
||||||
let stateDirExists = existsDir(stateDir);
|
|
||||||
if (!stateDirExists) {
|
|
||||||
warnings.push(
|
|
||||||
`- CRITICAL: state directory missing (${stateDir}). 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 ${stateDir} now?`,
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
if (create) {
|
|
||||||
const created = ensureDir(stateDir);
|
|
||||||
if (created.ok) {
|
|
||||||
changes.push(`- Created ${stateDir}`);
|
|
||||||
stateDirExists = true;
|
|
||||||
} else {
|
|
||||||
warnings.push(`- Failed to create ${stateDir}: ${created.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateDirExists && !canWriteDir(stateDir)) {
|
|
||||||
warnings.push(`- State directory not writable (${stateDir}).`);
|
|
||||||
const hint = dirPermissionHint(stateDir);
|
|
||||||
if (hint) warnings.push(` ${hint}`);
|
|
||||||
const repair = await prompter.confirmSkipInNonInteractive({
|
|
||||||
message: `Repair permissions on ${stateDir}?`,
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
if (repair) {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(stateDir);
|
|
||||||
const target = addUserRwx(stat.mode);
|
|
||||||
fs.chmodSync(stateDir, target);
|
|
||||||
changes.push(`- Repaired permissions on ${stateDir}`);
|
|
||||||
} catch (err) {
|
|
||||||
warnings.push(`- Failed to repair ${stateDir}: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateDirExists) {
|
|
||||||
const dirCandidates = new Map<string, string>();
|
|
||||||
dirCandidates.set(sessionsDir, "Sessions dir");
|
|
||||||
dirCandidates.set(storeDir, "Session store dir");
|
|
||||||
dirCandidates.set(oauthDir, "OAuth dir");
|
|
||||||
|
|
||||||
for (const [dir, label] of dirCandidates) {
|
|
||||||
if (!existsDir(dir)) {
|
|
||||||
warnings.push(`- ${label} missing (${dir}).`);
|
|
||||||
const create = await prompter.confirmSkipInNonInteractive({
|
|
||||||
message: `Create ${label} at ${dir}?`,
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
if (create) {
|
|
||||||
const created = ensureDir(dir);
|
|
||||||
if (created.ok) {
|
|
||||||
changes.push(`- Created ${label}: ${dir}`);
|
|
||||||
} else {
|
|
||||||
warnings.push(`- Failed to create ${dir}: ${created.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!canWriteDir(dir)) {
|
|
||||||
warnings.push(`- ${label} not writable (${dir}).`);
|
|
||||||
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}: ${dir}`);
|
|
||||||
} catch (err) {
|
|
||||||
warnings.push(`- Failed to repair ${dir}: ${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) => ` - ${dir}`),
|
|
||||||
` Active state dir: ${stateDir}`,
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const entries = Object.entries(store).filter(
|
|
||||||
([, entry]) => entry && typeof entry === "object",
|
|
||||||
);
|
|
||||||
if (entries.length > 0) {
|
|
||||||
const recent = entries
|
|
||||||
.slice()
|
|
||||||
.sort((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 missing = recent.filter(([, entry]) => {
|
|
||||||
const sessionId = entry.sessionId;
|
|
||||||
if (!sessionId) return false;
|
|
||||||
const transcriptPath = resolveSessionFilePath(sessionId, entry, {
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
return !existsFile(transcriptPath);
|
|
||||||
});
|
|
||||||
if (missing.length > 0) {
|
|
||||||
warnings.push(
|
|
||||||
`- ${missing.length}/${recent.length} recent sessions are missing transcripts. Check for deleted session files or split state dirs.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainKey = resolveMainSessionKey(cfg);
|
|
||||||
const mainEntry = store[mainKey];
|
|
||||||
if (mainEntry?.sessionId) {
|
|
||||||
const transcriptPath = resolveSessionFilePath(
|
|
||||||
mainEntry.sessionId,
|
|
||||||
mainEntry,
|
|
||||||
{ agentId },
|
|
||||||
);
|
|
||||||
if (!existsFile(transcriptPath)) {
|
|
||||||
warnings.push(
|
|
||||||
`- Main session transcript missing (${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 (warnings.length > 0) {
|
|
||||||
note(warnings.join("\n"), "State integrity");
|
|
||||||
}
|
|
||||||
if (changes.length > 0) {
|
|
||||||
note(changes.join("\n"), "Doctor changes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ~/.clawdbot out of git; it contains credentials and session history.",
|
|
||||||
"- Details: /concepts/agent-workspace#git-backup-recommended",
|
|
||||||
].join("\n"),
|
|
||||||
"Workspace",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function maybeRepairAnthropicOAuthProfileId(
|
async function maybeRepairAnthropicOAuthProfileId(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
prompter: DoctorPrompter,
|
prompter: DoctorPrompter,
|
||||||
|
|||||||
Reference in New Issue
Block a user