@oar-sh/oar 0.9.13 → 0.9.14
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 +302 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +296 -149
- 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/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 +4 -2
- 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 +6 -1
- 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 +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- 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/server/uploads/image_input.md +0 -540
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
6
|
+
|
|
7
|
+
// Pre-OAR managed config location — kept only as a migration source and as the
|
|
8
|
+
// config home for git checkouts, where nothing may change underfoot.
|
|
9
|
+
export function getLegacyManagedConfigDir(env = process.env, { platform = process.platform, pathImpl = path } = {}) {
|
|
10
|
+
if (platform === 'win32') {
|
|
11
|
+
return pathImpl.join(
|
|
12
|
+
env.LOCALAPPDATA || pathImpl.join(os.homedir(), 'AppData', 'Local'),
|
|
13
|
+
'copilot-remote',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return pathImpl.join(
|
|
17
|
+
env.XDG_CONFIG_HOME || pathImpl.join(os.homedir(), '.config'),
|
|
18
|
+
'copilot-remote',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isGitCheckout(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
23
|
+
try { return fsImpl.existsSync(pathImpl.join(packageRoot, '.git')); } catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where launcher-managed state lives. A git checkout keeps every pre-OAR
|
|
28
|
+
* default (repo-local server/data, legacy managed config) so development and
|
|
29
|
+
* the two existing machines change nothing. A global install gets the OAR
|
|
30
|
+
* state root: config, data, logs all under ~/.oar (APPDATA\oar on Windows),
|
|
31
|
+
* which survives `npm i -g` updates.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveStateLayout({
|
|
34
|
+
packageRoot,
|
|
35
|
+
env = process.env,
|
|
36
|
+
platform = process.platform,
|
|
37
|
+
fsImpl = fs,
|
|
38
|
+
pathImpl = path,
|
|
39
|
+
} = {}) {
|
|
40
|
+
if (isGitCheckout(packageRoot, { fsImpl, pathImpl })) {
|
|
41
|
+
return { checkout: true, root: null, configDir: getLegacyManagedConfigDir(env, { platform, pathImpl }), dataDir: null };
|
|
42
|
+
}
|
|
43
|
+
const root = resolveOarRoot(env, platform);
|
|
44
|
+
return { checkout: false, root, configDir: root, dataDir: pathImpl.join(root, 'data') };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The data and log folders a relay of this install is started with, '' where
|
|
49
|
+
* the relay is left to its own server-relative default. Every launcher pins
|
|
50
|
+
* these — `oar start`, `oar copilot` and both Windows autostart launchers —
|
|
51
|
+
* so the relay finds the same database however it was started. A folder the
|
|
52
|
+
* environment names wins; a global install otherwise uses the OAR state root;
|
|
53
|
+
* a checkout pins nothing of its own.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveRelayStateDirs({
|
|
56
|
+
packageRoot,
|
|
57
|
+
env = process.env,
|
|
58
|
+
platform = process.platform,
|
|
59
|
+
fsImpl = fs,
|
|
60
|
+
pathImpl = path,
|
|
61
|
+
layout = resolveStateLayout({ packageRoot, env, platform, fsImpl, pathImpl }),
|
|
62
|
+
} = {}) {
|
|
63
|
+
const envDataDir = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
|
|
64
|
+
const envLogDir = String(env?.COPILOT_WEB_RELAY_LOG_DIR || '').trim();
|
|
65
|
+
return {
|
|
66
|
+
dataDir: envDataDir || layout?.dataDir || '',
|
|
67
|
+
logDir: envLogDir || (layout && !layout.checkout ? pathImpl.join(layout.root, 'logs') : ''),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The data and log folders an automatic launcher repair may pin: those of the
|
|
73
|
+
* install's layout alone, never ones only this relay's environment names (a
|
|
74
|
+
* short-lived relay started with a scratch data folder must not repoint the
|
|
75
|
+
* install's autostart). Null when the layout has none (a checkout), or when
|
|
76
|
+
* this relay runs on other folders than the layout's.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveLayoutStateDirs({
|
|
79
|
+
packageRoot,
|
|
80
|
+
env = process.env,
|
|
81
|
+
platform = process.platform,
|
|
82
|
+
fsImpl = fs,
|
|
83
|
+
pathImpl = path,
|
|
84
|
+
} = {}) {
|
|
85
|
+
const layout = resolveStateLayout({ packageRoot, env, platform, fsImpl, pathImpl });
|
|
86
|
+
const dirs = resolveRelayStateDirs({ env: {}, layout, platform, pathImpl });
|
|
87
|
+
if (!dirs.dataDir || !dirs.logDir) return null;
|
|
88
|
+
const own = resolveRelayStateDirs({ env, layout, platform, pathImpl });
|
|
89
|
+
const same = (a, b) => {
|
|
90
|
+
const normalize = (value) => {
|
|
91
|
+
const resolved = pathImpl.resolve(String(value));
|
|
92
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
93
|
+
};
|
|
94
|
+
return normalize(a) === normalize(b);
|
|
95
|
+
};
|
|
96
|
+
return same(dirs.dataDir, own.dataDir) && same(dirs.logDir, own.logDir) ? dirs : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The log folder `oar copilot` and `oar start` give the relay (and write its
|
|
101
|
+
* output to): the relay's log folder, else, in a checkout, `logs` in the
|
|
102
|
+
* legacy managed config folder.
|
|
103
|
+
*/
|
|
104
|
+
export function getLauncherLogDir(env = process.env, layout = null, { platform = process.platform, pathImpl = path } = {}) {
|
|
105
|
+
return resolveRelayStateDirs({ env, layout, platform, pathImpl }).logDir
|
|
106
|
+
|| pathImpl.join(getLegacyManagedConfigDir(env, { platform, pathImpl }), 'logs');
|
|
107
|
+
}
|
|
@@ -4,20 +4,27 @@
|
|
|
4
4
|
* Global npm installs cannot keep state under the package directory — `npm i -g`
|
|
5
5
|
* replaces the tree on every update, database included. This service copies the
|
|
6
6
|
* old state (a git-checkout's server/ tree, and/or the legacy managed config
|
|
7
|
-
* dir) into the state root
|
|
7
|
+
* dir) into the state root. The automatic run happens once, guarded by a
|
|
8
|
+
* marker file written after a real copy; `--migrate-from` names a checkout
|
|
9
|
+
* explicitly and runs regardless of the marker.
|
|
8
10
|
*
|
|
9
11
|
* Contract (docs/plans/oar-rebrand-and-release.md §5.2):
|
|
10
12
|
* - refuse to run while a relay holds the source lock (live pid);
|
|
11
13
|
* - checkpoint the WAL before copying, then copy db + -wal + -shm together;
|
|
12
14
|
* - copy, never move — the source stays untouched as the rollback path;
|
|
13
15
|
* - verify integrity and row counts before writing the marker;
|
|
14
|
-
* -
|
|
16
|
+
* - never overwrite: an existing target database refuses the run unless it
|
|
17
|
+
* is fresh and no relay holds it (then it is moved into backups/), an
|
|
18
|
+
* existing config.json is kept, uploads and logs are merged;
|
|
19
|
+
* - on any mismatch remove what this run copied and leave the marker unwritten.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import fs from 'node:fs';
|
|
18
23
|
import path from 'node:path';
|
|
19
24
|
import os from 'node:os';
|
|
20
25
|
|
|
26
|
+
import { createRelaySingletonGuard } from './relay-singleton-guard.mjs';
|
|
27
|
+
|
|
21
28
|
export const MIGRATION_MARKER = '.migrated';
|
|
22
29
|
const DB_FILE = 'copilot.db';
|
|
23
30
|
const MAX_LOGS_BYTES = 50 * 1024 * 1024;
|
|
@@ -71,16 +78,99 @@ function directorySizeBytes(dir) {
|
|
|
71
78
|
return total;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
function
|
|
75
|
-
|
|
81
|
+
function makeDir(dir, created) {
|
|
82
|
+
const first = fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
if (first) created.push(first);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Whether anything — a link included, even one whose target is missing (a
|
|
87
|
+
// folder on a disk that is not mounted) — sits at this path.
|
|
88
|
+
function pathTaken(file) {
|
|
89
|
+
try {
|
|
90
|
+
fs.lstatSync(file);
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error?.code === 'ENOENT') return false;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Copies without overwriting: a file already at the target is left alone, a
|
|
99
|
+
// directory is merged entry by entry. The source is read through links (a
|
|
100
|
+
// symlinked server/uploads is copied as a real folder); links inside a
|
|
101
|
+
// copied folder are skipped and listed in `skipped`. A path goes into
|
|
102
|
+
// `created` only once this run made it, so a failed migration takes back
|
|
103
|
+
// exactly what it added and never a file or link that was already there.
|
|
104
|
+
function copyNew(source, target, created, skipped) {
|
|
76
105
|
const stat = fs.statSync(source);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
106
|
+
if (!stat.isDirectory()) {
|
|
107
|
+
if (pathTaken(target)) return;
|
|
108
|
+
makeDir(path.dirname(target), created);
|
|
109
|
+
try {
|
|
110
|
+
fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Someone else's file appeared in between: theirs, left alone.
|
|
113
|
+
if (error?.code === 'EEXIST') return;
|
|
114
|
+
try { fs.rmSync(target, { force: true }); } catch {}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
created.push(target);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!pathTaken(target)) {
|
|
121
|
+
makeDir(path.dirname(target), created);
|
|
122
|
+
try {
|
|
123
|
+
fs.mkdirSync(target);
|
|
124
|
+
created.push(target);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
130
|
+
const from = path.join(source, entry.name);
|
|
131
|
+
if (entry.isSymbolicLink()) skipped.push(from);
|
|
132
|
+
else if (entry.isDirectory() || entry.isFile()) copyNew(from, path.join(target, entry.name), created, skipped);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function copyIfExists(source, target, copied, created, skipped) {
|
|
137
|
+
if (!source || !fs.existsSync(source)) return false;
|
|
138
|
+
const before = created.length;
|
|
139
|
+
copyNew(source, target, created, skipped);
|
|
140
|
+
if (created.length === before) return false;
|
|
141
|
+
const bytes = fs.statSync(source).isDirectory() ? directorySizeBytes(source) : fs.statSync(source).size;
|
|
142
|
+
copied.push({ from: source, to: target, bytes });
|
|
81
143
|
return true;
|
|
82
144
|
}
|
|
83
145
|
|
|
146
|
+
function moveFile(from, to) {
|
|
147
|
+
try {
|
|
148
|
+
fs.renameSync(from, to);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
151
|
+
fs.copyFileSync(from, to);
|
|
152
|
+
fs.rmSync(from, { force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A database the relay created on its first start and never used: both
|
|
157
|
+
// tables there and empty. Anything unreadable or unexpected is not fresh.
|
|
158
|
+
function isFreshDatabase(Database, dbPath) {
|
|
159
|
+
if (!fs.existsSync(dbPath)) return false;
|
|
160
|
+
try {
|
|
161
|
+
const counts = readDbCounts(Database, dbPath);
|
|
162
|
+
return counts.integrity === 'ok' && counts.conversations === 0 && counts.messages === 0;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function removeCreated(created) {
|
|
169
|
+
for (const entry of [...created].reverse()) {
|
|
170
|
+
try { fs.rmSync(entry, { recursive: true, force: true }); } catch {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
84
174
|
async function loadDatabase(DatabaseImpl) {
|
|
85
175
|
if (DatabaseImpl) return DatabaseImpl;
|
|
86
176
|
const mod = await import('better-sqlite3');
|
|
@@ -108,24 +198,72 @@ function readDbCounts(Database, dbPath, { checkpoint = false } = {}) {
|
|
|
108
198
|
}
|
|
109
199
|
}
|
|
110
200
|
|
|
201
|
+
const RECEIPT_SOURCE_LINE = '- repo server dir: ';
|
|
202
|
+
|
|
203
|
+
// Whether an earlier run already brought this checkout over: the marker of a
|
|
204
|
+
// real migration, and a receipt naming it as its source.
|
|
205
|
+
function wasMigratedFrom(root, repoServerDir) {
|
|
206
|
+
if (!fs.existsSync(path.join(root, MIGRATION_MARKER))) return false;
|
|
207
|
+
let receipt = '';
|
|
208
|
+
try { receipt = fs.readFileSync(path.join(root, 'backups', 'MIGRATION.md'), 'utf8'); } catch { return false; }
|
|
209
|
+
const normalize = (value) => {
|
|
210
|
+
const resolved = path.resolve(value);
|
|
211
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
212
|
+
};
|
|
213
|
+
return receipt.split(/\r?\n/).some((line) => line.startsWith(RECEIPT_SOURCE_LINE)
|
|
214
|
+
&& normalize(line.slice(RECEIPT_SOURCE_LINE.length)) === normalize(repoServerDir));
|
|
215
|
+
}
|
|
216
|
+
|
|
111
217
|
/**
|
|
112
|
-
* Returns { status, ... } — statuses: 'already-migrated'
|
|
113
|
-
*
|
|
114
|
-
*
|
|
218
|
+
* Returns { status, ... } — statuses: 'already-migrated' (with `source` when
|
|
219
|
+
* an explicit source was brought over before), 'blocked-live-relay',
|
|
220
|
+
* 'refused-relay-running', 'refused-existing-database', 'nothing-to-migrate'
|
|
221
|
+
* (with a `reason`), 'migrated' (with `kept`: targets that already existed and
|
|
222
|
+
* were left alone, `skipped`: links inside copied folders, and
|
|
223
|
+
* `movedAsideTo`: where a fresh target database went).
|
|
224
|
+
* Throws only on verification failure or an unexpected copy error, after
|
|
225
|
+
* removing what this run copied.
|
|
226
|
+
*
|
|
227
|
+
* The marker guards only the automatic run (no repoServerDir): a source named
|
|
228
|
+
* explicitly (`--migrate-from`) runs again unless the receipt shows that same
|
|
229
|
+
* source was migrated and its database is still there. What keeps that safe
|
|
230
|
+
* is that a migration never overwrites — it refuses when the target already
|
|
231
|
+
* has a database with conversations, keeps an existing config.json, and
|
|
232
|
+
* merges uploads and logs. A fresh database without any conversation or
|
|
233
|
+
* message (what the installer's first relay start leaves) is moved into
|
|
234
|
+
* backups/<timestamp>/ first. A run that copies a database holds the relay's
|
|
235
|
+
* own lock while it does, so no relay can start on the data dir meanwhile; a
|
|
236
|
+
* relay holding it refuses the run. The marker is written only after
|
|
237
|
+
* something was really copied.
|
|
238
|
+
*
|
|
239
|
+
* `targetDataDir`/`targetUploadDir` are where the relay will read database
|
|
240
|
+
* and uploads; callers resolve them as the relay does (data-dir-resolution).
|
|
241
|
+
* The defaults are a global install's: `<root>/data` and `<root>/data/uploads`.
|
|
115
242
|
*/
|
|
116
243
|
export async function migrateStateToOarRoot({
|
|
117
244
|
targetRoot,
|
|
245
|
+
targetDataDir = null,
|
|
246
|
+
targetUploadDir = null,
|
|
118
247
|
repoServerDir = null,
|
|
119
248
|
managedConfigDir = null,
|
|
120
249
|
isProcessAliveImpl = defaultIsProcessAlive,
|
|
121
250
|
DatabaseImpl = null,
|
|
122
251
|
now = () => new Date().toISOString(),
|
|
123
|
-
logger = console,
|
|
124
252
|
} = {}) {
|
|
125
|
-
|
|
126
|
-
|
|
253
|
+
if (!String(targetRoot || '').trim()) throw new Error('targetRoot is required');
|
|
254
|
+
const root = path.resolve(String(targetRoot));
|
|
255
|
+
const dataDir = targetDataDir ? path.resolve(String(targetDataDir)) : path.join(root, 'data');
|
|
256
|
+
const uploadDir = targetUploadDir ? path.resolve(String(targetUploadDir)) : path.join(dataDir, 'uploads');
|
|
127
257
|
const markerPath = path.join(root, MIGRATION_MARKER);
|
|
128
|
-
if (fs.existsSync(markerPath)) return { status: 'already-migrated', root };
|
|
258
|
+
if (!repoServerDir && fs.existsSync(markerPath)) return { status: 'already-migrated', root };
|
|
259
|
+
|
|
260
|
+
const targetDbFiles = [DB_FILE, `${DB_FILE}-wal`, `${DB_FILE}-shm`].map((name) => path.join(dataDir, name));
|
|
261
|
+
const targetLock = path.join(dataDir, 'relay-server.lock');
|
|
262
|
+
// The same checkout once more (a repeated command, an alias): nothing to
|
|
263
|
+
// do while the database it brought is still there.
|
|
264
|
+
if (repoServerDir && pathTaken(targetDbFiles[0]) && wasMigratedFrom(root, repoServerDir)) {
|
|
265
|
+
return { status: 'already-migrated', root, source: repoServerDir };
|
|
266
|
+
}
|
|
129
267
|
|
|
130
268
|
const sourceData = repoServerDir ? path.join(repoServerDir, 'data') : null;
|
|
131
269
|
const sourceDb = sourceData ? path.join(sourceData, DB_FILE) : null;
|
|
@@ -146,98 +284,251 @@ export async function migrateStateToOarRoot({
|
|
|
146
284
|
}
|
|
147
285
|
|
|
148
286
|
const hasDb = !!(sourceDb && fs.existsSync(sourceDb));
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
287
|
+
const hasRepoConfig = !!(sourceRepoConfig && fs.existsSync(sourceRepoConfig));
|
|
288
|
+
if (repoServerDir && !hasDb && !hasRepoConfig) {
|
|
289
|
+
return { status: 'nothing-to-migrate', root, reason: `${repoServerDir} holds no database and no config.json` };
|
|
290
|
+
}
|
|
291
|
+
// Repo config wins over the legacy managed config when both exist.
|
|
292
|
+
const configSource = hasRepoConfig
|
|
293
|
+
? sourceRepoConfig
|
|
294
|
+
: (sourceManagedConfig && fs.existsSync(sourceManagedConfig) ? sourceManagedConfig : null);
|
|
295
|
+
if (!hasDb && !configSource) {
|
|
296
|
+
return { status: 'nothing-to-migrate', root, reason: 'no earlier relay state found' };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A database already in the target is never overwritten or mixed with.
|
|
300
|
+
// The one exception is the fresh, empty one the installer's relay creates
|
|
301
|
+
// on its first start: it is moved into backups/. Whether it is fresh is
|
|
302
|
+
// asked first (read-only), so a database with conversations is refused as
|
|
303
|
+
// such and nobody is asked to stop a relay for a run that cannot happen.
|
|
304
|
+
const existingDbFiles = () => targetDbFiles.filter((file) => pathTaken(file));
|
|
305
|
+
const refusedWithConversations = () => ({
|
|
306
|
+
status: 'refused-existing-database',
|
|
307
|
+
root,
|
|
308
|
+
error: `The state root already has a database with conversations (${targetDbFiles[0]}); `
|
|
309
|
+
+ `a migration never overwrites one. To bring ${repoServerDir} over instead: `
|
|
310
|
+
+ `stop the relay (oar stop), move ${dataDir} aside, then run the migration again.`,
|
|
311
|
+
});
|
|
312
|
+
if (hasDb && existingDbFiles().length) {
|
|
313
|
+
if (!pathTaken(targetDbFiles[0])) {
|
|
314
|
+
return {
|
|
315
|
+
status: 'refused-existing-database',
|
|
316
|
+
root,
|
|
317
|
+
error: `The state root has database files without copilot.db (${existingDbFiles().join(', ')}); `
|
|
318
|
+
+ 'a migration never mixes a database with them. Move them aside, then run the migration again.',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (!isFreshDatabase(await loadDatabase(DatabaseImpl), targetDbFiles[0])) return refusedWithConversations();
|
|
155
322
|
}
|
|
156
323
|
|
|
157
324
|
const copied = [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
325
|
+
const created = [];
|
|
326
|
+
const kept = [];
|
|
327
|
+
const skipped = [];
|
|
328
|
+
// A run that moves or copies a database holds the relay's own lock for its
|
|
329
|
+
// whole length (same file, same format), so no relay starts on the data
|
|
330
|
+
// dir in between; a relay that holds it refuses the run.
|
|
331
|
+
let guard = null;
|
|
332
|
+
if (hasDb) {
|
|
333
|
+
makeDir(dataDir, created);
|
|
334
|
+
guard = createRelaySingletonGuard({
|
|
335
|
+
lockPath: targetLock,
|
|
336
|
+
pid: process.pid,
|
|
337
|
+
token: '',
|
|
338
|
+
now,
|
|
339
|
+
isProcessAlive: isProcessAliveImpl,
|
|
340
|
+
logger: {},
|
|
341
|
+
});
|
|
342
|
+
try {
|
|
343
|
+
guard.acquire();
|
|
344
|
+
} catch (error) {
|
|
345
|
+
removeCreated(created);
|
|
346
|
+
const pid = readLockPid(targetLock);
|
|
347
|
+
if (!(pid && isProcessAliveImpl(pid))) throw error;
|
|
348
|
+
return {
|
|
349
|
+
status: 'refused-relay-running',
|
|
350
|
+
root,
|
|
351
|
+
pid,
|
|
352
|
+
error: `A relay is running on ${dataDir} (pid=${pid}, lock at ${targetLock}): `
|
|
353
|
+
+ 'stop the relay first (oar stop), then run the migration again.',
|
|
354
|
+
};
|
|
171
355
|
}
|
|
356
|
+
// Asked again under the lock: a relay that ran between the first look and
|
|
357
|
+
// the lock may have written conversations into the database meanwhile.
|
|
358
|
+
if (pathTaken(targetDbFiles[0]) && !isFreshDatabase(await loadDatabase(DatabaseImpl), targetDbFiles[0])) {
|
|
359
|
+
try { guard.release(); } catch {}
|
|
360
|
+
removeCreated(created);
|
|
361
|
+
return refusedWithConversations();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
172
364
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
365
|
+
try {
|
|
366
|
+
// The fresh database moved aside, and the backup folder made for it.
|
|
367
|
+
const movedAside = [];
|
|
368
|
+
const asideCreated = [];
|
|
369
|
+
const asideDir = path.join(root, 'backups', String(now()).replace(/[:.]/g, '-'));
|
|
370
|
+
let sourceCounts = null;
|
|
371
|
+
try {
|
|
372
|
+
// Listed again: reading the fresh database may have left a -shm beside it.
|
|
373
|
+
const toMoveAside = hasDb ? existingDbFiles() : [];
|
|
374
|
+
if (toMoveAside.length) {
|
|
375
|
+
makeDir(asideDir, asideCreated);
|
|
376
|
+
for (const file of toMoveAside) {
|
|
377
|
+
const to = path.join(asideDir, path.basename(file));
|
|
378
|
+
moveFile(file, to);
|
|
379
|
+
movedAside.push({ from: file, to });
|
|
380
|
+
}
|
|
184
381
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
382
|
+
if (hasDb) {
|
|
383
|
+
const Database = await loadDatabase(DatabaseImpl);
|
|
384
|
+
// Checkpoint first so recent writes leave the WAL, then copy all three
|
|
385
|
+
// files anyway — a hot -wal must never be dropped on the floor.
|
|
386
|
+
sourceCounts = readDbCounts(Database, sourceDb, { checkpoint: true });
|
|
387
|
+
copyIfExists(sourceDb, targetDbFiles[0], copied, created, skipped);
|
|
388
|
+
copyIfExists(`${sourceDb}-wal`, targetDbFiles[1], copied, created, skipped);
|
|
389
|
+
copyIfExists(`${sourceDb}-shm`, targetDbFiles[2], copied, created, skipped);
|
|
390
|
+
copyIfExists(path.join(sourceData, 'cursor-agents'), path.join(dataDir, 'cursor-agents'), copied, created, skipped);
|
|
190
391
|
}
|
|
191
|
-
}
|
|
192
392
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
393
|
+
const configTarget = path.join(root, 'config.json');
|
|
394
|
+
if (configSource && pathTaken(configTarget)) kept.push(configTarget);
|
|
395
|
+
else copyIfExists(configSource, configTarget, copied, created, skipped);
|
|
396
|
+
|
|
397
|
+
if (repoServerDir) {
|
|
398
|
+
copyIfExists(path.join(repoServerDir, 'uploads'), uploadDir, copied, created, skipped);
|
|
399
|
+
const repoLogs = path.join(repoServerDir, 'logs');
|
|
400
|
+
if (fs.existsSync(repoLogs) && directorySizeBytes(repoLogs) <= MAX_LOGS_BYTES) {
|
|
401
|
+
copyIfExists(repoLogs, path.join(root, 'logs'), copied, created, skipped);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (managedConfigDir) {
|
|
405
|
+
const managedLogs = path.join(managedConfigDir, 'logs');
|
|
406
|
+
if (fs.existsSync(managedLogs) && directorySizeBytes(managedLogs) <= MAX_LOGS_BYTES) {
|
|
407
|
+
copyIfExists(managedLogs, path.join(root, 'logs'), copied, created, skipped);
|
|
408
|
+
}
|
|
205
409
|
}
|
|
410
|
+
|
|
411
|
+
if (hasDb) {
|
|
412
|
+
const Database = await loadDatabase(DatabaseImpl);
|
|
413
|
+
const targetCounts = readDbCounts(Database, targetDbFiles[0]);
|
|
414
|
+
const mismatch = targetCounts.integrity !== 'ok'
|
|
415
|
+
|| targetCounts.conversations !== sourceCounts.conversations
|
|
416
|
+
|| targetCounts.messages !== sourceCounts.messages;
|
|
417
|
+
if (mismatch) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`Migrated database failed verification (integrity=${targetCounts.integrity}, `
|
|
420
|
+
+ `conversations ${targetCounts.conversations} vs ${sourceCounts.conversations}, `
|
|
421
|
+
+ `messages ${targetCounts.messages} vs ${sourceCounts.messages}).`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch (error) {
|
|
426
|
+
// Take back only what this run added and put a database it moved aside
|
|
427
|
+
// back: the state root is as before, the marker was never written, the
|
|
428
|
+
// source never touched. A database that cannot be put back (a locked
|
|
429
|
+
// file) stays in its backup folder, and the error says where.
|
|
430
|
+
removeCreated(created);
|
|
431
|
+
let restored = true;
|
|
432
|
+
for (const { from, to } of [...movedAside].reverse()) {
|
|
433
|
+
try { moveFile(to, from); } catch { restored = false; }
|
|
434
|
+
}
|
|
435
|
+
if (restored) {
|
|
436
|
+
removeCreated(asideCreated);
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
throw new Error(
|
|
440
|
+
`${error?.message || error} The database moved aside could not be put back; it is kept in ${asideDir}.`,
|
|
441
|
+
{ cause: error },
|
|
442
|
+
);
|
|
206
443
|
}
|
|
207
|
-
} catch (error) {
|
|
208
|
-
// Leave no partial target behind: the marker was never written, so the next
|
|
209
|
-
// attempt starts clean; the source was never touched.
|
|
210
|
-
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
211
|
-
throw error;
|
|
212
|
-
}
|
|
213
444
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
445
|
+
if (!copied.length) {
|
|
446
|
+
removeCreated(created);
|
|
447
|
+
return {
|
|
448
|
+
status: 'nothing-to-migrate',
|
|
449
|
+
root,
|
|
450
|
+
kept,
|
|
451
|
+
reason: kept.length ? `kept the existing ${kept.join(', ')}` : 'nothing new to copy',
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const backupsDir = path.join(root, 'backups');
|
|
456
|
+
fs.mkdirSync(backupsDir, { recursive: true });
|
|
457
|
+
const receipt = [
|
|
458
|
+
'# OAR state migration',
|
|
459
|
+
'',
|
|
460
|
+
`- when: ${now()}`,
|
|
461
|
+
`${RECEIPT_SOURCE_LINE}${repoServerDir || '(none)'}`,
|
|
462
|
+
`- managed config dir: ${managedConfigDir || '(none)'}`,
|
|
463
|
+
...(sourceCounts
|
|
464
|
+
? [`- verified: integrity ok, ${sourceCounts.conversations} conversations, ${sourceCounts.messages} messages`]
|
|
465
|
+
: ['- verified: no database in source']),
|
|
466
|
+
'',
|
|
467
|
+
'Copied (sources left untouched — delete them manually once satisfied):',
|
|
468
|
+
...copied.map((c) => `- ${c.from} -> ${c.to} (${c.bytes} bytes)`),
|
|
469
|
+
...(kept.length ? ['', 'Kept (already there, not overwritten):', ...kept.map((k) => `- ${k}`)] : []),
|
|
470
|
+
...(skipped.length
|
|
471
|
+
? ['', 'Skipped (links, not copied — what they point to is not in the state root):', ...skipped.map((k) => `- ${k}`)]
|
|
472
|
+
: []),
|
|
473
|
+
...(movedAside.length
|
|
474
|
+
? ['', 'Moved aside (a fresh database without conversations):', ...movedAside.map((m) => `- ${m.from} -> ${m.to}`)]
|
|
475
|
+
: []),
|
|
476
|
+
'',
|
|
477
|
+
].join('\n');
|
|
478
|
+
// A later explicit migration adds its receipt below the earlier one.
|
|
479
|
+
const receiptPath = path.join(backupsDir, 'MIGRATION.md');
|
|
480
|
+
fs.appendFileSync(receiptPath, fs.existsSync(receiptPath) ? `\n${receipt}` : receipt);
|
|
481
|
+
fs.appendFileSync(markerPath, `${now()} migrated${repoServerDir ? ` from ${repoServerDir}` : ''}\n`);
|
|
482
|
+
|
|
483
|
+
if (process.platform !== 'win32') {
|
|
484
|
+
try {
|
|
485
|
+
fs.chmodSync(root, 0o700);
|
|
486
|
+
const configTarget = path.join(root, 'config.json');
|
|
487
|
+
if (fs.existsSync(configTarget)) fs.chmodSync(configTarget, 0o600);
|
|
488
|
+
} catch {}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
status: 'migrated',
|
|
493
|
+
root,
|
|
494
|
+
copied: copied.length,
|
|
495
|
+
kept,
|
|
496
|
+
skipped,
|
|
497
|
+
movedAsideTo: movedAside.length ? asideDir : null,
|
|
498
|
+
counts: sourceCounts,
|
|
499
|
+
};
|
|
500
|
+
} finally {
|
|
501
|
+
try { guard?.release(); } catch {}
|
|
239
502
|
}
|
|
503
|
+
}
|
|
240
504
|
|
|
241
|
-
|
|
242
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Earlier releases copied a checkout's uploads to `<root>/uploads`, which no
|
|
507
|
+
* relay reads. When the relay's data dir sits in a state root an earlier
|
|
508
|
+
* migration marked, and its uploads are that data dir's `uploads/`, those
|
|
509
|
+
* blobs are merged in at start: content-addressed, so a file already there is
|
|
510
|
+
* never overwritten, and the old folder is never deleted (the log says it can
|
|
511
|
+
* be). Returns how many files were added.
|
|
512
|
+
*/
|
|
513
|
+
export function mergeStrayMigratedUploads({ dataDir, uploadDir, logger = console } = {}) {
|
|
514
|
+
if (!dataDir || !uploadDir) return 0;
|
|
515
|
+
const resolvedDataDir = path.resolve(String(dataDir));
|
|
516
|
+
if (path.resolve(String(uploadDir)) !== path.join(resolvedDataDir, 'uploads')) return 0;
|
|
517
|
+
const root = path.dirname(resolvedDataDir);
|
|
518
|
+
const stray = path.join(root, 'uploads');
|
|
519
|
+
if (!fs.existsSync(path.join(root, MIGRATION_MARKER)) || !fs.existsSync(stray)) return 0;
|
|
520
|
+
const created = [];
|
|
521
|
+
const skipped = [];
|
|
522
|
+
try {
|
|
523
|
+
copyNew(stray, path.resolve(String(uploadDir)), created, skipped);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
logger.warn?.(`[uploads] Could not merge ${stray} into ${uploadDir}: ${error?.message || error}`);
|
|
526
|
+
}
|
|
527
|
+
const added = created.filter((entry) => {
|
|
528
|
+
try { return fs.statSync(entry).isFile(); } catch { return false; }
|
|
529
|
+
}).length;
|
|
530
|
+
if (added) {
|
|
531
|
+
logger.log?.(`[uploads] Merged ${added} upload(s) an earlier migration left in ${stray} into ${uploadDir}; the files in ${stray} are kept and still read.`);
|
|
532
|
+
}
|
|
533
|
+
return added;
|
|
243
534
|
}
|