@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.
Files changed (72) 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 +302 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +296 -149
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +39 -13
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  14. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  15. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  16. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  17. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  18. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  19. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  20. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  21. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  22. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  24. package/server/public/app/api-client.js +3 -1
  25. package/server/public/app/ask-user-view.js +29 -4
  26. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  27. package/server/public/app/question-answer-rule.mjs +15 -0
  28. package/server/public/app/question-multi-select.mjs +9 -2
  29. package/server/public/app/relay-event-view.mjs +5 -1
  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 +4 -2
  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 +6 -1
  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 +329 -31
  61. package/server/services/windows-boot-autostart-service.mjs +188 -60
  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/server/uploads/image_input.md +0 -540
@@ -1,18 +1,21 @@
1
+ import crypto from 'crypto';
1
2
  import fs from 'fs';
2
3
  import path from 'path';
3
4
 
5
+ import { RELAY_STOP_EXIT_CODE } from '../relay-exit-codes.mjs';
6
+
4
7
  /**
5
8
  * Where the relay's database (and lock) live, decided before anything else
6
9
  * touches disk.
7
10
  *
8
- * Precedence: the COPILOT_WEB_RELAY_DATA_DIR env var (launchers and the e2e
9
- * harness pin it), then a `dataDir` key in config.json, then the server
10
- * directory's own `data/`. The config key exists so an instance's identity —
11
- * including where its data lives — travels entirely inside config.json: a
12
- * dev checkout can point at a release install's data dir (the singleton
13
- * guard's lock lives inside the data dir, so two relays sharing one refuse
14
- * to run concurrently), and a regenerated autostart launcher only has to pin
15
- * the config path to reproduce the whole instance.
11
+ * Precedence: the COPILOT_WEB_RELAY_DATA_DIR env var, then a `dataDir` key in
12
+ * config.json, then the server directory's own `data/`. Every launcher of a
13
+ * global install pins the env var (`oar start`, `oar copilot`, the systemd
14
+ * unit, both Windows autostart launchers; see resolveRelayStateDirs in
15
+ * oar-state-layout.mjs), and so does the e2e harness. Nothing writes the
16
+ * config key: it is for a hand-edited config, so a dev checkout can point at
17
+ * a release install's data dir (the singleton guard's lock lives inside the
18
+ * data dir, so two relays sharing one refuse to run concurrently).
16
19
  *
17
20
  * A relative `dataDir` resolves against the config file's directory, not the
18
21
  * process cwd — the same config must mean the same data dir no matter where
@@ -27,21 +30,207 @@ export function resolveDataDir({
27
30
  } = {}) {
28
31
  const fromEnv = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
29
32
  if (fromEnv) return pathImpl.resolve(fromEnv);
33
+ return readConfiguredDataDir({ configPath, fsImpl, pathImpl }) || pathImpl.join(String(serverDir || ''), 'data');
34
+ }
30
35
 
36
+ /**
37
+ * The `dataDir` config.json names, resolved against the config's folder; ''
38
+ * when it names none. An unreadable, absent or invalid config names none: the
39
+ * runtime reports config problems itself when it loads the file.
40
+ */
41
+ export function readConfiguredDataDir({ configPath, fsImpl = fs, pathImpl = path } = {}) {
31
42
  const resolvedConfigPath = String(configPath || '').trim();
32
- if (resolvedConfigPath) {
33
- let parsed = null;
43
+ if (!resolvedConfigPath) return '';
44
+ let parsed = null;
45
+ try { parsed = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8')); } catch {}
46
+ const fromConfig = String(parsed?.dataDir || '').trim();
47
+ return fromConfig ? pathImpl.resolve(pathImpl.dirname(resolvedConfigPath), fromConfig) : '';
48
+ }
49
+
50
+ /**
51
+ * Where uploads live, given the data dir resolveDataDir chose: `uploads/`
52
+ * inside it whenever the env var or config.json picked it, so `dataDir` moves
53
+ * database and uploads together. Only the plain default keeps the checkout
54
+ * layout, `<server>/uploads` beside `<server>/data`. Uploads earlier versions
55
+ * left elsewhere are still read (resolveUploadReadDirs).
56
+ */
57
+ export function resolveUploadDir({
58
+ dataDir,
59
+ env = process.env,
60
+ serverDir,
61
+ pathImpl = path,
62
+ platform = process.platform,
63
+ } = {}) {
64
+ const pinned = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
65
+ const normalize = (value) => {
66
+ const resolved = pathImpl.resolve(String(value || ''));
67
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
68
+ };
69
+ if (!pinned && normalize(dataDir) === normalize(pathImpl.join(String(serverDir || ''), 'data'))) {
70
+ return pathImpl.join(String(serverDir || ''), 'uploads');
71
+ }
72
+ return pathImpl.join(String(dataDir || ''), 'uploads');
73
+ }
74
+
75
+ /**
76
+ * Creates the data and upload folders at startup. One that cannot be created
77
+ * (its drive is missing: a RAM disk or a drive a startup script mounts later
78
+ * than the autostart launcher waited; a file in its place; no permission)
79
+ * ends this runtime with a line naming it and RELAY_STOP_EXIT_CODE instead
80
+ * of an uncaught error: the supervisors do not retry that code, so the line
81
+ * is said once, and the code goes on to whatever started the relay. No
82
+ * other folder is tried: the data must never go elsewhere unnoticed. False
83
+ * when it called `exit`.
84
+ */
85
+ export function ensureDataDirs({
86
+ dataDir,
87
+ uploadDir,
88
+ fsImpl = fs,
89
+ logger = console,
90
+ exit = (code) => process.exit(code),
91
+ } = {}) {
92
+ const fail = (line) => {
93
+ logger.error(`[startup] ${line}. Exiting with code ${RELAY_STOP_EXIT_CODE}.`);
94
+ exit(RELAY_STOP_EXIT_CODE);
95
+ return false;
96
+ };
97
+ for (const [dir, options] of [[dataDir, { recursive: true, mode: 0o700 }], [uploadDir, { recursive: true }]]) {
34
98
  try {
35
- parsed = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8'));
36
- } catch {
37
- // Unreadable/absent/invalid config falls through to the default; the
38
- // runtime reports config problems itself when it loads the file.
39
- }
40
- const fromConfig = String(parsed?.dataDir || '').trim();
41
- if (fromConfig) {
42
- return pathImpl.resolve(pathImpl.dirname(resolvedConfigPath), fromConfig);
99
+ let stat = null;
100
+ try {
101
+ stat = fsImpl.statSync(dir);
102
+ } catch (error) {
103
+ if (error?.code !== 'ENOENT') throw error;
104
+ }
105
+ if (stat && !stat.isDirectory()) return fail(`${dir} is a file, not a folder`);
106
+ if (!stat) fsImpl.mkdirSync(dir, options);
107
+ } catch (error) {
108
+ if (error?.code === 'ENOTDIR') return fail(`Cannot create ${dir}: it or a folder above it is a file, not a folder`);
109
+ const reason = error?.code === 'ENOENT' ? 'its drive or a folder above it is missing' : (error?.message || String(error));
110
+ return fail(`Cannot create ${dir}: ${reason}`);
43
111
  }
44
112
  }
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * The folders uploads are read from, the upload dir first. Earlier versions
118
+ * left uploads in two other places, and both stay folders to read from so no
119
+ * upload drops out of sight after an update: `<server>/uploads` (before 0.9.14
120
+ * a config `dataDir` did not move uploads) and `uploads/` in the folder that
121
+ * holds the data dir (where state migrations before 0.9.14 copied a
122
+ * checkout's uploads). Nothing is copied, moved or deleted there; new uploads
123
+ * go to the upload dir only.
124
+ */
125
+ export function resolveUploadReadDirs({
126
+ uploadDir,
127
+ dataDir,
128
+ serverDir,
129
+ pathImpl = path,
130
+ platform = process.platform,
131
+ } = {}) {
132
+ const normalize = (value) => {
133
+ const resolved = pathImpl.resolve(String(value || ''));
134
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
135
+ };
136
+ const dirs = [];
137
+ const add = (dir) => {
138
+ if (!String(dir || '').trim()) return;
139
+ if (dirs.some((known) => normalize(known) === normalize(dir))) return;
140
+ dirs.push(pathImpl.resolve(dir));
141
+ };
142
+ add(uploadDir);
143
+ if (String(serverDir || '').trim()) add(pathImpl.join(String(serverDir), 'uploads'));
144
+ if (String(dataDir || '').trim()) add(pathImpl.join(pathImpl.dirname(pathImpl.resolve(String(dataDir))), 'uploads'));
145
+ return dirs;
146
+ }
147
+
148
+ const SHA256_NAME = /^[a-f0-9]{64}$/;
149
+ const SAMPLE_BYTES = 64 * 1024;
150
+ const MAX_REMEMBERED_CHOICES = 10_000;
151
+ // Which of several same-size copies to read, per sha256, with the stat of
152
+ // every copy it was decided on: decided once, again only when a copy changed.
153
+ const rememberedChoices = new Map();
154
+
155
+ /**
156
+ * Every existing copy of an upload blob in the read dirs, upload dir first.
157
+ * Only a sha256 name is looked up: nothing else in those folders is a blob.
158
+ */
159
+ export function existingUploadPaths(dirs, sha256, { fsImpl = fs, pathImpl = path } = {}) {
160
+ const name = String(sha256 || '').trim().toLowerCase();
161
+ if (!SHA256_NAME.test(name)) return [];
162
+ const found = [];
163
+ for (const dir of dirs || []) {
164
+ const candidate = pathImpl.join(dir, name);
165
+ try {
166
+ const stat = fsImpl.statSync(candidate);
167
+ if (stat.isFile()) found.push({ path: candidate, size: stat.size, mtimeMs: stat.mtimeMs });
168
+ } catch {}
169
+ }
170
+ return found;
171
+ }
45
172
 
46
- return pathImpl.join(String(serverDir || ''), 'data');
173
+ // The first and last bytes of a file: copies that share them are taken as the
174
+ // same file without reading them whole.
175
+ function sampleOf(filePath, size, fsImpl) {
176
+ let fd = null;
177
+ try {
178
+ fd = fsImpl.openSync(filePath, 'r');
179
+ const head = Buffer.alloc(Math.min(SAMPLE_BYTES, size));
180
+ fsImpl.readSync(fd, head, 0, head.length, 0);
181
+ const tailLength = Math.min(SAMPLE_BYTES, Math.max(0, size - head.length));
182
+ const tail = Buffer.alloc(tailLength);
183
+ if (tailLength) fsImpl.readSync(fd, tail, 0, tailLength, size - tailLength);
184
+ return Buffer.concat([head, tail]);
185
+ } catch {
186
+ return null;
187
+ } finally {
188
+ if (fd !== null) try { fsImpl.closeSync(fd); } catch {}
189
+ }
190
+ }
191
+
192
+ function contentMatches(filePath, sha256, fsImpl) {
193
+ try {
194
+ return crypto.createHash('sha256').update(fsImpl.readFileSync(filePath)).digest('hex') === sha256;
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ function chooseAmongSameSize(copies, sha256, fsImpl) {
201
+ const signature = copies.map((copy) => `${copy.path}|${copy.size}|${copy.mtimeMs}`).join('\n');
202
+ const remembered = rememberedChoices.get(sha256);
203
+ if (remembered && remembered.signature === signature) return remembered.path;
204
+ let chosen = copies[0].path;
205
+ const first = sampleOf(copies[0].path, copies[0].size, fsImpl);
206
+ const alike = first && copies.slice(1).every((copy) => {
207
+ const sample = sampleOf(copy.path, copy.size, fsImpl);
208
+ return sample && sample.equals(first);
209
+ });
210
+ if (!alike) {
211
+ const verified = copies.find((copy) => contentMatches(copy.path, sha256, fsImpl));
212
+ if (verified) chosen = verified.path;
213
+ }
214
+ if (rememberedChoices.size >= MAX_REMEMBERED_CHOICES) rememberedChoices.delete(rememberedChoices.keys().next().value);
215
+ rememberedChoices.set(sha256, { signature, path: chosen });
216
+ return chosen;
217
+ }
218
+
219
+ /**
220
+ * The path an upload is read from. Blobs are named by the sha256 of their
221
+ * content, so copies in two folders are normally the same file. Should they
222
+ * differ, the copy of the recorded size wins (`expectedSize`); if more than
223
+ * one has that size and they are not alike, the one whose content hashes to
224
+ * its name (decided once per blob until a copy changes). No copy is ever
225
+ * touched. Without any copy: its place in the upload dir, where a new upload
226
+ * is written.
227
+ */
228
+ export function uploadPathInDirs(dirs, sha256, { expectedSize = null, fsImpl = fs, pathImpl = path } = {}) {
229
+ const name = String(sha256 || '').trim().toLowerCase();
230
+ const copies = existingUploadPaths(dirs, name, { fsImpl, pathImpl });
231
+ if (!copies.length) return pathImpl.join(dirs[0], name);
232
+ const size = Number(expectedSize);
233
+ const sized = Number.isFinite(size) && size > 0 ? copies.filter((copy) => copy.size === size) : [];
234
+ if (sized.length > 1) return chooseAmongSameSize(sized, name, fsImpl);
235
+ return (sized[0] || copies[0]).path;
47
236
  }