always-on-heartbeat.js 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { promises as fs } from 'fs';
  2. import path from 'path';
  3. import { sendCronDaemonRequest } from './services/cron-daemon-owner.js';
  4. import { getAlwaysOnHeartbeatPath } from './services/always-on-paths.js';
  5. function normalizeString(value) {
  6. return typeof value === 'string' ? value.trim() : '';
  7. }
  8. async function resolveProjectRoot(project) {
  9. const direct = normalizeString(project?.projectRoot) || normalizeString(project?.fullPath) || normalizeString(project?.path);
  10. if (direct) return path.resolve(direct);
  11. const projectName = normalizeString(project?.projectName) || normalizeString(project?.name);
  12. if (!projectName) return '';
  13. const { extractProjectDirectory } = await import('./projects.js');
  14. return path.resolve(await extractProjectDirectory(projectName));
  15. }
  16. async function resolveProjectRoots(projects) {
  17. if (!Array.isArray(projects)) return [];
  18. const roots = [];
  19. for (const project of projects) {
  20. const root = await resolveProjectRoot(project);
  21. if (root) roots.push(root);
  22. }
  23. return [...new Set(roots)];
  24. }
  25. async function registerProject(projectRoot) {
  26. try {
  27. await sendCronDaemonRequest({ type: 'register_project', projectRoot });
  28. } catch {
  29. // The daemon may not be up yet; the next heartbeat will retry.
  30. }
  31. }
  32. export function createAlwaysOnHeartbeatManager({
  33. getActivePilotDeckSessions = () => [],
  34. registerProjectFn = registerProject
  35. } = {}) {
  36. const wsFiles = new WeakMap();
  37. const wsIds = new WeakMap();
  38. const wsProjectRoots = new WeakMap();
  39. function getWsId(ws) {
  40. let id = wsIds.get(ws);
  41. if (!id) {
  42. id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
  43. wsIds.set(ws, id);
  44. }
  45. return id;
  46. }
  47. async function writeBeat(ws, projectRoot, payload) {
  48. const wsId = getWsId(ws);
  49. const fileName = `webui-${wsId}.beat`;
  50. const filePath = getAlwaysOnHeartbeatPath(projectRoot, fileName);
  51. const processingSessionIds = Array.isArray(payload.processingSessionIds)
  52. ? payload.processingSessionIds.filter((id) => typeof id === 'string')
  53. : [];
  54. const beat = {
  55. schemaVersion: 1,
  56. writerKind: 'webui',
  57. writerId: wsId,
  58. writtenAt: new Date().toISOString(),
  59. agentBusy: Boolean(payload.agentBusy) || processingSessionIds.length > 0,
  60. processingSessionIds,
  61. lastUserMsgAt: normalizeString(payload.lastUserMsgAt) || null,
  62. };
  63. await fs.mkdir(path.dirname(filePath), { recursive: true });
  64. await fs.writeFile(filePath, JSON.stringify(beat, null, 2), 'utf8');
  65. const files = wsFiles.get(ws) || new Set();
  66. files.add(filePath);
  67. wsFiles.set(ws, files);
  68. await registerProjectFn(projectRoot);
  69. }
  70. async function handlePresence(ws, payload = {}) {
  71. const roots = new Map();
  72. const selectedRoot = await resolveProjectRoot(payload.selectedProject);
  73. const alwaysOnRoots = await resolveProjectRoots(payload.alwaysOnProjects);
  74. for (const projectRoot of alwaysOnRoots) {
  75. roots.set(projectRoot, {
  76. agentBusy: false,
  77. processingSessionIds: [],
  78. lastUserMsgAt: selectedRoot === projectRoot ? payload.lastUserMsgAt : null,
  79. });
  80. }
  81. const activeSessions = getActivePilotDeckSessions();
  82. for (const session of activeSessions) {
  83. const cwd = normalizeString(session?.cwd);
  84. if (!cwd) continue;
  85. const projectRoot = path.resolve(cwd);
  86. const existing = roots.get(projectRoot);
  87. if (!existing) continue;
  88. existing.agentBusy = true;
  89. existing.processingSessionIds.push(session.sessionId);
  90. roots.set(projectRoot, existing);
  91. }
  92. for (const [projectRoot, beatPayload] of roots) {
  93. await writeBeat(ws, projectRoot, beatPayload);
  94. }
  95. wsProjectRoots.set(ws, new Set(roots.keys()));
  96. return [...roots.keys()];
  97. }
  98. async function clearPresence(ws) {
  99. const files = wsFiles.get(ws);
  100. if (!files) {
  101. wsProjectRoots.delete(ws);
  102. return;
  103. }
  104. await Promise.all([...files].map((filePath) => fs.rm(filePath, { force: true }).catch(() => {})));
  105. wsFiles.delete(ws);
  106. wsProjectRoots.delete(ws);
  107. }
  108. return {
  109. getWriterId: getWsId,
  110. getProjectRoots(ws) {
  111. return [...(wsProjectRoots.get(ws) || [])];
  112. },
  113. handlePresence,
  114. clearPresence,
  115. };
  116. }