@oar-sh/oar 0.9.9 → 0.9.11
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/CHANGELOG.md +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// What the About OAR modal shows: the running build, the providers that are
|
|
7
|
+
// on, what this relay has stored over its life, and how it is doing now.
|
|
8
|
+
//
|
|
9
|
+
// The totals read every message row, so they are never read on the request
|
|
10
|
+
// path in one go: the messages table is walked in rowid ranges with a turn of
|
|
11
|
+
// the event loop between them (a range of 10 000 rows takes a few tens of
|
|
12
|
+
// milliseconds on a cold cache), and the result is kept for a minute. Every
|
|
13
|
+
// other field is a cached value the relay already holds, so nothing here
|
|
14
|
+
// spawns a process.
|
|
15
|
+
|
|
16
|
+
export const ABOUT_TOTALS_CACHE_MS = 60_000;
|
|
17
|
+
export const ABOUT_MESSAGE_CHUNK_ROWS = 10_000;
|
|
18
|
+
|
|
19
|
+
// The packages each provider's workers run on, read from node_modules once.
|
|
20
|
+
export const ABOUT_PROVIDER_SDK_PACKAGES = Object.freeze({
|
|
21
|
+
github: '@github/copilot-sdk',
|
|
22
|
+
claude: '@anthropic-ai/claude-agent-sdk',
|
|
23
|
+
cursor: '@cursor/sdk',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const ABOUT_PROVIDER_LABELS = Object.freeze({
|
|
27
|
+
github: 'GitHub Copilot',
|
|
28
|
+
openai: 'OpenAI',
|
|
29
|
+
claude: 'Claude Code',
|
|
30
|
+
'claude-cloud': 'Claude Cloud',
|
|
31
|
+
cursor: 'Cursor',
|
|
32
|
+
grok: 'Grok',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The CLI each provider runs, keyed as the CLI install service keys them.
|
|
36
|
+
const PROVIDER_CLI_IDS = Object.freeze({ github: 'copilot', claude: 'claude', grok: 'grok' });
|
|
37
|
+
|
|
38
|
+
const ACTIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
39
|
+
|
|
40
|
+
const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
41
|
+
|
|
42
|
+
function toCount(value) {
|
|
43
|
+
const number = Number(value);
|
|
44
|
+
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toAmount(value) {
|
|
48
|
+
const number = Number(value);
|
|
49
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeProviderId(value) {
|
|
53
|
+
const text = String(value || '').trim().toLowerCase();
|
|
54
|
+
return text || 'github';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readFileText(fsImpl, filePath) {
|
|
58
|
+
try {
|
|
59
|
+
return String(fsImpl.readFileSync(filePath, 'utf8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The commit a git checkout has checked out, read from its .git files rather
|
|
69
|
+
* than from a `git` process: HEAD, then the loose ref, then packed-refs. A
|
|
70
|
+
* linked worktree's .git is a file naming its git dir, whose refs live in the
|
|
71
|
+
* common dir. Anything unexpected reads as null.
|
|
72
|
+
*/
|
|
73
|
+
export function readGitCommit(root, { fsImpl = fs } = {}) {
|
|
74
|
+
if (!root) return null;
|
|
75
|
+
const dotGit = path.join(root, '.git');
|
|
76
|
+
let gitDir = dotGit;
|
|
77
|
+
const pointer = readFileText(fsImpl, dotGit);
|
|
78
|
+
if (pointer !== null) {
|
|
79
|
+
const match = pointer.match(/^gitdir:\s*(.+)$/m);
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
gitDir = path.resolve(root, match[1].trim());
|
|
82
|
+
}
|
|
83
|
+
const head = readFileText(fsImpl, path.join(gitDir, 'HEAD'));
|
|
84
|
+
if (head === null) return null;
|
|
85
|
+
const headText = head.trim();
|
|
86
|
+
if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
|
|
87
|
+
const refMatch = headText.match(/^ref:\s*(\S+)$/);
|
|
88
|
+
if (!refMatch) return null;
|
|
89
|
+
const ref = refMatch[1];
|
|
90
|
+
const commonDirText = readFileText(fsImpl, path.join(gitDir, 'commondir'));
|
|
91
|
+
const commonDir = commonDirText ? path.resolve(gitDir, commonDirText.trim()) : gitDir;
|
|
92
|
+
for (const dir of new Set([gitDir, commonDir])) {
|
|
93
|
+
const loose = readFileText(fsImpl, path.join(dir, ...ref.split('/')));
|
|
94
|
+
if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
const packed = readFileText(fsImpl, path.join(commonDir, 'packed-refs')) || '';
|
|
97
|
+
for (const line of packed.split(/\r?\n/)) {
|
|
98
|
+
const [hash, name] = line.trim().split(/\s+/);
|
|
99
|
+
if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
|
|
105
|
+
export function resolveBuildCommit(packageRoot, { fsImpl = fs } = {}) {
|
|
106
|
+
const fromGit = readGitCommit(packageRoot, { fsImpl });
|
|
107
|
+
if (fromGit) return fromGit;
|
|
108
|
+
try {
|
|
109
|
+
const stamp = String(JSON.parse(readFileText(fsImpl, path.join(packageRoot, 'package.json')) || '{}')?.gitHead || '').trim();
|
|
110
|
+
return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readInstalledPackageVersion(packageRoot, packageName, { fsImpl = fs } = {}) {
|
|
117
|
+
if (!packageRoot || !packageName) return null;
|
|
118
|
+
try {
|
|
119
|
+
const manifest = JSON.parse(readFileText(fsImpl, path.join(packageRoot, 'node_modules', ...packageName.split('/'), 'package.json')) || '');
|
|
120
|
+
return String(manifest?.version || '').trim() || null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function tableExists(db, name) {
|
|
127
|
+
return !!db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function columnExists(db, table, column) {
|
|
131
|
+
try {
|
|
132
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().some((row) => row?.name === column);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Lifetime totals from the relay's database. Async on purpose: the message
|
|
140
|
+
* walk yields between rowid ranges, so a database of hundreds of megabytes
|
|
141
|
+
* never holds the event loop for long.
|
|
142
|
+
*
|
|
143
|
+
* A reply counts as one turn. Its provider is the one that ran it
|
|
144
|
+
* (`messages.executed_provider`), else the provider its conversation is bound
|
|
145
|
+
* to, else Copilot, which is what a conversation without a binding runs on.
|
|
146
|
+
*/
|
|
147
|
+
export async function collectAboutTotals(db, {
|
|
148
|
+
chunkRows = ABOUT_MESSAGE_CHUNK_ROWS,
|
|
149
|
+
yieldToLoop = yieldToEventLoop,
|
|
150
|
+
nowIso = () => new Date().toISOString(),
|
|
151
|
+
} = {}) {
|
|
152
|
+
const conversations = db.prepare(`
|
|
153
|
+
SELECT COUNT(*) AS total,
|
|
154
|
+
SUM(CASE WHEN archived = 1 THEN 1 ELSE 0 END) AS archived,
|
|
155
|
+
MIN(created_at) AS first_created_at
|
|
156
|
+
FROM conversations
|
|
157
|
+
WHERE status != 'deleted'
|
|
158
|
+
`).get() || {};
|
|
159
|
+
|
|
160
|
+
const conversationProvider = new Map();
|
|
161
|
+
for (const row of db.prepare(`SELECT conversation_id, provider_type FROM runtime_sessions`).iterate()) {
|
|
162
|
+
conversationProvider.set(String(row.conversation_id), normalizeProviderId(row.provider_type));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const chunk = db.prepare(`
|
|
166
|
+
SELECT conversation_id, executed_provider,
|
|
167
|
+
COUNT(*) AS messages,
|
|
168
|
+
SUM(CASE WHEN role = 'assistant' THEN 1 ELSE 0 END) AS turns
|
|
169
|
+
FROM messages
|
|
170
|
+
WHERE rowid > ? AND rowid <= ?
|
|
171
|
+
GROUP BY conversation_id, executed_provider
|
|
172
|
+
`);
|
|
173
|
+
const maxRowId = Number(db.prepare(`SELECT MAX(rowid) AS max_rowid FROM messages`).get()?.max_rowid || 0);
|
|
174
|
+
const size = Math.max(1, Math.trunc(Number(chunkRows) || ABOUT_MESSAGE_CHUNK_ROWS));
|
|
175
|
+
let messages = 0;
|
|
176
|
+
let turns = 0;
|
|
177
|
+
const turnsByProvider = new Map();
|
|
178
|
+
for (let low = 0; low < maxRowId; low += size) {
|
|
179
|
+
if (low > 0) await yieldToLoop();
|
|
180
|
+
for (const row of chunk.all(low, low + size)) {
|
|
181
|
+
messages += toCount(row.messages);
|
|
182
|
+
const rowTurns = toCount(row.turns);
|
|
183
|
+
if (!rowTurns) continue;
|
|
184
|
+
turns += rowTurns;
|
|
185
|
+
const provider = row.executed_provider
|
|
186
|
+
? normalizeProviderId(row.executed_provider)
|
|
187
|
+
: (conversationProvider.get(String(row.conversation_id)) || 'github');
|
|
188
|
+
turnsByProvider.set(provider, (turnsByProvider.get(provider) || 0) + rowTurns);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
await yieldToLoop();
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
conversations: {
|
|
195
|
+
total: toCount(conversations.total),
|
|
196
|
+
archived: toCount(conversations.archived),
|
|
197
|
+
firstCreatedAt: conversations.first_created_at || null,
|
|
198
|
+
},
|
|
199
|
+
messages,
|
|
200
|
+
turns,
|
|
201
|
+
turnsByProvider: Array.from(turnsByProvider, ([id, count]) => ({ id, turns: count }))
|
|
202
|
+
.sort((a, b) => b.turns - a.turns || a.id.localeCompare(b.id)),
|
|
203
|
+
usage: collectUsageTotals(db),
|
|
204
|
+
computedAt: nowIso(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What the providers report and the relay keeps, summed: Copilot's premium
|
|
210
|
+
* requests per reply, Cursor's and Grok's token and cost totals per billing
|
|
211
|
+
* cycle, Claude Cloud's cost per session. A provider with nothing stored is
|
|
212
|
+
* left out; Claude Code and OpenAI turns store no usage at all.
|
|
213
|
+
*/
|
|
214
|
+
export function collectUsageTotals(db) {
|
|
215
|
+
const usage = {};
|
|
216
|
+
if (tableExists(db, 'message_usage_snapshots')) {
|
|
217
|
+
const row = db.prepare(`
|
|
218
|
+
SELECT SUM(CASE WHEN premium_delta_used > 0 THEN premium_delta_used ELSE 0 END) AS premium,
|
|
219
|
+
SUM(CASE WHEN premium_delta_used > 0 THEN 1 ELSE 0 END) AS premium_turns
|
|
220
|
+
FROM message_usage_snapshots
|
|
221
|
+
`).get() || {};
|
|
222
|
+
if (toCount(row.premium_turns)) {
|
|
223
|
+
usage.github = { premiumRequests: toAmount(row.premium), turns: toCount(row.premium_turns) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (tableExists(db, 'cursor_usage_cycle_totals')) {
|
|
227
|
+
const row = db.prepare(`
|
|
228
|
+
SELECT SUM(input_tokens) AS input, SUM(output_tokens) AS output,
|
|
229
|
+
SUM(cache_read_tokens) AS cache_read, SUM(cache_write_tokens) AS cache_write,
|
|
230
|
+
SUM(total_tokens) AS total, SUM(run_count) AS runs,
|
|
231
|
+
SUM(raw_cost_cents) AS raw_cents, SUM(charged_cents) AS charged_cents
|
|
232
|
+
FROM cursor_usage_cycle_totals
|
|
233
|
+
`).get() || {};
|
|
234
|
+
if (toAmount(row.total) || toAmount(row.runs) || toAmount(row.raw_cents)) {
|
|
235
|
+
usage.cursor = {
|
|
236
|
+
inputTokens: toAmount(row.input),
|
|
237
|
+
outputTokens: toAmount(row.output),
|
|
238
|
+
cacheReadTokens: toAmount(row.cache_read),
|
|
239
|
+
cacheWriteTokens: toAmount(row.cache_write),
|
|
240
|
+
totalTokens: toAmount(row.total),
|
|
241
|
+
runs: toCount(row.runs),
|
|
242
|
+
rawCostUsd: toAmount(row.raw_cents) / 100,
|
|
243
|
+
chargedUsd: toAmount(row.charged_cents) / 100,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (tableExists(db, 'grok_usage_cycle_totals')) {
|
|
248
|
+
const row = db.prepare(`
|
|
249
|
+
SELECT SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(total_tokens) AS total,
|
|
250
|
+
SUM(turn_count) AS turns, SUM(cost_usd) AS cost
|
|
251
|
+
FROM grok_usage_cycle_totals
|
|
252
|
+
`).get() || {};
|
|
253
|
+
if (toAmount(row.total) || toAmount(row.turns) || toAmount(row.cost)) {
|
|
254
|
+
usage.grok = {
|
|
255
|
+
inputTokens: toAmount(row.input),
|
|
256
|
+
outputTokens: toAmount(row.output),
|
|
257
|
+
totalTokens: toAmount(row.total),
|
|
258
|
+
turns: toCount(row.turns),
|
|
259
|
+
costUsd: toAmount(row.cost),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (columnExists(db, 'runtime_sessions', 'claude_cloud_cost_usd')) {
|
|
264
|
+
const row = db.prepare(`
|
|
265
|
+
SELECT SUM(claude_cloud_cost_usd) AS cost, COUNT(claude_cloud_cost_usd) AS sessions
|
|
266
|
+
FROM runtime_sessions
|
|
267
|
+
WHERE claude_cloud_cost_usd IS NOT NULL
|
|
268
|
+
`).get() || {};
|
|
269
|
+
if (toCount(row.sessions)) {
|
|
270
|
+
usage['claude-cloud'] = { costUsd: toAmount(row.cost), sessions: toCount(row.sessions) };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return usage;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function defaultIsPidAlive(pidValue) {
|
|
277
|
+
const pid = Number(pidValue);
|
|
278
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 0);
|
|
281
|
+
return true;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return String(error?.code || '').toUpperCase() === 'EPERM';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function fileSize(fsImpl, filePath) {
|
|
288
|
+
try {
|
|
289
|
+
const stat = await fsImpl.promises.stat(filePath);
|
|
290
|
+
return Number(stat.size) || 0;
|
|
291
|
+
} catch {
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {object} deps
|
|
298
|
+
* @param {import('better-sqlite3').Database} deps.db
|
|
299
|
+
* @param {string|null} [deps.dbPath] the database file; its -wal file is counted too
|
|
300
|
+
* @param {string} deps.version
|
|
301
|
+
* @param {string|null} [deps.commit]
|
|
302
|
+
* @param {string} [deps.installMethod]
|
|
303
|
+
* @param {string} deps.startedAt ISO time this relay process started
|
|
304
|
+
* @param {() => object} [deps.getProviderSettings] `{ github: { enabled, engine }, claude: { enabled }, … }`
|
|
305
|
+
* @param {() => object} [deps.getCachedCliProviders] the CLI install service's cached rows, by CLI id
|
|
306
|
+
* @param {() => boolean} [deps.isRelayCliOnline] the Copilot relay CLI is connected
|
|
307
|
+
* @param {() => object[]} [deps.listWorkers] the session worker registry's entries
|
|
308
|
+
* @param {() => object|null} [deps.getUpdateState] buildUpdateStatePayload()
|
|
309
|
+
* @param {object} [deps.sdkVersions] package version by provider id; read from node_modules when absent
|
|
310
|
+
*/
|
|
311
|
+
export function createAboutService({
|
|
312
|
+
db,
|
|
313
|
+
dbPath = null,
|
|
314
|
+
version = '0.0.0',
|
|
315
|
+
commit = null,
|
|
316
|
+
installMethod = null,
|
|
317
|
+
startedAt,
|
|
318
|
+
packageRoot = null,
|
|
319
|
+
getProviderSettings = () => ({}),
|
|
320
|
+
getCachedCliProviders = () => ({}),
|
|
321
|
+
isRelayCliOnline = () => false,
|
|
322
|
+
listWorkers = () => [],
|
|
323
|
+
getUpdateState = () => null,
|
|
324
|
+
sdkVersions = null,
|
|
325
|
+
isPidAlive = defaultIsPidAlive,
|
|
326
|
+
fsImpl = fs,
|
|
327
|
+
now = Date.now,
|
|
328
|
+
cacheMs = ABOUT_TOTALS_CACHE_MS,
|
|
329
|
+
collectTotals = (database) => collectAboutTotals(database),
|
|
330
|
+
} = {}) {
|
|
331
|
+
let cachedTotals = null;
|
|
332
|
+
let totalsInFlight = null;
|
|
333
|
+
let resolvedSdkVersions = sdkVersions;
|
|
334
|
+
const providerForConversation = db.prepare(`SELECT provider_type FROM runtime_sessions WHERE conversation_id = ?`);
|
|
335
|
+
|
|
336
|
+
function sdkVersionFor(providerId) {
|
|
337
|
+
if (!resolvedSdkVersions) {
|
|
338
|
+
resolvedSdkVersions = {};
|
|
339
|
+
for (const [id, packageName] of Object.entries(ABOUT_PROVIDER_SDK_PACKAGES)) {
|
|
340
|
+
resolvedSdkVersions[id] = readInstalledPackageVersion(packageRoot, packageName, { fsImpl });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return resolvedSdkVersions[providerId] || null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readTotals() {
|
|
347
|
+
if (cachedTotals && now() - cachedTotals.at < cacheMs) return Promise.resolve(cachedTotals.value);
|
|
348
|
+
if (totalsInFlight) return totalsInFlight;
|
|
349
|
+
totalsInFlight = Promise.resolve()
|
|
350
|
+
.then(() => collectTotals(db))
|
|
351
|
+
.then((value) => {
|
|
352
|
+
cachedTotals = { at: now(), value };
|
|
353
|
+
return value;
|
|
354
|
+
})
|
|
355
|
+
.finally(() => { totalsInFlight = null; });
|
|
356
|
+
return totalsInFlight;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function runningWorkersByProvider() {
|
|
360
|
+
const counts = new Map();
|
|
361
|
+
let total = 0;
|
|
362
|
+
for (const worker of listWorkers() || []) {
|
|
363
|
+
const status = String(worker?.status || '').trim().toLowerCase();
|
|
364
|
+
if (!ACTIVE_WORKER_STATUSES.has(status) || !isPidAlive(worker?.pid)) continue;
|
|
365
|
+
total += 1;
|
|
366
|
+
const conversationId = String(worker?.conversationId || '').trim();
|
|
367
|
+
const provider = conversationId
|
|
368
|
+
? normalizeProviderId(providerForConversation.get(conversationId)?.provider_type)
|
|
369
|
+
: 'unassigned';
|
|
370
|
+
counts.set(provider, (counts.get(provider) || 0) + 1);
|
|
371
|
+
}
|
|
372
|
+
return { total, counts };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function describeProviders(workerCounts) {
|
|
376
|
+
const settings = getProviderSettings() || {};
|
|
377
|
+
const cli = getCachedCliProviders() || {};
|
|
378
|
+
const relayCliOnline = isRelayCliOnline() === true;
|
|
379
|
+
const rows = [];
|
|
380
|
+
for (const id of Object.keys(ABOUT_PROVIDER_LABELS)) {
|
|
381
|
+
const entry = settings[id];
|
|
382
|
+
if (!entry || entry.enabled !== true) continue;
|
|
383
|
+
const engine = id === 'github' ? (entry.engine === 'sdk' ? 'sdk' : 'extension') : null;
|
|
384
|
+
// Copilot on the extension engine and OpenAI run in the relay CLI; every
|
|
385
|
+
// other provider runs one worker process per active session.
|
|
386
|
+
const viaRelayCli = id === 'openai' || (id === 'github' && engine === 'extension');
|
|
387
|
+
const runningWorkers = workerCounts.get(id) || 0;
|
|
388
|
+
const cliRow = PROVIDER_CLI_IDS[id] ? cli[PROVIDER_CLI_IDS[id]] : null;
|
|
389
|
+
const showSdk = id !== 'github' || engine === 'sdk';
|
|
390
|
+
rows.push({
|
|
391
|
+
id,
|
|
392
|
+
label: ABOUT_PROVIDER_LABELS[id],
|
|
393
|
+
engine,
|
|
394
|
+
cliVersion: cliRow?.installed ? (cliRow.version || null) : null,
|
|
395
|
+
cliCheckedAt: cliRow?.checkedAt || null,
|
|
396
|
+
sdkPackage: showSdk ? (ABOUT_PROVIDER_SDK_PACKAGES[id] || null) : null,
|
|
397
|
+
sdkVersion: showSdk ? sdkVersionFor(id) : null,
|
|
398
|
+
state: viaRelayCli
|
|
399
|
+
? (relayCliOnline ? 'online' : 'offline')
|
|
400
|
+
: (runningWorkers > 0 ? 'online' : 'idle'),
|
|
401
|
+
runningWorkers,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return rows;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function describeUpdate() {
|
|
408
|
+
const state = getUpdateState() || null;
|
|
409
|
+
const check = state?.check || null;
|
|
410
|
+
const checkEnabled = !!check && check.autoCheckEnabled === true && state?.checkKilled !== true;
|
|
411
|
+
return {
|
|
412
|
+
checkEnabled,
|
|
413
|
+
checkKilled: state?.checkKilled === true,
|
|
414
|
+
available: checkEnabled && check.available === true,
|
|
415
|
+
latestVersion: checkEnabled && check.available === true ? (check.version || null) : null,
|
|
416
|
+
notesUrl: checkEnabled && check.available === true ? (check.notesUrl || null) : null,
|
|
417
|
+
lastCheckedAt: checkEnabled ? (check.lastCheckedAt || null) : null,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function describe() {
|
|
422
|
+
const totals = await readTotals();
|
|
423
|
+
const workers = runningWorkersByProvider();
|
|
424
|
+
const [dbFileBytes, walBytes] = dbPath
|
|
425
|
+
? await Promise.all([fileSize(fsImpl, dbPath), fileSize(fsImpl, `${dbPath}-wal`)])
|
|
426
|
+
: [0, 0];
|
|
427
|
+
const startedAtMs = Date.parse(startedAt);
|
|
428
|
+
const uptimeSeconds = Number.isFinite(startedAtMs) ? Math.max(0, Math.floor((now() - startedAtMs) / 1000)) : null;
|
|
429
|
+
return {
|
|
430
|
+
version,
|
|
431
|
+
commit: commit || null,
|
|
432
|
+
installMethod: installMethod || null,
|
|
433
|
+
startedAt: startedAt || null,
|
|
434
|
+
uptimeSeconds,
|
|
435
|
+
providers: describeProviders(workers.counts),
|
|
436
|
+
stats: {
|
|
437
|
+
conversations: totals.conversations.total,
|
|
438
|
+
archivedConversations: totals.conversations.archived,
|
|
439
|
+
firstConversationAt: totals.conversations.firstCreatedAt,
|
|
440
|
+
messages: totals.messages,
|
|
441
|
+
turns: totals.turns,
|
|
442
|
+
turnsByProvider: totals.turnsByProvider.map((row) => ({
|
|
443
|
+
...row,
|
|
444
|
+
label: ABOUT_PROVIDER_LABELS[row.id] || row.id,
|
|
445
|
+
})),
|
|
446
|
+
computedAt: totals.computedAt,
|
|
447
|
+
},
|
|
448
|
+
usage: totals.usage,
|
|
449
|
+
relay: {
|
|
450
|
+
uptimeSeconds,
|
|
451
|
+
dbBytes: dbFileBytes + walBytes,
|
|
452
|
+
dbFileBytes,
|
|
453
|
+
walBytes,
|
|
454
|
+
runningWorkers: workers.total,
|
|
455
|
+
},
|
|
456
|
+
update: describeUpdate(),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return { describe };
|
|
461
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live background-task sets, published by the session workers (REPLACE
|
|
5
|
+
* semantics, mirroring the SDK's background_tasks_changed signal) and keyed
|
|
6
|
+
* by the id the worker posts, its SDK session id.
|
|
7
|
+
*
|
|
8
|
+
* Memory only, and a relay restart starts it empty while the workers and
|
|
9
|
+
* their tasks keep running. The worker is the source of truth: its heartbeat
|
|
10
|
+
* carries a digest of its task ids, the relay compares it with the set held
|
|
11
|
+
* here and asks for the full set when the two differ (a restart, a lost
|
|
12
|
+
* post). `reported` remembers which workers this runtime has heard from
|
|
13
|
+
* about their tasks at all: until then "no set" means "not told yet", not
|
|
14
|
+
* "no tasks".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
18
|
+
|
|
19
|
+
function keyOf(value) {
|
|
20
|
+
return String(value || '').trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
|
|
24
|
+
const sets = new Map();
|
|
25
|
+
const reported = new Set();
|
|
26
|
+
// key → the order of the newest set applied, `{ epoch, seq }` as the
|
|
27
|
+
// worker numbered it. `epoch` names the worker process; it is compared
|
|
28
|
+
// for equality only, never as a time.
|
|
29
|
+
const applied = new Map();
|
|
30
|
+
// key → the pid of the worker process whose heartbeat came last: the
|
|
31
|
+
// process that holds the session, by the worker's own word.
|
|
32
|
+
const holders = new Map();
|
|
33
|
+
// Keys whose pages were told of the placeholder for unreported work.
|
|
34
|
+
const announced = new Set();
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
sets,
|
|
38
|
+
replace(conversationId, tasks) {
|
|
39
|
+
const id = keyOf(conversationId);
|
|
40
|
+
if (!id) return;
|
|
41
|
+
reported.add(id);
|
|
42
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
43
|
+
sets.delete(id);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
sets.set(id, { tasks, updatedAt: now() });
|
|
47
|
+
},
|
|
48
|
+
get(conversationId) {
|
|
49
|
+
return sets.get(keyOf(conversationId))?.tasks || [];
|
|
50
|
+
},
|
|
51
|
+
/** The digest of the set held under the first of `keys` that has one. */
|
|
52
|
+
digestFor(...keys) {
|
|
53
|
+
const held = keys.map(keyOf).find((key) => key && sets.has(key));
|
|
54
|
+
return backgroundTaskDigest((held ? sets.get(held).tasks : []).map((task) => task?.taskId));
|
|
55
|
+
},
|
|
56
|
+
/**
|
|
57
|
+
* A worker's heartbeat digest against the set held for it: true when
|
|
58
|
+
* they agree. Agreement counts as a report, an empty set included.
|
|
59
|
+
*/
|
|
60
|
+
matches(digest, ...keys) {
|
|
61
|
+
const same = sameBackgroundTaskDigest(digest, this.digestFor(...keys));
|
|
62
|
+
if (same) for (const key of keys.map(keyOf).filter(Boolean)) reported.add(key);
|
|
63
|
+
return same;
|
|
64
|
+
},
|
|
65
|
+
/** Whether this runtime has heard from the worker about its tasks. */
|
|
66
|
+
hasReport(...keys) {
|
|
67
|
+
return keys.map(keyOf).some((key) => key && reported.has(key));
|
|
68
|
+
},
|
|
69
|
+
/**
|
|
70
|
+
* Whether a set with this order may be applied, as `{ accepted, reason }`.
|
|
71
|
+
* Accepting records it. `seq` is the worker process's count of set
|
|
72
|
+
* posts, `epoch` names that process. A set without an order, from a
|
|
73
|
+
* worker that numbers none, always applies.
|
|
74
|
+
*
|
|
75
|
+
* Refused are:
|
|
76
|
+
* - `older`: same process, a count not above the newest applied. The
|
|
77
|
+
* post was overtaken on the way.
|
|
78
|
+
* - `not-holder`: the post's `pid` is not the pid of the process whose
|
|
79
|
+
* heartbeat came last for the session (`noteHolder`): a late post of
|
|
80
|
+
* the process before it, or a new process that has not beaten yet,
|
|
81
|
+
* which is told so and beats.
|
|
82
|
+
*
|
|
83
|
+
* A different epoch from the holder, or from a process that cannot be
|
|
84
|
+
* placed (no pid on the post, no heartbeat yet), is a new process and
|
|
85
|
+
* starts the count over, whichever of the two epochs is the larger: a clock that
|
|
86
|
+
* stepped back between two starts must not turn every set away. A late
|
|
87
|
+
* post that slips through that way is put right by the next heartbeat's
|
|
88
|
+
* digest.
|
|
89
|
+
*/
|
|
90
|
+
acceptOrder(conversationId, order, { pid = null } = {}) {
|
|
91
|
+
const id = keyOf(conversationId);
|
|
92
|
+
const epoch = Number(order?.epoch);
|
|
93
|
+
const seq = Number(order?.seq);
|
|
94
|
+
if (!id || !Number.isFinite(epoch) || !Number.isInteger(seq) || seq < 0) return { accepted: true, reason: null };
|
|
95
|
+
const holder = holders.get(id);
|
|
96
|
+
const from = Number(pid);
|
|
97
|
+
if (holder && Number.isInteger(from) && from > 0 && from !== holder) return { accepted: false, reason: 'not-holder' };
|
|
98
|
+
const last = applied.get(id);
|
|
99
|
+
if (last && epoch === last.epoch && seq <= last.seq) return { accepted: false, reason: 'older' };
|
|
100
|
+
applied.set(id, { epoch, seq });
|
|
101
|
+
return { accepted: true, reason: null };
|
|
102
|
+
},
|
|
103
|
+
/** A heartbeat of the session's worker came from this process. */
|
|
104
|
+
noteHolder(conversationId, pid) {
|
|
105
|
+
const id = keyOf(conversationId);
|
|
106
|
+
const from = Number(pid);
|
|
107
|
+
if (id && Number.isInteger(from) && from > 0) holders.set(id, from);
|
|
108
|
+
},
|
|
109
|
+
/** Records whether the pages know of the key's placeholder; true when that changed. */
|
|
110
|
+
announce(conversationId, shown) {
|
|
111
|
+
const id = keyOf(conversationId);
|
|
112
|
+
if (!id || announced.has(id) === !!shown) return false;
|
|
113
|
+
if (shown) announced.add(id);
|
|
114
|
+
else announced.delete(id);
|
|
115
|
+
return true;
|
|
116
|
+
},
|
|
117
|
+
/** The conversation is gone: everything held for these keys goes. */
|
|
118
|
+
forget(...keys) {
|
|
119
|
+
for (const id of keys.map(keyOf).filter(Boolean)) {
|
|
120
|
+
sets.delete(id);
|
|
121
|
+
reported.delete(id);
|
|
122
|
+
applied.delete(id);
|
|
123
|
+
holders.delete(id);
|
|
124
|
+
announced.delete(id);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|