@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
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay database's file name, the one place that spells it. New
|
|
3
|
+
* databases are `oar.db`; installs from before 0.9.20 run on `copilot.db`
|
|
4
|
+
* until their owner renames it by hand. The relay never renames a database
|
|
5
|
+
* it runs on: it only sets aside a `copilot.db` that is not newer than an
|
|
6
|
+
* `oar.db` beside it (settleDatabaseFile), never deleting anything.
|
|
7
|
+
*
|
|
8
|
+
* Also here, shared with earlier-database-service: reading a database for
|
|
9
|
+
* its latest changes (readDatabase, newerOf) and the rename that waits out
|
|
10
|
+
* Windows' brief file locks (renameWithRetry).
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
export const DB_FILE_NAME = 'oar.db';
|
|
17
|
+
export const LEGACY_DB_FILE_NAME = 'copilot.db';
|
|
18
|
+
// In the order the resolver prefers them.
|
|
19
|
+
export const DB_FILE_NAMES = [DB_FILE_NAME, LEGACY_DB_FILE_NAME];
|
|
20
|
+
// Where settleDatabaseFile reads both databases through copies; never the
|
|
21
|
+
// earlier-database service's `.earlier-database-` (a leftover candidate there).
|
|
22
|
+
const CHECK_DIR_PREFIX = '.database-file-check-';
|
|
23
|
+
|
|
24
|
+
// "Downgrade and rollback": an earlier version started on a folder
|
|
25
|
+
// whose database is oar.db creates an empty copilot.db and runs on it; with
|
|
26
|
+
// nothing done there, the next start sets it aside. "Nothing done" is no
|
|
27
|
+
// one's work, not no rows: every start writes these settings (seen on a
|
|
28
|
+
// real start of 0.9.19 on an empty folder). A later boot-time key goes here
|
|
29
|
+
// deliberately; any other key makes the start refuse.
|
|
30
|
+
export const STARTUP_SETTING_KEYS = Object.freeze(['push_vapid_public_key', 'push_vapid_private_key', 'relay_last_exit']);
|
|
31
|
+
// And the tables such a start fills that hold no one's work: the model
|
|
32
|
+
// catalog and its refresh state, the restart orchestrator's one row. (The
|
|
33
|
+
// shadow tables of the message search are left out too: they follow
|
|
34
|
+
// messages.) Any other table with a row makes the start refuse.
|
|
35
|
+
export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator']);
|
|
36
|
+
|
|
37
|
+
/** A database's files: the main file, its -wal and its -shm. */
|
|
38
|
+
export function sideFiles(name) {
|
|
39
|
+
return [name, `${name}-wal`, `${name}-shm`];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The latest change a database records, per table: what tells which of two
|
|
43
|
+
// databases was used last.
|
|
44
|
+
const STAMPS = [
|
|
45
|
+
['conversations', 'updated_at'],
|
|
46
|
+
['messages', 'timestamp'],
|
|
47
|
+
['app_settings', 'updated_at'],
|
|
48
|
+
];
|
|
49
|
+
// Defender or the indexer may hold a file for a moment at boot.
|
|
50
|
+
const RENAME_RETRY_MS = [50, 100, 200, 400, 800];
|
|
51
|
+
// What Windows answers for a file another program holds for a moment.
|
|
52
|
+
export const RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
53
|
+
|
|
54
|
+
export function sleepSync(ms) {
|
|
55
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renameWithRetry(fsImpl, from, to, sleep = sleepSync) {
|
|
59
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
60
|
+
try {
|
|
61
|
+
fsImpl.renameSync(from, to);
|
|
62
|
+
return;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!RETRY_CODES.has(error?.code) || attempt >= RENAME_RETRY_MS.length) throw error;
|
|
65
|
+
sleep(RENAME_RETRY_MS[attempt]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A quick_check report is many lines: the start and nothing more.
|
|
71
|
+
export function oneLine(text) {
|
|
72
|
+
return String(text || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 2).join(' ').slice(0, 200);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a database holds only what a start writes (STARTUP_SETTING_KEYS,
|
|
77
|
+
* STARTUP_TABLES): no conversation, message or other row of anyone's work.
|
|
78
|
+
* Anything it cannot read says no.
|
|
79
|
+
*/
|
|
80
|
+
export function holdsStartupOnly(Database, dbPath) {
|
|
81
|
+
let db;
|
|
82
|
+
try {
|
|
83
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
84
|
+
const tables = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table'").all();
|
|
85
|
+
const virtual = tables.filter((table) => /^\s*CREATE\s+VIRTUAL\s+TABLE/i.test(table.sql || '')).map((table) => table.name);
|
|
86
|
+
for (const { name } of tables) {
|
|
87
|
+
if (name.startsWith('sqlite_') || STARTUP_TABLES.includes(name)) continue;
|
|
88
|
+
if (virtual.some((table) => name === table || name.startsWith(`${table}_`))) continue;
|
|
89
|
+
if (name === 'app_settings') {
|
|
90
|
+
if (db.prepare('SELECT key FROM app_settings').all().some((row) => !STARTUP_SETTING_KEYS.includes(row.key))) return false;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (db.prepare(`SELECT 1 FROM "${name.replace(/"/g, '""')}" LIMIT 1`).get()) return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
} finally {
|
|
99
|
+
try { db?.close(); } catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseStamp(value) {
|
|
104
|
+
if (value === null || value === undefined || value === '') return null;
|
|
105
|
+
const ms = Date.parse(String(value));
|
|
106
|
+
return Number.isFinite(ms) ? ms : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function readDatabase(Database, dbPath, { readonly = false, singleFile = false, tick = () => {} } = {}) {
|
|
110
|
+
const db = new Database(dbPath, readonly ? { readonly: true, fileMustExist: true } : { fileMustExist: true });
|
|
111
|
+
try {
|
|
112
|
+
const integrity = String(db.pragma('quick_check', { simple: true }) || '');
|
|
113
|
+
const has = (table) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
114
|
+
const stamps = {};
|
|
115
|
+
const latest = {};
|
|
116
|
+
for (const [table, column] of STAMPS) {
|
|
117
|
+
// Parsed per row, not compared as text: a value that is no time tells nothing.
|
|
118
|
+
let newest = null;
|
|
119
|
+
let newestText = null;
|
|
120
|
+
if (has(table)) {
|
|
121
|
+
let rows = 0;
|
|
122
|
+
for (const row of db.prepare(`SELECT ${column} AS value FROM ${table}`).iterate()) {
|
|
123
|
+
if (++rows % 10000 === 0) tick();
|
|
124
|
+
const ms = parseStamp(row.value);
|
|
125
|
+
if (ms !== null && (newest === null || ms > newest)) { newest = ms; newestText = String(row.value); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
stamps[table] = newest;
|
|
129
|
+
latest[table] = newestText;
|
|
130
|
+
}
|
|
131
|
+
const count = (table) => (has(table) ? Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n || 0) : 0);
|
|
132
|
+
// The copy goes into place as one file: no -wal or -shm to move with it.
|
|
133
|
+
if (singleFile && integrity === 'ok') db.pragma('journal_mode = DELETE');
|
|
134
|
+
return { integrity, stamps, latest, conversations: count('conversations'), messages: count('messages') };
|
|
135
|
+
} finally {
|
|
136
|
+
db.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Which database has the later changes: 'earlier', 'pinned', 'same', or
|
|
141
|
+
// 'both' when each has a table the other changed less recently.
|
|
142
|
+
export function newerOf(earlier, pinned) {
|
|
143
|
+
let earlierNewer = 0;
|
|
144
|
+
let pinnedNewer = 0;
|
|
145
|
+
for (const [table] of STAMPS) {
|
|
146
|
+
const a = earlier[table];
|
|
147
|
+
const b = pinned ? pinned[table] : null;
|
|
148
|
+
if (a !== null && (b === null || a > b)) earlierNewer += 1;
|
|
149
|
+
if (b !== null && (a === null || b > a)) pinnedNewer += 1;
|
|
150
|
+
}
|
|
151
|
+
if (earlierNewer && pinnedNewer) return 'both';
|
|
152
|
+
if (earlierNewer) return 'earlier';
|
|
153
|
+
return pinnedNewer ? 'pinned' : 'same';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 'absent', 'present', or 'dangling' (a symlink to nothing: opening it
|
|
157
|
+
// would create a database where it points).
|
|
158
|
+
function fileState(fsImpl, file) {
|
|
159
|
+
let link;
|
|
160
|
+
try { link = fsImpl.lstatSync(file); } catch { return 'absent'; }
|
|
161
|
+
if (!link.isSymbolicLink()) return 'present';
|
|
162
|
+
try { fsImpl.statSync(file); return 'present'; } catch { return 'dangling'; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The database file of a data folder: `oar.db` if it is there, else
|
|
167
|
+
* `copilot.db` if it is, else a new `oar.db` (`created`). `both` when the
|
|
168
|
+
* two are there (settleDatabaseFile decides at start). Also what is odd:
|
|
169
|
+
* `dangling` symlinks named like a database, and `orphans`, a -wal or -shm
|
|
170
|
+
* whose database is not there (a rename by hand that missed it). Read
|
|
171
|
+
* only: never creates or opens anything.
|
|
172
|
+
*/
|
|
173
|
+
export function resolveDatabaseFile(dataDir, { fsImpl = fs } = {}) {
|
|
174
|
+
const dir = String(dataDir || '');
|
|
175
|
+
const states = Object.fromEntries(DB_FILE_NAMES.map((name) => [name, fileState(fsImpl, path.join(dir, name))]));
|
|
176
|
+
const found = DB_FILE_NAMES.filter((name) => states[name] !== 'absent');
|
|
177
|
+
const name = found[0] || DB_FILE_NAME;
|
|
178
|
+
const orphans = [];
|
|
179
|
+
for (const each of DB_FILE_NAMES) {
|
|
180
|
+
if (states[each] !== 'absent') continue;
|
|
181
|
+
for (const side of sideFiles(each).slice(1)) {
|
|
182
|
+
if (fileState(fsImpl, path.join(dir, side)) !== 'absent') orphans.push(path.join(dir, side));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
path: path.join(dir, name),
|
|
187
|
+
name,
|
|
188
|
+
legacy: name === LEGACY_DB_FILE_NAME,
|
|
189
|
+
both: found.length === DB_FILE_NAMES.length,
|
|
190
|
+
created: !found.length,
|
|
191
|
+
dangling: DB_FILE_NAMES.filter((each) => states[each] === 'dangling').map((each) => path.join(dir, each)),
|
|
192
|
+
orphans,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What a start must not go on with, from resolveDatabaseFile's answer:
|
|
198
|
+
* `refusal` (a line, exit 78) for a database name that is a link to
|
|
199
|
+
* nothing, or an `oar.db-wal`/`-shm` without `oar.db` (whatever name the
|
|
200
|
+
* relay runs on): a database renamed to `oar.db` later, or a new one, would
|
|
201
|
+
* take that -wal's pages in, and rows written since would be lost without a
|
|
202
|
+
* trace. `warnings` for the other strays (a `copilot.db-wal` without its
|
|
203
|
+
* database), which nothing deletes.
|
|
204
|
+
*/
|
|
205
|
+
export function checkDatabaseFile(file) {
|
|
206
|
+
const dir = path.dirname(file.path);
|
|
207
|
+
if (file.dangling.length) {
|
|
208
|
+
return {
|
|
209
|
+
refusal: `[data] Not starting: ${file.dangling.join(', ')} is a link to a file that is not there; a new database would be created where it points. Fix or remove the link, then start again.`,
|
|
210
|
+
warnings: [],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const current = path.join(dir, DB_FILE_NAME);
|
|
214
|
+
const strayCurrent = file.orphans.filter((orphan) => path.basename(orphan).startsWith(`${DB_FILE_NAME}-`));
|
|
215
|
+
if (strayCurrent.length) {
|
|
216
|
+
return {
|
|
217
|
+
refusal: `[data] Not starting: ${strayCurrent.join(' and ')} ${strayCurrent.length > 1 ? 'are' : 'is'} there without ${current}. `
|
|
218
|
+
+ `A database renamed to ${DB_FILE_NAME} (or a new one) would take those pages in, and the rows written since would be lost. `
|
|
219
|
+
+ `Move ${strayCurrent.length > 1 ? 'them' : 'it'} into a folder of your own (${strayCurrent.length > 1 ? 'they are' : 'it is'} not part of ${file.created ? 'any database here' : file.path}), then start again.`,
|
|
220
|
+
warnings: [],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
refusal: null,
|
|
225
|
+
warnings: file.orphans.map((orphan) => `[data] WARNING: ${orphan} is there without its database; it is not used. `
|
|
226
|
+
+ `If ${path.basename(orphan).replace(/-(wal|shm)$/, '')} was renamed by hand without it, its latest changes may be in that file. `
|
|
227
|
+
+ 'Nothing deletes it; move it away before anything is renamed to that name.'),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The database a script run by hand works on when given no path: the one
|
|
233
|
+
* the relay in `<serverDir>/data` runs on. Throws when there is none, so a
|
|
234
|
+
* script never creates an empty database.
|
|
235
|
+
*/
|
|
236
|
+
export function defaultCliDbPath(serverDir, { fsImpl = fs } = {}) {
|
|
237
|
+
const dataDir = path.join(String(serverDir || ''), 'data');
|
|
238
|
+
const file = resolveDatabaseFile(dataDir, { fsImpl });
|
|
239
|
+
if (file.created || file.dangling.length) {
|
|
240
|
+
throw new Error(`No database in ${dataDir} (neither ${DB_FILE_NAME} nor ${LEGACY_DB_FILE_NAME}); pass the path of the database to work on.`);
|
|
241
|
+
}
|
|
242
|
+
return file.path;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The database a script run by hand works on (the migrations, the DeepSeek
|
|
247
|
+
* import): the path it was given, which must be a database file, else
|
|
248
|
+
* defaultCliDbPath of the server folder (a path, or a file: URL such as
|
|
249
|
+
* `new URL('..', import.meta.url)`). Throws rather than let a script
|
|
250
|
+
* create an empty database.
|
|
251
|
+
*/
|
|
252
|
+
export function cliDbPath(given, serverDir, { fsImpl = fs } = {}) {
|
|
253
|
+
if (!given) return defaultCliDbPath(serverDir instanceof URL ? fileURLToPath(serverDir) : serverDir, { fsImpl });
|
|
254
|
+
const file = path.resolve(String(given));
|
|
255
|
+
let isFile = false;
|
|
256
|
+
try { isFile = fsImpl.statSync(file).isFile(); } catch {}
|
|
257
|
+
if (!isFile) throw new Error(`No database at ${file}; a script never creates one.`);
|
|
258
|
+
return file;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Renames a database (`name` in `dir`) with its -wal and -shm to
|
|
263
|
+
* `<name>.<suffix>` (`-2`, `-3` … when taken; nothing is renamed over a
|
|
264
|
+
* file), never deleting anything. Everything of it goes into its main file
|
|
265
|
+
* first (a checkpoint that must complete), then the side files move and the
|
|
266
|
+
* main file last: a kill in between leaves no -wal without its database.
|
|
267
|
+
* A failed rename puts back what moved. `{ ok, renamedTo }`, or `{ ok:
|
|
268
|
+
* false, reason, stuck }` naming files that could not be put back.
|
|
269
|
+
*/
|
|
270
|
+
export function setAsideDatabase({ dir, name, suffix, Database, rename, fsImpl = fs }) {
|
|
271
|
+
const exists = (target) => fileState(fsImpl, target) !== 'absent';
|
|
272
|
+
try {
|
|
273
|
+
const db = new Database(path.join(dir, name), { fileMustExist: true });
|
|
274
|
+
try {
|
|
275
|
+
const [result] = db.pragma('wal_checkpoint(TRUNCATE)');
|
|
276
|
+
// Another reader keeps frames in the -wal: they would move apart from it.
|
|
277
|
+
if (result && Number(result.busy) !== 0) return { ok: false, reason: 'another program has it open, so its -wal could not be emptied', stuck: [] };
|
|
278
|
+
} finally {
|
|
279
|
+
db.close();
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return { ok: false, reason: error?.message || String(error), stuck: [] };
|
|
283
|
+
}
|
|
284
|
+
let base = `${name}.${suffix}`;
|
|
285
|
+
for (let n = 2; sideFiles(base).some((each) => exists(path.join(dir, each))); n += 1) base = `${name}.${suffix}-${n}`;
|
|
286
|
+
const [main, ...sides] = sideFiles(name);
|
|
287
|
+
const [baseMain, ...baseSides] = sideFiles(base);
|
|
288
|
+
const moves = [...sides.map((each, index) => [each, baseSides[index]]), [main, baseMain]]
|
|
289
|
+
.map(([from, to]) => [path.join(dir, from), path.join(dir, to)])
|
|
290
|
+
.filter(([from]) => exists(from));
|
|
291
|
+
const done = [];
|
|
292
|
+
try {
|
|
293
|
+
for (const [from, to] of moves) {
|
|
294
|
+
rename(from, to);
|
|
295
|
+
done.push([from, to]);
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
const stuck = [];
|
|
299
|
+
for (const [from, to] of done.reverse()) {
|
|
300
|
+
try { rename(to, from); } catch { stuck.push(`${from} is now ${to}`); }
|
|
301
|
+
}
|
|
302
|
+
return { ok: false, reason: error?.message || String(error), stuck };
|
|
303
|
+
}
|
|
304
|
+
return { ok: true, renamedTo: path.join(dir, baseMain) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function removeCheckDirs(fsImpl, dataDir) {
|
|
308
|
+
let names = [];
|
|
309
|
+
try { names = fsImpl.readdirSync(dataDir); } catch {}
|
|
310
|
+
for (const name of names) {
|
|
311
|
+
if (name.startsWith(CHECK_DIR_PREFIX)) {
|
|
312
|
+
try { fsImpl.rmSync(path.join(dataDir, name), { recursive: true, force: true }); } catch {}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Before the database opens, under the relay's lock, when `oar.db` and
|
|
319
|
+
* `copilot.db` are both in the data folder (after a downgrade that skipped
|
|
320
|
+
* the rename back, or a stray file): both are read through copies (opening
|
|
321
|
+
* either, even read-only, would rebuild its -shm). `copilot.db` not newer,
|
|
322
|
+
* or empty: it and its -wal/-shm are renamed to `copilot.db.older-<time>`,
|
|
323
|
+
* never deleted, and the relay goes on with `oar.db` (`set-aside`).
|
|
324
|
+
* `copilot.db` newer, each newer somewhere, a failed copy or check:
|
|
325
|
+
* nothing changes, the relay stops (`refused`, with `error`).
|
|
326
|
+
*/
|
|
327
|
+
export function settleDatabaseFile({
|
|
328
|
+
dataDir,
|
|
329
|
+
Database,
|
|
330
|
+
now = () => new Date(),
|
|
331
|
+
clock = Date.now,
|
|
332
|
+
pid = process.pid,
|
|
333
|
+
progressFile = null,
|
|
334
|
+
fsImpl = fs,
|
|
335
|
+
sleep = sleepSync,
|
|
336
|
+
rename = (from, to) => renameWithRetry(fsImpl, from, to, sleep),
|
|
337
|
+
logger = console,
|
|
338
|
+
} = {}) {
|
|
339
|
+
const file = resolveDatabaseFile(dataDir, { fsImpl });
|
|
340
|
+
// Copies a kill left: only ever copies.
|
|
341
|
+
removeCheckDirs(fsImpl, dataDir);
|
|
342
|
+
if (!file.both) return { status: file.created ? 'none' : 'single', path: file.path };
|
|
343
|
+
|
|
344
|
+
const current = path.join(dataDir, DB_FILE_NAME);
|
|
345
|
+
const legacy = path.join(dataDir, LEGACY_DB_FILE_NAME);
|
|
346
|
+
const filesOf = (name) => sideFiles(name).join(', ');
|
|
347
|
+
const moveOne = `To go on with ${DB_FILE_NAME}, move ${filesOf(LEGACY_DB_FILE_NAME)} into a folder of your own; `
|
|
348
|
+
+ `to go on with ${LEGACY_DB_FILE_NAME}, move ${filesOf(DB_FILE_NAME)}. Then start again.`;
|
|
349
|
+
const refused = (reason) => ({
|
|
350
|
+
status: 'refused',
|
|
351
|
+
error: `[data] Not starting: ${reason} Both ${legacy} and ${current} are there; nothing was changed in either.`,
|
|
352
|
+
});
|
|
353
|
+
const exists = (target) => fileState(fsImpl, target) !== 'absent';
|
|
354
|
+
const stamp = now().toISOString().replace(/[:.]/g, '-');
|
|
355
|
+
const checkDir = path.join(dataDir, `${CHECK_DIR_PREFIX}${stamp}`);
|
|
356
|
+
const bytes = DB_FILE_NAMES.flatMap(sideFiles).reduce((sum, name) => {
|
|
357
|
+
try { return sum + fsImpl.statSync(path.join(dataDir, name)).size; } catch { return sum; }
|
|
358
|
+
}, 0);
|
|
359
|
+
const started = clock();
|
|
360
|
+
let saidAt = started;
|
|
361
|
+
const tick = () => {
|
|
362
|
+
const at = clock();
|
|
363
|
+
if (at - saidAt < 60_000) return;
|
|
364
|
+
saidAt = at;
|
|
365
|
+
logger.log?.(`[data] Still comparing ${legacy} with ${current}: ${Math.round((at - started) / 1000)} s so far.`);
|
|
366
|
+
};
|
|
367
|
+
logger.log?.(`[data] ${current} and ${legacy} are both there: comparing them (${Math.max(1, Math.round(bytes / 1048576))} MB); this can take a few minutes.`);
|
|
368
|
+
if (progressFile) {
|
|
369
|
+
try { fsImpl.writeFileSync(progressFile, `${JSON.stringify({ pid, task: 'compare', from: legacy, bytes, at: new Date(started).toISOString() }, null, 2)}\n`); } catch {}
|
|
370
|
+
}
|
|
371
|
+
const read = {};
|
|
372
|
+
let startupOnly = false;
|
|
373
|
+
try {
|
|
374
|
+
for (const name of DB_FILE_NAMES) {
|
|
375
|
+
const copyDir = path.join(checkDir, name);
|
|
376
|
+
fsImpl.mkdirSync(copyDir, { recursive: true });
|
|
377
|
+
for (const side of sideFiles(name)) {
|
|
378
|
+
if (exists(path.join(dataDir, side))) fsImpl.copyFileSync(path.join(dataDir, side), path.join(copyDir, side));
|
|
379
|
+
}
|
|
380
|
+
read[name] = readDatabase(Database, path.join(copyDir, name), { tick });
|
|
381
|
+
}
|
|
382
|
+
startupOnly = read[LEGACY_DB_FILE_NAME].integrity === 'ok'
|
|
383
|
+
&& holdsStartupOnly(Database, path.join(checkDir, LEGACY_DB_FILE_NAME, LEGACY_DB_FILE_NAME));
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return refused(`the two databases could not be copied and read (${error?.message || error}).`);
|
|
386
|
+
} finally {
|
|
387
|
+
try { fsImpl.rmSync(checkDir, { recursive: true, force: true }); } catch {}
|
|
388
|
+
if (progressFile) try { fsImpl.rmSync(progressFile, { force: true }); } catch {}
|
|
389
|
+
}
|
|
390
|
+
for (const name of DB_FILE_NAMES) {
|
|
391
|
+
if (read[name].integrity !== 'ok') return refused(`${path.join(dataDir, name)} failed its check (${oneLine(read[name].integrity)}).`);
|
|
392
|
+
}
|
|
393
|
+
// What an earlier version's start alone wrote is no newer work, whatever
|
|
394
|
+
// its times say.
|
|
395
|
+
const newer = startupOnly ? 'pinned' : newerOf(read[LEGACY_DB_FILE_NAME].stamps, read[DB_FILE_NAME].stamps);
|
|
396
|
+
if (newer === 'earlier') return refused(`${legacy} has changes newer than ${current}. ${moveOne}`);
|
|
397
|
+
if (newer === 'both') return refused(`each of ${legacy} and ${current} has changes newer than the other's. ${moveOne}`);
|
|
398
|
+
|
|
399
|
+
const aside = setAsideDatabase({ dir: dataDir, name: LEGACY_DB_FILE_NAME, suffix: `older-${stamp}`, Database, rename, fsImpl });
|
|
400
|
+
if (!aside.ok) {
|
|
401
|
+
return {
|
|
402
|
+
status: 'refused',
|
|
403
|
+
error: `[data] Not starting: ${legacy} could not be set aside (${aside.reason}); `
|
|
404
|
+
+ (aside.stuck.length ? `moving it back failed too: ${aside.stuck.join('; ')}. ` : 'it is back as it was. ')
|
|
405
|
+
+ `The relay runs on ${current} once ${legacy} is moved into a folder of your own.`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const { renamedTo } = aside;
|
|
409
|
+
logger.log?.(startupOnly
|
|
410
|
+
? `[data] ${legacy} holds start-up settings only and no chats (an earlier version started on this folder): set it aside as ${renamedTo}; the relay runs on ${current}.`
|
|
411
|
+
: `[data] ${legacy} is not newer than ${current}: set it aside as ${renamedTo}; the relay runs on ${current}.`);
|
|
412
|
+
return { status: 'set-aside', path: current, renamedTo, startupOnly };
|
|
413
|
+
}
|