@oar-sh/oar 0.9.18 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
  3. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  4. package/CHANGELOG.md +359 -0
  5. package/README.md +10 -10
  6. package/bin/oar.js +391 -97
  7. package/package.json +4 -2
  8. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -2
  11. package/server/claude-worker/claude-session-process.mjs +3 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  13. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  14. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  15. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  17. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  18. package/server/db-schema.mjs +18 -0
  19. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  21. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  22. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  23. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  24. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  25. package/server/migrations/0005-remote-relays.mjs +5 -7
  26. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  27. package/server/migrations/0007-claude-cloud.mjs +5 -7
  28. package/server/migrations/0008-message-pins.mjs +5 -7
  29. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  30. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  31. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  33. package/server/migrations/0013-previews.mjs +5 -7
  34. package/server/migrations/0014-conversation-groups.mjs +5 -7
  35. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  36. package/server/migrations/0016-share-access-log.mjs +5 -7
  37. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  38. package/server/migrations/0018-background-task-rows.mjs +5 -7
  39. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  40. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  41. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  42. package/server/migrations/0022-relay-schedules.mjs +5 -7
  43. package/server/migrations/0023-relay-asks.mjs +5 -7
  44. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  45. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  46. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  47. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  48. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  49. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  50. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  51. package/server/migrations/0031-queue-provider.mjs +5 -7
  52. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  53. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  54. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  55. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  56. package/server/migrations/0036-token-usage.mjs +267 -0
  57. package/server/public/app/about-view.mjs +87 -3
  58. package/server/public/app/api-client.js +2 -1
  59. package/server/public/app/bootstrap.js +55 -2
  60. package/server/public/app/composer-control-state.mjs +12 -2
  61. package/server/public/app/context-usage-view.mjs +16 -0
  62. package/server/public/app/conversation-view.js +17 -2
  63. package/server/public/app/inbox-ui.js +32 -0
  64. package/server/public/app/inbox-view.mjs +141 -5
  65. package/server/public/app/plan-board-shared.mjs +49 -0
  66. package/server/public/app/plan-usage-view.mjs +1 -1
  67. package/server/public/app/relay-board-inline.mjs +4 -1
  68. package/server/public/app/relay-board-view.js +38 -10
  69. package/server/public/app/relay-event-view.mjs +4 -0
  70. package/server/public/app/socket-handlers.js +10 -2
  71. package/server/public/index.html +14 -0
  72. package/server/public/sw.js +8 -0
  73. package/server/relay-heap-guard.mjs +351 -0
  74. package/server/relay-self-restart.mjs +116 -9
  75. package/server/relay-tools.md +5 -4
  76. package/server/repositories/message-repository.mjs +31 -1
  77. package/server/repositories/remote-relay-repository.mjs +10 -0
  78. package/server/repositories/session-repository.mjs +6 -1
  79. package/server/routes/messages-routes.mjs +120 -4
  80. package/server/routes/relay-ask-routes.mjs +138 -8
  81. package/server/routes/relay-board-routes.mjs +55 -40
  82. package/server/routes/sessions-routes.mjs +141 -31
  83. package/server/server-runtime.mjs +142 -11
  84. package/server/services/about-service.mjs +59 -0
  85. package/server/services/cache-rebuild-service.mjs +7 -0
  86. package/server/services/conversation-preferences-service.mjs +31 -0
  87. package/server/services/database-file.mjs +413 -0
  88. package/server/services/db-backup-service.mjs +809 -0
  89. package/server/services/deepseek-usage-import.mjs +6 -9
  90. package/server/services/delete-archive-service.mjs +3 -0
  91. package/server/services/earlier-database-service.mjs +69 -126
  92. package/server/services/oar-cli-helpers.mjs +214 -12
  93. package/server/services/oar-cli-menu.mjs +1 -0
  94. package/server/services/oar-state-migration-service.mjs +33 -13
  95. package/server/services/package-data-rescue.mjs +4 -2
  96. package/server/services/plan-usage-claude-api.mjs +14 -15
  97. package/server/services/plan-usage-contract.mjs +6 -2
  98. package/server/services/plan-usage-deepseek.mjs +1 -1
  99. package/server/services/plan-usage-tokens.mjs +248 -0
  100. package/server/services/push-dispatch-service.mjs +134 -9
  101. package/server/services/relay-cli-launcher-service.mjs +3 -0
  102. package/server/services/relay-event-service.mjs +30 -0
  103. package/server/services/relay-question-context.mjs +11 -0
  104. package/server/services/relay-restart-picture.mjs +67 -22
  105. package/server/services/remote-inbox-watch-service.mjs +204 -0
  106. package/server/services/remote-relay-caller-context.mjs +80 -7
  107. package/server/services/remote-relay-dispatcher.mjs +68 -8
  108. package/server/services/restart-resume-service.mjs +5 -2
  109. package/server/services/session-worker-launch-service.mjs +12 -3
  110. package/server/services/session-worker-stop-service.mjs +27 -6
  111. package/server/services/token-usage-service.mjs +430 -0
  112. package/server/services/update-install-service.mjs +13 -0
  113. package/server/services/usage-limit-pause-service.mjs +8 -1
  114. package/server/services/worker-scope.mjs +77 -0
  115. package/shared/plan-board-actions.mjs +130 -0
  116. package/shared/plan-only.mjs +43 -0
  117. package/shared/relay-asks.mjs +3 -0
  118. package/shared/relay-events.mjs +38 -4
  119. package/shared/remote-relay-contract.mjs +98 -21
  120. package/shared/turn-token-usage.mjs +144 -0
package/bin/oar.js CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  } from '../server/services/oar-state-migration-service.mjs';
15
15
  import { readConfiguredDataDir, resolveDataDir, resolveUploadDir } from '../server/services/data-dir-resolution.mjs';
16
16
  import { RESCUE_DIR_NAME, TAKEOVER_PROGRESS_FILE } from '../server/services/earlier-database-service.mjs';
17
+ import { DB_FILE_NAME, DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile } from '../server/services/database-file.mjs';
18
+ import { BACKUP_PROGRESS_FILE, BACKUP_WAIT_FILE, backupDatabase, configWarnings, describeBackups, sanitizeReason } from '../server/services/db-backup-service.mjs';
17
19
  import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from '../server/services/relay-singleton-guard.mjs';
