index.mjs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { loadRuntimeConfig } from './config-loader.mjs';
  2. import {
  3. assertRuntimeAvailable,
  4. clearRuntimeStopRequest,
  5. isProcessAlive,
  6. readRuntimeStopRequest,
  7. readRuntimeState,
  8. runtimeStatePath,
  9. stopRuntimeProcess,
  10. writeRuntimeState,
  11. } from './runtime-state.mjs';
  12. let activeRuntime = null;
  13. function mergeComponents(current = {}, patch = {}) {
  14. const output = { ...current };
  15. for (const [name, value] of Object.entries(patch)) {
  16. output[name] = { ...(current[name] || {}), ...value, updatedAt: new Date().toISOString() };
  17. }
  18. return output;
  19. }
  20. export async function startRuntime(options = {}) {
  21. if (activeRuntime) return activeRuntime;
  22. const loaded = await loadRuntimeConfig(options);
  23. const statePath = runtimeStatePath(options.statePath);
  24. assertRuntimeAvailable(statePath);
  25. clearRuntimeStopRequest(statePath);
  26. let state = writeRuntimeState({
  27. pid: process.pid,
  28. status: 'starting',
  29. mode: loaded.mode,
  30. transport: loaded.mode === 'enterprise' ? 'server_relay' : 'local_polling',
  31. workspaceRoot: loaded.workspaceRoot,
  32. configPath: loaded.configPath,
  33. startedAt: new Date().toISOString(),
  34. components: {},
  35. }, statePath);
  36. const updateComponents = patch => {
  37. state = writeRuntimeState({
  38. ...state,
  39. components: mergeComponents(state.components, patch),
  40. }, statePath);
  41. };
  42. let dashboard = null;
  43. if (options.dashboard !== false && loaded.config.dashboard.enabled) {
  44. const { startDashboard } = await import('./processor-bridge.mjs');
  45. dashboard = await startDashboard(loaded.config.dashboard.port);
  46. updateComponents({ dashboard: { status: 'running', port: loaded.config.dashboard.port } });
  47. }
  48. let controller;
  49. let portraitQueueWorker = null;
  50. if (loaded.mode === 'enterprise') {
  51. const { EnterpriseRelayRuntime } = await import('./enterprise-relay-client.mjs');
  52. const { PortraitQueueWorker } = await import('./portrait-queue-worker.mjs');
  53. controller = new EnterpriseRelayRuntime({
  54. config: loaded.config.enterprise.relay,
  55. guid: options.guid || '',
  56. onState: updateComponents,
  57. });
  58. portraitQueueWorker = new PortraitQueueWorker({ onState: updateComponents });
  59. } else {
  60. const { PersonalRuntime } = await import('./personal-runtime.mjs');
  61. controller = new PersonalRuntime({
  62. config: loaded.config.personal.polling,
  63. workspaceRoot: loaded.workspaceRoot,
  64. onState: updateComponents,
  65. });
  66. }
  67. if (options.dryRun !== true) {
  68. controller.start();
  69. portraitQueueWorker?.start();
  70. }
  71. state = writeRuntimeState({ ...state, status: options.dryRun ? 'ready' : 'running' }, statePath);
  72. let stopping = false;
  73. let stopWatcher = null;
  74. const stop = async reason => {
  75. if (stopping) return;
  76. stopping = true;
  77. if (stopWatcher) clearInterval(stopWatcher);
  78. clearRuntimeStopRequest(statePath);
  79. state = writeRuntimeState({ ...state, status: 'stopping', stopReason: reason || 'requested' }, statePath);
  80. await portraitQueueWorker?.stop();
  81. await controller.stop();
  82. if (dashboard?.server) {
  83. await new Promise(resolve => dashboard.server.close(resolve));
  84. }
  85. state = writeRuntimeState({
  86. ...state,
  87. status: 'stopped',
  88. stoppedAt: new Date().toISOString(),
  89. }, statePath);
  90. activeRuntime = null;
  91. };
  92. stopWatcher = setInterval(() => {
  93. const request = readRuntimeStopRequest(statePath);
  94. if (!request.requestedAt) return;
  95. if (request.targetPid && Number(request.targetPid) !== process.pid) return;
  96. void stop('external-stop');
  97. }, 500);
  98. activeRuntime = {
  99. mode: loaded.mode,
  100. transport: state.transport,
  101. config: loaded.config,
  102. configPath: loaded.configPath,
  103. workspaceRoot: loaded.workspaceRoot,
  104. statePath,
  105. controller,
  106. portraitQueueWorker,
  107. dashboard,
  108. stop,
  109. };
  110. return activeRuntime;
  111. }
  112. function parseArgs(argv) {
  113. const command = argv.find(arg => !arg.startsWith('-')) || 'start';
  114. const modeArg = argv.find(arg => arg.startsWith('--mode='));
  115. const guidArg = argv.find(arg => arg.startsWith('--guid='));
  116. return {
  117. command,
  118. forceMode: modeArg ? modeArg.slice('--mode='.length) : '',
  119. guid: guidArg ? guidArg.slice('--guid='.length) : '',
  120. dashboard: !argv.includes('--no-dashboard'),
  121. dryRun: argv.includes('--dry-run'),
  122. };
  123. }
  124. export async function runCli(argv = process.argv.slice(2)) {
  125. const options = parseArgs(argv);
  126. if (options.command === 'status') {
  127. const state = readRuntimeState();
  128. process.stdout.write(`${JSON.stringify({ ...state, alive: isProcessAlive(state.pid) }, null, 2)}\n`);
  129. return;
  130. }
  131. if (options.command === 'stop') {
  132. const result = stopRuntimeProcess();
  133. let state = readRuntimeState();
  134. for (let attempt = 0; result.requested && attempt < 50; attempt += 1) {
  135. await new Promise(resolve => setTimeout(resolve, 100));
  136. state = readRuntimeState();
  137. if (state.status === 'stopped' || !isProcessAlive(state.pid)) break;
  138. }
  139. process.stdout.write(`${JSON.stringify({ ...result, status: state.status || 'unknown' }, null, 2)}\n`);
  140. return;
  141. }
  142. if (options.command !== 'start') throw new Error('Use start, status, or stop.');
  143. if (!options.dryRun) {
  144. const current = readRuntimeState();
  145. if (['starting', 'running'].includes(current.status) && isProcessAlive(current.pid)) {
  146. process.stdout.write(`${JSON.stringify({ ...current, alive: true, alreadyRunning: true }, null, 2)}\n`);
  147. return;
  148. }
  149. }
  150. const runtime = await startRuntime(options);
  151. process.stdout.write(`${JSON.stringify({
  152. status: options.dryRun ? 'ready' : 'running',
  153. pid: process.pid,
  154. mode: runtime.mode,
  155. transport: runtime.transport,
  156. dashboard: runtime.dashboard?.url || null,
  157. statePath: runtime.statePath,
  158. }, null, 2)}\n`);
  159. if (options.dryRun) await runtime.stop('dry-run');
  160. const shutdown = signal => runtime.stop(signal).finally(() => process.exit(0));
  161. process.once('SIGINT', () => shutdown('SIGINT'));
  162. process.once('SIGTERM', () => shutdown('SIGTERM'));
  163. }
  164. export { readRuntimeState };