@oar-sh/oar 0.9.19 → 0.9.21
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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -7,6 +7,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import { formatMegabytes } from './db-backup-service.mjs';
|
|
10
11
|
|
|
11
12
|
export function generateAuthToken() {
|
|
12
13
|
return randomBytes(32).toString('base64url');
|
|
@@ -66,21 +67,22 @@ export function describeRelayAddress({ config, lanAddress = null, wslNat = false
|
|
|
66
67
|
return { url, lines, qrUrl, tunnelUrl };
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
const VALUE_OPTIONS = ['--port', '--migrate-from', '--to', '--autostart'];
|
|
70
|
+
const VALUE_OPTIONS = ['--port', '--migrate-from', '--to', '--autostart', '--reason'];
|
|
70
71
|
const LAUNCHER_FLAGS = ['--install-extension', '--no-install-extension', '--migrate-from'];
|
|
71
72
|
const COMMAND_OPTIONS = { '--setup': 'setup', '--start': 'start', '--stop': 'stop', '--status': 'status' };
|
|
72
73
|
const CLI_COMMANDS = {
|
|
73
74
|
menu: {},
|
|
74
75
|
start: {},
|
|
75
|
-
stop: {},
|
|
76
|
-
restart: {},
|
|
76
|
+
stop: { flags: ['--now'] },
|
|
77
|
+
restart: { flags: ['--now'] },
|
|
77
78
|
status: {},
|
|
78
79
|
url: {},
|
|
79
80
|
doctor: {},
|
|
81
|
+
backup: { values: ['--reason'] },
|
|
80
82
|
help: {},
|
|
81
83
|
version: {},
|
|
82
84
|
setup: { flags: ['--lan', '--local', '--new-token', '--defaults', '--start'], values: ['--port', '--migrate-from', '--autostart'] },
|
|
83
|
-
service: { words: ['install', 'remove', 'status'] },
|
|
85
|
+
service: { words: ['install', 'remove', 'status'], flags: ['--replace'] },
|
|
84
86
|
autostart: { words: ['signin', 'boot', 'off', 'status'], flags: ['--replace'] },
|
|
85
87
|
uninstall: { flags: ['--purge'] },
|
|
86
88
|
update: { flags: ['--beta'], values: ['--to'] },
|
|
@@ -93,8 +95,8 @@ export function usageText() {
|
|
|
93
95
|
'',
|
|
94
96
|
' oar Menu in a terminal; status and this text elsewhere',
|
|
95
97
|
' oar start Start the relay in the background',
|
|
96
|
-
' oar stop
|
|
97
|
-
' oar restart
|
|
98
|
+
' oar stop [--now] Stop the relay (waits for running turns; --now does not)',
|
|
99
|
+
' oar restart [--now] Restart the relay (waits for running turns; --now does not)',
|
|
98
100
|
' oar status Is the relay running, on which port, as a service',
|
|
99
101
|
' oar url Relay URL, with a QR code when a phone can reach it',
|
|
100
102
|
' oar setup Auth token, access, port and autostart; asks, or takes',
|
|
@@ -103,13 +105,16 @@ export function usageText() {
|
|
|
103
105
|
' oar autostart signin | boot | off | status [--replace]',
|
|
104
106
|
' Start the relay at sign-in or at boot (Windows: boot asks for an admin once);',
|
|
105
107
|
' --replace replaces hand-made entries, keeping a copy',
|
|
106
|
-
' oar service install | remove | status',
|
|
107
|
-
' Start the relay at
|
|
108
|
+
' oar service install | remove | status [--replace]',
|
|
109
|
+
' Start the relay at boot and keep it running (Linux: a systemd user service);',
|
|
110
|
+
' --replace replaces a hand-made unit, keeping a copy',
|
|
108
111
|
' oar uninstall [--purge]',
|
|
109
112
|
' Remove autostart and the package; the data stays (--purge deletes it)',
|
|
110
113
|
' oar copilot [--port <port>] [--install-extension] [--no-install-extension] [-- gh copilot args...]',
|
|
111
114
|
' Relay plus a Copilot terminal session',
|
|
112
115
|
' oar doctor Check the install and the provider CLIs',
|
|
116
|
+
' oar backup [--reason <text>]',
|
|
117
|
+
' A copy of the database into the backup folder (safe while the relay runs)',
|
|
113
118
|
' oar update [--beta] [--to <version>]',
|
|
114
119
|
' oar help This text',
|
|
115
120
|
' oar --version',
|
|
@@ -178,15 +183,75 @@ export function parseCliArgs(argv = []) {
|
|
|
178
183
|
return { command, args: [...args, ...forwarded] };
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
// The first systemd version with RestartSteps= and RestartMaxDelaySec=.
|
|
187
|
+
const SYSTEMD_BACKOFF_VERSION = 254;
|
|
188
|
+
const UNIT_MARKER = /^# Generated by `oar service install` for (.*)\.$/m;
|
|
189
|
+
|
|
190
|
+
/** The version `systemctl --version` prints first (`systemd 257 (257.9-1)`), or null. */
|
|
191
|
+
export function parseSystemdVersion(text) {
|
|
192
|
+
const version = Number(String(text || '').match(/^systemd (\d+)/m)?.[1]);
|
|
193
|
+
return Number.isInteger(version) && version > 0 ? version : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
181
196
|
/**
|
|
182
197
|
* A systemd *user* unit for the relay server. The launcher is not used here —
|
|
183
|
-
* the service runs server.js directly
|
|
184
|
-
*
|
|
198
|
+
* the service runs server.js directly, so a `gh copilot` session is not tied
|
|
199
|
+
* to the unit's lifetime. A global install pins its state paths; a git
|
|
200
|
+
* checkout (`checkout`) runs in the checkout and finds its config, data and
|
|
201
|
+
* logs as `npm start` does. Crashes are restarted with a backoff that never
|
|
202
|
+
* gives up; a systemd older than 254 (or one whose version is unknown) has no
|
|
203
|
+
* backoff, and gets a longer fixed delay instead.
|
|
185
204
|
*/
|
|
186
|
-
export function buildSystemdUnit({
|
|
187
|
-
|
|
205
|
+
export function buildSystemdUnit({
|
|
206
|
+
nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '', checkout = false, systemdVersion = null,
|
|
207
|
+
}) {
|
|
208
|
+
// systemd units are Linux-only, so the paths inside the unit are always
|
|
188
209
|
// posix-joined — host-platform path.join would write backslashes when this
|
|
189
210
|
// template is exercised on Windows (tests; the CLI never writes it there).
|
|
211
|
+
const backoff = Number(systemdVersion) >= SYSTEMD_BACKOFF_VERSION;
|
|
212
|
+
return [
|
|
213
|
+
`# Generated by \`oar service install\` for ${packageRoot}.`,
|
|
214
|
+
'# OAR rewrites this file; put settings of your own in oar.service.d/*.conf next to it.',
|
|
215
|
+
'[Unit]',
|
|
216
|
+
'Description=OAR — Open Agent Relay',
|
|
217
|
+
'After=network-online.target',
|
|
218
|
+
'StartLimitIntervalSec=0',
|
|
219
|
+
'',
|
|
220
|
+
'[Service]',
|
|
221
|
+
`ExecStart=${systemdExecWord(nodeBin)} ${systemdExecWord(path.posix.join(packageRoot, 'server', 'server.js'))}`,
|
|
222
|
+
...(checkout
|
|
223
|
+
? [`WorkingDirectory=${String(packageRoot).replace(/%/g, '%%')}`]
|
|
224
|
+
: [
|
|
225
|
+
systemdEnvironmentLine('COPILOT_WEB_RELAY_CONFIG', configPath),
|
|
226
|
+
systemdEnvironmentLine('COPILOT_WEB_RELAY_DATA_DIR', dataDir),
|
|
227
|
+
systemdEnvironmentLine('COPILOT_WEB_RELAY_LOG_DIR', logDir),
|
|
228
|
+
]),
|
|
229
|
+
// A user service starts with systemd's bare PATH: without this line the
|
|
230
|
+
// relay's sessions find neither the provider CLIs in ~/.local/bin nor, on
|
|
231
|
+
// an install with a private Node, `node` and `npm` themselves.
|
|
232
|
+
...(pathEnv ? [`Environment="PATH=${escapeSystemdValue(pathEnv)}"`] : []),
|
|
233
|
+
// The supervisor alone gets the SIGTERM of a stop, and stops its runtime
|
|
234
|
+
// itself; whatever is left then gets SIGKILL. Session workers run in
|
|
235
|
+
// scopes of their own and are not part of the unit.
|
|
236
|
+
'KillMode=mixed',
|
|
237
|
+
'Restart=on-failure',
|
|
238
|
+
// 78 (RELAY_STOP_EXIT_CODE): the relay stopped on purpose, its database
|
|
239
|
+
// or data folder needs a decision; restarting it cannot help.
|
|
240
|
+
'# 78: stopped on purpose (database or data folder needs a decision).',
|
|
241
|
+
'RestartPreventExitStatus=78',
|
|
242
|
+
...(backoff ? ['RestartSec=2', 'RestartSteps=8', 'RestartMaxDelaySec=300'] : ['RestartSec=10']),
|
|
243
|
+
'',
|
|
244
|
+
'[Install]',
|
|
245
|
+
'WantedBy=default.target',
|
|
246
|
+
'',
|
|
247
|
+
].join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The unit 0.9.19 wrote, frozen: a unit with this text is OAR's own, of an
|
|
252
|
+
* earlier version (classifySystemdUnit). Never change it.
|
|
253
|
+
*/
|
|
254
|
+
export function buildSystemdUnit0919({ nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '' }) {
|
|
190
255
|
return [
|
|
191
256
|
'[Unit]',
|
|
192
257
|
'Description=OAR — Open Agent Relay',
|
|
@@ -219,6 +284,102 @@ function escapeSystemdValue(value) {
|
|
|
219
284
|
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
|
|
220
285
|
}
|
|
221
286
|
|
|
287
|
+
// Characters that end or change a word of an unquoted systemd value.
|
|
288
|
+
const SYSTEMD_NEEDS_QUOTES = /[\s"'\\]/;
|
|
289
|
+
|
|
290
|
+
// One word of ExecStart=: quoted when it has to be; `%` and `$` (a specifier,
|
|
291
|
+
// a variable) are doubled either way.
|
|
292
|
+
function systemdExecWord(value) {
|
|
293
|
+
const text = String(value).replace(/\$/g, '$$$$');
|
|
294
|
+
return SYSTEMD_NEEDS_QUOTES.test(text) ? `"${escapeSystemdValue(text)}"` : text.replace(/%/g, '%%');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// `Environment=KEY=value`, quoted only when the value has to be: a path
|
|
298
|
+
// without a space is written as 0.9.19 wrote it.
|
|
299
|
+
function systemdEnvironmentLine(name, value) {
|
|
300
|
+
const text = String(value);
|
|
301
|
+
return SYSTEMD_NEEDS_QUOTES.test(text)
|
|
302
|
+
? `Environment="${name}=${escapeSystemdValue(text)}"`
|
|
303
|
+
: `Environment=${name}=${text.replace(/%/g, '%%')}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The first word of a unit's ExecStart= line: `written` as it stands, `value`
|
|
307
|
+
// unquoted and unescaped as systemdExecWord wrote it.
|
|
308
|
+
function readExecStartNode(text) {
|
|
309
|
+
const line = String(text || '').match(/^ExecStart=(.*)$/m)?.[1] || '';
|
|
310
|
+
if (line.startsWith('"')) {
|
|
311
|
+
const written = line.match(/^"((?:[^"\\]|\\.)*)"/)?.[0] || '';
|
|
312
|
+
const value = written.slice(1, -1).replace(/\\(.)/g, '$1').replace(/%%/g, '%').replace(/\$\$/g, '$');
|
|
313
|
+
return { written, value };
|
|
314
|
+
}
|
|
315
|
+
const written = line.split(' ')[0];
|
|
316
|
+
return { written, value: written.replace(/%%/g, '%').replace(/\$\$/g, '$') };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The words of a unit's ExecStart= line, unquoted and unescaped.
|
|
320
|
+
function readExecStartWords(text) {
|
|
321
|
+
const line = String(text || '').match(/^ExecStart=(.*)$/m)?.[1] || '';
|
|
322
|
+
const words = [];
|
|
323
|
+
for (const match of line.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)) {
|
|
324
|
+
const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
|
|
325
|
+
words.push(word.replace(/%%/g, '%').replace(/\$\$/g, '$'));
|
|
326
|
+
}
|
|
327
|
+
return words;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Whether a unit without OAR's marker runs the relay of the checkout at
|
|
332
|
+
* `root`: its ExecStart names `<root>/server/server.js`. A unit that only
|
|
333
|
+
* runs in `<root>` (WorkingDirectory) does not count: it may start anything
|
|
334
|
+
* there. A hand-made unit of another install is not a checkout's service.
|
|
335
|
+
*/
|
|
336
|
+
export function systemdUnitRunsCheckout(text, root) {
|
|
337
|
+
const resolve = (value) => path.posix.resolve(String(value));
|
|
338
|
+
const server = path.posix.join(resolve(root), 'server', 'server.js');
|
|
339
|
+
return readExecStartWords(text).some((word) => word.startsWith('/') && resolve(word) === server);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The install a unit's marker names, or ''. */
|
|
343
|
+
export function systemdUnitMarkerRoot(text) {
|
|
344
|
+
return String(text || '').match(UNIT_MARKER)?.[1] || '';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Whose a unit is, for this install (`{ nodeBin, packageRoot, configPath,
|
|
349
|
+
* dataDir, logDir, checkout }`): 'generated' (the text this version writes for
|
|
350
|
+
* it, with the Node of its own ExecStart and for either systemd variant),
|
|
351
|
+
* 'generated-earlier' (the text 0.9.19 wrote for it), 'other-install' (OAR's
|
|
352
|
+
* marker for another install path), or 'hand-made' (anything else, a
|
|
353
|
+
* generated unit edited by hand too). The PATH line does not count: it is the
|
|
354
|
+
* PATH of the shell that ran the install.
|
|
355
|
+
*/
|
|
356
|
+
export function classifySystemdUnit({ text, install }) {
|
|
357
|
+
const strip = (unit) => String(unit).replace(/\r\n/g, '\n').split('\n')
|
|
358
|
+
.filter((line) => !line.startsWith('Environment="PATH=')).join('\n').trim();
|
|
359
|
+
const given = strip(text);
|
|
360
|
+
const node = readExecStartNode(text);
|
|
361
|
+
const args = { ...install, nodeBin: node.value || install.nodeBin };
|
|
362
|
+
if ([null, SYSTEMD_BACKOFF_VERSION].some((systemdVersion) => strip(buildSystemdUnit({ ...args, systemdVersion })) === given)) {
|
|
363
|
+
return 'generated';
|
|
364
|
+
}
|
|
365
|
+
if (!install.checkout && strip(buildSystemdUnit0919({ ...install, nodeBin: node.written || install.nodeBin })) === given) {
|
|
366
|
+
return 'generated-earlier';
|
|
367
|
+
}
|
|
368
|
+
const root = systemdUnitMarkerRoot(text);
|
|
369
|
+
if (root && path.posix.resolve(root) !== path.posix.resolve(String(install.packageRoot))) return 'other-install';
|
|
370
|
+
return 'hand-made';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The drop-ins next to the unit (`oar.service.d/*.conf`), sorted; OAR never writes or deletes them. */
|
|
374
|
+
export function listSystemdDropIns(homeDir, { fsImpl = fs } = {}) {
|
|
375
|
+
const dir = path.join(homeDir, '.config', 'systemd', 'user', 'oar.service.d');
|
|
376
|
+
try {
|
|
377
|
+
return fsImpl.readdirSync(dir).filter((name) => name.endsWith('.conf')).sort().map((name) => path.join(dir, name));
|
|
378
|
+
} catch {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
222
383
|
/**
|
|
223
384
|
* The PATH a relay service gets: the PATH `oar setup` ran with (the user's
|
|
224
385
|
* shell, so their tools), with two directories added when they are not on it:
|
|
@@ -287,6 +448,42 @@ export const DOCTOR_PROBES = Object.freeze([
|
|
|
287
448
|
{ id: 'grok', binary: 'grok', args: ['--version'] },
|
|
288
449
|
]);
|
|
289
450
|
|
|
451
|
+
|
|
452
|
+
function doctorAge(at, now) {
|
|
453
|
+
const ms = now - Date.parse(String(at || ''));
|
|
454
|
+
if (!Number.isFinite(ms)) return 'unknown age';
|
|
455
|
+
const hours = Math.floor(ms / 3_600_000);
|
|
456
|
+
return hours < 1 ? 'less than an hour ago' : (hours < 48 ? `${hours} h ago` : `${Math.floor(hours / 24)} days ago`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// The backup lines of `oar doctor` (db-backup-service.mjs describeBackups).
|
|
460
|
+
function renderBackupLines(backups, now) {
|
|
461
|
+
const pad = ' ';
|
|
462
|
+
const lines = [` backups : ${backups.dir || '(no folder)'}${backups.problem ? ` (${backups.problem})` : ''}`];
|
|
463
|
+
lines.push(`${pad}at start: ${backups.enabled ? 'on' : 'off (dbBackups.enabled is false)'}, keep ${backups.keep}`);
|
|
464
|
+
for (const warning of backups.warnings || []) lines.push(`${pad}warning: ${warning}`);
|
|
465
|
+
const last = backups.lastResult;
|
|
466
|
+
if (last) {
|
|
467
|
+
const what = last.status === 'written' ? `written ${last.file} (${formatMegabytes(last.bytes)})` : `${last.status}: ${last.reason || ''}`;
|
|
468
|
+
lines.push(`${pad}last: ${what}, ${doctorAge(last.at, now)}`);
|
|
469
|
+
}
|
|
470
|
+
if (backups.own?.length) {
|
|
471
|
+
lines.push(`${pad}this relay's (${backups.own.length} of ${backups.keep}):`);
|
|
472
|
+
for (const entry of backups.own) lines.push(`${pad} ${entry.file} ${formatMegabytes(entry.bytes)}, ${doctorAge(entry.at, now)}`);
|
|
473
|
+
} else {
|
|
474
|
+
lines.push(`${pad}this relay's: none yet`);
|
|
475
|
+
}
|
|
476
|
+
if (backups.changed?.length) {
|
|
477
|
+
lines.push(`${pad}changed since written, not rotated:`);
|
|
478
|
+
for (const entry of backups.changed) lines.push(`${pad} ${entry.file} ${formatMegabytes(entry.bytes)}`);
|
|
479
|
+
}
|
|
480
|
+
if (backups.others?.length) {
|
|
481
|
+
lines.push(`${pad}other files here (not rotated):`);
|
|
482
|
+
for (const entry of backups.others) lines.push(`${pad} ${entry.name}${entry.folder ? '/' : ''} ${formatMegabytes(entry.bytes)}`);
|
|
483
|
+
}
|
|
484
|
+
return lines;
|
|
485
|
+
}
|
|
486
|
+
|
|
290
487
|
export function renderDoctorReport({
|
|
291
488
|
version,
|
|
292
489
|
nodeVersion,
|
|
@@ -296,6 +493,9 @@ export function renderDoctorReport({
|
|
|
296
493
|
config,
|
|
297
494
|
dbPath,
|
|
298
495
|
dbSizeBytes,
|
|
496
|
+
databaseNotes = [],
|
|
497
|
+
backups = null,
|
|
498
|
+
now = Date.now(),
|
|
299
499
|
probes = [],
|
|
300
500
|
warnings = [],
|
|
301
501
|
tunnel = '',
|
|
@@ -318,6 +518,8 @@ export function renderDoctorReport({
|
|
|
318
518
|
lines.push(` tunnel : ${tunnel || configured}`);
|
|
319
519
|
}
|
|
320
520
|
lines.push(` database : ${dbSizeBytes !== null ? `${dbPath} (${(dbSizeBytes / 1048576).toFixed(1)} MB)` : `${dbPath} (not created yet)`}`);
|
|
521
|
+
for (const note of databaseNotes) lines.push(` ${note}`);
|
|
522
|
+
if (backups) lines.push(...renderBackupLines(backups, now));
|
|
321
523
|
lines.push(' provider CLIs :');
|
|
322
524
|
for (const probe of probes) {
|
|
323
525
|
lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
|
|
@@ -23,10 +23,10 @@ import fs from 'node:fs';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
|
|
26
|
+
import { DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile, sideFiles } from './database-file.mjs';
|
|
26
27
|
import { createRelaySingletonGuard } from './relay-singleton-guard.mjs';
|
|
27
28
|
|
|
28
29
|
export const MIGRATION_MARKER = '.migrated';
|
|
29
|
-
const DB_FILE = 'copilot.db';
|
|
30
30
|
const MAX_LOGS_BYTES = 50 * 1024 * 1024;
|
|
31
31
|
|
|
32
32
|
export function resolveOarRoot(env = process.env, platform = process.platform) {
|
|
@@ -257,16 +257,23 @@ export async function migrateStateToOarRoot({
|
|
|
257
257
|
const markerPath = path.join(root, MIGRATION_MARKER);
|
|
258
258
|
if (!repoServerDir && fs.existsSync(markerPath)) return { status: 'already-migrated', root };
|
|
259
259
|
|
|
260
|
-
|
|
260
|
+
// A database there under either name, and the -wal/-shm of each.
|
|
261
|
+
const targetDbFiles = DB_FILE_NAMES.flatMap(sideFiles).map((name) => path.join(dataDir, name));
|
|
262
|
+
const takenTargetDbs = () => DB_FILE_NAMES.map((name) => path.join(dataDir, name)).filter((file) => pathTaken(file));
|
|
261
263
|
const targetLock = path.join(dataDir, 'relay-server.lock');
|
|
262
264
|
// The same checkout once more (a repeated command, an alias): nothing to
|
|
263
265
|
// do while the database it brought is still there.
|
|
264
|
-
if (repoServerDir &&
|
|
266
|
+
if (repoServerDir && takenTargetDbs().length && wasMigratedFrom(root, repoServerDir)) {
|
|
265
267
|
return { status: 'already-migrated', root, source: repoServerDir };
|
|
266
268
|
}
|
|
267
269
|
|
|
268
270
|
const sourceData = repoServerDir ? path.join(repoServerDir, 'data') : null;
|
|
269
|
-
|
|
271
|
+
// The database keeps its name; with both names there the other comes
|
|
272
|
+
// along as it is, and the relay's next start compares the two.
|
|
273
|
+
const sourceFile = sourceData ? resolveDatabaseFile(sourceData) : null;
|
|
274
|
+
const sourceDb = sourceFile && !sourceFile.created ? sourceFile.path : null;
|
|
275
|
+
const sourceOther = sourceFile?.both ? path.join(sourceData, LEGACY_DB_FILE_NAME) : null;
|
|
276
|
+
const targetDb = sourceDb ? path.join(dataDir, path.basename(sourceDb)) : null;
|
|
270
277
|
const sourceRepoConfig = repoServerDir ? path.join(repoServerDir, 'config.json') : null;
|
|
271
278
|
const sourceManagedConfig = managedConfigDir ? path.join(managedConfigDir, 'config.json') : null;
|
|
272
279
|
|
|
@@ -305,20 +312,28 @@ export async function migrateStateToOarRoot({
|
|
|
305
312
|
const refusedWithConversations = () => ({
|
|
306
313
|
status: 'refused-existing-database',
|
|
307
314
|
root,
|
|
308
|
-
error: `The state root already has a database with conversations (${
|
|
315
|
+
error: `The state root already has a database with conversations (${takenTargetDbs().join(', ')}); `
|
|
309
316
|
+ `a migration never overwrites one. To bring ${repoServerDir} over instead: `
|
|
310
317
|
+ `stop the relay (oar stop), move ${dataDir} aside, then run the migration again.`,
|
|
311
318
|
});
|
|
319
|
+
const anyTargetNotFresh = async () => {
|
|
320
|
+
const Database = await loadDatabase(DatabaseImpl);
|
|
321
|
+
return takenTargetDbs().some((file) => !isFreshDatabase(Database, file));
|
|
322
|
+
};
|
|
312
323
|
if (hasDb && existingDbFiles().length) {
|
|
313
|
-
|
|
324
|
+
// A -wal or -shm whose database is not there.
|
|
325
|
+
const orphans = DB_FILE_NAMES.filter((name) => !pathTaken(path.join(dataDir, name)))
|
|
326
|
+
.flatMap((name) => sideFiles(name).slice(1).map((side) => path.join(dataDir, side)))
|
|
327
|
+
.filter((file) => pathTaken(file));
|
|
328
|
+
if (orphans.length) {
|
|
314
329
|
return {
|
|
315
330
|
status: 'refused-existing-database',
|
|
316
331
|
root,
|
|
317
|
-
error: `The state root has database files without
|
|
332
|
+
error: `The state root has database files without their database (${orphans.join(', ')}); `
|
|
318
333
|
+ 'a migration never mixes a database with them. Move them aside, then run the migration again.',
|
|
319
334
|
};
|
|
320
335
|
}
|
|
321
|
-
if (
|
|
336
|
+
if (await anyTargetNotFresh()) return refusedWithConversations();
|
|
322
337
|
}
|
|
323
338
|
|
|
324
339
|
const copied = [];
|
|
@@ -355,7 +370,7 @@ export async function migrateStateToOarRoot({
|
|
|
355
370
|
}
|
|
356
371
|
// Asked again under the lock: a relay that ran between the first look and
|
|
357
372
|
// the lock may have written conversations into the database meanwhile.
|
|
358
|
-
if (
|
|
373
|
+
if (await anyTargetNotFresh()) {
|
|
359
374
|
try { guard.release(); } catch {}
|
|
360
375
|
removeCreated(created);
|
|
361
376
|
return refusedWithConversations();
|
|
@@ -384,9 +399,14 @@ export async function migrateStateToOarRoot({
|
|
|
384
399
|
// Checkpoint first so recent writes leave the WAL, then copy all three
|
|
385
400
|
// files anyway — a hot -wal must never be dropped on the floor.
|
|
386
401
|
sourceCounts = readDbCounts(Database, sourceDb, { checkpoint: true });
|
|
387
|
-
copyIfExists(sourceDb,
|
|
388
|
-
copyIfExists(`${sourceDb}-wal`,
|
|
389
|
-
copyIfExists(`${sourceDb}-shm`,
|
|
402
|
+
copyIfExists(sourceDb, targetDb, copied, created, skipped);
|
|
403
|
+
copyIfExists(`${sourceDb}-wal`, `${targetDb}-wal`, copied, created, skipped);
|
|
404
|
+
copyIfExists(`${sourceDb}-shm`, `${targetDb}-shm`, copied, created, skipped);
|
|
405
|
+
if (sourceOther) {
|
|
406
|
+
for (const name of sideFiles(path.basename(sourceOther))) {
|
|
407
|
+
copyIfExists(path.join(sourceData, name), path.join(dataDir, name), copied, created, skipped);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
390
410
|
copyIfExists(path.join(sourceData, 'cursor-agents'), path.join(dataDir, 'cursor-agents'), copied, created, skipped);
|
|
391
411
|
}
|
|
392
412
|
|
|
@@ -410,7 +430,7 @@ export async function migrateStateToOarRoot({
|
|
|
410
430
|
|
|
411
431
|
if (hasDb) {
|
|
412
432
|
const Database = await loadDatabase(DatabaseImpl);
|
|
413
|
-
const targetCounts = readDbCounts(Database,
|
|
433
|
+
const targetCounts = readDbCounts(Database, targetDb);
|
|
414
434
|
const mismatch = targetCounts.integrity !== 'ok'
|
|
415
435
|
|| targetCounts.conversations !== sourceCounts.conversations
|
|
416
436
|
|| targetCounts.messages !== sourceCounts.messages;
|
|
@@ -17,17 +17,19 @@ import crypto from 'crypto';
|
|
|
17
17
|
import fs from 'fs';
|
|
18
18
|
import path from 'path';
|
|
19
19
|
|
|
20
|
+
import { DB_FILE_NAMES, sideFiles } from './database-file.mjs';
|
|
20
21
|
import { RESCUE_DIR_NAME, copyMissing, isRetiredName } from './earlier-database-service.mjs';
|
|
21
22
|
import { resolveRelayStateDirs, resolveStateLayout } from './oar-state-layout.mjs';
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// Old packages only ever held the name before 0.9.20; a later one may hold oar.db.
|
|
25
|
+
const DB_FILES = DB_FILE_NAMES.flatMap(sideFiles);
|
|
24
26
|
|
|
25
27
|
// What a relay left in a package's server folder; a folder that cannot be
|
|
26
28
|
// read throws (no copy failure: nothing was detected yet).
|
|
27
29
|
function hasRelayData(fsImpl, serverDir) {
|
|
28
30
|
const has = (...parts) => fsImpl.existsSync(path.join(serverDir, ...parts));
|
|
29
31
|
const anyUpload = (...parts) => has(...parts) && fsImpl.readdirSync(path.join(serverDir, ...parts)).some((name) => /^[a-f0-9]{64}$/.test(name));
|
|
30
|
-
return has('data',
|
|
32
|
+
return DB_FILE_NAMES.some((name) => has('data', name)) || has('data', 'cursor-agents') || has('config.json') || anyUpload('uploads') || anyUpload('data', 'uploads');
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** Where npm's reify moves a package it replaces (@npmcli/arborist retire-path.js). */
|
|
@@ -326,20 +326,7 @@ export function buildClaudeApiPlanCard({
|
|
|
326
326
|
note: 'Claude API turns on this relay only; the Console has every request on the key.',
|
|
327
327
|
open: true,
|
|
328
328
|
}));
|
|
329
|
-
|
|
330
|
-
id: 'claude-api-tokens-by-model',
|
|
331
|
-
label: 'Tokens by model, this month',
|
|
332
|
-
rows: month.models.map((entry) => {
|
|
333
|
-
const input = entry.hitTokens + entry.missTokens;
|
|
334
|
-
return {
|
|
335
|
-
label: modelLabel(entry.model, settings),
|
|
336
|
-
value: `cache read ${formatTokens(entry.hitTokens)} · other input ${formatTokens(entry.missTokens)} · output ${formatTokens(entry.outputTokens)}`,
|
|
337
|
-
hint: input > 0 ? `${Math.round((entry.hitTokens / input) * 100)}% of input from the cache` : null,
|
|
338
|
-
};
|
|
339
|
-
}),
|
|
340
|
-
note: 'Other input includes the writes to the prompt cache.',
|
|
341
|
-
open: true,
|
|
342
|
-
}));
|
|
329
|
+
// Tokens by model: the Tokens sections every tab gets (plan-usage-tokens.mjs).
|
|
343
330
|
}
|
|
344
331
|
|
|
345
332
|
return buildProviderCard({
|
|
@@ -395,7 +382,19 @@ export function createClaudeApiPlanUsage({
|
|
|
395
382
|
return buildClaudeApiPlanCard({ settings, spend, resetAt, timeZone: zoneName, error });
|
|
396
383
|
}
|
|
397
384
|
|
|
398
|
-
|
|
385
|
+
/**
|
|
386
|
+
* A model's name as the card shows it: the Console's display name, else
|
|
387
|
+
* the Claude label for the id (the Tokens sections use it too).
|
|
388
|
+
*/
|
|
389
|
+
function labelModel(model) {
|
|
390
|
+
try {
|
|
391
|
+
return modelLabel(model, getSettings());
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { readSpend, getCapState, buildCard, modelLabel: labelModel };
|
|
399
398
|
}
|
|
400
399
|
|
|
401
400
|
/**
|
|
@@ -260,7 +260,10 @@ export function buildCardNote({ id, text, link = null, hours = null, label = nul
|
|
|
260
260
|
};
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
/**
|
|
263
|
+
/**
|
|
264
|
+
* A collapsible block of label/value rows shown under the meters. A row may
|
|
265
|
+
* carry a `title`, shown when pointing at it (the model ids behind one name).
|
|
266
|
+
*/
|
|
264
267
|
export function buildDetailSection({ id, label, rows = [], note = null, open = false } = {}) {
|
|
265
268
|
const sectionId = toTrimmedString(id);
|
|
266
269
|
const sectionLabel = toTrimmedString(label);
|
|
@@ -270,7 +273,8 @@ export function buildDetailSection({ id, label, rows = [], note = null, open = f
|
|
|
270
273
|
const rowLabel = toTrimmedString(row?.label);
|
|
271
274
|
const rowValue = row?.value === 0 ? '0' : toTrimmedString(row?.value);
|
|
272
275
|
if (!rowLabel || rowValue === null) return null;
|
|
273
|
-
|
|
276
|
+
const rowTitle = toTrimmedString(row?.title);
|
|
277
|
+
return { label: rowLabel, value: rowValue, hint: toTrimmedString(row?.hint), ...(rowTitle ? { title: rowTitle } : {}) };
|
|
274
278
|
})
|
|
275
279
|
.filter(Boolean);
|
|
276
280
|
if (!normalizedRows.length) return null;
|
|
@@ -877,7 +877,7 @@ export function buildDeepSeekPlanCard({
|
|
|
877
877
|
}));
|
|
878
878
|
details.push(buildDetailSection({
|
|
879
879
|
id: 'deepseek-tokens-by-model',
|
|
880
|
-
label: '
|
|
880
|
+
label: 'Priced tokens by model',
|
|
881
881
|
rows: spend.models.map((entry) => {
|
|
882
882
|
const input = entry.hitTokens + entry.missTokens;
|
|
883
883
|
return {
|