import { promises as fs } from 'fs';
import path from 'path';
import { sendCronDaemonRequest } from './services/cron-daemon-owner.js';
import { getAlwaysOnHeartbeatPath } from './services/always-on-paths.js';
function normalizeString(value) {
return typeof value === 'string' ? value.trim() : '';
}
async function resolveProjectRoot(project) {
const direct = normalizeString(project?.projectRoot) || normalizeString(project?.fullPath) || normalizeString(project?.path);
if (direct) return path.resolve(direct);
const projectName = normalizeString(project?.projectName) || normalizeString(project?.name);
if (!projectName) return '';
const { extractProjectDirectory } = await import('./projects.js');
return path.resolve(await extractProjectDirectory(projectName));
}
async function resolveProjectRoots(projects) {
if (!Array.isArray(projects)) return [];
const roots = [];
for (const project of projects) {
const root = await resolveProjectRoot(project);
if (root) roots.push(root);
}
return [...new Set(roots)];
}
async function registerProject(projectRoot) {
try {
await sendCronDaemonRequest({ type: 'register_project', projectRoot });
} catch {
}
}
export function createAlwaysOnHeartbeatManager({
getActivePilotDeckSessions = () => [],
registerProjectFn = registerProject
} = {}) {
const wsFiles = new WeakMap();
const wsIds = new WeakMap();
const wsProjectRoots = new WeakMap();
function getWsId(ws) {
let id = wsIds.get(ws);
if (!id) {
id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
wsIds.set(ws, id);
}
return id;
}
async function writeBeat(ws, projectRoot, payload) {
const wsId = getWsId(ws);
const fileName = `webui-${wsId}.beat`;
const filePath = getAlwaysOnHeartbeatPath(projectRoot, fileName);
const processingSessionIds = Array.isArray(payload.processingSessionIds)
? payload.processingSessionIds.filter((id) => typeof id === 'string')
: [];
const beat = {
schemaVersion: 1,
writerKind: 'webui',
writerId: wsId,
writtenAt: new Date().toISOString(),
agentBusy: Boolean(payload.agentBusy) || processingSessionIds.length > 0,
processingSessionIds,
lastUserMsgAt: normalizeString(payload.lastUserMsgAt) || null,
};
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(beat, null, 2), 'utf8');
const files = wsFiles.get(ws) || new Set();
files.add(filePath);
wsFiles.set(ws, files);
await registerProjectFn(projectRoot);
}
async function handlePresence(ws, payload = {}) {
const roots = new Map();
const selectedRoot = await resolveProjectRoot(payload.selectedProject);
const alwaysOnRoots = await resolveProjectRoots(payload.alwaysOnProjects);
for (const projectRoot of alwaysOnRoots) {
roots.set(projectRoot, {
agentBusy: false,
processingSessionIds: [],
lastUserMsgAt: selectedRoot === projectRoot ? payload.lastUserMsgAt : null,
});
}
const activeSessions = getActivePilotDeckSessions();
for (const session of activeSessions) {
const cwd = normalizeString(session?.cwd);
if (!cwd) continue;
const projectRoot = path.resolve(cwd);
const existing = roots.get(projectRoot);
if (!existing) continue;
existing.agentBusy = true;
existing.processingSessionIds.push(session.sessionId);
roots.set(projectRoot, existing);
}
for (const [projectRoot, beatPayload] of roots) {
await writeBeat(ws, projectRoot, beatPayload);
}
wsProjectRoots.set(ws, new Set(roots.keys()));
return [...roots.keys()];
}
async function clearPresence(ws) {
const files = wsFiles.get(ws);
if (!files) {
wsProjectRoots.delete(ws);
return;
}
await Promise.all([...files].map((filePath) => fs.rm(filePath, { force: true }).catch(() => {})));
wsFiles.delete(ws);
wsProjectRoots.delete(ws);
}
return {
getWriterId: getWsId,
getProjectRoots(ws) {
return [...(wsProjectRoots.get(ws) || [])];
},
handlePresence,
clearPresence,
};
}