@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
|
@@ -42,7 +42,7 @@ import { registerRelayBoardRoutes } from './routes/relay-board-routes.mjs';
|
|
|
42
42
|
import { registerCacheRoutes } from './routes/cache-routes.mjs';
|
|
43
43
|
import { registerGitRoutes } from './routes/git-routes.mjs';
|
|
44
44
|
import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
|
|
45
|
-
import { registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
45
|
+
import { createPreviewScopeResolver, registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
46
46
|
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
47
47
|
import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
|
|
48
48
|
import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
|
|
@@ -124,7 +124,9 @@ import { createClaudeSessionRootResolver } from './services/claude-session-root-
|
|
|
124
124
|
import { createCursorSessionRootResolver } from './services/cursor-session-root-service.mjs';
|
|
125
125
|
import { createGitChangesService } from './services/git-changes-service.mjs';
|
|
126
126
|
import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
|
|
127
|
-
import {
|
|
127
|
+
import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
|
|
128
|
+
import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
|
|
129
|
+
import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
|
|
128
130
|
import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
|
|
129
131
|
import { createRelayBridgeOwnerService } from './services/relay-bridge-owner-service.mjs';
|
|
130
132
|
import { createRelayCliLauncherService } from './services/relay-cli-launcher-service.mjs';
|
|
@@ -173,7 +175,7 @@ import {
|
|
|
173
175
|
migrateLegacyConfigFeatures,
|
|
174
176
|
resolveBootFeatureFlags,
|
|
175
177
|
} from './features.mjs';
|
|
176
|
-
import { RELAY_RESTART_EXIT_CODE } from './relay-exit-codes.mjs';
|
|
178
|
+
import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
|
|
177
179
|
import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../shared/question-timeout.mjs';
|
|
178
180
|
import {
|
|
179
181
|
parseTurnCeilingUpdate,
|
|
@@ -201,6 +203,7 @@ import {
|
|
|
201
203
|
} from './services/relay-restart-queue.mjs';
|
|
202
204
|
import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
|
|
203
205
|
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
206
|
+
import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
|
|
204
207
|
import {
|
|
205
208
|
STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
|
|
206
209
|
STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS,
|
|
@@ -248,7 +251,7 @@ import {
|
|
|
248
251
|
import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
|
|
249
252
|
import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
|
|
250
253
|
import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
|
|
251
|
-
import { describeExecError, describeSpawnEnvironment, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
254
|
+
import { describeExecError, describeSpawnEnvironment, normalizeTempEnv, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
252
255
|
|
|
253
256
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
254
257
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
@@ -279,9 +282,8 @@ const APP_CONFIG_PLACEHOLDER = /window\.__COPILOT_APP_CONFIG = \{ basePath: '[^'
|
|
|
279
282
|
const PWA_VERSION_PLACEHOLDER = /const __PWA_VERSION = '[^']*';/;
|
|
280
283
|
// The console goes to a terminal, a tmux pane or nowhere: keep a copy to read
|
|
281
284
|
// after a restart or an incident.
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
});
|
|
285
|
+
const relayConsoleLogPath = resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname });
|
|
286
|
+
const relayConsoleLog = installRelayConsoleLog({ logPath: relayConsoleLogPath });
|
|
285
287
|
const ttyConsoleRuntime = await maybeStartTtyConsole({
|
|
286
288
|
serverDir: __dirname,
|
|
287
289
|
logsDir: path.join(__dirname, 'logs'),
|
|
@@ -289,6 +291,8 @@ const ttyConsoleRuntime = await maybeStartTtyConsole({
|
|
|
289
291
|
});
|
|
290
292
|
// The terminal console takes the console's methods over when it starts.
|
|
291
293
|
relayConsoleLog?.rewrap();
|
|
294
|
+
// TEMP and TMP for this relay and everything it starts (normalizeTempEnv).
|
|
295
|
+
for (const change of normalizeTempEnv()) console.log(`[startup] ${change} (quotes taken off, or the folder is not there)`);
|
|
292
296
|
|
|
293
297
|
// ─── Config ────────────────────────────────────────────────────────────────────
|
|
294
298
|
const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
|
|
@@ -296,26 +300,32 @@ const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
|
|
|
296
300
|
: path.join(__dirname, 'config.json');
|
|
297
301
|
// env var > config.json `dataDir` > <server>/data — see data-dir-resolution.mjs
|
|
298
302
|
// for why the config can carry it (a dev checkout pointing at a release
|
|
299
|
-
// install's data
|
|
303
|
+
// install's data).
|
|
300
304
|
const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
|
|
301
305
|
const DB_PATH = path.join(DATA_DIR, 'copilot.db');
|
|
302
306
|
const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
307
|
+
// Uploads follow the data dir whenever the env var or config.json chose it.
|
|
308
|
+
const UPLOAD_DIR = resolveUploadDir({ dataDir: DATA_DIR, env: process.env, serverDir: __dirname });
|
|
309
|
+
// Uploads earlier versions left in <server>/uploads or <state root>/uploads
|
|
310
|
+
// are read in place; nothing there is written, moved or deleted.
|
|
311
|
+
const UPLOAD_READ_DIRS = resolveUploadReadDirs({ uploadDir: UPLOAD_DIR, dataDir: DATA_DIR, serverDir: __dirname });
|
|
306
312
|
const INITIAL_WORKSPACE_ROOT = resolveStartupWorkspaceRoot(__dirname);
|
|
307
313
|
const WORKSPACE_ROOT_LOCKED = true;
|
|
308
314
|
const SESSION_COOKIE = 'copilot_session';
|
|
309
315
|
const AUTH_COOKIE = 'copilot_auth';
|
|
310
316
|
|
|
311
|
-
|
|
312
|
-
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
313
|
-
}
|
|
317
|
+
ensureDataDirs({ dataDir: DATA_DIR, uploadDir: UPLOAD_DIR });
|
|
314
318
|
if (process.platform !== 'win32') {
|
|
315
319
|
fs.chmodSync(DATA_DIR, 0o700);
|
|
316
320
|
}
|
|
317
|
-
|
|
318
|
-
|
|
321
|
+
// Earlier migrations put a checkout's uploads in <state root>/uploads, which
|
|
322
|
+
// nothing read: merge them in (never overwriting, never deleting the source).
|
|
323
|
+
mergeStrayMigratedUploads({ dataDir: DATA_DIR, uploadDir: UPLOAD_DIR, logger: console });
|
|
324
|
+
for (const dir of UPLOAD_READ_DIRS.slice(1)) {
|
|
325
|
+
try {
|
|
326
|
+
const count = fs.readdirSync(dir).filter((name) => isSha256(name)).length;
|
|
327
|
+
if (count) console.log(`[uploads] New uploads go to ${UPLOAD_DIR}; ${count} earlier upload${count === 1 ? '' : 's'} in ${dir} are read from there.`);
|
|
328
|
+
} catch {}
|
|
319
329
|
}
|
|
320
330
|
|
|
321
331
|
async function refreshOpenAIProviderModels() {
|
|
@@ -1097,18 +1107,20 @@ function setCursorProviderSettings(update = {}) {
|
|
|
1097
1107
|
const CURSOR_MODEL_DISCOVERY_TIMEOUT_MS = 20_000;
|
|
1098
1108
|
|
|
1099
1109
|
// Reasoning tiers for one discovered Cursor model, in composer vocabulary.
|
|
1100
|
-
// Cursor exposes effort as a per-model parameter named 'effort'
|
|
1101
|
-
// whose value list varies by model; 'extra-high' is
|
|
1102
|
-
// composer's 'xhigh'. Values with no composer
|
|
1103
|
-
// dropped rather than remapped onto a tier the
|
|
1110
|
+
// Cursor exposes effort as a per-model parameter named 'effort', 'reasoning'
|
|
1111
|
+
// or 'reasoning_effort' whose value list varies by model; 'extra-high' is
|
|
1112
|
+
// Cursor's spelling of the composer's 'xhigh'. Values with no composer
|
|
1113
|
+
// equivalent (e.g. 'minimal') are dropped rather than remapped onto a tier the
|
|
1114
|
+
// model does not actually have.
|
|
1104
1115
|
// Mirrors resolveCursorReasoningParams exactly, including its parameter
|
|
1105
|
-
// precedence: it consults 'effort' first
|
|
1106
|
-
// so scanning
|
|
1116
|
+
// precedence: it consults 'effort' first, then 'reasoning', then
|
|
1117
|
+
// 'reasoning_effort', so scanning several would advertise an off switch the
|
|
1118
|
+
// worker never sends.
|
|
1107
1119
|
function cursorEntrySupportsReasoningOff(entry) {
|
|
1108
1120
|
const parameters = Array.isArray(entry?.parameters) ? entry.parameters : [];
|
|
1109
1121
|
const byId = new Map(parameters.map((param) => [String(param?.id || '').trim().toLowerCase(), param]));
|
|
1110
1122
|
if (byId.has('thinking')) return true;
|
|
1111
|
-
const effortParam = byId.get('effort') || byId.get('reasoning');
|
|
1123
|
+
const effortParam = byId.get('effort') || byId.get('reasoning') || byId.get('reasoning_effort');
|
|
1112
1124
|
const values = Array.isArray(effortParam?.values) ? effortParam.values : [];
|
|
1113
1125
|
return values.some((value) => (
|
|
1114
1126
|
String((value && typeof value === 'object' ? value.value : value) || '').trim().toLowerCase() === 'none'
|
|
@@ -1121,7 +1133,7 @@ function cursorEntryEffortLevels(entry) {
|
|
|
1121
1133
|
// listing both parameters must not have its tiers read from one and its off
|
|
1122
1134
|
// switch from the other.
|
|
1123
1135
|
const byId = new Map(parameters.map((param) => [String(param?.id || '').trim().toLowerCase(), param]));
|
|
1124
|
-
const effortParam = byId.get('effort') || byId.get('reasoning');
|
|
1136
|
+
const effortParam = byId.get('effort') || byId.get('reasoning') || byId.get('reasoning_effort');
|
|
1125
1137
|
if (!effortParam) return [];
|
|
1126
1138
|
const levels = [];
|
|
1127
1139
|
for (const value of (Array.isArray(effortParam.values) ? effortParam.values : [])) {
|
|
@@ -1824,18 +1836,43 @@ function isProcessAlive(pid) {
|
|
|
1824
1836
|
}
|
|
1825
1837
|
}
|
|
1826
1838
|
|
|
1839
|
+
// The guard's own stale-lock rules (lockHolderMayRun; an EPERM is alive).
|
|
1827
1840
|
const relaySingletonGuard = createRelaySingletonGuard({
|
|
1828
1841
|
lockPath: RELAY_LOCK_PATH,
|
|
1829
1842
|
pid: process.pid,
|
|
1830
1843
|
token: config.authToken,
|
|
1831
|
-
isProcessAlive,
|
|
1832
1844
|
logger: console,
|
|
1833
1845
|
});
|
|
1834
1846
|
try {
|
|
1835
1847
|
relaySingletonGuard.acquire();
|
|
1836
1848
|
} catch (error) {
|
|
1849
|
+
// A held lock is not a crash: the supervisors do not retry 78.
|
|
1837
1850
|
console.error(`[server] ${error?.message || String(error)}`);
|
|
1838
|
-
process.exit(1);
|
|
1851
|
+
process.exit(error?.code === 'ERELAYLOCKED' ? RELAY_STOP_EXIT_CODE : 1);
|
|
1852
|
+
}
|
|
1853
|
+
// Before the database opens, under the lock (adoptEarlierDatabase); the
|
|
1854
|
+
// result also feeds lostPackageDatabaseWarning.
|
|
1855
|
+
let earlierDatabase;
|
|
1856
|
+
{
|
|
1857
|
+
try {
|
|
1858
|
+
earlierDatabase = adoptEarlierDatabase({
|
|
1859
|
+
dataDir: DATA_DIR,
|
|
1860
|
+
uploadDir: UPLOAD_DIR,
|
|
1861
|
+
serverDir: __dirname,
|
|
1862
|
+
configPath: CONFIG_PATH,
|
|
1863
|
+
packageRoot: REPO_ROOT,
|
|
1864
|
+
Database,
|
|
1865
|
+
authToken: config.authToken,
|
|
1866
|
+
logger: console,
|
|
1867
|
+
});
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
earlierDatabase = { status: 'refused', error: `[data] Not starting: the database this relay ran on until now could not be looked at (${error?.message || error}).` };
|
|
1870
|
+
}
|
|
1871
|
+
if (earlierDatabase.status === 'refused') {
|
|
1872
|
+
console.error(earlierDatabase.error);
|
|
1873
|
+
try { relaySingletonGuard.release(); } catch {}
|
|
1874
|
+
process.exit(RELAY_STOP_EXIT_CODE);
|
|
1875
|
+
}
|
|
1839
1876
|
}
|
|
1840
1877
|
|
|
1841
1878
|
const MAX_REQUEUE_RETRIES = Number.isFinite(Number(config.maxRequeueRetries))
|
|
@@ -2128,7 +2165,7 @@ function setSharePathPrivacyMode(value) {
|
|
|
2128
2165
|
// checkout, data and upload dirs, the workspace roots it knows, and the OS
|
|
2129
2166
|
// user name. The share routes add the conversation's own roots.
|
|
2130
2167
|
function sharePathKnownRoots() {
|
|
2131
|
-
const roots = [REPO_ROOT, DATA_DIR,
|
|
2168
|
+
const roots = [REPO_ROOT, DATA_DIR, ...UPLOAD_READ_DIRS, currentWorkspaceRootPath(), INITIAL_WORKSPACE_ROOT];
|
|
2132
2169
|
const userNames = [];
|
|
2133
2170
|
try { roots.push(os.homedir()); } catch {}
|
|
2134
2171
|
try { userNames.push(os.userInfo().username); } catch {}
|
|
@@ -2679,6 +2716,7 @@ const configuredConversationSessionMode =
|
|
|
2679
2716
|
updateModelCatalog({ models: [DEFAULT_MODEL], currentModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL, source: 'bootstrap' });
|
|
2680
2717
|
|
|
2681
2718
|
// ─── SQLite Setup ─────────────────────────────────────────────────────────────
|
|
2719
|
+
const databaseCreatedAtStart = !fs.existsSync(DB_PATH);
|
|
2682
2720
|
const db = new Database(DB_PATH);
|
|
2683
2721
|
if (process.platform !== 'win32') {
|
|
2684
2722
|
fs.chmodSync(DB_PATH, 0o600);
|
|
@@ -3042,6 +3080,7 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
|
|
|
3042
3080
|
enabled: true,
|
|
3043
3081
|
model: cursorModel,
|
|
3044
3082
|
apiKey: cursorSettings.apiKey,
|
|
3083
|
+
dataDir: DATA_DIR,
|
|
3045
3084
|
});
|
|
3046
3085
|
}
|
|
3047
3086
|
if (providerType === 'grok') {
|
|
@@ -3526,8 +3565,11 @@ function collectRestartPicture({ action = 'restart' } = {}) {
|
|
|
3526
3565
|
const relayRestartQueue = createRelayRestartQueue({
|
|
3527
3566
|
getCounts: () => queueCounts(),
|
|
3528
3567
|
collectPicture: collectRestartPicture,
|
|
3529
|
-
startShutdown: ({ action, reason }) => {
|
|
3568
|
+
startShutdown: ({ action, reason, requestedBy, requestedAt }) => {
|
|
3530
3569
|
const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
|
|
3570
|
+
// The next start tells the conversations that asked for relay_restarted
|
|
3571
|
+
// how this one ended (services/relay-event-service.mjs).
|
|
3572
|
+
try { relayEventService?.noteRelayExit({ action, reason, requestedBy, requestedAt }); } catch {}
|
|
3531
3573
|
sessionWorkerWebSocketService.emitDraining(`api-${action}:${reason}`);
|
|
3532
3574
|
void shutdownRuntime(`api-${action}:${reason}`, { exitCode });
|
|
3533
3575
|
},
|
|
@@ -3554,7 +3596,9 @@ function relayShutdownStatePayload() {
|
|
|
3554
3596
|
}
|
|
3555
3597
|
|
|
3556
3598
|
function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
3557
|
-
|
|
3599
|
+
const result = relayRestartQueue.cancel({ requestedBy });
|
|
3600
|
+
if (result?.cancelled) relayEventService?.noteRestartCancelled();
|
|
3601
|
+
return result;
|
|
3558
3602
|
}
|
|
3559
3603
|
|
|
3560
3604
|
function keepWaitingForRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
@@ -3562,7 +3606,13 @@ function keepWaitingForRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
|
3562
3606
|
}
|
|
3563
3607
|
|
|
3564
3608
|
function requestRelayShutdown(options = {}) {
|
|
3565
|
-
|
|
3609
|
+
// The loop guard of relay_restarted: a conversation whose turn answers the
|
|
3610
|
+
// last restart is not woken by this one. Noted first, since an immediate
|
|
3611
|
+
// request exits from inside the call.
|
|
3612
|
+
if (!runtimeShutdownStarted) relayEventService?.noteRestartRequested();
|
|
3613
|
+
const result = relayRestartQueue.request(options);
|
|
3614
|
+
if (!relayRestartQueue.isQueued() && !runtimeShutdownStarted) relayEventService?.noteRestartCancelled();
|
|
3615
|
+
return result;
|
|
3566
3616
|
}
|
|
3567
3617
|
|
|
3568
3618
|
function toNullableInt(value) {
|
|
@@ -4205,7 +4255,9 @@ function isSha256(value) {
|
|
|
4205
4255
|
}
|
|
4206
4256
|
|
|
4207
4257
|
function uploadPathForSha(sha256) {
|
|
4208
|
-
|
|
4258
|
+
let expectedSize = null;
|
|
4259
|
+
try { expectedSize = stmts.getUploadFile.get(String(sha256 || '').trim().toLowerCase())?.size_bytes ?? null; } catch {}
|
|
4260
|
+
return uploadPathInDirs(UPLOAD_READ_DIRS, sha256, { expectedSize });
|
|
4209
4261
|
}
|
|
4210
4262
|
|
|
4211
4263
|
function uploadContentUrlForSha(sha256) {
|
|
@@ -4886,7 +4938,10 @@ function persistUploadBuffer(buffer, options = {}) {
|
|
|
4886
4938
|
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Empty upload');
|
|
4887
4939
|
if (buffer.length > MAX_UPLOAD_BYTES) throw new Error('Uploaded file too large');
|
|
4888
4940
|
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
4889
|
-
|
|
4941
|
+
// Always kept in the upload dir: the folders of earlier versions can go
|
|
4942
|
+
// (a package update, a clean checkout), so a new reference never relies on
|
|
4943
|
+
// them. An existing file is never overwritten.
|
|
4944
|
+
const filePath = path.join(UPLOAD_DIR, sha256);
|
|
4890
4945
|
if (!fs.existsSync(filePath)) {
|
|
4891
4946
|
const tmpPath = path.join(UPLOAD_DIR, `${sha256}.${process.pid}.${Date.now()}.tmp`);
|
|
4892
4947
|
fs.writeFileSync(tmpPath, buffer);
|
|
@@ -4924,13 +4979,14 @@ function buildStoredAttachment({ sha256, name, type, size }) {
|
|
|
4924
4979
|
if (!isSha256(normalizedSha)) return null;
|
|
4925
4980
|
const normalizedName = String(name || '').trim().slice(0, 255) || `upload-${normalizedSha.slice(0, 12)}`;
|
|
4926
4981
|
const normalizedType = String(type || '').trim().toLowerCase().slice(0, 127) || 'application/octet-stream';
|
|
4982
|
+
const filePath = uploadPathForSha(normalizedSha);
|
|
4927
4983
|
return {
|
|
4928
4984
|
name: normalizedName,
|
|
4929
4985
|
type: normalizedType,
|
|
4930
4986
|
size: Number(size || 0),
|
|
4931
4987
|
sha256: normalizedSha,
|
|
4932
|
-
path:
|
|
4933
|
-
reference: `@${
|
|
4988
|
+
path: filePath,
|
|
4989
|
+
reference: `@${filePath}`,
|
|
4934
4990
|
contentUrl: uploadContentUrlForSha(normalizedSha),
|
|
4935
4991
|
};
|
|
4936
4992
|
}
|
|
@@ -5003,7 +5059,9 @@ function deleteOrphanedUploads(hashes) {
|
|
|
5003
5059
|
const refs = Number(stmts.countUploadRefsBySha.get(sha256)?.cnt || 0);
|
|
5004
5060
|
if (refs > 0) continue;
|
|
5005
5061
|
stmts.deleteUploadFile.run(sha256);
|
|
5006
|
-
|
|
5062
|
+
// Only the upload dir is swept; the folders of earlier versions are
|
|
5063
|
+
// read-only to the relay.
|
|
5064
|
+
const filePath = path.join(UPLOAD_DIR, sha256);
|
|
5007
5065
|
if (fs.existsSync(filePath)) {
|
|
5008
5066
|
try { fs.unlinkSync(filePath); } catch {}
|
|
5009
5067
|
}
|
|
@@ -5536,64 +5594,21 @@ function failConsumedRowTerminally(row, reason) {
|
|
|
5536
5594
|
});
|
|
5537
5595
|
}
|
|
5538
5596
|
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
}
|
|
5555
|
-
const listedRows = stmts.listRecoverableProcessing.all(params);
|
|
5556
|
-
for (const row of listedRows.filter((entry) => entry?.consumed_at)) {
|
|
5557
|
-
failConsumedRowTerminally(row, 'stale-recovery');
|
|
5558
|
-
noteRestartTurnOutcome(row, 'cut');
|
|
5559
|
-
}
|
|
5560
|
-
const allRows = listedRows.filter((entry) => !entry?.consumed_at);
|
|
5561
|
-
if (!allRows.length) return [];
|
|
5562
|
-
// Enforce the retry budget before requeueing: failing these rows first
|
|
5563
|
-
// flips them out of 'processing' so the recovery UPDATE below skips them.
|
|
5564
|
-
const overBudget = allRows.filter((row) => Number(row?.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES);
|
|
5565
|
-
for (const row of overBudget) {
|
|
5566
|
-
failRecoveredRowTerminally(row, 'stale-recovery');
|
|
5567
|
-
noteRestartTurnOutcome(row, 'failed');
|
|
5568
|
-
}
|
|
5569
|
-
const rows = allRows.filter((row) => Number(row?.retry_count || 0) + 1 < MAX_REQUEUE_RETRIES);
|
|
5570
|
-
if (!rows.length) return [];
|
|
5571
|
-
|
|
5572
|
-
const tx = db.transaction(() => {
|
|
5573
|
-
stmts.recoverProcessingBefore.run({ ...params, requeueAt: requeueAtIso });
|
|
5574
|
-
const settleRecoveredAbortControls = db.prepare(`
|
|
5575
|
-
UPDATE relay_control_requests
|
|
5576
|
-
SET status = 'failed',
|
|
5577
|
-
error = ?,
|
|
5578
|
-
updated_at = ?,
|
|
5579
|
-
completed_at = ?
|
|
5580
|
-
WHERE queue_message_id = ?
|
|
5581
|
-
AND type IN ('abort_turn', 'abort_subagent')
|
|
5582
|
-
AND status IN ('pending', 'processing')
|
|
5583
|
-
`);
|
|
5584
|
-
const now = new Date().toISOString();
|
|
5585
|
-
for (const row of rows) {
|
|
5586
|
-
settleRecoveredAbortControls.run('queue-recovered', now, now, row.id);
|
|
5587
|
-
}
|
|
5588
|
-
});
|
|
5589
|
-
tx();
|
|
5590
|
-
|
|
5591
|
-
for (const row of rows) {
|
|
5592
|
-
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
5593
|
-
noteRestartTurnOutcome(row, 'requeued');
|
|
5594
|
-
}
|
|
5595
|
-
io.emit('queue_updated', { recovered: rows.length });
|
|
5596
|
-
return rows;
|
|
5597
|
+
// The stale-turn sweep itself lives in services/stale-turn-recovery.mjs so it
|
|
5598
|
+
// can be tested against a real database; io is created further down, hence
|
|
5599
|
+
// the late-bound emit.
|
|
5600
|
+
const staleTurnRecovery = createStaleTurnRecovery({
|
|
5601
|
+
db,
|
|
5602
|
+
stmts,
|
|
5603
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
5604
|
+
maxRequeueRetries: MAX_REQUEUE_RETRIES,
|
|
5605
|
+
failRecoveredRowTerminally,
|
|
5606
|
+
failConsumedRowTerminally,
|
|
5607
|
+
noteRestartTurnOutcome,
|
|
5608
|
+
warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
|
|
5609
|
+
});
|
|
5610
|
+
function recoverProcessingOlderThan(cutoffIso, requeueAtIso, options) {
|
|
5611
|
+
return staleTurnRecovery.recoverProcessingOlderThan(cutoffIso, requeueAtIso, options);
|
|
5597
5612
|
}
|
|
5598
5613
|
|
|
5599
5614
|
// One `/api/usage` needs the same token for the quota read and the billing
|
|
@@ -5928,7 +5943,7 @@ const readContextFromSessionEvents = contextSnapshotService.readContextFromSessi
|
|
|
5928
5943
|
// Claude-provider sessions live under the Agent SDK's project layout rather than
|
|
5929
5944
|
// ~/.copilot/session-state, so their browsable session folder resolves separately.
|
|
5930
5945
|
const claudeSessionRootResolver = createClaudeSessionRootResolver({ fs, path });
|
|
5931
|
-
const cursorSessionRootResolver = createCursorSessionRootResolver({ fs, path, serverDir: __dirname });
|
|
5946
|
+
const cursorSessionRootResolver = createCursorSessionRootResolver({ fs, path, serverDir: __dirname, dataDir: DATA_DIR });
|
|
5932
5947
|
|
|
5933
5948
|
// ─── Express + Socket.io Setup ────────────────────────────────────────────────
|
|
5934
5949
|
const app = express();
|
|
@@ -6805,6 +6820,34 @@ const windowsBootAutostartService = createWindowsBootAutostartService({
|
|
|
6805
6820
|
packageRoot: REPO_ROOT,
|
|
6806
6821
|
configPath: CONFIG_PATH,
|
|
6807
6822
|
});
|
|
6823
|
+
// Autostart entries written before they pinned the data and log folders start
|
|
6824
|
+
// the relay on a database inside the package directory, or before TEMP and
|
|
6825
|
+
// the data drive are there. Brought up to date once this relay holds the
|
|
6826
|
+
// lock and its port; edited ones stay.
|
|
6827
|
+
function refreshWindowsAutostartEntries() {
|
|
6828
|
+
// Entries that ran the relay on the database inside the package.
|
|
6829
|
+
const unpinnedLaunchers = [];
|
|
6830
|
+
const onEarlierForm = ({ path: launcher, pinnedData }) => { if (!pinnedData) unpinnedLaunchers.push(launcher); };
|
|
6831
|
+
for (const [name, refresh] of [
|
|
6832
|
+
['sign-in entry', () => windowsAutostartService.refreshInstalledEntry({ onEarlierForm })],
|
|
6833
|
+
['boot launcher', () => windowsBootAutostartService.refreshInstalledLauncher({ onEarlierForm })],
|
|
6834
|
+
]) {
|
|
6835
|
+
try {
|
|
6836
|
+
if (refresh()) console.log(`[autostart] brought the ${name} up to date: it pins the data and log folders and waits for TEMP and the data drive`);
|
|
6837
|
+
} catch (error) {
|
|
6838
|
+
console.warn(`[autostart] could not bring the ${name} up to date: ${error?.message || error}`);
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
const warning = lostPackageDatabaseWarning({
|
|
6842
|
+
unpinnedLaunchers,
|
|
6843
|
+
earlierDatabase,
|
|
6844
|
+
databaseCreated: databaseCreatedAtStart,
|
|
6845
|
+
evidence: unpinnedLaunchers.length > 0 && packageDatabaseEvidence({ serverDir: __dirname, ownDirs: [DATA_DIR, path.dirname(relayConsoleLogPath)] }),
|
|
6846
|
+
serverDir: __dirname,
|
|
6847
|
+
dbPath: DB_PATH,
|
|
6848
|
+
});
|
|
6849
|
+
if (warning) console.error(warning);
|
|
6850
|
+
}
|
|
6808
6851
|
|
|
6809
6852
|
const gitChangesService = createGitChangesService();
|
|
6810
6853
|
// GET /api/git/remote: where a folder lives on GitHub (New Chat, Claude Cloud).
|
|
@@ -7069,6 +7112,7 @@ const sharedRouteDeps = {
|
|
|
7069
7112
|
fs,
|
|
7070
7113
|
path,
|
|
7071
7114
|
uploadsDir: UPLOAD_DIR,
|
|
7115
|
+
uploadReadDirs: UPLOAD_READ_DIRS,
|
|
7072
7116
|
fetchUsageSummary,
|
|
7073
7117
|
fetchCopilotBillingUsage,
|
|
7074
7118
|
fetchGrokBillingUsage: createGrokBillingUsageFetcher(),
|
|
@@ -7776,6 +7820,14 @@ registerPreviewRoutes(app, {
|
|
|
7776
7820
|
new Date().toISOString(),
|
|
7777
7821
|
);
|
|
7778
7822
|
},
|
|
7823
|
+
// A scope named by a conversation's id or its session id covers both.
|
|
7824
|
+
resolveConversationScope: createPreviewScopeResolver({
|
|
7825
|
+
getConversation: (id) => {
|
|
7826
|
+
const conv = stmts.getConvAnyStatus.get(id) || null;
|
|
7827
|
+
return conv && String(conv.status || '').trim().toLowerCase() !== 'deleted' ? conv : null;
|
|
7828
|
+
},
|
|
7829
|
+
getConversationBySdkSessionId: (id) => stmts.getConvBySdkSessionId?.get?.(id) || null,
|
|
7830
|
+
}),
|
|
7779
7831
|
});
|
|
7780
7832
|
|
|
7781
7833
|
function clearRuntimeTimers() {
|
|
@@ -7835,6 +7887,9 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
7835
7887
|
console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
|
|
7836
7888
|
}
|
|
7837
7889
|
sessionWorkerWebSocketService.stop();
|
|
7890
|
+
// A queued request wrote its own end already; this covers the rest
|
|
7891
|
+
// (signals, the owner gone).
|
|
7892
|
+
try { relayEventService.noteRelayExit({ action: runtimeShutdownExitCode === RELAY_RESTART_EXIT_CODE ? 'restart' : 'shutdown', reason }); } catch {}
|
|
7838
7893
|
try { relayEventService.stop(); } catch {}
|
|
7839
7894
|
try { relayEventLinkService.stop(); } catch {}
|
|
7840
7895
|
tmuxInspectorSocketService.stop();
|
|
@@ -7965,12 +8020,19 @@ httpServer.on('error', (error) => {
|
|
|
7965
8020
|
process.exit(1);
|
|
7966
8021
|
});
|
|
7967
8022
|
httpServer.listen(config.port, listenHost, () => {
|
|
8023
|
+
// Conversations that asked for relay_restarted hear that the relay is back:
|
|
8024
|
+
// once it listens, so a start that fails to bind does not take the record
|
|
8025
|
+
// of how the run before ended.
|
|
8026
|
+
try { relayEventService.noteRelayStarted(); } catch (error) {
|
|
8027
|
+
console.warn(`${runtimeLogPrefix()}relay_restarted not recorded: ${error?.message || error}`);
|
|
8028
|
+
}
|
|
7968
8029
|
const readyBanner = buildRelayReadyBannerData();
|
|
7969
8030
|
console.log('');
|
|
7970
8031
|
for (const line of (readyBanner.lines || [])) {
|
|
7971
8032
|
console.log(line);
|
|
7972
8033
|
}
|
|
7973
8034
|
console.log('\nCLI status: waiting for first heartbeat...\n');
|
|
8035
|
+
refreshWindowsAutostartEntries();
|
|
7974
8036
|
|
|
7975
8037
|
// Start SSH tunnel after server is listening
|
|
7976
8038
|
sshTunnelManager.start();
|
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
import nodeFs from 'fs';
|
|
4
4
|
import nodePath from 'path';
|
|
5
5
|
|
|
6
|
+
import { resolveDataDir } from './data-dir-resolution.mjs';
|
|
7
|
+
|
|
6
8
|
// Resolve the on-disk folder the Cursor SDK integration keeps for one session,
|
|
7
9
|
// so the file explorer's Session root works on `cursor`-provider conversations.
|
|
8
10
|
//
|
|
9
11
|
// The Cursor worker roots its per-session agent store at
|
|
10
12
|
// `<storeDir>/<sdkSessionId>/` (see cursor-session-worker.mjs / the storePath
|
|
11
13
|
// in cursor-sdk-adapter.mjs), where storeDir is `$CURSOR_AGENT_STORE_DIR` or
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
14
|
+
// `cursor-agents` in the relay's data dir. The worker and this resolver both
|
|
15
|
+
// go through resolveCursorSessionStoreDir, so they agree. The directory is
|
|
16
|
+
// created on the session's first turn, so a missing directory means "nothing
|
|
17
|
+
// to browse yet" rather than an error — the resolver returns null and the
|
|
18
|
+
// Session button stays disabled until the first turn lands.
|
|
16
19
|
|
|
17
20
|
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
18
21
|
|
|
@@ -21,16 +24,74 @@ function normalizeText(value) {
|
|
|
21
24
|
return text || '';
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
/**
|
|
28
|
+
* `$CURSOR_AGENT_STORE_DIR`, else `cursor-agents` in the data dir the relay
|
|
29
|
+
* uses (resolveDataDir: env var, config `dataDir`, `<serverDir>/data`), which
|
|
30
|
+
* for a global install lies outside the package directory an update replaces.
|
|
31
|
+
*/
|
|
24
32
|
export function resolveCursorAgentStoreDir({
|
|
25
33
|
env = process.env,
|
|
26
34
|
serverDir = '',
|
|
35
|
+
configPath = '',
|
|
36
|
+
// The relay's data dir when the caller already has it resolved.
|
|
37
|
+
dataDir = '',
|
|
38
|
+
fs = nodeFs,
|
|
27
39
|
path = nodePath,
|
|
28
40
|
} = {}) {
|
|
29
41
|
const override = normalizeText(env?.CURSOR_AGENT_STORE_DIR);
|
|
30
42
|
if (override) return override;
|
|
31
43
|
const base = normalizeText(serverDir);
|
|
32
44
|
if (!base) return '';
|
|
33
|
-
|
|
45
|
+
const resolvedDataDir = normalizeText(dataDir)
|
|
46
|
+
|| resolveDataDir({ env, configPath, serverDir: base, fsImpl: fs, pathImpl: path });
|
|
47
|
+
return path.join(resolvedDataDir, 'cursor-agents');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isDirectory(fs, target) {
|
|
51
|
+
try {
|
|
52
|
+
return fs.statSync(target).isDirectory();
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function agentDbMtimeMs(fs, path, sessionDir) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(path.join(sessionDir, 'agent.db')).mtimeMs || 0;
|
|
61
|
+
} catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The store folder one session uses. Before the store followed the data dir
|
|
68
|
+
* it always lived in `<serverDir>/data/cursor-agents`, so a session can have
|
|
69
|
+
* its store there (a relay whose data dir lies elsewhere, such as a checkout
|
|
70
|
+
* pointed at other data; on a global install that folder went with the
|
|
71
|
+
* update). A session with a store only there keeps using it in place; with
|
|
72
|
+
* one in both places, the one whose agent.db changed last wins (the copy the
|
|
73
|
+
* state migration made dates from the migration). Nothing is moved or
|
|
74
|
+
* deleted.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveCursorSessionStoreDir({
|
|
77
|
+
env = process.env,
|
|
78
|
+
serverDir = '',
|
|
79
|
+
configPath = '',
|
|
80
|
+
dataDir = '',
|
|
81
|
+
sdkSessionId = '',
|
|
82
|
+
fs = nodeFs,
|
|
83
|
+
path = nodePath,
|
|
84
|
+
} = {}) {
|
|
85
|
+
const storeDir = resolveCursorAgentStoreDir({ env, serverDir, configPath, dataDir, fs, path });
|
|
86
|
+
const sessionId = normalizeText(sdkSessionId);
|
|
87
|
+
if (!storeDir || !sessionId || normalizeText(env?.CURSOR_AGENT_STORE_DIR)) return storeDir;
|
|
88
|
+
const earlierDir = path.join(normalizeText(serverDir), 'data', 'cursor-agents');
|
|
89
|
+
if (path.resolve(earlierDir) === path.resolve(storeDir)) return storeDir;
|
|
90
|
+
const current = path.join(storeDir, sessionId);
|
|
91
|
+
const earlier = path.join(earlierDir, sessionId);
|
|
92
|
+
if (!isDirectory(fs, earlier)) return storeDir;
|
|
93
|
+
if (!isDirectory(fs, current)) return earlierDir;
|
|
94
|
+
return agentDbMtimeMs(fs, path, earlier) > agentDbMtimeMs(fs, path, current) ? earlierDir : storeDir;
|
|
34
95
|
}
|
|
35
96
|
|
|
36
97
|
export function createCursorSessionRootResolver({
|
|
@@ -38,6 +99,9 @@ export function createCursorSessionRootResolver({
|
|
|
38
99
|
path = nodePath,
|
|
39
100
|
env = process.env,
|
|
40
101
|
serverDir = '',
|
|
102
|
+
configPath = '',
|
|
103
|
+
// The relay passes its resolved data dir, so no request reads config.json.
|
|
104
|
+
dataDir = '',
|
|
41
105
|
} = {}) {
|
|
42
106
|
/**
|
|
43
107
|
* @returns {{ sessionRootPath: string, sessionRootName: string } | null}
|
|
@@ -47,7 +111,7 @@ export function createCursorSessionRootResolver({
|
|
|
47
111
|
// The id is joined into a filesystem path, so it is validated before any
|
|
48
112
|
// syscall (mirrors the Claude session-root resolver).
|
|
49
113
|
if (!SESSION_ID_PATTERN.test(sessionId)) return null;
|
|
50
|
-
const storeDir =
|
|
114
|
+
const storeDir = resolveCursorSessionStoreDir({ env, serverDir, configPath, dataDir, sdkSessionId: sessionId, fs, path });
|
|
51
115
|
if (!storeDir) return null;
|
|
52
116
|
const sessionRootPath = path.join(storeDir, sessionId);
|
|
53
117
|
try {
|