import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import type { StartupProvisioningReadiness, TeamProvisioningState, TeamState, WorkerIdentity, } from "./types.js"; function resolvePluginStateDir(): string { const explicitStateDir = process.env.OPENCLAW_STATE_DIR?.trim(); if (explicitStateDir) { return path.join(explicitStateDir, "plugins", "teamclaw"); } const explicitHome = process.env.OPENCLAW_HOME?.trim() || process.env.HOME?.trim(); const homeDir = explicitHome ? path.resolve(explicitHome) : os.homedir(); return path.join(homeDir, ".openclaw", "plugins", "teamclaw"); } const STATE_DIR = resolvePluginStateDir(); const writeQueues = new Map>(); function createEmptyProvisioningState(): TeamProvisioningState { return { workers: {}, }; } function normalizeStartupReadiness(value: unknown): StartupProvisioningReadiness | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } const record = value as Record; const status = record.status === "checking" || record.status === "ready" || record.status === "degraded" ? record.status : undefined; const startedAt = typeof record.startedAt === "number" ? record.startedAt : undefined; const checkedAt = typeof record.checkedAt === "number" ? record.checkedAt : undefined; const attempts = typeof record.attempts === "number" && record.attempts >= 0 ? Math.floor(record.attempts) : 0; const requiredRoles = Array.isArray(record.requiredRoles) ? record.requiredRoles.filter((entry): entry is string => typeof entry === "string") : []; const readyWorkerIds = Array.isArray(record.readyWorkerIds) ? record.readyWorkerIds.filter((entry): entry is string => typeof entry === "string") : []; const message = typeof record.message === "string" && record.message.trim() ? record.message : undefined; if (!status || startedAt === undefined || checkedAt === undefined) { return undefined; } return { status, startedAt, checkedAt, attempts, requiredRoles, readyWorkerIds, message, }; } async function ensureDir(dir: string): Promise { await fs.mkdir(dir, { recursive: true }); } async function writeFileAtomically(filePath: string, contents: string): Promise { const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; await fs.writeFile(tmpPath, contents, "utf8"); await fs.rename(tmpPath, filePath); } function enqueueAtomicWrite(filePath: string, contents: string): Promise { const previous = writeQueues.get(filePath) ?? Promise.resolve(); const next = previous .catch(() => {}) .then(() => writeFileAtomically(filePath, contents)); writeQueues.set(filePath, next); return next.finally(() => { if (writeQueues.get(filePath) === next) { writeQueues.delete(filePath); } }); } async function loadTeamState(teamName: string): Promise { const filePath = path.join(STATE_DIR, `${teamName}-team-state.json`); try { const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as TeamState; if ( typeof parsed.teamName !== "string" || typeof parsed.createdAt !== "number" || typeof parsed.updatedAt !== "number" || !parsed.workers || !parsed.tasks ) { return null; } if (!parsed.controllerRuns || typeof parsed.controllerRuns !== "object") { parsed.controllerRuns = {}; } if (!parsed.projects || typeof parsed.projects !== "object") { parsed.projects = {}; } if (!Array.isArray(parsed.messages)) { parsed.messages = []; } if (!parsed.clarifications || typeof parsed.clarifications !== "object") { parsed.clarifications = {}; } if (parsed.repo && typeof parsed.repo !== "object") { delete parsed.repo; } if (!parsed.provisioning || typeof parsed.provisioning !== "object") { parsed.provisioning = createEmptyProvisioningState(); } if (!parsed.provisioning.workers || typeof parsed.provisioning.workers !== "object") { parsed.provisioning.workers = {}; } parsed.provisioning.startupReadiness = normalizeStartupReadiness(parsed.provisioning.startupReadiness); return parsed; } catch { return null; } } async function saveTeamState(state: TeamState): Promise { await ensureDir(STATE_DIR); const filePath = path.join(STATE_DIR, `${state.teamName}-team-state.json`); state.updatedAt = Date.now(); state.provisioning = state.provisioning && typeof state.provisioning === "object" ? state.provisioning : createEmptyProvisioningState(); state.provisioning.workers = state.provisioning.workers && typeof state.provisioning.workers === "object" ? state.provisioning.workers : {}; state.provisioning.startupReadiness = normalizeStartupReadiness(state.provisioning.startupReadiness); state.controllerRuns = state.controllerRuns && typeof state.controllerRuns === "object" ? state.controllerRuns : {}; state.projects = state.projects && typeof state.projects === "object" ? state.projects : {}; await enqueueAtomicWrite(filePath, `${JSON.stringify(state, null, 2)}\n`); } async function loadWorkerIdentity(): Promise { const filePath = path.join(STATE_DIR, "worker-identity.json"); try { const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as WorkerIdentity; if ( typeof parsed.workerId !== "string" || typeof parsed.role !== "string" || typeof parsed.controllerUrl !== "string" || typeof parsed.registeredAt !== "number" ) { return null; } return parsed; } catch { return null; } } async function saveWorkerIdentity(identity: WorkerIdentity): Promise { await ensureDir(STATE_DIR); const filePath = path.join(STATE_DIR, "worker-identity.json"); await enqueueAtomicWrite(filePath, `${JSON.stringify(identity, null, 2)}\n`); } async function clearWorkerIdentity(): Promise { const filePath = path.join(STATE_DIR, "worker-identity.json"); try { await fs.unlink(filePath); } catch { // ignore } } export { STATE_DIR, loadTeamState, saveTeamState, loadWorkerIdentity, saveWorkerIdentity, clearWorkerIdentity, };