@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.
Files changed (74) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. package/server/uploads/image_input.md +0 -540
@@ -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 exactly once, guarded by a marker file.
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
- * - on any mismatch remove the partial target and leave the marker unwritten.
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 copyIfExists(source, target, copied) {
75
- if (!source || !fs.existsSync(source)) return false;
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
- fs.mkdirSync(path.dirname(target), { recursive: true });
78
- if (stat.isDirectory()) fs.cpSync(source, target, { recursive: true });
79
- else fs.copyFileSync(source, target);
80
- copied.push({ from: source, to: target, bytes: stat.isDirectory() ? directorySizeBytes(source) : stat.size });
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', 'blocked-live-relay',
113
- * 'nothing-to-migrate', 'migrated'. Throws only on verification failure or an
114
- * unexpected copy error, after removing the partial target state.
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
- const root = path.resolve(String(targetRoot || ''));
126
- if (!root) throw new Error('targetRoot is required');
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 hasAnyConfig = !!((sourceRepoConfig && fs.existsSync(sourceRepoConfig))
150
- || (sourceManagedConfig && fs.existsSync(sourceManagedConfig)));
151
- if (!hasDb && !hasAnyConfig) {
152
- fs.mkdirSync(root, { recursive: true });
153
- fs.writeFileSync(markerPath, `${now()} nothing-to-migrate\n`);
154
- return { status: 'nothing-to-migrate', root };
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
- let sourceCounts = null;
159
- try {
160
- fs.mkdirSync(path.join(root, 'data'), { recursive: true });
161
-
162
- if (hasDb) {
163
- const Database = await loadDatabase(DatabaseImpl);
164
- // Checkpoint first so recent writes leave the WAL, then copy all three
165
- // files anyway — a hot -wal must never be dropped on the floor.
166
- sourceCounts = readDbCounts(Database, sourceDb, { checkpoint: true });
167
- copyIfExists(sourceDb, path.join(root, 'data', DB_FILE), copied);
168
- copyIfExists(`${sourceDb}-wal`, path.join(root, 'data', `${DB_FILE}-wal`), copied);
169
- copyIfExists(`${sourceDb}-shm`, path.join(root, 'data', `${DB_FILE}-shm`), copied);
170
- copyIfExists(path.join(sourceData, 'cursor-agents'), path.join(root, 'data', 'cursor-agents'), copied);
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
- // Repo config wins over the legacy managed config when both exist.
174
- const configSource = (sourceRepoConfig && fs.existsSync(sourceRepoConfig))
175
- ? sourceRepoConfig
176
- : sourceManagedConfig;
177
- copyIfExists(configSource, path.join(root, 'config.json'), copied);
178
-
179
- if (repoServerDir) {
180
- copyIfExists(path.join(repoServerDir, 'uploads'), path.join(root, 'uploads'), copied);
181
- const repoLogs = path.join(repoServerDir, 'logs');
182
- if (fs.existsSync(repoLogs) && directorySizeBytes(repoLogs) <= MAX_LOGS_BYTES) {
183
- copyIfExists(repoLogs, path.join(root, 'logs'), copied);
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
- if (managedConfigDir) {
187
- const managedLogs = path.join(managedConfigDir, 'logs');
188
- if (fs.existsSync(managedLogs) && directorySizeBytes(managedLogs) <= MAX_LOGS_BYTES) {
189
- copyIfExists(managedLogs, path.join(root, 'logs'), copied);
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
- if (hasDb) {
194
- const Database = await loadDatabase(DatabaseImpl);
195
- const targetCounts = readDbCounts(Database, path.join(root, 'data', DB_FILE));
196
- const mismatch = targetCounts.integrity !== 'ok'
197
- || targetCounts.conversations !== sourceCounts.conversations
198
- || targetCounts.messages !== sourceCounts.messages;
199
- if (mismatch) {
200
- throw new Error(
201
- `Migrated database failed verification (integrity=${targetCounts.integrity}, `
202
- + `conversations ${targetCounts.conversations} vs ${sourceCounts.conversations}, `
203
- + `messages ${targetCounts.messages} vs ${sourceCounts.messages}).`,
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
- const backupsDir = path.join(root, 'backups');
215
- fs.mkdirSync(backupsDir, { recursive: true });
216
- const receipt = [
217
- '# OAR state migration',
218
- '',
219
- `- when: ${now()}`,
220
- `- repo server dir: ${repoServerDir || '(none)'}`,
221
- `- managed config dir: ${managedConfigDir || '(none)'}`,
222
- ...(sourceCounts
223
- ? [`- verified: integrity ok, ${sourceCounts.conversations} conversations, ${sourceCounts.messages} messages`]
224
- : ['- verified: no database in source']),
225
- '',
226
- 'Copied (sources left untouched — delete them manually once satisfied):',
227
- ...copied.map((c) => `- ${c.from} -> ${c.to} (${c.bytes} bytes)`),
228
- '',
229
- ].join('\n');
230
- fs.writeFileSync(path.join(backupsDir, 'MIGRATION.md'), receipt);
231
- fs.writeFileSync(markerPath, `${now()} migrated\n`);
232
-
233
- if (process.platform !== 'win32') {
234
- try {
235
- fs.chmodSync(root, 0o700);
236
- const configTarget = path.join(root, 'config.json');
237
- if (fs.existsSync(configTarget)) fs.chmodSync(configTarget, 0o600);
238
- } catch {}
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
- logger.log?.(`[oar] Migrated relay state into ${root} (${copied.length} items; sources untouched).`);
242
- return { status: 'migrated', root, copied: copied.length, counts: sourceCounts };
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
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The `preinstall` of @oar-sh/oar. Nothing of OAR loads before the `try`:
3
+ * a module of this package that cannot load (a broken file, a missing one)
4
+ * must not fail the install on any platform. Only a copy of an old
5
+ * database that failed (RescueCopyError) exits 1, so npm stops the update
6
+ * and puts the old package, data and all, back in place; anything else is
7
+ * one line and exit 0, as this script only ever copies. See
8
+ * package-data-rescue.mjs.
9
+ */
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ try {
13
+ const { rescuePackageData } = await import('./package-data-rescue.mjs');
14
+ rescuePackageData({ packageRoot: fileURLToPath(new URL('../..', import.meta.url)) });
15
+ } catch (error) {
16
+ if (error?.rescueCopyFailed === true) {
17
+ console.error(`[oar] ${error.message}`);
18
+ process.exitCode = 1;
19
+ } else {
20
+ console.error(`[oar] Did not copy the data of the version being replaced: ${error?.message || error}`);
21
+ }
22
+ }