@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
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
}
|