@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +359 -0
- package/README.md +10 -10
- package/bin/oar.js +391 -97
- 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 +27 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -2
- package/server/claude-worker/claude-session-process.mjs +3 -0
- 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 +57 -4
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +44 -6
- 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-view.mjs +87 -3
- package/server/public/app/api-client.js +2 -1
- package/server/public/app/bootstrap.js +55 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-view.js +17 -2
- package/server/public/app/inbox-ui.js +32 -0
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- 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-view.mjs +4 -0
- package/server/public/app/socket-handlers.js +10 -2
- package/server/public/index.html +14 -0
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +351 -0
- package/server/relay-self-restart.mjs +116 -9
- package/server/relay-tools.md +5 -4
- package/server/repositories/message-repository.mjs +31 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/messages-routes.mjs +120 -4
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +141 -31
- package/server/server-runtime.mjs +142 -11
- package/server/services/about-service.mjs +59 -0
- package/server/services/cache-rebuild-service.mjs +7 -0
- 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 +68 -8
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-stop-service.mjs +27 -6
- 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-scope.mjs +77 -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 +98 -21
- package/shared/turn-token-usage.mjs +144 -0
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { resumeRelayMode } from '../../shared/plan-only.mjs';
|
|
43
|
+
import { readPlanOnly } from './conversation-preferences-service.mjs';
|
|
42
44
|
|
|
43
45
|
export const RESTART_RESUME_SETTING_KEY = 'restart_resume_notes';
|
|
44
46
|
export const RESTART_RESUME_KIND = 'relay-restart-resume';
|
|
@@ -91,7 +93,7 @@ export function createRestartResumeService({
|
|
|
91
93
|
if (!db || !stmts) throw new Error('createRestartResumeService requires db and stmts');
|
|
92
94
|
const listProcessing = db.prepare(`
|
|
93
95
|
SELECT id, conversation_id, model, model_variant_id, reasoning_effort, context_tier, relay_mode,
|
|
94
|
-
owner_sdk_session_id, runtime_session_id
|
|
96
|
+
owner_sdk_session_id, runtime_session_id, text
|
|
95
97
|
FROM queue WHERE status = 'processing'
|
|
96
98
|
`);
|
|
97
99
|
const statusOf = db.prepare(`SELECT status FROM queue WHERE id = ?`);
|
|
@@ -212,7 +214,8 @@ export function createRestartResumeService({
|
|
|
212
214
|
// The model and mode the interrupted turn ran with.
|
|
213
215
|
const model = text(cutRow?.model) || text(latestModel) || text(runtimeSession?.provider_model) || text(runtimeSession?.model) || null;
|
|
214
216
|
const modelVariant = text(cutRow?.model_variant_id) || model;
|
|
215
|
-
|
|
217
|
+
// On a plan-only session only the "Approve plan" turn goes on in agent mode.
|
|
218
|
+
const relayMode = resumeRelayMode({ relayMode: cutRow?.relay_mode, text: cutRow?.text, planOnly: readPlanOnly(conversation) });
|
|
216
219
|
const owner = text(cutRow?.owner_sdk_session_id) || text(runtimeSession?.sdk_session_id) || null;
|
|
217
220
|
const write = db.transaction(() => {
|
|
218
221
|
stmts.insertMsg.run(
|
|
@@ -1126,6 +1126,9 @@ export async function launchSessionCli({
|
|
|
1126
1126
|
locateExecutableImpl = locateExecutable,
|
|
1127
1127
|
// Asked after a failed spawn whether its folder is gone (the error, or null).
|
|
1128
1128
|
checkWorkingDirectoryImpl = missingWorkingDirectoryError,
|
|
1129
|
+
// Under a systemd user service: puts the worker in a scope of its own
|
|
1130
|
+
// (worker-scope.mjs). None: the worker starts as it is.
|
|
1131
|
+
workerScope = null,
|
|
1129
1132
|
} = {}) {
|
|
1130
1133
|
const target = String(targetSessionId || '').trim();
|
|
1131
1134
|
if (!target) throw new Error('missing-target-session-id');
|
|
@@ -1237,7 +1240,7 @@ export async function launchSessionCli({
|
|
|
1237
1240
|
? prepareWorkerLogFile(target, launchSessionEnv)
|
|
1238
1241
|
: null;
|
|
1239
1242
|
try {
|
|
1240
|
-
|
|
1243
|
+
const tmuxArgs = [
|
|
1241
1244
|
'new-session',
|
|
1242
1245
|
'-d',
|
|
1243
1246
|
'-s',
|
|
@@ -1251,7 +1254,10 @@ export async function launchSessionCli({
|
|
|
1251
1254
|
workerLogPath: tmuxWorkerLogPath || '',
|
|
1252
1255
|
extraCliArgs: extensionCliArgs,
|
|
1253
1256
|
}),
|
|
1254
|
-
]
|
|
1257
|
+
];
|
|
1258
|
+
// A tmux server this call starts is in the worker's own scope.
|
|
1259
|
+
const tmuxLaunch = workerScope ? workerScope.wrap('tmux', tmuxArgs) : { command: 'tmux', args: tmuxArgs };
|
|
1260
|
+
execFileSyncImpl(tmuxLaunch.command, tmuxLaunch.args, {
|
|
1255
1261
|
env: tmuxEnv,
|
|
1256
1262
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1257
1263
|
});
|
|
@@ -1351,7 +1357,10 @@ export async function launchSessionCli({
|
|
|
1351
1357
|
}
|
|
1352
1358
|
}
|
|
1353
1359
|
}
|
|
1354
|
-
|
|
1360
|
+
// POSIX: in a scope of its own under a systemd user service; the process
|
|
1361
|
+
// id is still the worker's.
|
|
1362
|
+
const detachedLaunch = platform !== 'win32' && workerScope ? workerScope.wrap(spawnCommand, spawnArgs) : { command: spawnCommand, args: spawnArgs };
|
|
1363
|
+
const child = spawnImpl(detachedLaunch.command, detachedLaunch.args, {
|
|
1355
1364
|
cwd: launchProcessCwd,
|
|
1356
1365
|
env: launchSessionEnv,
|
|
1357
1366
|
detached: true,
|
|
@@ -22,12 +22,17 @@ function toPidList(rows, extraPid) {
|
|
|
22
22
|
|
|
23
23
|
// The process list is read off the relay's thread, fresh (a stop must see
|
|
24
24
|
// the children started a moment ago). Throws when the list could not be
|
|
25
|
-
// read: the caller must not pretend it stopped what it could not see.
|
|
26
|
-
|
|
25
|
+
// read: the caller must not pretend it stopped what it could not see. The
|
|
26
|
+
// registry's pid counts only while that process is alive: a worker that is
|
|
27
|
+
// gone is stopped already (on Windows its stop call alone could fail).
|
|
28
|
+
async function enumeratePids({ sdkSessionId, worker, processInspector, platform, isPidAliveImpl }) {
|
|
27
29
|
const rows = platform === 'win32'
|
|
28
30
|
? await processInspector?.findWindowsProcessTreeForSessionAsync?.(sdkSessionId, { fresh: true })
|
|
29
31
|
: await processInspector?.findProcessesForSessionAsync?.(sdkSessionId, { fresh: true });
|
|
30
|
-
|
|
32
|
+
const listed = toPidList(rows || []);
|
|
33
|
+
const workerPid = Number(worker?.pid);
|
|
34
|
+
const workerAlive = Number.isInteger(workerPid) && workerPid > 0 && (listed.includes(workerPid) || isPidAliveImpl(workerPid));
|
|
35
|
+
return workerAlive ? toPidList(rows || [], workerPid) : listed;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
async function stopWindowsPids(processInspector, pids) {
|
|
@@ -36,13 +41,29 @@ async function stopWindowsPids(processInspector, pids) {
|
|
|
36
41
|
|
|
37
42
|
export const PROCESS_LIST_UNREADABLE = 'process-list-unreadable';
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Whether `pid` is a live process: signal 0 reaches it. EPERM means it
|
|
46
|
+
* exists but belongs to someone else: alive. The default for every stop
|
|
47
|
+
* that is given no check of its own.
|
|
48
|
+
*/
|
|
49
|
+
export function isProcessAlive(pid, { killImpl = (target, signal) => process.kill(target, signal) } = {}) {
|
|
50
|
+
const value = Number(pid);
|
|
51
|
+
if (!Number.isInteger(value) || value <= 0) return false;
|
|
52
|
+
try {
|
|
53
|
+
killImpl(value, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return String(error?.code || '').toUpperCase() === 'EPERM';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
export async function stopSessionWorkerProcesses({
|
|
40
61
|
sdkSessionId,
|
|
41
62
|
worker = null,
|
|
42
63
|
processInspector = null,
|
|
43
64
|
platform = process.platform,
|
|
44
65
|
killImpl = (pid, signal) => process.kill(pid, signal),
|
|
45
|
-
isPidAliveImpl =
|
|
66
|
+
isPidAliveImpl = isProcessAlive,
|
|
46
67
|
killTmuxSessionImpl = null,
|
|
47
68
|
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
48
69
|
nowMs = Date.now,
|
|
@@ -67,7 +88,7 @@ export async function stopSessionWorkerProcesses({
|
|
|
67
88
|
|
|
68
89
|
let pids;
|
|
69
90
|
try {
|
|
70
|
-
pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
|
|
91
|
+
pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
|
|
71
92
|
} catch (error) {
|
|
72
93
|
// Nothing is stopped blind: the known pid alone would leave the tree
|
|
73
94
|
// running, and the caller would relaunch next to it.
|
|
@@ -115,7 +136,7 @@ export async function stopSessionWorkerProcesses({
|
|
|
115
136
|
try {
|
|
116
137
|
if (platform === 'win32') {
|
|
117
138
|
// Re-enumerate: children spawned after the first snapshot are invisible to it.
|
|
118
|
-
const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
|
|
139
|
+
const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
|
|
119
140
|
escalationTargets = [...new Set([...remainingPids, ...rediscovered.filter((pid) => isPidAliveImpl(pid))])];
|
|
120
141
|
await stopWindowsPids(processInspector, escalationTargets);
|
|
121
142
|
} else {
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tokens per turn for every provider, and their daily sums: booked
|
|
5
|
+
* from the workers' reports (POST /api/turn-token-usage), read by the Tokens
|
|
6
|
+
* sections in Check Usage and the About panel.
|
|
7
|
+
*
|
|
8
|
+
* A turn's row (`turn_token_usage`, migration 0036) is keyed by turn, run
|
|
9
|
+
* and model. Its counts only rise: a later report of the same key keeps,
|
|
10
|
+
* field by field, the larger of the two (a field it leaves out keeps the
|
|
11
|
+
* earlier value), and only the rise goes into the day's row
|
|
12
|
+
* (`token_usage_days`, source `turns`). A retried or reordered report
|
|
13
|
+
* therefore books nothing twice. The day is the relay's local date (its
|
|
14
|
+
* time zone) of the turn's first report: the worker's `at`, or the relay's
|
|
15
|
+
* clock when that is missing or more than a day off. A later report of the
|
|
16
|
+
* same turn books into that day too.
|
|
17
|
+
*
|
|
18
|
+
* A Claude cloud report carries the session's running totals instead: the
|
|
19
|
+
* turn is what they add to the other turns already booked for the session
|
|
20
|
+
* and model (floored at 0), so a retry of the same turn stays the same.
|
|
21
|
+
*
|
|
22
|
+
* Day rows survive the chat: deleting it drops only its turn rows.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
TOKEN_USAGE_DAYS_TABLE,
|
|
27
|
+
TURN_TOKEN_USAGE_TABLE,
|
|
28
|
+
migrateTokenUsage,
|
|
29
|
+
tokenUsageProblem,
|
|
30
|
+
} from '../migrations/0036-token-usage.mjs';
|
|
31
|
+
import { TURN_TOKEN_FIELDS, addNullable } from '../../shared/turn-token-usage.mjs';
|
|
32
|
+
|
|
33
|
+
const COLUMN = Object.freeze({
|
|
34
|
+
inputTokens: 'input_tokens',
|
|
35
|
+
outputTokens: 'output_tokens',
|
|
36
|
+
cacheWriteTokens: 'cache_write_tokens',
|
|
37
|
+
cacheReadTokens: 'cache_read_tokens',
|
|
38
|
+
reasoningTokens: 'reasoning_tokens',
|
|
39
|
+
});
|
|
40
|
+
// The fields the views show and total; reasoning is a part of output.
|
|
41
|
+
const PRICED_FIELDS = Object.freeze(['inputTokens', 'outputTokens', 'cacheWriteTokens', 'cacheReadTokens']);
|
|
42
|
+
// How far a worker's `at` may be off the relay's clock before the relay's
|
|
43
|
+
// own time is taken instead.
|
|
44
|
+
const AT_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
function dayFormatter(timeZone) {
|
|
47
|
+
try {
|
|
48
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
49
|
+
} catch {
|
|
50
|
+
return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function maxNullable(a, b) {
|
|
55
|
+
if (a == null) return b == null ? null : b;
|
|
56
|
+
if (b == null) return a;
|
|
57
|
+
return Math.max(a, b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// What `merged` adds over `old`: null when nothing (the field stays as it is).
|
|
61
|
+
function rise(merged, old) {
|
|
62
|
+
if (merged == null) return null;
|
|
63
|
+
const by = merged - (old ?? 0);
|
|
64
|
+
return old == null || by > 0 ? by : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emptyTotals() {
|
|
68
|
+
return {
|
|
69
|
+
...Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, null])),
|
|
70
|
+
costUsd: null,
|
|
71
|
+
unknownCostTokens: 0,
|
|
72
|
+
turnCount: 0,
|
|
73
|
+
rows: 0,
|
|
74
|
+
// Per field, how many rows reported it: fewer than `rows` (and more than
|
|
75
|
+
// 0) means some turns left it out.
|
|
76
|
+
reported: Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, 0])),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function addDayRow(totals, row) {
|
|
81
|
+
for (const field of TURN_TOKEN_FIELDS) {
|
|
82
|
+
const value = row[COLUMN[field]];
|
|
83
|
+
totals[field] = addNullable(totals[field], value);
|
|
84
|
+
if (value != null) totals.reported[field] += 1;
|
|
85
|
+
}
|
|
86
|
+
totals.costUsd = addNullable(totals.costUsd, row.cost_usd);
|
|
87
|
+
totals.unknownCostTokens += Number(row.unknown_cost_tokens || 0);
|
|
88
|
+
totals.turnCount += Number(row.turn_count || 0);
|
|
89
|
+
totals.rows += 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The fields some rows of `totals` reported and others did not. */
|
|
93
|
+
export function partlyReportedFields(totals) {
|
|
94
|
+
return TURN_TOKEN_FIELDS.filter((field) => totals.reported[field] > 0 && totals.reported[field] < totals.rows);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function turnRowOut(row) {
|
|
98
|
+
return {
|
|
99
|
+
conversationId: row.conversation_id,
|
|
100
|
+
turnId: row.turn_id,
|
|
101
|
+
runId: row.run_id,
|
|
102
|
+
model: row.model,
|
|
103
|
+
provider: row.provider,
|
|
104
|
+
billing: row.billing,
|
|
105
|
+
nativeSessionId: row.native_session_id,
|
|
106
|
+
responseMessageId: row.response_message_id,
|
|
107
|
+
at: row.at,
|
|
108
|
+
day: row.day,
|
|
109
|
+
...Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, row[COLUMN[field]]])),
|
|
110
|
+
costUsd: row.cost_usd,
|
|
111
|
+
costSource: row.cost_source,
|
|
112
|
+
costUnknown: row.cost_unknown === 1,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sumTurnRows(rows) {
|
|
117
|
+
const totals = { ...Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, null])), costUsd: null };
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
for (const field of TURN_TOKEN_FIELDS) totals[field] = addNullable(totals[field], row[field]);
|
|
120
|
+
totals.costUsd = addNullable(totals.costUsd, row.costUsd);
|
|
121
|
+
}
|
|
122
|
+
return totals;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Drop the turn rows of chats removed for good, from the delete paths that
|
|
127
|
+
* run outside the conversation routes (the startup delete retry, the cache
|
|
128
|
+
* rebuild), inside their own transaction. The day rows stay. A database
|
|
129
|
+
* without the table (0036 not run yet) has nothing to drop.
|
|
130
|
+
*/
|
|
131
|
+
export function forgetTurnTokenRows(db, conversationIds) {
|
|
132
|
+
const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(TURN_TOKEN_USAGE_TABLE);
|
|
133
|
+
if (!exists) return 0;
|
|
134
|
+
const remove = db.prepare(`DELETE FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ?`);
|
|
135
|
+
let changes = 0;
|
|
136
|
+
for (const id of conversationIds) changes += remove.run(String(id || '')).changes;
|
|
137
|
+
return changes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createTokenUsageService({
|
|
141
|
+
db,
|
|
142
|
+
now = () => Date.now(),
|
|
143
|
+
// The relay's time zone (the month is a calendar month in relay local
|
|
144
|
+
// time, as on the Claude API card); a test names one.
|
|
145
|
+
timeZone = undefined,
|
|
146
|
+
logger = console,
|
|
147
|
+
} = {}) {
|
|
148
|
+
if (!db) throw new Error('createTokenUsageService requires db');
|
|
149
|
+
const formatDay = dayFormatter(timeZone);
|
|
150
|
+
const localDay = (ms) => formatDay.format(new Date(ms));
|
|
151
|
+
|
|
152
|
+
// The schema boot runs 0036; one that failed (a source table was missing)
|
|
153
|
+
// retries here, and until the tables are there every call is a no-op.
|
|
154
|
+
let stmts = null;
|
|
155
|
+
// Each distinct failure is logged once, not on every post and read.
|
|
156
|
+
const warned = new Set();
|
|
157
|
+
function ready() {
|
|
158
|
+
if (stmts) return stmts;
|
|
159
|
+
try {
|
|
160
|
+
migrateTokenUsage(db);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const text = `[token-usage] migration 0036 failed; retrying later: ${error?.message || error}`;
|
|
163
|
+
if (!warned.has(text)) {
|
|
164
|
+
warned.add(text);
|
|
165
|
+
logger?.warn?.(text);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (tokenUsageProblem(db)) return null;
|
|
169
|
+
stmts = prepare();
|
|
170
|
+
return stmts;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function prepare() {
|
|
174
|
+
const fieldColumns = TURN_TOKEN_FIELDS.map((field) => COLUMN[field]);
|
|
175
|
+
return {
|
|
176
|
+
getTurnRow: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE turn_id = ? AND run_id = ? AND model = ?`),
|
|
177
|
+
upsertTurnRow: db.prepare(`
|
|
178
|
+
INSERT INTO ${TURN_TOKEN_USAGE_TABLE} (
|
|
179
|
+
conversation_id, turn_id, run_id, model, provider, billing, native_session_id, response_message_id,
|
|
180
|
+
at, day, ${fieldColumns.join(', ')}, cost_usd, cost_source, cost_unknown, updated_at
|
|
181
|
+
) VALUES (
|
|
182
|
+
@conversationId, @turnId, @runId, @model, @provider, @billing, @nativeSessionId, @responseMessageId,
|
|
183
|
+
@at, @day, ${TURN_TOKEN_FIELDS.map((field) => `@${field}`).join(', ')}, @costUsd, @costSource, @costUnknown, @updatedAt
|
|
184
|
+
)
|
|
185
|
+
ON CONFLICT (turn_id, run_id, model) DO UPDATE SET
|
|
186
|
+
native_session_id = COALESCE(excluded.native_session_id, native_session_id),
|
|
187
|
+
response_message_id = COALESCE(excluded.response_message_id, response_message_id),
|
|
188
|
+
${fieldColumns.map((column) => `${column} = excluded.${column}`).join(',\n ')},
|
|
189
|
+
cost_usd = excluded.cost_usd,
|
|
190
|
+
cost_source = excluded.cost_source,
|
|
191
|
+
cost_unknown = excluded.cost_unknown,
|
|
192
|
+
updated_at = excluded.updated_at
|
|
193
|
+
`),
|
|
194
|
+
// Null-aware: a field stays NULL until a turn reports it.
|
|
195
|
+
addDayRow: db.prepare(`
|
|
196
|
+
INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (
|
|
197
|
+
day, provider, billing, model, source, ${fieldColumns.join(', ')},
|
|
198
|
+
cost_usd, unknown_cost_tokens, turn_count, first_seen_at, last_seen_at, updated_at
|
|
199
|
+
) VALUES (
|
|
200
|
+
@day, @provider, @billing, @model, 'turns', ${TURN_TOKEN_FIELDS.map((field) => `@${field}`).join(', ')},
|
|
201
|
+
@costUsd, @unknownCostTokens, @turnCount, @at, @at, @updatedAt
|
|
202
|
+
)
|
|
203
|
+
ON CONFLICT (day, provider, billing, model, source) DO UPDATE SET
|
|
204
|
+
${fieldColumns.map((column) => `${column} = CASE WHEN excluded.${column} IS NULL THEN ${column} ELSE COALESCE(${column}, 0) + excluded.${column} END`).join(',\n ')},
|
|
205
|
+
cost_usd = CASE WHEN excluded.cost_usd IS NULL THEN cost_usd ELSE COALESCE(cost_usd, 0) + excluded.cost_usd END,
|
|
206
|
+
unknown_cost_tokens = unknown_cost_tokens + excluded.unknown_cost_tokens,
|
|
207
|
+
turn_count = turn_count + excluded.turn_count,
|
|
208
|
+
first_seen_at = MIN(first_seen_at, excluded.first_seen_at),
|
|
209
|
+
last_seen_at = MAX(last_seen_at, excluded.last_seen_at),
|
|
210
|
+
updated_at = excluded.updated_at
|
|
211
|
+
`),
|
|
212
|
+
// The other turns booked for a cloud session and model.
|
|
213
|
+
sumSessionRows: db.prepare(`
|
|
214
|
+
SELECT ${fieldColumns.map((column) => `SUM(${column}) AS ${column}`).join(', ')}, SUM(cost_usd) AS cost_usd
|
|
215
|
+
FROM ${TURN_TOKEN_USAGE_TABLE}
|
|
216
|
+
WHERE conversation_id = ? AND native_session_id = ? AND model = ? AND NOT (turn_id = ? AND run_id = ?)
|
|
217
|
+
`),
|
|
218
|
+
listDayRows: db.prepare(`SELECT * FROM ${TOKEN_USAGE_DAYS_TABLE} WHERE provider = ?`),
|
|
219
|
+
listAllDayRows: db.prepare(`SELECT * FROM ${TOKEN_USAGE_DAYS_TABLE}`),
|
|
220
|
+
listTurnRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE turn_id = ? ORDER BY at, run_id, model`),
|
|
221
|
+
listConversationRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ? ORDER BY at, turn_id, run_id, model`),
|
|
222
|
+
deleteConversationRows: db.prepare(`DELETE FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ?`),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function bookEntries(s, { conversationId, provider, billing, report, entries }) {
|
|
227
|
+
const nowMs = now();
|
|
228
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
229
|
+
// The turn's time: the worker's, unless it is more than a day off the
|
|
230
|
+
// relay's clock.
|
|
231
|
+
const reportedMs = report.at ? Date.parse(report.at) : Number.NaN;
|
|
232
|
+
const atMs = Number.isFinite(reportedMs) && Math.abs(reportedMs - nowMs) <= AT_SKEW_MS ? reportedMs : nowMs;
|
|
233
|
+
const booked = [];
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
const old = s.getTurnRow.get(report.turnId, report.runId, entry.model);
|
|
236
|
+
const merged = {};
|
|
237
|
+
const rises = {};
|
|
238
|
+
for (const field of TURN_TOKEN_FIELDS) {
|
|
239
|
+
const before = old ? old[COLUMN[field]] : null;
|
|
240
|
+
merged[field] = maxNullable(before, entry[field]);
|
|
241
|
+
rises[field] = rise(merged[field], before);
|
|
242
|
+
}
|
|
243
|
+
const costUsd = maxNullable(old ? old.cost_usd : null, entry.costUsd);
|
|
244
|
+
const costRise = rise(costUsd, old ? old.cost_usd : null);
|
|
245
|
+
const costUnknown = Boolean(old?.cost_unknown) || entry.costUnknown;
|
|
246
|
+
const day = old ? old.day : localDay(atMs);
|
|
247
|
+
const at = old ? old.at : new Date(atMs).toISOString();
|
|
248
|
+
// The row keeps the provider and billing of its first report.
|
|
249
|
+
const rowProvider = old ? old.provider : provider;
|
|
250
|
+
const rowBilling = old ? old.billing : billing;
|
|
251
|
+
s.upsertTurnRow.run({
|
|
252
|
+
conversationId,
|
|
253
|
+
turnId: report.turnId,
|
|
254
|
+
runId: report.runId,
|
|
255
|
+
model: entry.model,
|
|
256
|
+
provider: rowProvider,
|
|
257
|
+
billing: rowBilling,
|
|
258
|
+
nativeSessionId: report.nativeSessionId,
|
|
259
|
+
responseMessageId: report.responseMessageId,
|
|
260
|
+
at,
|
|
261
|
+
day,
|
|
262
|
+
...merged,
|
|
263
|
+
costUsd,
|
|
264
|
+
costSource: entry.costSource || old?.cost_source || null,
|
|
265
|
+
costUnknown: costUnknown ? 1 : 0,
|
|
266
|
+
updatedAt: nowIso,
|
|
267
|
+
});
|
|
268
|
+
const changed = TURN_TOKEN_FIELDS.some((field) => rises[field] != null) || costRise != null;
|
|
269
|
+
if (!old || changed) {
|
|
270
|
+
const risenTokens = PRICED_FIELDS.reduce((sum, field) => sum + (rises[field] ?? 0), 0);
|
|
271
|
+
s.addDayRow.run({
|
|
272
|
+
day,
|
|
273
|
+
provider: rowProvider,
|
|
274
|
+
billing: rowBilling,
|
|
275
|
+
model: entry.model,
|
|
276
|
+
...rises,
|
|
277
|
+
costUsd: costRise,
|
|
278
|
+
unknownCostTokens: costUnknown ? risenTokens : 0,
|
|
279
|
+
turnCount: old ? 0 : 1,
|
|
280
|
+
at,
|
|
281
|
+
updatedAt: nowIso,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
booked.push({ model: entry.model, day, ...merged, costUsd, added: old ? changed : true });
|
|
285
|
+
}
|
|
286
|
+
return booked;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Book a turn's counts per model (`report.models`). `provider` and
|
|
291
|
+
* `billing` are the relay's: what the reporting worker is. Returns the
|
|
292
|
+
* booked rows, or null when the tables are not there.
|
|
293
|
+
*/
|
|
294
|
+
function recordTurn({ conversationId, provider, billing = '', report }) {
|
|
295
|
+
const s = ready();
|
|
296
|
+
if (!s) return null;
|
|
297
|
+
return db.transaction(() => bookEntries(s, { conversationId, provider, billing, report, entries: report.models || [] }))();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Book a Claude cloud turn from the session's running totals per model
|
|
302
|
+
* (`report.sessionTotals`): the turn is what they add to the session's
|
|
303
|
+
* other booked turns, floored at 0.
|
|
304
|
+
*/
|
|
305
|
+
function recordCloudSessionTotals({ conversationId, provider = 'claude-cloud', billing = '', report }) {
|
|
306
|
+
const s = ready();
|
|
307
|
+
if (!s) return null;
|
|
308
|
+
return db.transaction(() => {
|
|
309
|
+
const entries = (report.sessionTotals || []).map((entry) => {
|
|
310
|
+
const earlier = s.sumSessionRows.get(conversationId, report.nativeSessionId, entry.model, report.turnId, report.runId);
|
|
311
|
+
const turn = { ...entry };
|
|
312
|
+
for (const field of TURN_TOKEN_FIELDS) {
|
|
313
|
+
turn[field] = entry[field] == null ? null : Math.max(0, entry[field] - (earlier?.[COLUMN[field]] ?? 0));
|
|
314
|
+
}
|
|
315
|
+
turn.costUsd = entry.costUsd == null ? null : Math.max(0, entry.costUsd - (earlier?.cost_usd ?? 0));
|
|
316
|
+
return turn;
|
|
317
|
+
});
|
|
318
|
+
return bookEntries(s, { conversationId, provider, billing, report, entries });
|
|
319
|
+
})();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Today, this month and all time for a provider, per billing ('' first,
|
|
324
|
+
* then the own keys) and per model. `since` is the first day anything was
|
|
325
|
+
* counted, copied days included; `backfilled` says which periods hold days
|
|
326
|
+
* copied at the upgrade (whose input includes cache writes),
|
|
327
|
+
* `backfillThrough` the last such day and `firstTurnsDay` the first day a
|
|
328
|
+
* turn was booked.
|
|
329
|
+
*/
|
|
330
|
+
function readPeriods({ provider, now: nowMs = now() } = {}) {
|
|
331
|
+
const s = ready();
|
|
332
|
+
if (!s) return null;
|
|
333
|
+
const today = localDay(nowMs);
|
|
334
|
+
const month = today.slice(0, 7);
|
|
335
|
+
const groups = new Map();
|
|
336
|
+
const rows = provider ? s.listDayRows.all(provider) : s.listAllDayRows.all();
|
|
337
|
+
for (const row of rows) {
|
|
338
|
+
const key = `${row.provider}\n${row.billing}`;
|
|
339
|
+
let group = groups.get(key);
|
|
340
|
+
if (!group) {
|
|
341
|
+
group = {
|
|
342
|
+
provider: row.provider,
|
|
343
|
+
billing: row.billing,
|
|
344
|
+
periods: { today: emptyTotals(), month: emptyTotals(), allTime: emptyTotals() },
|
|
345
|
+
models: new Map(),
|
|
346
|
+
since: null,
|
|
347
|
+
backfilled: { today: false, month: false, allTime: false },
|
|
348
|
+
backfillThrough: null,
|
|
349
|
+
firstTurnsDay: null,
|
|
350
|
+
};
|
|
351
|
+
groups.set(key, group);
|
|
352
|
+
}
|
|
353
|
+
let model = group.models.get(row.model);
|
|
354
|
+
if (!model) {
|
|
355
|
+
model = { model: row.model, today: emptyTotals(), month: emptyTotals(), allTime: emptyTotals() };
|
|
356
|
+
group.models.set(row.model, model);
|
|
357
|
+
}
|
|
358
|
+
const inPeriods = ['allTime'];
|
|
359
|
+
if (row.day.startsWith(`${month}-`)) inPeriods.push('month');
|
|
360
|
+
if (row.day === today) inPeriods.push('today');
|
|
361
|
+
for (const period of inPeriods) {
|
|
362
|
+
addDayRow(group.periods[period], row);
|
|
363
|
+
addDayRow(model[period], row);
|
|
364
|
+
if (row.source === 'backfill') group.backfilled[period] = true;
|
|
365
|
+
}
|
|
366
|
+
if (!group.since || row.day < group.since) group.since = row.day;
|
|
367
|
+
if (row.source === 'backfill' && (!group.backfillThrough || row.day > group.backfillThrough)) group.backfillThrough = row.day;
|
|
368
|
+
if (row.source === 'turns' && (!group.firstTurnsDay || row.day < group.firstTurnsDay)) group.firstTurnsDay = row.day;
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
today,
|
|
372
|
+
month,
|
|
373
|
+
groups: [...groups.values()]
|
|
374
|
+
.sort((a, b) => a.provider.localeCompare(b.provider) || (a.billing ? 1 : 0) - (b.billing ? 1 : 0) || a.billing.localeCompare(b.billing))
|
|
375
|
+
.map((group) => ({
|
|
376
|
+
...group,
|
|
377
|
+
models: [...group.models.values()].sort((a, b) => a.model.localeCompare(b.model)),
|
|
378
|
+
})),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A turn's rows (every run and model) and their sum; null when none. */
|
|
383
|
+
function readTurn(turnId) {
|
|
384
|
+
const s = ready();
|
|
385
|
+
if (!s) return null;
|
|
386
|
+
const rows = s.listTurnRows.all(String(turnId || '')).map(turnRowOut);
|
|
387
|
+
if (!rows.length) return null;
|
|
388
|
+
return { turnId: String(turnId), rows, totals: sumTurnRows(rows) };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A chat's rows and their sums, overall and per Claude Code session. */
|
|
392
|
+
function readConversation(conversationId) {
|
|
393
|
+
const s = ready();
|
|
394
|
+
if (!s) return null;
|
|
395
|
+
const rows = s.listConversationRows.all(String(conversationId || '')).map(turnRowOut);
|
|
396
|
+
const bySession = new Map();
|
|
397
|
+
for (const row of rows) {
|
|
398
|
+
const key = row.nativeSessionId || '';
|
|
399
|
+
if (!bySession.has(key)) bySession.set(key, []);
|
|
400
|
+
bySession.get(key).push(row);
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
conversationId: String(conversationId),
|
|
404
|
+
rows,
|
|
405
|
+
totals: sumTurnRows(rows),
|
|
406
|
+
sessions: [...bySession.entries()].map(([nativeSessionId, sessionRows]) => ({
|
|
407
|
+
nativeSessionId: nativeSessionId || null,
|
|
408
|
+
totals: sumTurnRows(sessionRows),
|
|
409
|
+
})),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The chat is deleted: its turn rows go, the day rows stay. */
|
|
414
|
+
function forgetConversation(conversationId) {
|
|
415
|
+
const s = ready();
|
|
416
|
+
if (!s) return 0;
|
|
417
|
+
return s.deleteConversationRows.run(String(conversationId || '')).changes;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
recordTurn,
|
|
422
|
+
recordCloudSessionTotals,
|
|
423
|
+
readPeriods,
|
|
424
|
+
readTurn,
|
|
425
|
+
readConversation,
|
|
426
|
+
forgetConversation,
|
|
427
|
+
localDay: (ms = now()) => localDay(ms),
|
|
428
|
+
timeZone: formatDay.resolvedOptions().timeZone,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
@@ -114,6 +114,9 @@ export function createUpdateInstallService({
|
|
|
114
114
|
writeSetting,
|
|
115
115
|
deleteSetting,
|
|
116
116
|
requestRelayShutdown,
|
|
117
|
+
// The line on the next start's database backup (db-backup-service.mjs),
|
|
118
|
+
// said in the log before npm installs; null says nothing.
|
|
119
|
+
describeNextBackup = null,
|
|
117
120
|
logger = console,
|
|
118
121
|
} = {}) {
|
|
119
122
|
const listeners = new Set();
|
|
@@ -206,6 +209,16 @@ export function createUpdateInstallService({
|
|
|
206
209
|
lastError = null;
|
|
207
210
|
deleteSetting(UPDATE_OUTCOME_SETTING_KEY);
|
|
208
211
|
broadcast();
|
|
212
|
+
// Seen before the restart: whether the new version's first start backs
|
|
213
|
+
// up the database, or why not (a full disk).
|
|
214
|
+
if (describeNextBackup) {
|
|
215
|
+
try {
|
|
216
|
+
const line = describeNextBackup();
|
|
217
|
+
if (line) appendLog(`${line}\n`);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
appendLog(`[backup] Whether the next start backs up the database could not be told (${error?.message || error}).\n`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
209
222
|
|
|
210
223
|
// npm itself is a shim on Windows; spawn npm.cmd through a shell there
|
|
211
224
|
// (args are fixed strings + a validated semver, so no injection surface).
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import { randomUUID } from 'node:crypto';
|
|
20
20
|
|
|
21
|
+
import { resumeRelayMode } from '../../shared/plan-only.mjs';
|
|
22
|
+
import { readPlanOnly } from './conversation-preferences-service.mjs';
|
|
21
23
|
import {
|
|
22
24
|
USAGE_LIMIT_CODE,
|
|
23
25
|
USAGE_LIMIT_KIND,
|
|
@@ -300,7 +302,12 @@ export function createUsageLimitPauseService({
|
|
|
300
302
|
const latestModel = stmts.getLatestConversationModel?.get?.(plan.conversationId)?.model || null;
|
|
301
303
|
const model = String(queueRow?.model || '').trim() || latestModel || null;
|
|
302
304
|
const modelVariant = String(queueRow?.model_variant_id || '').trim() || latestModel || model;
|
|
303
|
-
|
|
305
|
+
// On a plan-only session only the "Approve plan" turn goes on in agent mode.
|
|
306
|
+
const relayMode = resumeRelayMode({
|
|
307
|
+
relayMode: queueRow?.relay_mode,
|
|
308
|
+
text: queueRow?.text,
|
|
309
|
+
planOnly: readPlanOnly(stmts.getConvAnyStatus?.get?.(plan.conversationId)),
|
|
310
|
+
});
|
|
304
311
|
const owner = String(queueRow?.owner_sdk_session_id || '').trim() || null;
|
|
305
312
|
stmts.insertMsg.run(
|
|
306
313
|
messageId,
|