18
20
  import {
19
21
  getLauncherLogDir,
@@ -23,15 +25,16 @@ import {
23
25
  resolveStateLayout,
24
26
  } from '../server/services/oar-state-layout.mjs';
25
27
  import {
26
- buildDefaultConfig, buildServicePath, buildSystemdUnit, describeRelayAddress, DOCTOR_PROBES, generateAuthToken,
27
- NPM_ALLOW_SCRIPTS_ARG, parseCliArgs, primaryLanAddress, renderDoctorReport, resolveNpmInvocation, usageText,
28
+ buildDefaultConfig, buildServicePath, buildSystemdUnit, classifySystemdUnit, describeRelayAddress, DOCTOR_PROBES, generateAuthToken,
29
+ listSystemdDropIns, NPM_ALLOW_SCRIPTS_ARG, parseCliArgs, parseSystemdVersion, primaryLanAddress, renderDoctorReport,
30
+ resolveNpmInvocation, systemdUnitMarkerRoot, systemdUnitRunsCheckout, usageText,
28
31
  } from '../server/services/oar-cli-helpers.mjs';
29
32
  import { describeConfiguredTunnel, describeTunnelState } from '../server/services/cloudflared-tunnel-service.mjs';
30
33
  import { createPrompter, PromptAborted, runMenu } from '../server/services/oar-cli-menu.mjs';
31
34
  import { createWindowsPortCheck, isWsl, isWslNat, windowsPortWarning } from '../server/services/oar-cli-wsl.mjs';
32
35
  import { readEnvValue, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
33
36
  import { createProcessLister } from '../server/services/process-list-service.mjs';
34
- import { createWindowsAutostartService, launcherScriptsMatch } from '../server/services/windows-autostart-service.mjs';
37
+ import { backupPathFor, createWindowsAutostartService, launcherScriptsMatch } from '../server/services/windows-autostart-service.mjs';
35
38
  import {
36
39
  BOOT_ELEVATION_SCRIPT_FILENAME, BOOT_TASK_XML_FILENAME,
37
40
  bootTaskLabel, createWindowsBootAutostartService, describeHandMade, describeWindowsAutostart, handMadeLines, switchBlockers,
@@ -82,10 +85,17 @@ function readRelayLock(lockPath) {
82
85
  // pid, and that pid is the lock's holder still (lockHolderMayRun: a pid
83
86
  // reused after a crash is not).
84
87
  function readTakeover(lockPath, isProcessAliveImpl = isProcessAlive) {
85
- const progress = readJsonFile(path.join(path.dirname(lockPath), TAKEOVER_PROGRESS_FILE));
86
88
  const lock = readJsonFile(lockPath);
87
- const pid = Number.parseInt(String(progress?.pid ?? ''), 10);
88
- return Number.isInteger(pid) && Number(lock?.pid) === pid && lockHolderMayRun({ lock, isProcessAlive: isProcessAliveImpl }) ? progress : null;
89
+ if (!lockHolderMayRun({ lock, isProcessAlive: isProcessAliveImpl })) return null;
90
+ // Also while it compares two database files, backs its database up, or
91
+ // waits for an `oar backup` to finish first; each file counts only with
92
+ // the lock's pid (one a crash left does not).
93
+ for (const name of [TAKEOVER_PROGRESS_FILE, BACKUP_PROGRESS_FILE, BACKUP_WAIT_FILE]) {
94
+ const progress = readJsonFile(path.join(path.dirname(lockPath), name));
95
+ const pid = Number.parseInt(String(progress?.pid ?? ''), 10);
96
+ if (Number.isInteger(pid) && Number(lock?.pid) === pid) return progress;
97
+ }
98
+ return null;
89
99
  }
90
100
 
91
101
  // The lock a relay that did not come up found held, and whether its holder
@@ -100,7 +110,12 @@ function heldLock(lockPath, isProcessAliveImpl = isProcessAlive) {
100
110
 
101
111
  function takeoverLine(takeover, logDir) {
102
112
  const what = takeover ? ` (${takeover.from}, ${Math.max(1, Math.round(Number(takeover.bytes || 0) / 1048576))} MB)` : '';
103
- return `[oar] The relay${takeover ? ` (process ${takeover.pid})` : ''} is bringing over the database it ran on until now${what} and is still running; it answers once that is done. Its log: ${path.join(String(logDir || ''), 'server.log')}`;
113
+ const doing = {
114
+ backup: `is backing up its database${what} before this version's migrations`,
115
+ compare: `is comparing its two database files${what}`,
116
+ 'wait-backup': `is waiting for \`oar backup\` (process ${takeover?.waitingFor}) to finish before its own backup`,
117
+ }[takeover?.task] || `is bringing over the database it ran on until now${what}`;
118
+ return `[oar] The relay${takeover ? ` (process ${takeover.pid})` : ''} ${doing} and is still running; it answers once that is done. Its log: ${path.join(String(logDir || ''), 'server.log')}`;
104
119
  }
105
120
 
106
121
  // As the server tells it: a process this user may not signal (EPERM) lives.
@@ -741,10 +756,12 @@ async function probeRelayBehindLock({
741
756
  }
742
757
 
743
758
  /**
744
- * Makes sure the relay of a global install runs, without a Copilot session
745
- * tied to it: an already running relay is left alone, an installed systemd
746
- * user service is started, and anywhere else the server is started detached,
747
- * so it outlives this command.
759
+ * Makes sure the relay of an install runs, without a Copilot session tied to
760
+ * it: an already running relay is left alone, an installed systemd user
761
+ * service is started, and anywhere else the server of a global install is
762
+ * started detached, so it outlives this command. `serviceInstalled` says
763
+ * whether the unit there is this install's (unset: any unit is); `lockPath`
764
+ * is the relay's lock (default: in the layout's data folder).
748
765
  */
749
766
  export async function ensureRelayRunning({
750
767
  packageRoot = resolvePackageRoot(),
@@ -763,12 +780,13 @@ export async function ensureRelayRunning({
763
780
  homeDir = os.homedir(),
764
781
  platform = process.platform,
765
782
  logger = console,
783
+ serviceInstalled = null,
784
+ lockPath = path.join(layout.dataDir, 'relay-server.lock'),
766
785
  } = {}) {
767
786
  const port = normalizePort(config?.port) ?? 3333;
768
787
  const token = String(config?.authToken || '').trim();
769
788
  const statusUrl = `http://localhost:${port}/api/status`;
770
789
  const logDir = getLauncherLogDir(env, layout);
771
- const lockPath = path.join(layout.dataDir, 'relay-server.lock');
772
790
  const answer = await probeRelayBehindLock({
773
791
  statusUrl, token, lockPath, port, fetchImpl, isProcessAliveImpl, lockGraceMs, logger,
774
792
  });
@@ -780,8 +798,12 @@ export async function ensureRelayRunning({
780
798
  return { ok: false, how: 'port-taken', logDir };
781
799
  }
782
800
 
783
- if (fs.existsSync(systemdUnitPath(homeDir)) && systemdUserAvailable({ platform, spawnSyncImpl })) {
801
+ const unitThere = serviceInstalled ?? fs.existsSync(systemdUnitPath(homeDir));
802
+ if (unitThere && systemdUserAvailable({ platform, spawnSyncImpl })) {
784
803
  runQuiet('systemctl', ['--user', 'daemon-reload'], { spawnSyncImpl });
804
+ // A unit that failed (a crash loop, a stop on purpose) is started afresh,
805
+ // with its restart backoff from the start.
806
+ runQuiet('systemctl', ['--user', 'reset-failed', 'oar'], { spawnSyncImpl });
785
807
  if (!runQuiet('systemctl', ['--user', 'enable', '--now', 'oar'], { spawnSyncImpl })) {
786
808
  logger.error?.('[oar] systemctl --user enable --now oar failed — see: systemctl --user status oar');
787
809
  return { ok: false, how: 'service', logDir };
@@ -886,6 +908,15 @@ function installPaths(ctx) {
886
908
  };
887
909
  }
888
910
 
911
+ // The data folder the relay of this install runs on: in a checkout as the
912
+ // relay resolves it (COPILOT_WEB_RELAY_DATA_DIR, the config's dataDir,
913
+ // server/data); a global install's relay is pinned to the state root's
914
+ // (COPILOT_WEB_RELAY_DATA_DIR wins there too).
915
+ function relayDataDir(ctx, paths) {
916
+ if (paths.layout.checkout) return resolveDataDir({ env: ctx.env, configPath: paths.configPath, serverDir: path.join(ctx.packageRoot, 'server') });
917
+ return resolveRelayStateDirs({ env: ctx.env, layout: paths.layout }).dataDir || paths.dataDir;
918
+ }
919
+
889
920
  function endpointOf(config) {
890
921
  const port = normalizePort(config?.port) ?? 3333;
891
922
  return { port, token: String(config?.authToken || '').trim(), statusUrl: `http://localhost:${port}/api/status` };
@@ -925,13 +956,16 @@ async function pollUntil(check, { waitMs, pollMs }) {
925
956
  }
926
957
  }
927
958
 
928
- /** Asks the relay to stop or restart itself; it does so once no turn is running. */
929
- async function requestShutdown(ctx, endpoint, restart) {
959
+ /**
960
+ * Asks the relay to stop or restart itself; it does so once no turn is
961
+ * running, or at once with `immediate` (`--now` of a relay without a service).
962
+ */
963
+ async function requestShutdown(ctx, endpoint, restart, { immediate = false } = {}) {
930
964
  try {
931
965
  const response = await ctx.fetchImpl(`http://localhost:${endpoint.port}/api/relay/shutdown`, {
932
966
  method: 'POST',
933
967
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${endpoint.token}` },
934
- body: JSON.stringify({ restart, reason: 'oar-cli', requestedBy: 'oar-cli' }),
968
+ body: JSON.stringify({ restart, reason: 'oar-cli', requestedBy: 'oar-cli', ...(immediate ? { immediate: true } : {}) }),
935
969
  signal: AbortSignal.timeout(5000),
936
970
  });
937
971
  if (!response?.ok) return null;
@@ -944,26 +978,37 @@ async function requestShutdown(ctx, endpoint, restart) {
944
978
  }
945
979
  }
946
980
 
981
+ // systemd restarts the service at once, its restart backoff cleared first.
982
+ function restartService(ctx) {
983
+ runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
984
+ runQuiet('systemctl', ['--user', 'reset-failed', 'oar'], ctx);
985
+ if (runQuiet('systemctl', ['--user', 'restart', 'oar'], ctx)) return true;
986
+ ctx.logger.error('[oar] systemctl --user restart oar failed — see: systemctl --user status oar');
987
+ return false;
988
+ }
989
+
947
990
  /**
948
991
  * Restarts this install's running relay so that it reads the config again.
949
- * The service is restarted by systemd. Any other relay is asked on the port
950
- * and with the token it runs with (`old`): its supervisor starts a new runtime
951
- * with the same arguments and environment, and that runtime reads the config
952
- * file, so a new port, token or access setting applies. Done when the relay
953
- * answers on the config's port with the config's token.
992
+ * The relay is asked on the port and with the token it runs with (`old`):
993
+ * once no turn is running it exits for a restart, and a new runtime with the
994
+ * same arguments and environment reads the config file, so a new port, token
995
+ * or access setting applies. Under the service (`viaService`) its supervisor
996
+ * hands that restart to systemd, which starts the code on disk afresh; there
997
+ * systemd restarts it at once with `now`, or when the relay does not answer
998
+ * (`answered` false) or refuses. Done when the relay answers on the config's
999
+ * port with the config's token.
954
1000
  */
955
- async function restartRelay(ctx, paths, { old, viaService }) {
1001
+ async function restartRelay(ctx, paths, { old, viaService, answered = true, now = false }) {
956
1002
  const { logger, fetchImpl } = ctx;
957
1003
  const next = endpointOf(paths.config);
958
- if (viaService) {
959
- runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
960
- if (!runQuiet('systemctl', ['--user', 'restart', 'oar'], ctx)) {
961
- logger.error('[oar] systemctl --user restart oar failed — see: systemctl --user status oar');
962
- return { ok: false, reason: 'service' };
1004
+ if (viaService && (now || !answered)) {
1005
+ if (!restartService(ctx)) return { ok: false, reason: 'service' };
1006
+ } else if (!(await requestShutdown(ctx, old, true, { immediate: now }))) {
1007
+ if (!viaService) {
1008
+ logger.error('[oar] The relay did not accept the restart request. Restart it from the web UI (⋯ → Restart web relay).');
1009
+ return { ok: false, reason: 'restart-refused' };
963
1010
  }
964
- } else if (!(await requestShutdown(ctx, old, true))) {
965
- logger.error('[oar] The relay did not accept the restart request. Restart it from the web UI (⋯ → Restart web relay).');
966
- return { ok: false, reason: 'restart-refused' };
1011
+ if (!restartService(ctx)) return { ok: false, reason: 'service' };
967
1012
  }
968
1013
  const up = await pollUntil(async () => {
969
1014
  const { answer, status } = await readRelayStatus({ ...next, fetchImpl });
@@ -1122,13 +1167,14 @@ export async function runAutostart(options = {}) {
1122
1167
  const paths = installPaths(ctx);
1123
1168
  const { logger } = ctx;
1124
1169
  const target = ctx.argv.find((arg) => [...AUTOSTART_MODES, 'status'].includes(arg)) || 'status';
1170
+ if (ctx.platform === 'linux') {
1171
+ const replace = ctx.argv.includes('--replace') ? ['--replace'] : [];
1172
+ return runService({ ...options, argv: [{ status: 'status', off: 'remove' }[target] || 'install', ...replace] });
1173
+ }
1125
1174
  if (paths.layout.checkout) {
1126
1175
  logger.error('[oar] This is a git checkout — autostart is for global installs.');
1127
1176
  return { code: 1, reason: 'git-checkout' };
1128
1177
  }
1129
- if (ctx.platform === 'linux') {
1130
- return runService({ ...options, argv: [{ status: 'status', off: 'remove' }[target] || 'install'] });
1131
- }
1132
1178
  if (ctx.platform !== 'win32') {
1133
1179
  logger.error(`[oar] Autostart is not supported on ${ctx.platform === 'darwin' ? 'macOS' : ctx.platform} yet. Start the relay with: oar start`);
1134
1180
  return { code: 1, reason: 'unsupported' };
@@ -1158,9 +1204,75 @@ async function askAutostart(ctx, paths) {
1158
1204
  }
1159
1205
  }
1160
1206
 
1161
- /** 'active' (systemd runs the relay), 'installed', 'none', or 'unavailable' on this host. */
1207
+ // What a systemctl call printed, or null when it could not run.
1208
+ function systemctlOutput(ctx, args) {
1209
+ try {
1210
+ const result = ctx.spawnSyncImpl('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000 });
1211
+ return result?.error ? null : result || null;
1212
+ } catch {
1213
+ return null;
1214
+ }
1215
+ }
1216
+
1217
+ // States in which systemd runs the relay, starts it (a restart backoff
1218
+ // included) or stops it: `oar stop` and `oar restart` go to systemd then.
1219
+ const SYSTEMD_RUNNING_STATES = ['active', 'activating', 'reloading', 'refreshing', 'deactivating'];
1220
+
1221
+ function systemdUnitActive(ctx) {
1222
+ const result = systemctlOutput(ctx, ['is-active', 'oar']);
1223
+ if (!result) return false;
1224
+ const state = String(result.stdout ?? '').trim();
1225
+ return state ? SYSTEMD_RUNNING_STATES.includes(state) : result.status === 0;
1226
+ }
1227
+
1228
+ // What this install's unit would be built from.
1229
+ function unitInstall(ctx, paths) {
1230
+ return {
1231
+ nodeBin: ctx.nodeBin,
1232
+ packageRoot: ctx.packageRoot,
1233
+ configPath: paths.configPath,
1234
+ dataDir: paths.dataDir,
1235
+ logDir: paths.logDir,
1236
+ checkout: Boolean(paths.layout.checkout),
1237
+ };
1238
+ }
1239
+
1240
+ /**
1241
+ * The unit at ~/.config/systemd/user/oar.service: `text` (null when there is
1242
+ * none), `kind` (classifySystemdUnit: whose it is, for this install) and
1243
+ * `ours` (it is this install's service: start, stop and restart go through
1244
+ * it). A hand-made unit is a global install's service, the one it has; a
1245
+ * checkout's only when its ExecStart runs that checkout's server.js (running
1246
+ * in the checkout is not enough), so a worktree never takes over the unit of
1247
+ * another relay.
1248
+ */
1249
+ function readSystemdUnit(ctx, paths) {
1250
+ const unitPath = systemdUnitPath(ctx.homeDir);
1251
+ const text = readText(unitPath);
1252
+ if (text === null) return { path: unitPath, text, kind: null, ours: false };
1253
+ const kind = classifySystemdUnit({ text, install: unitInstall(ctx, paths) });
1254
+ const ours = kind !== 'other-install'
1255
+ && !(kind === 'hand-made' && paths.layout.checkout && !systemdUnitRunsCheckout(text, ctx.packageRoot));
1256
+ return { path: unitPath, text, kind, ours };
1257
+ }
1258
+
1259
+ // The relay's own lock: a checkout's data folder may be the config's dataDir.
1260
+ function relayLockPath(ctx, paths) {
1261
+ return paths.layout.checkout ? path.join(relayDataDir(ctx, paths), 'relay-server.lock') : paths.lockPath;
1262
+ }
1263
+
1264
+ // How a command of this install is typed: a checkout has no `oar` on PATH.
1265
+ function oarCommand(paths) {
1266
+ return paths.layout.checkout ? 'node bin/oar.js' : 'oar';
1267
+ }
1268
+
1269
+ /**
1270
+ * 'active' (systemd runs the relay, starts or stops it), 'installed',
1271
+ * 'none', 'other' (oar.service is another OAR install's), or 'unavailable'
1272
+ * on this host.
1273
+ */
1162
1274
  async function serviceState(ctx, paths) {
1163
- if (paths.layout.checkout) return 'unavailable';
1275
+ if (paths.layout.checkout && ctx.platform !== 'linux') return 'unavailable';
1164
1276
  if (ctx.platform === 'win32') {
1165
1277
  try {
1166
1278
  return windowsAutostart(ctx, paths).getState().enabled || await windowsBootTaskReady(ctx, paths) ? 'installed' : 'none';
@@ -1169,30 +1281,65 @@ async function serviceState(ctx, paths) {
1169
1281
  }
1170
1282
  }
1171
1283
  if (!systemdUserAvailable(ctx)) return 'unavailable';
1172
- if (!fs.existsSync(systemdUnitPath(ctx.homeDir))) return 'none';
1173
- return runQuiet('systemctl', ['--user', 'is-active', '--quiet', 'oar'], ctx) ? 'active' : 'installed';
1284
+ const unit = readSystemdUnit(ctx, paths);
1285
+ if (unit.text === null) return 'none';
1286
+ if (!unit.ours) return 'other';
1287
+ return systemdUnitActive(ctx) ? 'active' : 'installed';
1174
1288
  }
1175
1289
 
1176
- function writeSystemdUnit(ctx, paths) {
1177
- const unitPath = systemdUnitPath(ctx.homeDir);
1178
- fs.mkdirSync(path.dirname(unitPath), { recursive: true });
1179
- fs.writeFileSync(unitPath, buildSystemdUnit({
1180
- nodeBin: ctx.nodeBin,
1181
- packageRoot: ctx.packageRoot,
1182
- configPath: paths.configPath,
1183
- dataDir: paths.dataDir,
1184
- logDir: paths.logDir,
1290
+ const UNIT_KIND_TEXT = {
1291
+ generated: 'written by OAR for this install',
1292
+ 'generated-earlier': 'written by an earlier OAR version',
1293
+ 'other-install': 'written by OAR for another install',
1294
+ 'hand-made': 'hand-made',
1295
+ };
1296
+
1297
+ /**
1298
+ * Writes this install's unit, unless the one there is hand-made or another
1299
+ * install's: that one stays as it is (`kept`), unless `replace`, which keeps
1300
+ * a copy of it first. `changed` when the text on disk is new.
1301
+ */
1302
+ function writeSystemdUnit(ctx, paths, { replace = false } = {}) {
1303
+ const { logger } = ctx;
1304
+ const unit = readSystemdUnit(ctx, paths);
1305
+ if (unit.text !== null && ['hand-made', 'other-install'].includes(unit.kind) && !replace) {
1306
+ return { kept: unit };
1307
+ }
1308
+ let backup = null;
1309
+ if (unit.text !== null && ['hand-made', 'other-install'].includes(unit.kind)) {
1310
+ backup = backupPathFor(unit.path, path.dirname(unit.path));
1311
+ fs.copyFileSync(unit.path, backup);
1312
+ logger.log(`[oar] Kept a copy of ${unit.path} as ${backup}`);
1313
+ }
1314
+ const text = buildSystemdUnit({
1315
+ ...unitInstall(ctx, paths),
1185
1316
  pathEnv: buildServicePath({ nodeBin: ctx.nodeBin, envPath: ctx.env.PATH, homeDir: ctx.homeDir }),
1186
- }));
1187
- ctx.logger.log(`[oar] Wrote ${unitPath}`);
1317
+ systemdVersion: parseSystemdVersion(systemctlOutput(ctx, ['--version'])?.stdout),
1318
+ });
1319
+ fs.mkdirSync(path.dirname(unit.path), { recursive: true });
1320
+ fs.writeFileSync(unit.path, text);
1321
+ logger.log(`[oar] Wrote ${unit.path}`);
1322
+ // A Node of a version manager is gone once another version replaces it.
1323
+ if (/\/(\.nvm|\.fnm|\.volta|\.asdf|versions?)\//.test(String(ctx.nodeBin))) {
1324
+ logger.log(`[oar] The service runs the Node at ${ctx.nodeBin}; after switching Node versions, run ${oarCommand(paths)} service install again.`);
1325
+ }
1188
1326
  // Lingering keeps the user's services up while nobody is logged in;
1189
1327
  // without it the relay of a headless host stops at logout.
1190
1328
  if (!runQuiet('loginctl', ['enable-linger', ctx.userName || os.userInfo().username], ctx)) {
1191
- ctx.logger.log('[oar] Could not enable lingering — the relay stops when you log out. Enable it with: loginctl enable-linger "$USER"');
1329
+ logger.log('[oar] Could not enable lingering — the relay stops when you log out. Enable it with: loginctl enable-linger "$USER"');
1192
1330
  }
1331
+ return { written: true, changed: unit.text !== text, backup };
1332
+ }
1333
+
1334
+ // One line for a unit `oar service install` (or `remove`) leaves as it is.
1335
+ function keptUnitLine(ctx, paths, unit, action = 'install') {
1336
+ const whose = unit.kind === 'other-install' ? `written by OAR for another install (${systemdUnitMarkerRoot(unit.text)})` : 'hand-made';
1337
+ const what = action === 'remove' ? 'To remove it' : 'To replace it with this install\'s';
1338
+ return `[oar] Left as it is: ${unit.path} (${whose}). ${what}, keeping a copy: ${oarCommand(paths)} service ${action} --replace`;
1193
1339
  }
1194
1340
 
1195
1341
  function startRelay(ctx, paths) {
1342
+ const unit = ctx.platform === 'linux' ? readSystemdUnit(ctx, paths) : { text: null };
1196
1343
  return ensureRelayRunning({
1197
1344
  packageRoot: ctx.packageRoot,
1198
1345
  layout: paths.layout,
@@ -1210,6 +1357,8 @@ function startRelay(ctx, paths) {
1210
1357
  homeDir: ctx.homeDir,
1211
1358
  platform: ctx.platform,
1212
1359
  logger: ctx.logger,
1360
+ serviceInstalled: unit.text !== null && unit.ours,
1361
+ lockPath: relayLockPath(ctx, paths),
1213
1362
  });
1214
1363
  }
1215
1364
 
@@ -1289,8 +1438,9 @@ export async function runStart(options = {}) {
1289
1438
  const ctx = cliContext(options);
1290
1439
  const paths = installPaths(ctx);
1291
1440
  const { logger } = ctx;
1292
- if (paths.layout.checkout) {
1293
- logger.error('[oar] This is a git checkout — start the relay with: npm start');
1441
+ // A checkout starts only through its service; anywhere else it runs in a terminal.
1442
+ if (paths.layout.checkout && !['active', 'installed'].includes(await serviceState(ctx, paths))) {
1443
+ logger.error('[oar] This is a git checkout — start the relay with: npm start, or run it as a service: node bin/oar.js service install');
1294
1444
  return { code: 1, reason: 'git-checkout' };
1295
1445
  }
1296
1446
  const missing = requireConfig(ctx, paths);
@@ -1307,28 +1457,40 @@ export async function runStart(options = {}) {
1307
1457
  return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
1308
1458
  }
1309
1459
 
1310
- /** `oar stop` — systemd stops the service; any other relay stops itself once no turn is running. */
1460
+ // systemd stops the service at once; running turns end.
1461
+ function stopService(ctx, paths) {
1462
+ if (!runQuiet('systemctl', ['--user', 'stop', 'oar'], ctx)) {
1463
+ ctx.logger.error('[oar] systemctl --user stop oar failed — see: systemctl --user status oar');
1464
+ return { code: 1, reason: 'service' };
1465
+ }
1466
+ ctx.logger.log(`[oar] The relay service is stopped. It starts again at the next boot (without lingering: at the next login); to take it out: ${oarCommand(paths)} service remove`);
1467
+ return { code: 0, reason: 'stopped' };
1468
+ }
1469
+
1470
+ /**
1471
+ * `oar stop` — the relay stops itself once no turn is running, under the
1472
+ * service too (its unit stays enabled). `--now` under the service, or a
1473
+ * service whose relay does not answer: systemd stops it at once. `--now` of
1474
+ * any other relay asks it to stop without waiting.
1475
+ */
1311
1476
  export async function runStop(options = {}) {
1312
1477
  const ctx = cliContext(options);
1313
1478
  const paths = installPaths(ctx);
1314
1479
  const { logger } = ctx;
1315
1480
  const missing = requireConfig(ctx, paths);
1316
1481
  if (missing) return missing;
1317
- if ((await serviceState(ctx, paths)) === 'active') {
1318
- if (!runQuiet('systemctl', ['--user', 'stop', 'oar'], ctx)) {
1319
- logger.error('[oar] systemctl --user stop oar failed — see: systemctl --user status oar');
1320
- return { code: 1, reason: 'service' };
1321
- }
1322
- logger.log('[oar] The relay service is stopped. It starts again at the next boot (without lingering: at the next login); to take it out: oar service remove');
1323
- return { code: 0, reason: 'stopped' };
1324
- }
1482
+ const now = ctx.argv.includes('--now');
1483
+ const viaService = (await serviceState(ctx, paths)) === 'active';
1484
+ if (viaService && now) return stopService(ctx, paths);
1325
1485
  const relay = await findRelay(ctx, paths);
1486
+ if (viaService && relay.answer !== 'oar') return stopService(ctx, paths);
1326
1487
  if (relay.answer === 'none') {
1327
1488
  logger.log('[oar] The relay is not running.');
1328
1489
  return { code: 0, reason: 'not-running' };
1329
1490
  }
1330
1491
  if (relay.answer === 'taken') return reportPortTaken(ctx, paths, relay.endpoint.port);
1331
- if (!(await requestShutdown(ctx, relay.endpoint, false))) {
1492
+ if (!(await requestShutdown(ctx, relay.endpoint, false, { immediate: now }))) {
1493
+ if (viaService) return stopService(ctx, paths);
1332
1494
  logger.error('[oar] The relay did not accept the stop request.');
1333
1495
  return { code: 1, reason: 'stop-refused' };
1334
1496
  }
@@ -1336,11 +1498,19 @@ export async function runStop(options = {}) {
1336
1498
  async () => (await probeRelayPort({ ...relay.endpoint, fetchImpl: ctx.fetchImpl })) === 'none',
1337
1499
  { waitMs: ctx.stopWaitMs, pollMs: ctx.pollMs },
1338
1500
  );
1339
- logger.log(closed ? '[oar] The relay is stopped.' : '[oar] The relay is still busy; it stops by itself when its running turns finish.');
1501
+ const command = oarCommand(paths);
1502
+ const enabled = viaService ? ` The service stays enabled: it starts again at the next boot, or with: ${command} start` : '';
1503
+ logger.log(closed
1504
+ ? `[oar] The relay is stopped.${enabled}`
1505
+ : `[oar] The relay is still busy; it stops by itself when its running turns finish (at once: ${command} stop --now).${enabled}`);
1340
1506
  return { code: 0, reason: closed ? 'stopped' : 'stop-queued' };
1341
1507
  }
1342
1508
 
1343
- /** `oar restart` — restarts a running relay, starts a stopped one. */
1509
+ /**
1510
+ * `oar restart` — restarts a running relay once no turn is running (`--now`:
1511
+ * at once), starts a stopped one. Under the service systemd restarts it at
1512
+ * once with `--now`, or when the relay does not answer.
1513
+ */
1344
1514
  export async function runRestart(options = {}) {
1345
1515
  const ctx = cliContext(options);
1346
1516
  const paths = installPaths(ctx);
@@ -1350,7 +1520,9 @@ export async function runRestart(options = {}) {
1350
1520
  const relay = await findRelay(ctx, paths);
1351
1521
  if (!viaService && relay.answer === 'none') return runStart(options);
1352
1522
  if (!viaService && relay.answer === 'taken') return reportPortTaken(ctx, paths, relay.endpoint.port);
1353
- const outcome = await restartRelay(ctx, paths, { old: relay.endpoint, viaService });
1523
+ const outcome = await restartRelay(ctx, paths, {
1524
+ old: relay.endpoint, viaService, answered: relay.answer === 'oar', now: ctx.argv.includes('--now'),
1525
+ });
1354
1526
  if (outcome.ok) await showAddress(ctx, paths);
1355
1527
  return { code: outcome.ok ? 0 : 1, reason: outcome.reason };
1356
1528
  }
@@ -1401,7 +1573,7 @@ export async function runStatus(options = {}) {
1401
1573
  else if (status.relay === 'stopped') logger.log(`[oar] The relay is stopped (port ${status.port}, ${access}). Start it with: oar start`);
1402
1574
  else logger.log(`[oar] Port ${status.port} is in use by another program, or by a relay with another token.`);
1403
1575
  if (status.service !== 'unavailable') {
1404
- logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed', none: 'not installed' }[status.service]}`);
1576
+ logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed', none: 'not installed', other: 'not installed (oar.service belongs to another OAR install)' }[status.service]}`);
1405
1577
  }
1406
1578
  if (status.tunnel) logger.log(`[oar] Tunnel: ${status.tunnel}`);
1407
1579
  for (const warning of status.warnings) logger.log(`[oar] ${warning}`);
@@ -1443,14 +1615,47 @@ async function runWindowsService(ctx, paths, action) {
1443
1615
  return { code: 0, reason: `service-${action}` };
1444
1616
  }
1445
1617
 
1618
+ // The relay paths a checkout's service does not get: the relay it runs
1619
+ // finds its config, data and logs in the checkout.
1620
+ const CHECKOUT_UNPINNED_ENV = ['COPILOT_WEB_RELAY_CONFIG', 'COPILOT_WEB_RELAY_DATA_DIR', 'COPILOT_WEB_RELAY_LOG_DIR'];
1621
+
1622
+ // `oar service status` on Linux: the state, whose the unit is, its drop-ins, lingering.
1623
+ function reportSystemdService(ctx, paths, state) {
1624
+ const { logger } = ctx;
1625
+ const command = oarCommand(paths);
1626
+ const unit = readSystemdUnit(ctx, paths);
1627
+ if (unit.text === null) {
1628
+ logger.log('[oar] Service: not installed');
1629
+ return;
1630
+ }
1631
+ logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed, not running', other: 'not installed for this install' }[state]}`);
1632
+ const whose = unit.kind === 'other-install'
1633
+ ? `${UNIT_KIND_TEXT[unit.kind]} (${systemdUnitMarkerRoot(unit.text)})`
1634
+ : `${UNIT_KIND_TEXT[unit.kind]}${unit.ours ? '' : ', not for this checkout'}`;
1635
+ logger.log(`[oar] Unit: ${unit.path} (${whose})`);
1636
+ if (unit.kind === 'generated-earlier') logger.log(`[oar] It is from an earlier OAR version; write this version's with: ${command} service install`);
1637
+ if (['hand-made', 'other-install'].includes(unit.kind)) {
1638
+ logger.log(`[oar] OAR leaves it as it is. To replace it with this install's, keeping a copy: ${command} service install --replace`);
1639
+ }
1640
+ for (const dropIn of listSystemdDropIns(ctx.homeDir)) logger.log(`[oar] Drop-in: ${dropIn} (your own settings, kept)`);
1641
+ const user = ctx.userName || os.userInfo().username;
1642
+ let linger = '';
1643
+ try {
1644
+ const result = ctx.spawnSyncImpl('loginctl', ['show-user', user, '-p', 'Linger', '--value'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000 });
1645
+ linger = String(result?.stdout ?? '').trim();
1646
+ } catch {}
1647
+ if (linger === 'no') logger.log('[oar] Lingering is off: the relay stops when you log out. Switch it on with: loginctl enable-linger "$USER"');
1648
+ }
1649
+
1446
1650
  /** `oar service install|remove|status` — the systemd user service on Linux, the autostart entry on Windows. */
1447
1651
  export async function runService(options = {}) {
1448
1652
  const ctx = cliContext(options);
1449
1653
  const paths = installPaths(ctx);
1450
1654
  const { logger } = ctx;
1451
1655
  const action = ctx.argv.find((arg) => ['install', 'remove', 'status'].includes(arg)) || 'status';
1452
- if (paths.layout.checkout) {
1453
- logger.error('[oar] This is a git checkout — the service is for global installs.');
1656
+ const replace = ctx.argv.includes('--replace');
1657
+ if (paths.layout.checkout && ctx.platform !== 'linux') {
1658
+ logger.error('[oar] This is a git checkout — the service is for global installs here.');
1454
1659
  return { code: 1, reason: 'git-checkout' };
1455
1660
  }
1456
1661
  if (ctx.platform === 'win32') return runWindowsService(ctx, paths, action);
@@ -1458,37 +1663,60 @@ export async function runService(options = {}) {
1458
1663
  logger.error(`[oar] The service is not supported on ${ctx.platform === 'darwin' ? 'macOS' : ctx.platform} yet. Start the relay with: oar start`);
1459
1664
  return { code: 1, reason: 'unsupported' };
1460
1665
  }
1666
+ const command = oarCommand(paths);
1461
1667
  if (!systemdUserAvailable(ctx)) {
1462
1668
  const wslHint = isWsl(ctx) ? ' In WSL, switch systemd on: systemd=true under [boot] in /etc/wsl.conf, then restart the distro (from Windows: wsl --terminate <distro>).' : '';
1463
- logger.error(`[oar] No systemd user session here, so there is no service to install.${wslHint} Start the relay with: oar start`);
1669
+ const start = paths.layout.checkout ? 'npm start' : 'oar start';
1670
+ logger.error(`[oar] No systemd user session here, so there is no service to install.${wslHint} Start the relay with: ${start}`);
1464
1671
  return { code: 1, reason: 'no-systemd' };
1465
1672
  }
1466
- const unitPath = systemdUnitPath(ctx.homeDir);
1467
1673
  const state = await serviceState(ctx, paths);
1468
1674
  if (action === 'status') {
1469
- logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed, not running', none: 'not installed' }[state]}`);
1675
+ reportSystemdService(ctx, paths, state);
1470
1676
  return { code: 0, reason: 'service-status' };
1471
1677
  }
1472
1678
  if (action === 'remove') {
1473
- if (state === 'none') {
1679
+ const unit = readSystemdUnit(ctx, paths);
1680
+ if (unit.text === null) {
1474
1681
  logger.log('[oar] The service is not installed.');
1475
1682
  return { code: 0, reason: 'service-remove' };
1476
1683
  }
1684
+ if (['hand-made', 'other-install'].includes(unit.kind)) {
1685
+ if (!replace) {
1686
+ logger.error(keptUnitLine(ctx, paths, unit, 'remove'));
1687
+ return { code: 1, reason: 'hand-made-unit' };
1688
+ }
1689
+ const backup = backupPathFor(unit.path, path.dirname(unit.path));
1690
+ fs.copyFileSync(unit.path, backup);
1691
+ logger.log(`[oar] Kept a copy of ${unit.path} as ${backup}`);
1692
+ }
1477
1693
  runQuiet('systemctl', ['--user', 'disable', '--now', 'oar'], ctx);
1478
- try { fs.unlinkSync(unitPath); } catch {}
1694
+ try { fs.unlinkSync(unit.path); } catch {}
1479
1695
  runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
1480
- logger.log(`[oar] Removed ${unitPath}; the relay is stopped. Start it by hand with: oar start`);
1696
+ logger.log(`[oar] Removed ${unit.path}; the relay is stopped. Start it by hand with: ${paths.layout.checkout ? 'npm start' : 'oar start'}`);
1481
1697
  return { code: 0, reason: 'service-remove' };
1482
1698
  }
1483
1699
  const missing = requireConfig(ctx, paths);
1484
1700
  if (missing) return missing;
1485
- writeSystemdUnit(ctx, paths);
1701
+ if (paths.layout.checkout) {
1702
+ for (const name of CHECKOUT_UNPINNED_ENV.filter((key) => String(ctx.env[key] || '').trim())) {
1703
+ logger.log(`[oar] ${name} is set in this shell; the service does not get it. If the relay needs it, put it in ~/.config/systemd/user/oar.service.d/*.conf`);
1704
+ }
1705
+ }
1706
+ const wasActive = state === 'active' || (state === 'other' && systemdUnitActive(ctx));
1707
+ const written = writeSystemdUnit(ctx, paths, { replace });
1708
+ if (written.kept) {
1709
+ logger.error(keptUnitLine(ctx, paths, written.kept));
1710
+ return { code: 1, reason: 'hand-made-unit' };
1711
+ }
1486
1712
  runQuiet('systemctl', ['--user', 'daemon-reload'], ctx);
1487
- if (state !== 'active' && (await findRelay(ctx, paths)).answer === 'oar') {
1713
+ if (wasActive) {
1714
+ if (written.changed) logger.log(`[oar] The new unit applies at the next restart: ${command} restart --now`);
1715
+ } else if ((await findRelay(ctx, paths)).answer === 'oar') {
1488
1716
  // A relay started by hand holds the port and the lock: it makes way first.
1489
1717
  if ((await runStop({ ...options, argv: [] })).reason !== 'stopped') {
1490
1718
  runQuiet('systemctl', ['--user', 'enable', 'oar'], ctx);
1491
- logger.log('[oar] The service is installed and takes over at the next boot (without lingering: at the next login), or once the relay has stopped: oar start');
1719
+ logger.log(`[oar] The service is installed and takes over at the next boot (without lingering: at the next login), or once the relay has stopped: ${command} start`);
1492
1720
  return { code: 0, reason: 'service-install' };
1493
1721
  }
1494
1722
  }
@@ -1638,7 +1866,10 @@ export async function runSetup(options = {}) {
1638
1866
 
1639
1867
  const startRequested = has('--start') && !layout.checkout;
1640
1868
  const viaService = running && (await serviceState(ctx, paths)) === 'active';
1641
- if (startRequested && systemdUserAvailable(ctx)) writeSystemdUnit(ctx, paths);
1869
+ if (startRequested && systemdUserAvailable(ctx)) {
1870
+ const written = writeSystemdUnit(ctx, paths);
1871
+ if (written.kept) logger.log(keptUnitLine(ctx, paths, written.kept));
1872
+ }
1642
1873
 
1643
1874
  let outcome = null;
1644
1875
  if (changed) {
@@ -1751,9 +1982,9 @@ function purgeRefusal(ctx, dir, marker = null) {
1751
1982
  return '';
1752
1983
  }
1753
1984
 
1754
- // An OAR state root: its config, or a database in its data folder.
1755
- const looksLikeStateRoot = (dir) => fs.existsSync(path.join(dir, 'config.json')) || fs.existsSync(path.join(dir, 'data', 'copilot.db'));
1756
- const looksLikeDataDir = (dir) => fs.existsSync(path.join(dir, 'copilot.db'));
1985
+ // An OAR state root: its config, or a database (either name) in its data folder.
1986
+ const looksLikeDataDir = (dir) => DB_FILE_NAMES.some((name) => fs.existsSync(path.join(dir, name)));
1987
+ const looksLikeStateRoot = (dir) => fs.existsSync(path.join(dir, 'config.json')) || looksLikeDataDir(path.join(dir, 'data'));
1757
1988
 
1758
1989
  /**
1759
1990
  * What `oar uninstall --purge` deletes: the state root (`refused` when
@@ -1783,7 +2014,11 @@ function purgePlan(ctx, paths) {
1783
2014
  const checkout = checkoutAround(dir);
1784
2015
  add(dir, checkout ? `a data folder in the git checkout ${checkout}` : 'a data folder outside the state root', looksLikeDataDir);
1785
2016
  }
1786
- return { root, refused, extra, skipped };
2017
+ // Backups the config keeps outside the state root are never deleted.
2018
+ const configuredBackups = paths.config?.dbBackups?.dir;
2019
+ const backupsElsewhere = typeof configuredBackups === 'string' && path.isAbsolute(configuredBackups)
2020
+ && !isInside(path.resolve(configuredBackups), root) && path.resolve(configuredBackups) !== root ? path.resolve(configuredBackups) : null;
2021
+ return { root, refused, extra, skipped, backupsElsewhere };
1787
2022
  }
1788
2023
  // Deletes `target` except the paths in `keep` (and the folders they are in).
1789
2024
  function deleteTree(target, keep) {
@@ -1795,16 +2030,6 @@ function deleteTree(target, keep) {
1795
2030
  for (const name of fs.readdirSync(target)) deleteTree(path.join(target, name), keep);
1796
2031
  }
1797
2032
 
1798
- // A unit `oar service install` wrote for this install: the same lines, with
1799
- // whatever Node and PATH it was written with.
1800
- function systemdUnitIsGenerated(ctx, paths, text) {
1801
- const nodeBin = String(text).match(/^ExecStart=(\S+) /m)?.[1] || ctx.nodeBin;
1802
- const strip = (unit) => String(unit).split('\n').filter((line) => !line.startsWith('Environment="PATH=')).join('\n').trim();
1803
- return strip(text) === strip(buildSystemdUnit({
1804
- nodeBin, packageRoot: ctx.packageRoot, configPath: paths.configPath, dataDir: paths.dataDir, logDir: paths.logDir,
1805
- }));
1806
- }
1807
-
1808
2033
  function readText(filePath) {
1809
2034
  try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
1810
2035
  }
@@ -1840,9 +2065,9 @@ async function removeGeneratedEntries(ctx, paths, done) {
1840
2065
  done.push(...signin.filter((entry) => entry.generated).map((entry) => `removed ${entry.path}`));
1841
2066
  kept.push(...now.entries.filter((entry) => !entry.generated));
1842
2067
  } else {
1843
- const unitPath = systemdUnitPath(ctx.homeDir);
1844
- const unit = readText(unitPath);
1845
- if (unit !== null && !systemdUnitIsGenerated(ctx, paths, unit)) kept.push({ path: unitPath });
2068
+ // Only a unit OAR wrote for this install (this version or 0.9.19) goes.
2069
+ const { path: unitPath, text: unit, kind } = readSystemdUnit(ctx, paths);
2070
+ if (unit !== null && !['generated', 'generated-earlier'].includes(kind)) kept.push({ path: unitPath });
1846
2071
  else if (unit !== null) {
1847
2072
  runQuiet('systemctl', ['--user', 'disable', '--now', 'oar'], ctx);
1848
2073
  fs.rmSync(unitPath, { force: true });
@@ -1910,7 +2135,8 @@ export async function runUninstall(options = {}) {
1910
2135
  return { code: 1, reason: 'purge-unsafe' };
1911
2136
  }
1912
2137
  for (const item of plan.skipped) logger.log(`[oar] Not deleted, and not offered: ${item.path} (${item.why}).`);
1913
- logger.log(`[oar] --purge deletes ${plan.root} for good: the config, the database with every conversation, the uploads and the logs.`);
2138
+ logger.log(`[oar] --purge deletes ${plan.root} for good: the config, the database with every conversation, its backups, the uploads and the logs.`);
2139
+ if (plan.backupsElsewhere) logger.log(`[oar] Not deleted: the database backups in ${plan.backupsElsewhere} (dbBackups.dir in the config); delete them yourself if you want them gone.`);
1914
2140
  if ((await prompter.line('Type "delete" to confirm: ')) !== 'delete') {
1915
2141
  logger.log('[oar] Not confirmed. Nothing was changed.');
1916
2142
  return { code: 1, reason: 'purge-not-confirmed' };
@@ -2004,7 +2230,17 @@ export async function runUninstall(options = {}) {
2004
2230
  export async function runDoctor(options = {}) {
2005
2231
  const ctx = cliContext(options);
2006
2232
  const paths = installPaths(ctx);
2007
- const dbPath = path.join(paths.dataDir, 'copilot.db');
2233
+ // The file the relay runs on (read only: nothing is created).
2234
+ const dataDir = relayDataDir(ctx, paths);
2235
+ const databaseFile = resolveDatabaseFile(dataDir);
2236
+ const dbPath = databaseFile.path;
2237
+ const databaseNotes = [
2238
+ ...(databaseFile.both ? [`${path.join(dataDir, LEGACY_DB_FILE_NAME)} is also there; the next start compares the two`] : []),
2239
+ ...databaseFile.dangling.map((file) => `${file} is a link to a file that is not there`),
2240
+ ...databaseFile.orphans.map((file) => (path.basename(file).startsWith(`${DB_FILE_NAME}-`)
2241
+ ? `${file} is there without ${path.join(dataDir, DB_FILE_NAME)}: the relay does not start until it is moved away`
2242
+ : `${file} is there without its database (not used, never deleted); move it away before renaming a database to that name`)),
2243
+ ];
2008
2244
 
2009
2245
  let dbSizeBytes = null;
2010
2246
  try { dbSizeBytes = fs.statSync(dbPath).size; } catch {}
@@ -2023,6 +2259,7 @@ export async function runDoctor(options = {}) {
2023
2259
 
2024
2260
  const port = endpointOf(paths.config).port;
2025
2261
  const holder = paths.config ? createWindowsPortCheck(ctx)(port) : null;
2262
+ const backups = describeBackups({ layout: paths.layout, serverDir: path.join(ctx.packageRoot, 'server'), dataDir, config: paths.config });
2026
2263
  ctx.logger.log(renderDoctorReport({
2027
2264
  version: readPackageVersion(ctx.packageRoot),
2028
2265
  nodeVersion: process.version,
@@ -2032,6 +2269,9 @@ export async function runDoctor(options = {}) {
2032
2269
  config: paths.config,
2033
2270
  dbPath,
2034
2271
  dbSizeBytes,
2272
+ databaseNotes,
2273
+ backups,
2274
+ now: Date.now(),
2035
2275
  probes,
2036
2276
  warnings: holder ? [windowsPortWarning(port, holder)] : [],
2037
2277
  // The relay resolves a relative cloudflaredTunnel.binary against server/.
@@ -2040,6 +2280,59 @@ export async function runDoctor(options = {}) {
2040
2280
  return { code: 0, reason: 'doctor' };
2041
2281
  }
2042
2282
 
2283
+ /**
2284
+ * `oar backup [--reason <text>]`: a copy of the relay's database, online
2285
+ * (safe while the relay runs), into the backup folder with the start's
2286
+ * name, manifest and rotation (db-backup-service.mjs). Never creates a
2287
+ * database; never touches db_last_run. Exit 1 when no copy could be made,
2288
+ * 0 for a copy or a skip (with its warning).
2289
+ */
2290
+ export async function runBackup(options = {}) {
2291
+ const ctx = cliContext(options);
2292
+ const paths = installPaths(ctx);
2293
+ const dataDir = relayDataDir(ctx, paths);
2294
+ const argv = ctx.argv || [];
2295
+ const reasonAt = argv.indexOf('--reason');
2296
+ const given = reasonAt === -1 ? argv.find((arg) => arg.startsWith('--reason='))?.slice('--reason='.length) : argv[reasonAt + 1];
2297
+ const reason = given === undefined ? 'manual' : sanitizeReason(given);
2298
+ const fail = (message, why = 'backup-failed') => {
2299
+ ctx.logger.error(`[oar] ${message}`);
2300
+ return { code: 1, reason: why };
2301
+ };
2302
+ const file = resolveDatabaseFile(dataDir);
2303
+ if (file.created || file.dangling.length) {
2304
+ return fail(`No database in ${dataDir} to back up (neither ${DB_FILE_NAME} nor ${LEGACY_DB_FILE_NAME}).`, 'no-database');
2305
+ }
2306
+ for (const warning of configWarnings(paths.config)) ctx.logger.error(warning);
2307
+ let source;
2308
+ try {
2309
+ const Database = (await import('better-sqlite3')).default;
2310
+ source = new Database(file.path, { readonly: true, fileMustExist: true });
2311
+ const say = { log: (line) => ctx.logger.log(String(line).replace(/^\[backup\]/, '[oar]')), warn: (line) => ctx.logger.error(String(line).replace(/^\[backup\]/, '[oar]')) };
2312
+ const result = await backupDatabase({
2313
+ source,
2314
+ Database,
2315
+ dbPath: file.path,
2316
+ dataDir,
2317
+ layout: paths.layout,
2318
+ serverDir: path.join(ctx.packageRoot, 'server'),
2319
+ config: paths.config,
2320
+ version: readPackageVersion(ctx.packageRoot),
2321
+ reason,
2322
+ logger: say,
2323
+ ...(ctx.isProcessAliveImpl ? { isProcessAlive: ctx.isProcessAliveImpl } : {}),
2324
+ ...(ctx.fsImpl ? { fsImpl: ctx.fsImpl } : {}),
2325
+ });
2326
+ if (result.status === 'busy') return fail(`Not backing up now: ${result.reason}; run oar backup again once it is done.`, 'backup-busy');
2327
+ if (result.status === 'failed') return { code: 1, reason: 'backup-failed' };
2328
+ return { code: 0, reason: result.status === 'written' ? 'backup-written' : 'backup-skipped', file: result.file ? path.join(result.dir, result.file) : null };
2329
+ } catch (error) {
2330
+ return fail(`The backup of ${file.path} failed: ${error?.message || error}.`);
2331
+ } finally {
2332
+ try { source?.close(); } catch {}
2333
+ }
2334
+ }
2335
+
2043
2336
  /**
2044
2337
  * `oar update [--beta] [--to X.Y.Z]` — the terminal twin of the web UI's
2045
2338
  * Update button. Fetching the manifest here is an explicit user action, so it
@@ -2177,7 +2470,7 @@ async function runMenuCommand(options = {}) {
2177
2470
  doctor: run(runDoctor),
2178
2471
  copilot: run(launchRelay),
2179
2472
  service: async (status) => {
2180
- const action = { none: 'install', unavailable: 'status' }[status.service] || 'remove';
2473
+ const action = { none: 'install', unavailable: 'status', other: 'status' }[status.service] || 'remove';
2181
2474
  if (action === 'remove' && !(await ctx.prompter.yesNo('Remove the service?', { defaultYes: false }))) return null;
2182
2475
  return runService({ ...options, argv: [action] });
2183
2476
  },
@@ -2200,6 +2493,7 @@ const COMMANDS = {
2200
2493
  uninstall: runUninstall,
2201
2494
  copilot: launchRelay,
2202
2495
  doctor: runDoctor,
2496
+ backup: runBackup,
2203
2497
  update: runUpdate,
2204
2498
  help: async ({ logger = console } = {}) => {
2205
2499
  logger.log(usageText());