@oar-sh/oar 0.9.13 → 0.9.15
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/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +396 -0
- package/README.md +12 -12
- package/bin/oar.js +677 -103
- package/package.json +7 -5
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +49 -16
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +18 -5
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +415 -45
- package/server/services/windows-boot-autostart-service.mjs +962 -185
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2187
- package/server/uploads/image_input.md +0 -540
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import { resolveDataDir, resolveUploadDir } from './data-dir-resolution.mjs';
|
|
7
|
+
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
8
|
+
import { resolveStateLayout } from './oar-state-layout.mjs';
|
|
9
|
+
import { defaultIsProcessAlive, lockHolderMayRun, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
|
|
10
|
+
|
|
11
|
+
const DB_FILE = 'copilot.db';
|
|
12
|
+
const DB_FILES = [DB_FILE, `${DB_FILE}-wal`, `${DB_FILE}-shm`];
|
|
13
|
+
const SHA256_NAME = /^[a-f0-9]{64}$/;
|
|
14
|
+
// The install-time rescue's copies (package-data-rescue.mjs), in the data folder.
|
|
15
|
+
export const RESCUE_DIR_NAME = 'rescued-package-data';
|
|
16
|
+
// What was looked at already: never offered again unless it changed.
|
|
17
|
+
export const CHECKED_FILE = 'earlier-databases.json';
|
|
18
|
+
// Written before the pinned database moves aside, removed once the copy is
|
|
19
|
+
// in place: a start after a kill in between finishes the swap.
|
|
20
|
+
export const SWAP_JOURNAL_FILE = 'earlier-database-swap.json';
|
|
21
|
+
// While a database is brought over, with the relay's pid: `oar start` tells
|
|
22
|
+
// a relay busy with it from one that did not come up.
|
|
23
|
+
export const TAKEOVER_PROGRESS_FILE = 'earlier-database-progress.json';
|
|
24
|
+
const STAGING_PREFIX = '.earlier-database-';
|
|
25
|
+
// In a staged copy: written once the copy passed its check, so a copy a
|
|
26
|
+
// kill cut short is never taken for one; and where it came from.
|
|
27
|
+
const STAGED_CHECKED_FILE = '.checked';
|
|
28
|
+
const STAGED_SOURCE_FILE = 'source.json';
|
|
29
|
+
// The latest change a database records, per table: what tells which of two
|
|
30
|
+
// databases was used last.
|
|
31
|
+
const STAMPS = [
|
|
32
|
+
['conversations', 'updated_at'],
|
|
33
|
+
['messages', 'timestamp'],
|
|
34
|
+
['app_settings', 'updated_at'],
|
|
35
|
+
];
|
|
36
|
+
// Defender or the indexer may hold a file for a moment at boot.
|
|
37
|
+
const RENAME_RETRY_MS = [50, 100, 200, 400, 800];
|
|
38
|
+
const RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
39
|
+
|
|
40
|
+
function sleepSync(ms) {
|
|
41
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renameWithRetry(fsImpl, from, to, sleep = sleepSync) {
|
|
45
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
46
|
+
try {
|
|
47
|
+
fsImpl.renameSync(from, to);
|
|
48
|
+
return;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!RETRY_CODES.has(error?.code) || attempt >= RENAME_RETRY_MS.length) throw error;
|
|
51
|
+
sleep(RENAME_RETRY_MS[attempt]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A quick_check report is many lines: the start and nothing more.
|
|
57
|
+
function oneLine(text) {
|
|
58
|
+
return String(text || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 2).join(' ').slice(0, 200);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseStamp(value) {
|
|
62
|
+
if (value === null || value === undefined || value === '') return null;
|
|
63
|
+
const ms = Date.parse(String(value));
|
|
64
|
+
return Number.isFinite(ms) ? ms : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hashToken(value) {
|
|
68
|
+
const token = String(value || '').trim();
|
|
69
|
+
return token ? crypto.createHash('sha256').update(token).digest('hex') : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readJson(fsImpl, file) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readdir(fsImpl, dir) {
|
|
81
|
+
try { return fsImpl.readdirSync(dir); } catch { return []; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeJson(fsImpl, file, value) {
|
|
85
|
+
const temporary = `${file}.tmp`;
|
|
86
|
+
fsImpl.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
87
|
+
renameWithRetry(fsImpl, temporary, file);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readDatabase(Database, dbPath, { readonly = false, singleFile = false, tick = () => {} } = {}) {
|
|
91
|
+
const db = new Database(dbPath, readonly ? { readonly: true, fileMustExist: true } : { fileMustExist: true });
|
|
92
|
+
try {
|
|
93
|
+
const integrity = String(db.pragma('quick_check', { simple: true }) || '');
|
|
94
|
+
const has = (table) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
95
|
+
const stamps = {};
|
|
96
|
+
const latest = {};
|
|
97
|
+
for (const [table, column] of STAMPS) {
|
|
98
|
+
// Parsed per row, not compared as text: a value that is no time tells nothing.
|
|
99
|
+
let newest = null;
|
|
100
|
+
let newestText = null;
|
|
101
|
+
if (has(table)) {
|
|
102
|
+
let rows = 0;
|
|
103
|
+
for (const row of db.prepare(`SELECT ${column} AS value FROM ${table}`).iterate()) {
|
|
104
|
+
if (++rows % 10000 === 0) tick();
|
|
105
|
+
const ms = parseStamp(row.value);
|
|
106
|
+
if (ms !== null && (newest === null || ms > newest)) { newest = ms; newestText = String(row.value); }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
stamps[table] = newest;
|
|
110
|
+
latest[table] = newestText;
|
|
111
|
+
}
|
|
112
|
+
const count = (table) => (has(table) ? Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n || 0) : 0);
|
|
113
|
+
// The copy goes into place as one file: no -wal or -shm to move with it.
|
|
114
|
+
if (singleFile && integrity === 'ok') db.pragma('journal_mode = DELETE');
|
|
115
|
+
return { integrity, stamps, latest, conversations: count('conversations'), messages: count('messages') };
|
|
116
|
+
} finally {
|
|
117
|
+
db.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Which database has the later changes: 'earlier', 'pinned', 'same', or
|
|
122
|
+
// 'both' when each has a table the other changed less recently.
|
|
123
|
+
function newerOf(earlier, pinned) {
|
|
124
|
+
let earlierNewer = 0;
|
|
125
|
+
let pinnedNewer = 0;
|
|
126
|
+
for (const [table] of STAMPS) {
|
|
127
|
+
const a = earlier[table];
|
|
128
|
+
const b = pinned ? pinned[table] : null;
|
|
129
|
+
if (a !== null && (b === null || a > b)) earlierNewer += 1;
|
|
130
|
+
if (b !== null && (a === null || b > a)) pinnedNewer += 1;
|
|
131
|
+
}
|
|
132
|
+
if (earlierNewer && pinnedNewer) return 'both';
|
|
133
|
+
if (earlierNewer) return 'earlier';
|
|
134
|
+
return pinnedNewer ? 'pinned' : 'same';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Before the database opens, under the relay's lock: the databases a relay
|
|
139
|
+
* pinned to the state root's data folder may have run on until now (the one
|
|
140
|
+
* resolveDataDir picks without the pin, i.e. `server/data` in the package;
|
|
141
|
+
* the install-time rescue copies; a copy an interrupted run left) are each
|
|
142
|
+
* looked at once. Newer than the pinned one: copied in after a quick_check
|
|
143
|
+
* of a copy (never the original), the pinned one to `backups/<time>/`
|
|
144
|
+
* (`adopted`). As new or older: nothing changes (`kept`). Undecidable (each
|
|
145
|
+
* newer somewhere, a relay that may still run on it, a failed copy or
|
|
146
|
+
* check): nothing changes, the relay stops with `error` (`refused`). Each
|
|
147
|
+
* one looked at is recorded in earlier-databases.json and renamed in place
|
|
148
|
+
* (`.adopted-`/`.older-`), never deleted; the swap is journalled.
|
|
149
|
+
*
|
|
150
|
+
* Left alone: a checkout (a pinned folder there is deliberate), a relay on
|
|
151
|
+
* other folders, and the package's database for another state root unless
|
|
152
|
+
* the earlier lock carries this relay's token. A config `dataDir` is only
|
|
153
|
+
* copied from, never renamed. Copied, not pinned in place: the next
|
|
154
|
+
* `npm i -g` deletes the package.
|
|
155
|
+
*/
|
|
156
|
+
export function adoptEarlierDatabase(options = {}) {
|
|
157
|
+
const fsImpl = options.fsImpl || fs;
|
|
158
|
+
try {
|
|
159
|
+
return adopt(options);
|
|
160
|
+
} finally {
|
|
161
|
+
try { fsImpl.rmSync(path.join(String(options.dataDir || ''), TAKEOVER_PROGRESS_FILE), { force: true }); } catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function adopt({
|
|
166
|
+
dataDir,
|
|
167
|
+
uploadDir,
|
|
168
|
+
serverDir,
|
|
169
|
+
configPath = '',
|
|
170
|
+
packageRoot,
|
|
171
|
+
Database,
|
|
172
|
+
authToken = '',
|
|
173
|
+
env = process.env,
|
|
174
|
+
platform = process.platform,
|
|
175
|
+
isProcessAlive = defaultIsProcessAlive,
|
|
176
|
+
processStartedAt = (pid) => defaultProcessStartedAt(pid, { platform }),
|
|
177
|
+
bootTime = Date.now() - os.uptime() * 1000,
|
|
178
|
+
now = () => new Date(),
|
|
179
|
+
clock = Date.now,
|
|
180
|
+
pid = process.pid,
|
|
181
|
+
logger = console,
|
|
182
|
+
fsImpl = fs,
|
|
183
|
+
sleep = sleepSync,
|
|
184
|
+
}) {
|
|
185
|
+
const normalize = (value) => {
|
|
186
|
+
const resolved = path.resolve(String(value || ''));
|
|
187
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
188
|
+
};
|
|
189
|
+
const exists = (file) => { try { return fsImpl.existsSync(file); } catch { return false; } };
|
|
190
|
+
const layout = resolveStateLayout({ packageRoot, env, platform });
|
|
191
|
+
if (layout.checkout) return { status: 'not-applicable', reason: 'checkout' };
|
|
192
|
+
if (!layout.dataDir || normalize(dataDir) !== normalize(layout.dataDir)) {
|
|
193
|
+
return { status: 'not-applicable', reason: 'own-folders' };
|
|
194
|
+
}
|
|
195
|
+
const pinnedDb = path.join(dataDir, DB_FILE);
|
|
196
|
+
const stamp = now().toISOString().replace(/[:.]/g, '-');
|
|
197
|
+
const rename = (from, to) => renameWithRetry(fsImpl, from, to, sleep);
|
|
198
|
+
|
|
199
|
+
// A swap a kill interrupted is finished first: its copy was checked
|
|
200
|
+
// before the journal was written.
|
|
201
|
+
const recovered = finishInterruptedSwap({ dataDir, fsImpl, rename, exists, logger });
|
|
202
|
+
if (recovered?.status === 'refused') return recovered;
|
|
203
|
+
|
|
204
|
+
const checkedFile = path.join(dataDir, CHECKED_FILE);
|
|
205
|
+
const checked = readJson(fsImpl, checkedFile)?.sources || [];
|
|
206
|
+
const signatureOf = (dbPath) => {
|
|
207
|
+
const files = DB_FILES.map((name) => path.join(path.dirname(dbPath), name.replace(DB_FILE, path.basename(dbPath))));
|
|
208
|
+
let size = 0;
|
|
209
|
+
let mtimeMs = 0;
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
try {
|
|
212
|
+
const stat = fsImpl.statSync(file);
|
|
213
|
+
size += stat.size;
|
|
214
|
+
mtimeMs = Math.max(mtimeMs, stat.mtimeMs);
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
return { source: path.resolve(dbPath), size, mtimeMs };
|
|
218
|
+
};
|
|
219
|
+
const seen = (signature) => checked.some((entry) => normalize(entry.source) === normalize(signature.source)
|
|
220
|
+
&& entry.size === signature.size && entry.mtimeMs === signature.mtimeMs);
|
|
221
|
+
|
|
222
|
+
const unpinned = { ...env };
|
|
223
|
+
for (const key of Object.keys(unpinned)) if (key.toUpperCase() === 'COPILOT_WEB_RELAY_DATA_DIR') delete unpinned[key];
|
|
224
|
+
const candidates = [];
|
|
225
|
+
// 1. Every rescue copy not looked at yet, compared in turn: the newest wins.
|
|
226
|
+
const rescueRoot = path.join(dataDir, RESCUE_DIR_NAME);
|
|
227
|
+
let rescues = [];
|
|
228
|
+
try { rescues = fsImpl.readdirSync(rescueRoot).sort(); } catch {}
|
|
229
|
+
// One without a database (its package had only uploads or Cursor stores)
|
|
230
|
+
// gives its files where missing, whichever database wins.
|
|
231
|
+
const filesOnly = [];
|
|
232
|
+
for (const name of rescues) {
|
|
233
|
+
// A copy the rescue did not finish is not a rescue.
|
|
234
|
+
if (name.endsWith('.partial')) continue;
|
|
235
|
+
const dbDir = path.join(rescueRoot, name, 'data');
|
|
236
|
+
if (seen(signatureOf(path.join(dbDir, DB_FILE)))) continue;
|
|
237
|
+
const rescue = { kind: 'rescue', dbDir, uploadDirs: [path.join(rescueRoot, name, 'uploads')], cursorDir: path.join(dbDir, 'cursor-agents') };
|
|
238
|
+
if (exists(path.join(dbDir, DB_FILE))) candidates.push(rescue);
|
|
239
|
+
else if (!readdir(fsImpl, dbDir).some((file) => file.startsWith(DB_FILE))) filesOnly.push(rescue);
|
|
240
|
+
}
|
|
241
|
+
// 2. Copies an interrupted run left, checked complete: possibly the only
|
|
242
|
+
// one there is. They take the upload and Cursor folders of their source.
|
|
243
|
+
let leftovers = [];
|
|
244
|
+
try { leftovers = fsImpl.readdirSync(dataDir).filter((name) => name.startsWith(STAGING_PREFIX)).sort(); } catch {}
|
|
245
|
+
for (const name of leftovers) {
|
|
246
|
+
const dbDir = path.join(dataDir, name);
|
|
247
|
+
if (!exists(path.join(dbDir, STAGED_CHECKED_FILE)) || !exists(path.join(dbDir, DB_FILE))) continue;
|
|
248
|
+
if (seen(signatureOf(path.join(dbDir, DB_FILE)))) continue;
|
|
249
|
+
const source = readJson(fsImpl, path.join(dbDir, STAGED_SOURCE_FILE)) || {};
|
|
250
|
+
candidates.push({
|
|
251
|
+
kind: 'leftover',
|
|
252
|
+
dbDir,
|
|
253
|
+
uploadDirs: Array.isArray(source.uploadDirs) ? source.uploadDirs : [],
|
|
254
|
+
cursorDir: source.cursorDir || null,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// 3. What the relay ran on without the pin (`server/data` or a config
|
|
258
|
+
// `dataDir`, copied from, never renamed); never the pinned folder itself.
|
|
259
|
+
const earlierDir = resolveDataDir({ env: unpinned, configPath, serverDir });
|
|
260
|
+
const earlierDb = path.join(earlierDir, DB_FILE);
|
|
261
|
+
if (normalize(earlierDir) !== normalize(dataDir) && exists(earlierDb) && !seen(signatureOf(earlierDb))) {
|
|
262
|
+
const lock = readJson(fsImpl, path.join(earlierDir, 'relay-server.lock'));
|
|
263
|
+
const defaultRoot = resolveOarRoot({ ...env, OAR_STATE_ROOT: '' }, platform);
|
|
264
|
+
const ownRoot = normalize(layout.root) === normalize(defaultRoot)
|
|
265
|
+
|| (!!lock?.tokenHash && lock.tokenHash === hashToken(authToken));
|
|
266
|
+
if (ownRoot) {
|
|
267
|
+
// 0.9.13 kept uploads in server/uploads whatever the data folder.
|
|
268
|
+
const uploadDirs = [
|
|
269
|
+
path.join(serverDir, 'uploads'),
|
|
270
|
+
resolveUploadDir({ dataDir: earlierDir, env: unpinned, serverDir, platform }),
|
|
271
|
+
].filter((dir, index, all) => all.findIndex((other) => normalize(other) === normalize(dir)) === index);
|
|
272
|
+
candidates.push({ kind: 'earlier', dbDir: earlierDir, uploadDirs, cursorDir: path.join(earlierDir, 'cursor-agents'), lock });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const candidate of filesOnly) {
|
|
276
|
+
const filled = fillMissing({ candidate, dataDir, uploadDir, fsImpl, exists, normalize });
|
|
277
|
+
// Looked at once it all came over; a failed copy is tried again.
|
|
278
|
+
if (!filled.ok) continue;
|
|
279
|
+
checked.push({ ...signatureOf(path.join(candidate.dbDir, DB_FILE)), outcome: 'files', at: now().toISOString() });
|
|
280
|
+
try { writeJson(fsImpl, checkedFile, { sources: checked }); } catch {}
|
|
281
|
+
logger.log?.(`[data] Copied ${filled.uploads} upload(s) and ${filled.cursorFiles} Cursor file(s) of ${path.dirname(candidate.dbDir)} where missing.`);
|
|
282
|
+
}
|
|
283
|
+
if (!candidates.length) return { status: filesOnly.length ? 'files' : 'no-earlier-database', from: earlierDir };
|
|
284
|
+
|
|
285
|
+
const results = [];
|
|
286
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
287
|
+
const sourceDb = path.join(candidate.dbDir, DB_FILE);
|
|
288
|
+
const progress = trackProgress({
|
|
289
|
+
sourceDb, bytes: signatureOf(sourceDb).size, pid, clock, logger,
|
|
290
|
+
write: (value) => writeJson(fsImpl, path.join(dataDir, TAKEOVER_PROGRESS_FILE), value),
|
|
291
|
+
});
|
|
292
|
+
const result = considerCandidate({
|
|
293
|
+
candidate,
|
|
294
|
+
stagingDir: path.join(dataDir, `${STAGING_PREFIX}${stamp}-${index}`),
|
|
295
|
+
backupDir: path.join(layout.root, 'backups', candidates.length > 1 ? `${stamp}-${index}` : stamp),
|
|
296
|
+
dataDir,
|
|
297
|
+
uploadDir,
|
|
298
|
+
pinnedDb,
|
|
299
|
+
Database,
|
|
300
|
+
fsImpl,
|
|
301
|
+
rename,
|
|
302
|
+
exists,
|
|
303
|
+
lockMayRun: (lock) => lockHolderMayRun({ lock, isProcessAlive, startedAtOf: processStartedAt, bootTime }),
|
|
304
|
+
normalize,
|
|
305
|
+
progress,
|
|
306
|
+
});
|
|
307
|
+
progress.done();
|
|
308
|
+
if (result.status === 'refused') return result;
|
|
309
|
+
// Whatever the outcome, the uploads and Cursor agent stores of every
|
|
310
|
+
// database looked at are copied where missing: a start a kill cut short
|
|
311
|
+
// may have taken the database over without them.
|
|
312
|
+
const filled = fillMissing({ candidate, dataDir, uploadDir, fsImpl, exists, normalize });
|
|
313
|
+
// Looked at: recorded; renamed only in our folders (package, rescue,
|
|
314
|
+
// leftover), never in a user's.
|
|
315
|
+
const signature = signatureOf(sourceDb);
|
|
316
|
+
const suffix = `${result.status === 'adopted' ? 'adopted' : 'older'}-${stamp}`;
|
|
317
|
+
const packageRoot = normalize(path.dirname(serverDir));
|
|
318
|
+
const ours = candidate.kind !== 'earlier' || normalize(candidate.dbDir).startsWith(packageRoot + path.sep);
|
|
319
|
+
let renamedTo = null;
|
|
320
|
+
if (ours) try {
|
|
321
|
+
for (const name of DB_FILES) {
|
|
322
|
+
const from = path.join(candidate.dbDir, name);
|
|
323
|
+
if (exists(from)) rename(from, path.join(candidate.dbDir, name.replace(DB_FILE, `${DB_FILE}.${suffix}`)));
|
|
324
|
+
}
|
|
325
|
+
renamedTo = path.join(candidate.dbDir, `${DB_FILE}.${suffix}`);
|
|
326
|
+
} catch {}
|
|
327
|
+
checked.push({ ...signature, outcome: result.status, at: now().toISOString(), latest: result.latest, renamedTo });
|
|
328
|
+
try { writeJson(fsImpl, checkedFile, { sources: checked }); } catch {}
|
|
329
|
+
if (result.status === 'adopted') {
|
|
330
|
+
logger.log?.(
|
|
331
|
+
`[data] Brought over the database this relay ran on until now, ${sourceDb} (${result.conversations} conversations, ${result.messages} messages), to ${pinnedDb}`
|
|
332
|
+
+ `${result.uploads ? ` with ${result.uploads} upload(s)` : ''}; the original is kept${renamedTo ? ` as ${renamedTo}` : ` at ${sourceDb}`}`
|
|
333
|
+
+ `${result.backupDir ? `, the older database that was there in ${result.backupDir}` : ''}.`,
|
|
334
|
+
);
|
|
335
|
+
} else {
|
|
336
|
+
logger.log?.(`[data] ${sourceDb} is not newer than ${pinnedDb}; left it${renamedTo ? ` as ${renamedTo}` : ''}, not used.`);
|
|
337
|
+
}
|
|
338
|
+
results.push({ ...result, from: candidate.dbDir, renamedTo, cursorFiles: filled.cursorFiles });
|
|
339
|
+
}
|
|
340
|
+
return results.filter((result) => result.status === 'adopted').at(-1) || results.at(-1);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// A large database takes minutes: said at the start with its size, then at
|
|
344
|
+
// least once a minute (between steps, every 10000 rows or each upload), and
|
|
345
|
+
// at the end with each step's time. `write` keeps the progress file.
|
|
346
|
+
function trackProgress({ sourceDb, bytes, pid, clock, logger, write }) {
|
|
347
|
+
const started = clock();
|
|
348
|
+
const spent = {};
|
|
349
|
+
let step = null;
|
|
350
|
+
let stepAt = started;
|
|
351
|
+
let saidAt = started;
|
|
352
|
+
const seconds = (ms) => Math.round(ms / 1000);
|
|
353
|
+
const close = (at) => { if (step) spent[step] = (spent[step] || 0) + at - stepAt; };
|
|
354
|
+
const tick = () => {
|
|
355
|
+
const at = clock();
|
|
356
|
+
if (at - saidAt < 60_000) return;
|
|
357
|
+
saidAt = at;
|
|
358
|
+
logger.log?.(`[data] Still bringing over ${sourceDb}: ${step}, ${seconds(at - started)} s so far.`);
|
|
359
|
+
};
|
|
360
|
+
return {
|
|
361
|
+
tick,
|
|
362
|
+
step(name) {
|
|
363
|
+
const at = clock();
|
|
364
|
+
if (!step && !Object.keys(spent).length) logger.log?.(`[data] Bringing over ${sourceDb} (${Math.max(1, Math.round(bytes / 1048576))} MB) if it is newer; this can take a few minutes.`);
|
|
365
|
+
close(at);
|
|
366
|
+
step = name;
|
|
367
|
+
stepAt = at;
|
|
368
|
+
try { write({ pid, from: sourceDb, bytes, step, at: new Date(at).toISOString() }); } catch {}
|
|
369
|
+
tick();
|
|
370
|
+
},
|
|
371
|
+
done() {
|
|
372
|
+
if (!step) return;
|
|
373
|
+
const at = clock();
|
|
374
|
+
close(at);
|
|
375
|
+
step = null;
|
|
376
|
+
logger.log?.(`[data] Looking at ${sourceDb} took ${seconds(at - started)} s (${Object.entries(spent).map(([name, ms]) => `${name} ${seconds(ms)} s`).join(', ')}).`);
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The line for a start that found an earlier launcher which did not pin the
|
|
383
|
+
* data folder, but neither the package's database nor a copy of it: the
|
|
384
|
+
* update may have deleted it. Said as a fact only with `evidence`
|
|
385
|
+
* (packageDatabaseEvidence). Null when nothing needs saying.
|
|
386
|
+
*/
|
|
387
|
+
export function lostPackageDatabaseWarning({ unpinnedLaunchers = [], earlierDatabase = null, databaseCreated = false, evidence = false, serverDir, dbPath } = {}) {
|
|
388
|
+
if (!unpinnedLaunchers.length) return null;
|
|
389
|
+
const status = earlierDatabase?.status;
|
|
390
|
+
if (status === 'adopted' || status === 'kept' || status === 'recovered' || status === 'files') return null;
|
|
391
|
+
if (status !== 'no-earlier-database' && !databaseCreated) return null;
|
|
392
|
+
const folder = path.join(String(serverDir || ''), 'data');
|
|
393
|
+
return `[data] WARNING: an autostart launcher of an earlier version ${evidence ? 'ran' : 'may have run'} this relay on the database inside the installed package (${folder}), `
|
|
394
|
+
+ `and no copy of it was found: ${evidence ? '' : 'if it did, '}the update removed it with the old version. This relay runs on ${dbPath}. `
|
|
395
|
+
+ 'If you saved that folder, see "save your data before this update" in the upgrade notes (CHANGELOG.md).';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The names npm gives a retired package folder: `.<name>-` and 8 letters or
|
|
399
|
+
// digits (@npmcli/arborist retire-path.js).
|
|
400
|
+
export function isRetiredName(entry, packageName) {
|
|
401
|
+
return entry.length === packageName.length + 10 && entry.startsWith(`.${packageName}-`)
|
|
402
|
+
&& /^[A-Za-z0-9]{8}$/.test(entry.slice(packageName.length + 2));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* A trace that a relay ran inside the package: its `server/data` or
|
|
407
|
+
* `server/logs` (other than this relay's own folders), or npm's retired
|
|
408
|
+
* folder of the old package still holding `server/data`.
|
|
409
|
+
*/
|
|
410
|
+
export function packageDatabaseEvidence({ serverDir, ownDirs = [], fsImpl = fs, platform = process.platform } = {}) {
|
|
411
|
+
const normalize = (value) => {
|
|
412
|
+
const resolved = path.resolve(String(value || ''));
|
|
413
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
414
|
+
};
|
|
415
|
+
const own = new Set(ownDirs.filter(Boolean).map(normalize));
|
|
416
|
+
const exists = (file) => { try { return fsImpl.existsSync(file); } catch { return false; } };
|
|
417
|
+
if (['data', 'logs'].some((name) => !own.has(normalize(path.join(serverDir, name))) && exists(path.join(serverDir, name)))) return true;
|
|
418
|
+
const packageRoot = path.dirname(path.resolve(String(serverDir || '')));
|
|
419
|
+
const scope = path.dirname(packageRoot);
|
|
420
|
+
const name = path.basename(packageRoot);
|
|
421
|
+
let siblings = [];
|
|
422
|
+
try { siblings = fsImpl.readdirSync(scope); } catch {}
|
|
423
|
+
return siblings.some((entry) => isRetiredName(entry, name) && exists(path.join(scope, entry, 'server', 'data')));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function finishInterruptedSwap({ dataDir, fsImpl, rename, exists, logger }) {
|
|
427
|
+
const journalPath = path.join(dataDir, SWAP_JOURNAL_FILE);
|
|
428
|
+
const journal = readJson(fsImpl, journalPath);
|
|
429
|
+
if (!journal) return null;
|
|
430
|
+
const staged = path.join(String(journal.stagingDir || ''), DB_FILE);
|
|
431
|
+
try {
|
|
432
|
+
if (exists(staged)) {
|
|
433
|
+
// The copy is not in place yet: what is still in the data folder is
|
|
434
|
+
// the pinned database, and goes where the journal put the rest of it.
|
|
435
|
+
fsImpl.mkdirSync(journal.backupDir, { recursive: true });
|
|
436
|
+
for (const name of DB_FILES) {
|
|
437
|
+
if (exists(path.join(dataDir, name))) rename(path.join(dataDir, name), path.join(journal.backupDir, name));
|
|
438
|
+
}
|
|
439
|
+
rename(staged, path.join(dataDir, DB_FILE));
|
|
440
|
+
} else if (!exists(path.join(dataDir, DB_FILE)) && exists(path.join(journal.backupDir, DB_FILE))) {
|
|
441
|
+
// Neither the copy nor a database in place: the pinned one goes back.
|
|
442
|
+
for (const name of DB_FILES) {
|
|
443
|
+
if (exists(path.join(journal.backupDir, name))) rename(path.join(journal.backupDir, name), path.join(dataDir, name));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
fsImpl.rmSync(journalPath, { force: true });
|
|
447
|
+
try { fsImpl.rmSync(journal.stagingDir, { recursive: true, force: true }); } catch {}
|
|
448
|
+
logger.log?.(`[data] Finished bringing over a database an interrupted start left half done; the database that was there is in ${journal.backupDir}.`);
|
|
449
|
+
return { status: 'recovered' };
|
|
450
|
+
} catch (error) {
|
|
451
|
+
return {
|
|
452
|
+
status: 'refused',
|
|
453
|
+
error: `[data] Not starting: an interrupted swap of the database in ${dataDir} could not be finished (${error?.message || error}); `
|
|
454
|
+
+ `the checked copy is in ${journal.stagingDir} and the earlier database in ${journal.backupDir}.`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function considerCandidate({
|
|
460
|
+
candidate, stagingDir, backupDir, dataDir, uploadDir, pinnedDb, Database, fsImpl, rename, exists, lockMayRun, normalize, progress,
|
|
461
|
+
}) {
|
|
462
|
+
const sourceDb = path.join(candidate.dbDir, DB_FILE);
|
|
463
|
+
const refused = (reason) => ({
|
|
464
|
+
status: 'refused',
|
|
465
|
+
from: candidate.dbDir,
|
|
466
|
+
error: `[data] Not starting: ${reason} The relay ran on ${sourceDb} until now and is pinned to ${pinnedDb}; nothing was changed in either.`,
|
|
467
|
+
});
|
|
468
|
+
if (candidate.lock && lockMayRun(candidate.lock)) {
|
|
469
|
+
return refused(`a relay (pid ${candidate.lock.pid}) may still run on ${candidate.dbDir}; stop it, then start again.`);
|
|
470
|
+
}
|
|
471
|
+
const removeStaging = () => { try { fsImpl.rmSync(stagingDir, { recursive: true, force: true }); } catch {} };
|
|
472
|
+
let earlier;
|
|
473
|
+
try {
|
|
474
|
+
progress.step('copy');
|
|
475
|
+
fsImpl.mkdirSync(stagingDir, { recursive: true });
|
|
476
|
+
for (const name of DB_FILES) {
|
|
477
|
+
if (exists(path.join(candidate.dbDir, name))) fsImpl.copyFileSync(path.join(candidate.dbDir, name), path.join(stagingDir, name));
|
|
478
|
+
}
|
|
479
|
+
// The copy, never the original: opening it applies its -wal.
|
|
480
|
+
progress.step('check');
|
|
481
|
+
earlier = readDatabase(Database, path.join(stagingDir, DB_FILE), { singleFile: true, tick: progress.tick });
|
|
482
|
+
} catch (error) {
|
|
483
|
+
removeStaging();
|
|
484
|
+
return refused(`its database could not be copied and read (${error?.message || error}).`);
|
|
485
|
+
}
|
|
486
|
+
if (earlier.integrity !== 'ok') {
|
|
487
|
+
removeStaging();
|
|
488
|
+
return refused(`its database failed its check (${oneLine(earlier.integrity)}).`);
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
writeJson(fsImpl, path.join(stagingDir, STAGED_SOURCE_FILE), { dbDir: candidate.dbDir, uploadDirs: candidate.uploadDirs, cursorDir: candidate.cursorDir });
|
|
492
|
+
fsImpl.writeFileSync(path.join(stagingDir, STAGED_CHECKED_FILE), '');
|
|
493
|
+
} catch (error) {
|
|
494
|
+
removeStaging();
|
|
495
|
+
return refused(`its database could not be copied and read (${error?.message || error}).`);
|
|
496
|
+
}
|
|
497
|
+
let pinned = null;
|
|
498
|
+
if (exists(pinnedDb)) {
|
|
499
|
+
// Read through a copy as well: opening it, even read-only, would rebuild
|
|
500
|
+
// its -shm, and a refused start must leave it as it was.
|
|
501
|
+
const pinnedCopy = `${stagingDir}-pinned`;
|
|
502
|
+
try {
|
|
503
|
+
progress.step('pinned');
|
|
504
|
+
fsImpl.mkdirSync(pinnedCopy, { recursive: true });
|
|
505
|
+
for (const name of DB_FILES) {
|
|
506
|
+
if (exists(path.join(dataDir, name))) fsImpl.copyFileSync(path.join(dataDir, name), path.join(pinnedCopy, name));
|
|
507
|
+
}
|
|
508
|
+
pinned = readDatabase(Database, path.join(pinnedCopy, DB_FILE), { tick: progress.tick });
|
|
509
|
+
} catch (error) {
|
|
510
|
+
removeStaging();
|
|
511
|
+
return refused(`the pinned database could not be read (${error?.message || error}).`);
|
|
512
|
+
} finally {
|
|
513
|
+
try { fsImpl.rmSync(pinnedCopy, { recursive: true, force: true }); } catch {}
|
|
514
|
+
}
|
|
515
|
+
if (pinned.integrity !== 'ok') {
|
|
516
|
+
removeStaging();
|
|
517
|
+
return refused(`the pinned database failed its check (${oneLine(pinned.integrity)}).`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const newer = newerOf(earlier.stamps, pinned?.stamps);
|
|
521
|
+
if (newer === 'both') {
|
|
522
|
+
removeStaging();
|
|
523
|
+
return refused('each database has changes newer than the other\'s. Move the copilot.db, copilot.db-wal and copilot.db-shm of the one not to go on with into a folder of your own, then start again.');
|
|
524
|
+
}
|
|
525
|
+
if (newer !== 'earlier') {
|
|
526
|
+
removeStaging();
|
|
527
|
+
return { status: 'kept', latest: earlier.latest };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Uploads first: the database about to come over refers to them.
|
|
531
|
+
// Content-addressed, so a file already there is the same file and stays.
|
|
532
|
+
const addedUploads = [];
|
|
533
|
+
const takeBackUploads = () => { for (const file of addedUploads) { try { fsImpl.rmSync(file, { force: true }); } catch {} } };
|
|
534
|
+
try {
|
|
535
|
+
progress.step('uploads');
|
|
536
|
+
copyUploads({ from: candidate.uploadDirs, uploadDir, added: addedUploads, fsImpl, exists, normalize, tick: progress.tick });
|
|
537
|
+
// Everything of the pinned database into its main file, so it moves as
|
|
538
|
+
// one: no row left behind in a -wal that moves separately.
|
|
539
|
+
if (pinned) {
|
|
540
|
+
const db = new Database(pinnedDb, { fileMustExist: true });
|
|
541
|
+
try { db.pragma('wal_checkpoint(TRUNCATE)'); } finally { db.close(); }
|
|
542
|
+
}
|
|
543
|
+
} catch (error) {
|
|
544
|
+
takeBackUploads();
|
|
545
|
+
removeStaging();
|
|
546
|
+
return refused(`it could not be brought over (${error?.message || error}).`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const journalPath = path.join(dataDir, SWAP_JOURNAL_FILE);
|
|
550
|
+
const movedAside = [];
|
|
551
|
+
try {
|
|
552
|
+
progress.step('swap');
|
|
553
|
+
writeJson(fsImpl, journalPath, { stagingDir, backupDir, at: new Date().toISOString() });
|
|
554
|
+
for (const name of DB_FILES) {
|
|
555
|
+
if (!exists(path.join(dataDir, name))) continue;
|
|
556
|
+
fsImpl.mkdirSync(backupDir, { recursive: true });
|
|
557
|
+
rename(path.join(dataDir, name), path.join(backupDir, name));
|
|
558
|
+
movedAside.push(name);
|
|
559
|
+
}
|
|
560
|
+
rename(path.join(stagingDir, DB_FILE), pinnedDb);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
// Back as it was. Only the pinned files this run moved go back; the copy
|
|
563
|
+
// is still in staging, so nothing of it is in the data folder.
|
|
564
|
+
let restored = true;
|
|
565
|
+
for (const name of movedAside) {
|
|
566
|
+
try { rename(path.join(backupDir, name), path.join(dataDir, name)); } catch { restored = false; }
|
|
567
|
+
}
|
|
568
|
+
if (!restored) {
|
|
569
|
+
// The journal stays: the next start finishes the swap with the copy.
|
|
570
|
+
return refused(`it could not be brought over (${error?.message || error}); the next start finishes it from ${stagingDir}.`);
|
|
571
|
+
}
|
|
572
|
+
try { fsImpl.rmSync(journalPath, { force: true }); } catch {}
|
|
573
|
+
takeBackUploads();
|
|
574
|
+
removeStaging();
|
|
575
|
+
return refused(`it could not be brought over (${error?.message || error}).`);
|
|
576
|
+
}
|
|
577
|
+
try { fsImpl.rmSync(journalPath, { force: true }); } catch {}
|
|
578
|
+
// Only what is left beside the copy that moved out: no copy of anything.
|
|
579
|
+
removeStaging();
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
status: 'adopted',
|
|
583
|
+
backupDir: movedAside.length ? backupDir : null,
|
|
584
|
+
uploads: addedUploads.length,
|
|
585
|
+
conversations: earlier.conversations,
|
|
586
|
+
messages: earlier.messages,
|
|
587
|
+
latest: earlier.latest,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Content-addressed uploads, never over one there; added ones go into `added`.
|
|
592
|
+
function copyUploads({ from, uploadDir, added, fsImpl, exists, normalize, tick = () => {} }) {
|
|
593
|
+
for (const dir of from || []) {
|
|
594
|
+
if (normalize(dir) === normalize(uploadDir) || !exists(dir)) continue;
|
|
595
|
+
fsImpl.mkdirSync(uploadDir, { recursive: true });
|
|
596
|
+
for (const name of fsImpl.readdirSync(dir)) {
|
|
597
|
+
if (!SHA256_NAME.test(name) || exists(path.join(uploadDir, name))) continue;
|
|
598
|
+
fsImpl.copyFileSync(path.join(dir, name), path.join(uploadDir, name), fs.constants.COPYFILE_EXCL);
|
|
599
|
+
added.push(path.join(uploadDir, name));
|
|
600
|
+
tick();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Its uploads and Cursor stores, where the pinned folder lacks them.
|
|
606
|
+
function fillMissing({ candidate, dataDir, uploadDir, fsImpl, exists, normalize }) {
|
|
607
|
+
let cursorFiles = 0;
|
|
608
|
+
let ok = true;
|
|
609
|
+
const added = [];
|
|
610
|
+
try { copyUploads({ from: candidate.uploadDirs, uploadDir, added, fsImpl, exists, normalize }); } catch { ok = false; }
|
|
611
|
+
if (candidate.cursorDir && exists(candidate.cursorDir)) {
|
|
612
|
+
try { cursorFiles = copyMissing(fsImpl, candidate.cursorDir, path.join(dataDir, 'cursor-agents')); } catch { ok = false; }
|
|
613
|
+
}
|
|
614
|
+
return { cursorFiles, uploads: added.length, ok };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** A folder's files copied into another, never over one there; returns how many. */
|
|
618
|
+
export function copyMissing(fsImpl, from, to) {
|
|
619
|
+
let copied = 0;
|
|
620
|
+
fsImpl.mkdirSync(to, { recursive: true });
|
|
621
|
+
for (const entry of fsImpl.readdirSync(from, { withFileTypes: true })) {
|
|
622
|
+
const source = path.join(from, entry.name);
|
|
623
|
+
const target = path.join(to, entry.name);
|
|
624
|
+
if (entry.isDirectory()) copied += copyMissing(fsImpl, source, target);
|
|
625
|
+
else if (entry.isFile() && !fsImpl.existsSync(target)) {
|
|
626
|
+
fsImpl.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
|
|
627
|
+
copied += 1;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return copied;
|
|
631
|
+
}
|