@oar-sh/oar 0.9.9 → 0.9.10
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/CHANGELOG.md +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -43,6 +43,7 @@ import { registerGitRoutes } from './routes/git-routes.mjs';
|
|
|
43
43
|
import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
|
|
44
44
|
import { registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
45
45
|
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
46
|
+
import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
|
|
46
47
|
import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
|
|
47
48
|
import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
|
|
48
49
|
import { createRemoteRelayLoopbackClient } from './services/remote-relay-loopback.mjs';
|
|
@@ -68,6 +69,9 @@ import webpush from 'web-push';
|
|
|
68
69
|
import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
|
|
69
70
|
import { createHostSuspendService } from './services/host-suspend-service.mjs';
|
|
70
71
|
import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
|
|
72
|
+
import { createSessionActivityTracker } from './services/session-activity-tracker.mjs';
|
|
73
|
+
import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
74
|
+
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
71
75
|
import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
|
|
72
76
|
import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
|
|
73
77
|
import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
|
|
@@ -116,12 +120,14 @@ import {
|
|
|
116
120
|
import { createPreviewProxyServer } from './services/preview-proxy-server.mjs';
|
|
117
121
|
import { createPreviewHealthProbe } from './services/preview-health-probe.mjs';
|
|
118
122
|
import { createTunnelWorkerPathGuard } from './services/tunnel-worker-path-guard.mjs';
|
|
123
|
+
import { guardUpgradeSocket } from './services/upgrade-socket-guard.mjs';
|
|
119
124
|
import { createWindowsAutostartService } from './services/windows-autostart-service.mjs';
|
|
120
125
|
import { createWindowsBootAutostartService } from './services/windows-boot-autostart-service.mjs';
|
|
121
126
|
import { createSessionWorkerWebSocketService } from './services/session-worker-websocket-service.mjs';
|
|
122
127
|
import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-access-policy.mjs';
|
|
123
128
|
import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
|
|
124
129
|
import { createTmuxInspectorSocketService } from './services/tmux-inspector-socket-service.mjs';
|
|
130
|
+
import { createSubagentViewerRooms } from './services/subagent-viewer-rooms.mjs';
|
|
125
131
|
import {
|
|
126
132
|
resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
|
|
127
133
|
resolveLaunchWorkspaceRootPath,
|
|
@@ -142,6 +148,19 @@ import {
|
|
|
142
148
|
readTurnCeilingSetting,
|
|
143
149
|
turnCeilingMinutesToMs,
|
|
144
150
|
} from '../shared/turn-ceiling.mjs';
|
|
151
|
+
import {
|
|
152
|
+
SUBAGENT_RECORD_RETENTION_SETTING_KEY,
|
|
153
|
+
parseSubagentRecordRetentionUpdate,
|
|
154
|
+
readSubagentRecordRetentionSetting,
|
|
155
|
+
} from '../shared/subagent-record-retention.mjs';
|
|
156
|
+
import { createSubagentRecordRetention } from './services/subagent-record-retention.mjs';
|
|
157
|
+
import { isRelayShutdownQueued, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
|
|
158
|
+
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
159
|
+
import {
|
|
160
|
+
STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
|
|
161
|
+
STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS,
|
|
162
|
+
createStaleSubagentRunSweep,
|
|
163
|
+
} from './services/stale-subagent-run-sweep.mjs';
|
|
145
164
|
import {
|
|
146
165
|
normalizePwaAppName,
|
|
147
166
|
readPwaAppNameSetting,
|
|
@@ -178,6 +197,7 @@ import {
|
|
|
178
197
|
} from '../shared/model-id.mjs';
|
|
179
198
|
import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
|
|
180
199
|
import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
|
|
200
|
+
import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
|
|
181
201
|
|
|
182
202
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
183
203
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
@@ -1792,6 +1812,8 @@ const OFFLINE_STALE_RECOVER_MS = 45_000;
|
|
|
1792
1812
|
// reaches the browser.
|
|
1793
1813
|
const SHUTDOWN_SOCKET_FLUSH_MS = 300;
|
|
1794
1814
|
let runtimeShutdownStarted = false;
|
|
1815
|
+
// Set once the database is open (see the retention sweep near the other sweeps).
|
|
1816
|
+
let subagentRecordRetention = null;
|
|
1795
1817
|
const runtimeTimers = {
|
|
1796
1818
|
cliStatus: null,
|
|
1797
1819
|
ownerWatchdog: null,
|
|
@@ -2000,6 +2022,22 @@ function setTurnCeilingMinutes(value) {
|
|
|
2000
2022
|
return { ok: true, ceilingMinutes: parsed.minutes };
|
|
2001
2023
|
}
|
|
2002
2024
|
|
|
2025
|
+
function getSubagentRecordRetentionDays() {
|
|
2026
|
+
return readSubagentRecordRetentionSetting(readAppSettingValue(SUBAGENT_RECORD_RETENTION_SETTING_KEY));
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function setSubagentRecordRetentionDays(value) {
|
|
2030
|
+
if (typeof stmts?.upsertAppSetting?.run !== 'function') {
|
|
2031
|
+
return { ok: false, error: 'Sub-agent record settings are unavailable' };
|
|
2032
|
+
}
|
|
2033
|
+
const parsed = parseSubagentRecordRetentionUpdate(value);
|
|
2034
|
+
if (!parsed.ok) return parsed;
|
|
2035
|
+
stmts.upsertAppSetting.run(SUBAGENT_RECORD_RETENTION_SETTING_KEY, String(parsed.days), new Date().toISOString());
|
|
2036
|
+
// A shorter period takes effect now, not at the next daily sweep.
|
|
2037
|
+
subagentRecordRetention?.scheduleSweep(5_000);
|
|
2038
|
+
return { ok: true, retentionDays: parsed.days };
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2003
2041
|
const PWA_APP_NAME_SETTING_KEY = 'pwa_app_name';
|
|
2004
2042
|
|
|
2005
2043
|
/** '' means "no custom name": the manifest falls back to the template default. */
|
|
@@ -2541,6 +2579,14 @@ const stmts = {
|
|
|
2541
2579
|
...createImageConversationRepository(db),
|
|
2542
2580
|
};
|
|
2543
2581
|
const statusEventService = createStatusEventService(db);
|
|
2582
|
+
// Relay events (built further down, next to the usage-limit pause): declared
|
|
2583
|
+
// here so recovery code that runs before then finds null, not a TDZ error.
|
|
2584
|
+
let sessionActivityTracker = null;
|
|
2585
|
+
let relayEventService = null;
|
|
2586
|
+
// Fetches the events of subscriptions on paired relays (built with the
|
|
2587
|
+
// remote relays below; the event service asks it to fetch at once when an
|
|
2588
|
+
// end or a new watcher must reach one).
|
|
2589
|
+
let relayEventLinkService = null;
|
|
2544
2590
|
// Owns the `claude auth …` child processes, so the runtime (not the route
|
|
2545
2591
|
// module) constructs it: shutdownRuntime() has to be able to dispose a login
|
|
2546
2592
|
// PTY that is still holding a process group.
|
|
@@ -3344,26 +3390,6 @@ function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
|
3344
3390
|
return { cancelled: true, dropped, ...relayShutdownStatePayload() };
|
|
3345
3391
|
}
|
|
3346
3392
|
|
|
3347
|
-
const countPendingRowsByOwnerSession = db.prepare(`
|
|
3348
|
-
SELECT owner_sdk_session_id AS sdk_session_id, COUNT(*) AS n
|
|
3349
|
-
FROM queue
|
|
3350
|
-
WHERE status = 'pending' AND owner_sdk_session_id IS NOT NULL AND owner_sdk_session_id != ''
|
|
3351
|
-
GROUP BY owner_sdk_session_id
|
|
3352
|
-
`);
|
|
3353
|
-
|
|
3354
|
-
function countPendingRowsOfStoppedSessions() {
|
|
3355
|
-
try {
|
|
3356
|
-
let stalled = 0;
|
|
3357
|
-
for (const row of countPendingRowsByOwnerSession.all()) {
|
|
3358
|
-
const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(String(row.sdk_session_id || '').trim());
|
|
3359
|
-
if (lifecycle?.retriesStopped === true) stalled += Number(row.n || 0);
|
|
3360
|
-
}
|
|
3361
|
-
return stalled;
|
|
3362
|
-
} catch {
|
|
3363
|
-
return 0;
|
|
3364
|
-
}
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
3393
|
function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
|
|
3368
3394
|
const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
|
|
3369
3395
|
const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
|
|
@@ -3379,7 +3405,11 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
|
|
|
3379
3405
|
};
|
|
3380
3406
|
}
|
|
3381
3407
|
|
|
3382
|
-
// Shutdown is
|
|
3408
|
+
// Shutdown is deferred until no turn is processing; it never interrupts an
|
|
3409
|
+
// active turn. From here on no pending row is handed out (the dequeue paths
|
|
3410
|
+
// read relayShutdownQueued): a turn started meanwhile would postpone the
|
|
3411
|
+
// restart again. Pending and parked rows wait in the database and run
|
|
3412
|
+
// after the restart.
|
|
3383
3413
|
if (!pendingRelayShutdownRequest) {
|
|
3384
3414
|
pendingRelayShutdownRequest = {
|
|
3385
3415
|
action: requestedAction,
|
|
@@ -3400,12 +3430,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
|
|
|
3400
3430
|
const tryShutdownWhenIdle = () => {
|
|
3401
3431
|
if (runtimeShutdownStarted) return;
|
|
3402
3432
|
const counts = queueCounts();
|
|
3403
|
-
|
|
3404
|
-
// retrying makes no progress until somebody presses Retry; it must not
|
|
3405
|
-
// hold the restart (which, fresh, is itself the next launch attempt).
|
|
3406
|
-
const stalledPending = countPendingRowsOfStoppedSessions();
|
|
3407
|
-
const queueFree = (counts.pendingCount - stalledPending + counts.processingCount + counts.parkedCount) <= 0;
|
|
3408
|
-
if (!queueFree) return;
|
|
3433
|
+
if (!queuedShutdownCanProceed(counts)) return;
|
|
3409
3434
|
const requestInfo = pendingRelayShutdownRequest;
|
|
3410
3435
|
pendingRelayShutdownRequest = null;
|
|
3411
3436
|
if (runtimeTimers.shutdownDrain) {
|
|
@@ -3416,7 +3441,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
|
|
|
3416
3441
|
const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
|
|
3417
3442
|
console.log(
|
|
3418
3443
|
`${runtimeLogPrefix()}Relay ${action} request accepted by ${requestInfo?.requestedBy || 'unknown'}`
|
|
3419
|
-
+ `;
|
|
3444
|
+
+ `; no turn is processing (${counts.pendingCount} pending, ${counts.parkedCount} parked wait), exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
|
|
3420
3445
|
);
|
|
3421
3446
|
sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
|
|
3422
3447
|
void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
|
|
@@ -5049,19 +5074,18 @@ function subagentRunsForResponse(responseMessageId) {
|
|
|
5049
5074
|
return rows.map(mapSubagentRunRow).filter(Boolean).slice(0, 96);
|
|
5050
5075
|
}
|
|
5051
5076
|
|
|
5077
|
+
// The run as a row of the panel, the reply bubble and the viewer header. Stop
|
|
5078
|
+
// is offered while it runs: a background run through its live task, a
|
|
5079
|
+
// foreground run through the sub-agent cancel route.
|
|
5052
5080
|
function mapSubagentRunRow(row) {
|
|
5053
|
-
const
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
status: String(row?.status || 'running').trim().toLowerCase() || 'running',
|
|
5062
|
-
startedAt: row?.started_at || null,
|
|
5063
|
-
updatedAt: row?.updated_at || null,
|
|
5064
|
-
};
|
|
5081
|
+
const status = String(row?.status || 'running').trim().toLowerCase();
|
|
5082
|
+
let stoppable = false;
|
|
5083
|
+
if (status === 'running') {
|
|
5084
|
+
const tasks = backgroundTaskStore.get(row?.conversation_id);
|
|
5085
|
+
const task = tasks.find((entry) => entry?.toolUseId && entry.toolUseId === String(row?.id || ''));
|
|
5086
|
+
stoppable = task ? task.stoppable !== false : Number(row?.background || 0) !== 1;
|
|
5087
|
+
}
|
|
5088
|
+
return mapSubagentRunHeader(row, { stoppable });
|
|
5065
5089
|
}
|
|
5066
5090
|
|
|
5067
5091
|
// Final workflow digests persisted with an assistant message (the "Finished
|
|
@@ -5228,6 +5252,46 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5228
5252
|
logger: console,
|
|
5229
5253
|
});
|
|
5230
5254
|
|
|
5255
|
+
// ─── Relay events ─────────────────────────────────────────────────────────────
|
|
5256
|
+
// What each session is doing (state, tasks, quiet time) and the subscriptions
|
|
5257
|
+
// through which a watching session is woken with the events of the sessions
|
|
5258
|
+
// it watches (docs: server/README.md "Relay event subscriptions").
|
|
5259
|
+
sessionActivityTracker = createSessionActivityTracker({
|
|
5260
|
+
db,
|
|
5261
|
+
onEdge: (edge) => relayEventService?.noteTaskEdge(edge),
|
|
5262
|
+
logger: console,
|
|
5263
|
+
});
|
|
5264
|
+
relayEventService = createRelayEventService({
|
|
5265
|
+
db,
|
|
5266
|
+
stmts,
|
|
5267
|
+
tracker: sessionActivityTracker,
|
|
5268
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
5269
|
+
// The event row is an ordinary pending message: the primer starts a
|
|
5270
|
+
// stopped worker, the socket service hands it to a running one.
|
|
5271
|
+
wakeWatcher: () => {
|
|
5272
|
+
void primePendingSessionWorkers('relay-event');
|
|
5273
|
+
sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
|
|
5274
|
+
},
|
|
5275
|
+
relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
|
|
5276
|
+
onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
|
|
5277
|
+
logger: console,
|
|
5278
|
+
});
|
|
5279
|
+
|
|
5280
|
+
/** A worker died: its tasks are gone, and whoever watches its sessions hears of it. */
|
|
5281
|
+
function noteWorkerDeathForEvents(sessionId, conversationIds, reason) {
|
|
5282
|
+
const ids = new Set((conversationIds || []).map((id) => String(id || '').trim()).filter(Boolean));
|
|
5283
|
+
if (!ids.size) {
|
|
5284
|
+
const conversation = resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId });
|
|
5285
|
+
if (conversation?.id) ids.add(String(conversation.id));
|
|
5286
|
+
}
|
|
5287
|
+
for (const conversationId of ids) {
|
|
5288
|
+
try {
|
|
5289
|
+
sessionActivityTracker?.noteWorkerDied(conversationId);
|
|
5290
|
+
relayEventService?.noteError({ conversationId, kind: 'worker_died', message: `the session's worker process ended (${reason})` });
|
|
5291
|
+
} catch {}
|
|
5292
|
+
}
|
|
5293
|
+
}
|
|
5294
|
+
|
|
5231
5295
|
// The live turn among a conversation's processing rows. Mid-turn steering can
|
|
5232
5296
|
// leave several processing at once — the running turn, steered messages the
|
|
5233
5297
|
// CLI folded into it (no output of their own), a handed-off row still settling
|
|
@@ -5330,6 +5394,9 @@ function failRecoveredRowTerminally(row, reason, { failureRecord = null, failure
|
|
|
5330
5394
|
message: { role: 'assistant', text: failureText, model: requestedModel || 'unknown', mode: String(row?.relay_mode || '').trim() || null, timestamp: now },
|
|
5331
5395
|
});
|
|
5332
5396
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
5397
|
+
try {
|
|
5398
|
+
relayEventService?.noteError({ conversationId: row.conversation_id, kind: 'turn_failed', message: failureText, queueMessageId: row.id });
|
|
5399
|
+
} catch {}
|
|
5333
5400
|
console.warn(`${runtimeLogPrefix()}${failureRecord ? 'CONSUMED FAILED' : 'RECOVERY LIMIT'} ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
|
|
5334
5401
|
}
|
|
5335
5402
|
|
|
@@ -5354,6 +5421,12 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
|
|
|
5354
5421
|
for (const row of staleContinuations) {
|
|
5355
5422
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
5356
5423
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
5424
|
+
// A teardown is a turn ending with no reply; it must be visible in the
|
|
5425
|
+
// console log (the only trace of a silent one was `ignored=already_failed`
|
|
5426
|
+
// hours later, when the worker's reply arrived for the dead row).
|
|
5427
|
+
const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
|
|
5428
|
+
const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
|
|
5429
|
+
console.warn(`${runtimeLogPrefix()}STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
|
|
5357
5430
|
}
|
|
5358
5431
|
const listedRows = stmts.listRecoverableProcessing.all(params);
|
|
5359
5432
|
for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
|
|
@@ -5749,6 +5822,10 @@ httpServer.prependListener('request', (req, _res) => {
|
|
|
5749
5822
|
httpServer.prependListener('upgrade', (req, _socket, _head) => {
|
|
5750
5823
|
rewriteSocketIoRequestPath(req, remotePath);
|
|
5751
5824
|
});
|
|
5825
|
+
// A client reset during an upgrade must not end the relay; see the guard.
|
|
5826
|
+
httpServer.prependListener('upgrade', (_req, socket, _head) => {
|
|
5827
|
+
guardUpgradeSocket(socket);
|
|
5828
|
+
});
|
|
5752
5829
|
// Public tunnels (Cloudflare, or a VPS/Caddy front) forward every path on the
|
|
5753
5830
|
// bound hostname to this port, including the session-worker WebSocket plumbing.
|
|
5754
5831
|
// Reject those before auth; local workers connect over 127.0.0.1 with no edge
|
|
@@ -5958,6 +6035,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
5958
6035
|
if (runtimeState.relayPaused) {
|
|
5959
6036
|
return { message: null, paused: true, reason: 'relay_paused' };
|
|
5960
6037
|
}
|
|
6038
|
+
if (isRelayShutdownQueued(runtimeState)) {
|
|
6039
|
+
return relayShutdownGateReply(runtimeState);
|
|
6040
|
+
}
|
|
5961
6041
|
if (runtimeState.tunnelState?.blocking) {
|
|
5962
6042
|
return {
|
|
5963
6043
|
message: null,
|
|
@@ -6172,11 +6252,36 @@ async function isWorkerSessionProcessAlive(sessionId, hintPid = null) {
|
|
|
6172
6252
|
return false;
|
|
6173
6253
|
}
|
|
6174
6254
|
|
|
6255
|
+
// Runs a dead worker can no longer end: marked failed, each with the full
|
|
6256
|
+
// header and its own turn's messageId, so an open page settles the row
|
|
6257
|
+
// without a reload (the browser drops a status event it cannot place).
|
|
6258
|
+
function closeStrandedSubagentRuns(runs, queueMessageId = null) {
|
|
6259
|
+
if (!runs.length) return;
|
|
6260
|
+
const nowIso = new Date().toISOString();
|
|
6261
|
+
for (const run of runs) {
|
|
6262
|
+
stmts.updateSubagentRunStatus?.run?.('failed', nowIso, nowIso, run.id);
|
|
6263
|
+
const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status: 'failed', completed_at: nowIso };
|
|
6264
|
+
const header = mapSubagentRunHeader(closed, { stoppable: false });
|
|
6265
|
+
if (header) {
|
|
6266
|
+
io.emit('subagent_status', {
|
|
6267
|
+
...header,
|
|
6268
|
+
timestamp: nowIso,
|
|
6269
|
+
queueMessageId: queueMessageId || header.messageId,
|
|
6270
|
+
reconciled: true,
|
|
6271
|
+
});
|
|
6272
|
+
}
|
|
6273
|
+
}
|
|
6274
|
+
}
|
|
6275
|
+
|
|
6175
6276
|
function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
6176
6277
|
const sid = String(sessionId || '').trim();
|
|
6177
6278
|
if (!sid) return 0;
|
|
6279
|
+
// A background agent outlives its turn, so its run is closed with the
|
|
6280
|
+
// worker rather than with a processing row (its turn is usually done).
|
|
6281
|
+
closeStrandedSubagentRuns(stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []);
|
|
6178
6282
|
const rows = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
6179
6283
|
if (!rows.length) return 0;
|
|
6284
|
+
noteWorkerDeathForEvents(sid, rows.map((row) => row.conversation_id), reason);
|
|
6180
6285
|
let recovered = 0;
|
|
6181
6286
|
const requeueAt = addMsIso(2_000);
|
|
6182
6287
|
for (const row of rows) {
|
|
@@ -6196,22 +6301,7 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
|
6196
6301
|
}
|
|
6197
6302
|
// The dead worker's subagent runs can never be terminated by the replay
|
|
6198
6303
|
// (fresh worker, fresh call ids) — close them out now.
|
|
6199
|
-
|
|
6200
|
-
if (strandedRuns.length) {
|
|
6201
|
-
const nowIso = new Date().toISOString();
|
|
6202
|
-
stmts.closeRunningSubagentRunsByQueueMessage?.run?.('failed', nowIso, nowIso, row.id);
|
|
6203
|
-
for (const run of strandedRuns) {
|
|
6204
|
-
io.emit('subagent_status', {
|
|
6205
|
-
conversationId: run.conversation_id,
|
|
6206
|
-
subagentRunId: run.id,
|
|
6207
|
-
parentSubagentId: run.parent_subagent_id || null,
|
|
6208
|
-
displayName: run.display_name || null,
|
|
6209
|
-
status: 'failed',
|
|
6210
|
-
queueMessageId: row.id,
|
|
6211
|
-
reconciled: true,
|
|
6212
|
-
});
|
|
6213
|
-
}
|
|
6214
|
-
}
|
|
6304
|
+
closeStrandedSubagentRuns(stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [], row.id);
|
|
6215
6305
|
stmts.recoverProcessingRowKeepOwner?.run?.(requeueAt, row.id);
|
|
6216
6306
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
6217
6307
|
recovered += 1;
|
|
@@ -6235,14 +6325,22 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
|
|
|
6235
6325
|
// A dead worker's steering snapshot must not keep holding the composer even
|
|
6236
6326
|
// when it owned no rows (the hold can outlive them, e.g. a continuation's).
|
|
6237
6327
|
const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
|
|
6238
|
-
|
|
6328
|
+
// Background agents run between turns too: a worker that dies then owns no
|
|
6329
|
+
// row, but its agents' runs still read "running".
|
|
6330
|
+
const backgroundRuns = () => stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || [];
|
|
6331
|
+
if (!owned.length && !heldSteering && !backgroundRuns().length) return;
|
|
6239
6332
|
const timer = setTimeout(async () => {
|
|
6240
6333
|
try {
|
|
6241
6334
|
if (runtimeShutdownStarted) return;
|
|
6242
6335
|
if (await isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
|
|
6243
6336
|
sessionWorkerRegistry?.clearSteering?.(sid);
|
|
6244
6337
|
const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
6245
|
-
if (!stillOwned.length)
|
|
6338
|
+
if (!stillOwned.length) {
|
|
6339
|
+
const stranded = backgroundRuns();
|
|
6340
|
+
closeStrandedSubagentRuns(stranded);
|
|
6341
|
+
if (stranded.length) noteWorkerDeathForEvents(sid, stranded.map((run) => run.conversation_id), 'socket-close');
|
|
6342
|
+
return;
|
|
6343
|
+
}
|
|
6246
6344
|
console.warn(`${runtimeLogPrefix()}WORKER DIED session=${sid.slice(0, 8)} rows=${stillOwned.length} trigger=socket-close — recovering owned turns`);
|
|
6247
6345
|
recoverDeadWorkerProcessingRows(sid, 'socket-close');
|
|
6248
6346
|
} catch (error) {
|
|
@@ -6437,6 +6535,9 @@ const runtimeState = {
|
|
|
6437
6535
|
set relayPaused(value) { relayPaused = value; },
|
|
6438
6536
|
get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
|
|
6439
6537
|
get relayShutdown() { return getRelayShutdownState(); },
|
|
6538
|
+
// A shutdown or restart waits for the processing turns: no pending row
|
|
6539
|
+
// goes out meanwhile (see requestRelayShutdown).
|
|
6540
|
+
get relayShutdownQueued() { return !!pendingRelayShutdownRequest && !runtimeShutdownStarted; },
|
|
6440
6541
|
get hostSuspend() { return hostSuspendService.getState(); },
|
|
6441
6542
|
get usageLimit() {
|
|
6442
6543
|
return { account: usageLimitPauseService.getAccountState(), pauses: usageLimitPauseService.listPauses() };
|
|
@@ -6490,6 +6591,13 @@ const remoteRelayRegistry = createRemoteRelayRegistry({
|
|
|
6490
6591
|
platform: process.platform,
|
|
6491
6592
|
version: RUNNING_VERSION,
|
|
6492
6593
|
emit: (event, payload) => io.emit(event, payload),
|
|
6594
|
+
onRemove: (entry) => { void relayEventLinkService?.noteRelayRemoved(entry); },
|
|
6595
|
+
logger: console,
|
|
6596
|
+
});
|
|
6597
|
+
relayEventLinkService = createRelayEventLinkService({
|
|
6598
|
+
relayEvents: relayEventService,
|
|
6599
|
+
registry: remoteRelayRegistry,
|
|
6600
|
+
client: remoteRelayClient,
|
|
6493
6601
|
logger: console,
|
|
6494
6602
|
});
|
|
6495
6603
|
const remoteRelayPairing = createRemoteRelayPairing({
|
|
@@ -6520,7 +6628,10 @@ const remoteRelayCallerContext = createRemoteRelayCallerContext({
|
|
|
6520
6628
|
normalizeRelayMode,
|
|
6521
6629
|
defaultRelayMode: DEFAULT_RELAY_MODE,
|
|
6522
6630
|
emit: (event, payload) => io.emit(event, payload),
|
|
6523
|
-
notifyQuestion: (question) =>
|
|
6631
|
+
notifyQuestion: (question) => {
|
|
6632
|
+
try { relayEventService?.noteQuestion(question); } catch {}
|
|
6633
|
+
return pushDispatchService.notifyQuestion?.(question);
|
|
6634
|
+
},
|
|
6524
6635
|
uuid: uuidv4,
|
|
6525
6636
|
logger: console,
|
|
6526
6637
|
});
|
|
@@ -6541,6 +6652,8 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
|
|
|
6541
6652
|
requestApproval: (request) => remoteRelayCallerContext.requestApproval(request),
|
|
6542
6653
|
emitStatusEvent: ({ type, ...details }) => statusEventService.recordEvent(type, details),
|
|
6543
6654
|
resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
|
|
6655
|
+
relayEvents: relayEventService,
|
|
6656
|
+
relayEventLink: relayEventLinkService,
|
|
6544
6657
|
logger: console,
|
|
6545
6658
|
});
|
|
6546
6659
|
|
|
@@ -6729,6 +6842,8 @@ const sharedRouteDeps = {
|
|
|
6729
6842
|
resolveCursorSessionRoot: cursorSessionRootResolver.resolveCursorSessionRoot,
|
|
6730
6843
|
getTurnCeilingMinutes,
|
|
6731
6844
|
setTurnCeilingMinutes,
|
|
6845
|
+
getSubagentRecordRetentionDays,
|
|
6846
|
+
setSubagentRecordRetentionDays,
|
|
6732
6847
|
getBackgroundTaskTimeoutMinutes,
|
|
6733
6848
|
setBackgroundTaskTimeoutMinutes,
|
|
6734
6849
|
getFeatureFlagsSettingsState,
|
|
@@ -6741,6 +6856,9 @@ const sharedRouteDeps = {
|
|
|
6741
6856
|
hostSuspendService,
|
|
6742
6857
|
collectHostActivity,
|
|
6743
6858
|
usageLimitPauseService,
|
|
6859
|
+
relayEventService,
|
|
6860
|
+
sessionActivityTracker,
|
|
6861
|
+
resolveConversationRecord,
|
|
6744
6862
|
markSharedViewerPresence,
|
|
6745
6863
|
getSharedWatcherCount,
|
|
6746
6864
|
statusEventService,
|
|
@@ -6774,6 +6892,12 @@ registerRemoteRelayRoutes(app, {
|
|
|
6774
6892
|
registry: remoteRelayRegistry,
|
|
6775
6893
|
pairing: remoteRelayPairing,
|
|
6776
6894
|
dispatcher: remoteRelayDispatcher,
|
|
6895
|
+
remoteRelayInbound,
|
|
6896
|
+
});
|
|
6897
|
+
registerRelayEventRoutes(app, {
|
|
6898
|
+
auth,
|
|
6899
|
+
relayEventService,
|
|
6900
|
+
remoteRelayInbound,
|
|
6777
6901
|
});
|
|
6778
6902
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
6779
6903
|
cliLastSeen = null;
|
|
@@ -6817,9 +6941,22 @@ if (managedOwnerPid) {
|
|
|
6817
6941
|
// The window is measured from the last heartbeat that named the message, not
|
|
6818
6942
|
// from when the turn began, so a turn stays alive for as long as its worker
|
|
6819
6943
|
// keeps reporting it — however many minutes of tool calls and subagents that
|
|
6820
|
-
// takes. The separate, user-configurable ceiling is the only elapsed-time cap
|
|
6821
|
-
//
|
|
6944
|
+
// takes. The separate, user-configurable ceiling is the only elapsed-time cap;
|
|
6945
|
+
// it counts from the last answered question rather than from the start, and
|
|
6946
|
+
// both exempt a turn that is waiting on an unanswered question (continuation
|
|
6947
|
+
// rows included — an interview hangs its cards on one).
|
|
6948
|
+
//
|
|
6949
|
+
// After a wall-clock jump (the host slept, the event loop was blocked) every
|
|
6950
|
+
// heartbeat looks old at once although the workers may be alive: the guard
|
|
6951
|
+
// skips recovery for a full inactivity window, so a live worker can report.
|
|
6952
|
+
const STALE_RECOVERY_INTERVAL_MS = 15_000;
|
|
6953
|
+
const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
|
|
6954
|
+
intervalMs: STALE_RECOVERY_INTERVAL_MS,
|
|
6955
|
+
graceMs: Math.max(processingTimeoutMs, 60_000),
|
|
6956
|
+
log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
|
|
6957
|
+
});
|
|
6822
6958
|
function recoverStaleMessages() {
|
|
6959
|
+
if (staleRecoveryClockGuard.tick().skip) return;
|
|
6823
6960
|
const staleWindowMs = cliOnline
|
|
6824
6961
|
? processingTimeoutMs
|
|
6825
6962
|
: Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
|
|
@@ -6835,7 +6972,7 @@ function recoverStaleMessages() {
|
|
|
6835
6972
|
);
|
|
6836
6973
|
}
|
|
6837
6974
|
}
|
|
6838
|
-
runtimeTimers.staleRecovery = setInterval(recoverStaleMessages,
|
|
6975
|
+
runtimeTimers.staleRecovery = setInterval(recoverStaleMessages, STALE_RECOVERY_INTERVAL_MS);
|
|
6839
6976
|
recoverStaleMessages(); // run immediately on startup
|
|
6840
6977
|
|
|
6841
6978
|
function expirePendingQuestions() {
|
|
@@ -6865,6 +7002,40 @@ if (sweptUploads > 0) {
|
|
|
6865
7002
|
}
|
|
6866
7003
|
runtimeTimers.uploadSweep = setInterval(sweepUnreferencedUploadBlobs, 6 * 60 * 60 * 1000);
|
|
6867
7004
|
if (typeof runtimeTimers.uploadSweep.unref === 'function') runtimeTimers.uploadSweep.unref();
|
|
7005
|
+
// Old sub-agent records lose their tool inputs and output: shortly after
|
|
7006
|
+
// start (not in the boot path), then once a day.
|
|
7007
|
+
subagentRecordRetention = createSubagentRecordRetention({
|
|
7008
|
+
db,
|
|
7009
|
+
getRetentionDays: getSubagentRecordRetentionDays,
|
|
7010
|
+
isStopped: () => runtimeShutdownStarted,
|
|
7011
|
+
log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
|
|
7012
|
+
warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
|
|
7013
|
+
});
|
|
7014
|
+
subagentRecordRetention.scheduleSweep(30_000);
|
|
7015
|
+
runtimeTimers.subagentRecordRetention = setInterval(() => { void subagentRecordRetention.sweep(); }, 24 * 60 * 60 * 1000);
|
|
7016
|
+
if (typeof runtimeTimers.subagentRecordRetention.unref === 'function') runtimeTimers.subagentRecordRetention.unref();
|
|
7017
|
+
// Sub-agent runs left `running` by a worker that is gone are closed: once
|
|
7018
|
+
// the previous runtime's workers have had time to reconnect after boot, then
|
|
7019
|
+
// periodically. A live worker's runs are never touched.
|
|
7020
|
+
const staleSubagentRunSweep = createStaleSubagentRunSweep({
|
|
7021
|
+
db,
|
|
7022
|
+
isConversationWorkerAlive: (conversationId) => {
|
|
7023
|
+
if (hostActivityWorkerAlive(conversationId)) return true;
|
|
7024
|
+
const sdkSessionId = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id || '').trim();
|
|
7025
|
+
return !!sdkSessionId && hostActivityWorkerAlive(sdkSessionId);
|
|
7026
|
+
},
|
|
7027
|
+
log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
|
|
7028
|
+
warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
|
|
7029
|
+
});
|
|
7030
|
+
runtimeTimers.staleSubagentRunsBoot = setTimeout(() => {
|
|
7031
|
+
runtimeTimers.staleSubagentRunsBoot = null;
|
|
7032
|
+
if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
|
|
7033
|
+
}, STALE_SUBAGENT_RUN_BOOT_DELAY_MS);
|
|
7034
|
+
if (typeof runtimeTimers.staleSubagentRunsBoot.unref === 'function') runtimeTimers.staleSubagentRunsBoot.unref();
|
|
7035
|
+
runtimeTimers.staleSubagentRuns = setInterval(() => {
|
|
7036
|
+
if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
|
|
7037
|
+
}, STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS);
|
|
7038
|
+
if (typeof runtimeTimers.staleSubagentRuns.unref === 'function') runtimeTimers.staleSubagentRuns.unref();
|
|
6868
7039
|
// Arms the 12h update poller only when the user opted in; otherwise silent.
|
|
6869
7040
|
updateCheckService?.startIfEnabled();
|
|
6870
7041
|
const runtimeBindingsBootstrapped = bootstrapRuntimeSessionBindings();
|
|
@@ -7067,6 +7238,10 @@ function touchCli() {
|
|
|
7067
7238
|
}
|
|
7068
7239
|
sessionWorkerWebSocketService.start();
|
|
7069
7240
|
void primePendingSessionWorkers('startup');
|
|
7241
|
+
// Undelivered events get their timers back; stalled, all_idle and the
|
|
7242
|
+
// heartbeat are swept from here on.
|
|
7243
|
+
relayEventService.start();
|
|
7244
|
+
relayEventLinkService.start();
|
|
7070
7245
|
runtimeTimers.pendingWorkerPrime = setInterval(() => {
|
|
7071
7246
|
void primePendingSessionWorkers('maintenance');
|
|
7072
7247
|
}, 5_000);
|
|
@@ -7113,6 +7288,10 @@ function socketAuthToken(socket) {
|
|
|
7113
7288
|
const cookies = parseCookies(socket.request.headers.cookie);
|
|
7114
7289
|
return socket.handshake.auth?.token || socket.handshake.query?.token || cookies[AUTH_COOKIE];
|
|
7115
7290
|
}
|
|
7291
|
+
// A sub-agent's record goes live only to the pages whose viewer shows it.
|
|
7292
|
+
const subagentViewerRooms = createSubagentViewerRooms({
|
|
7293
|
+
getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
|
|
7294
|
+
});
|
|
7116
7295
|
io.use((socket, next) => {
|
|
7117
7296
|
if (tokensMatch(socketAuthToken(socket), config.authToken)) return next();
|
|
7118
7297
|
next(new Error('Unauthorized'));
|
|
@@ -7150,6 +7329,7 @@ io.on('connection', (socket) => {
|
|
|
7150
7329
|
// deviceVisible flag restored by connectionStateRecovery.
|
|
7151
7330
|
activeDeviceTracker.registerSocket(socket);
|
|
7152
7331
|
tmuxInspectorSocketService.registerSocket(socket);
|
|
7332
|
+
subagentViewerRooms.registerSocket(socket);
|
|
7153
7333
|
});
|
|
7154
7334
|
|
|
7155
7335
|
// ─── SSH Reverse Tunnel ────────────────────────────────────────────────────────
|
|
@@ -7259,6 +7439,7 @@ function clearRuntimeTimers() {
|
|
|
7259
7439
|
if (!timer) continue;
|
|
7260
7440
|
try { clearInterval(timer); } catch {}
|
|
7261
7441
|
}
|
|
7442
|
+
subagentRecordRetention?.stop();
|
|
7262
7443
|
runtimeTimers.cliStatus = null;
|
|
7263
7444
|
runtimeTimers.ownerWatchdog = null;
|
|
7264
7445
|
runtimeTimers.staleRecovery = null;
|
|
@@ -7306,6 +7487,8 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
7306
7487
|
console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
|
|
7307
7488
|
}
|
|
7308
7489
|
sessionWorkerWebSocketService.stop();
|
|
7490
|
+
try { relayEventService.stop(); } catch {}
|
|
7491
|
+
try { relayEventLinkService.stop(); } catch {}
|
|
7309
7492
|
tmuxInspectorSocketService.stop();
|
|
7310
7493
|
stopWorkspaceFileWatcher();
|
|
7311
7494
|
sshTunnelManager.stop();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The one rule set for a file or folder name typed in the explorer: a single
|
|
2
|
+
// path segment, nothing that could leave the parent, and on Windows nothing
|
|
3
|
+
// the filesystem itself refuses. The Windows set applies only when the relay
|
|
4
|
+
// runs there; a Linux name such as `a:b` is legal and stays legal.
|
|
5
|
+
|
|
6
|
+
const MAX_NAME_BYTES = 255;
|
|
7
|
+
const WINDOWS_FORBIDDEN_CHARS = /[<>:"|?*\u0000-\u001f]/;
|
|
8
|
+
const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate a name. Returns `{ ok: true, name }` with the trimmed name, or
|
|
12
|
+
* `{ ok: false, error }` with a message fit for the UI.
|
|
13
|
+
*/
|
|
14
|
+
export function validateEntryName(rawName, { platform = process.platform } = {}) {
|
|
15
|
+
const name = String(rawName ?? '').trim();
|
|
16
|
+
if (!name) return { ok: false, error: 'Enter a name' };
|
|
17
|
+
if (name === '.' || name === '..') return { ok: false, error: 'That name is not allowed' };
|
|
18
|
+
if (/[/\\\0]/.test(name)) return { ok: false, error: 'A name cannot contain / or \\' };
|
|
19
|
+
if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) return { ok: false, error: 'That name is too long' };
|
|
20
|
+
if (platform === 'win32') { // host-platform: Windows filesystem naming rules
|
|
21
|
+
if (WINDOWS_FORBIDDEN_CHARS.test(name)) return { ok: false, error: 'A name cannot contain < > : " | ? *' };
|
|
22
|
+
if (/[. ]$/.test(name)) return { ok: false, error: 'A name cannot end with a dot or a space' };
|
|
23
|
+
if (WINDOWS_RESERVED_NAMES.test(name)) return { ok: false, error: 'That name is reserved by Windows' };
|
|
24
|
+
}
|
|
25
|
+
return { ok: true, name };
|
|
26
|
+
}
|