@oar-sh/oar 0.9.3 → 0.9.4
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 +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +92 -25
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +98 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
|
@@ -40,6 +40,13 @@ import { registerRelayBoardRoutes } from './routes/relay-board-routes.mjs';
|
|
|
40
40
|
import { registerCacheRoutes } from './routes/cache-routes.mjs';
|
|
41
41
|
import { registerGitRoutes } from './routes/git-routes.mjs';
|
|
42
42
|
import { registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
43
|
+
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
44
|
+
import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
|
|
45
|
+
import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
|
|
46
|
+
import { createRemoteRelayRegistry } from './services/remote-relay-registry-service.mjs';
|
|
47
|
+
import { createRemoteRelayPairing } from './services/remote-relay-pairing.mjs';
|
|
48
|
+
import { createRemoteRelayCallerContext } from './services/remote-relay-caller-context.mjs';
|
|
49
|
+
import { createRemoteRelayDispatcher } from './services/remote-relay-dispatcher.mjs';
|
|
43
50
|
import { createDeleteArchiveService } from './services/delete-archive-service.mjs';
|
|
44
51
|
import { createStatusEventService } from './services/status-event-service.mjs';
|
|
45
52
|
import { createClaudeAuthService } from './services/claude-auth-service.mjs';
|
|
@@ -48,6 +55,9 @@ import { createGrokAuthService } from './services/grok-auth-service.mjs';
|
|
|
48
55
|
import { sweepUnreferencedUploads, UNREFERENCED_UPLOADS_QUERY } from './services/upload-sweep.mjs';
|
|
49
56
|
import webpush from 'web-push';
|
|
50
57
|
import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
|
|
58
|
+
import { createHostSuspendService } from './services/host-suspend-service.mjs';
|
|
59
|
+
import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
|
|
60
|
+
import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
|
|
51
61
|
import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
|
|
52
62
|
import { registerPushRoutes } from './routes/push-routes.mjs';
|
|
53
63
|
import { createImageOperationService } from './services/image-operation-service.mjs';
|
|
@@ -3136,6 +3146,36 @@ function getRelayShutdownState() {
|
|
|
3136
3146
|
};
|
|
3137
3147
|
}
|
|
3138
3148
|
|
|
3149
|
+
// Every client shows a banner while a shutdown/restart is queued, so the
|
|
3150
|
+
// state goes out on each transition (request, cancel, drain) and on connect.
|
|
3151
|
+
function relayShutdownStatePayload() {
|
|
3152
|
+
return { ...getRelayShutdownState(), queue: queueCounts() };
|
|
3153
|
+
}
|
|
3154
|
+
function emitRelayShutdownState() {
|
|
3155
|
+
try { io.emit('relay_shutdown_state', relayShutdownStatePayload()); } catch {}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
3159
|
+
if (runtimeShutdownStarted) {
|
|
3160
|
+
return { cancelled: false, status: 'shutting_down', ...relayShutdownStatePayload() };
|
|
3161
|
+
}
|
|
3162
|
+
if (!pendingRelayShutdownRequest) {
|
|
3163
|
+
return { cancelled: false, ...relayShutdownStatePayload() };
|
|
3164
|
+
}
|
|
3165
|
+
const dropped = pendingRelayShutdownRequest;
|
|
3166
|
+
pendingRelayShutdownRequest = null;
|
|
3167
|
+
if (runtimeTimers.shutdownDrain) {
|
|
3168
|
+
try { clearInterval(runtimeTimers.shutdownDrain); } catch {}
|
|
3169
|
+
runtimeTimers.shutdownDrain = null;
|
|
3170
|
+
}
|
|
3171
|
+
console.log(
|
|
3172
|
+
`${runtimeLogPrefix()}Relay ${dropped.action || 'shutdown'} request cancelled by `
|
|
3173
|
+
+ `${String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api'}`,
|
|
3174
|
+
);
|
|
3175
|
+
emitRelayShutdownState();
|
|
3176
|
+
return { cancelled: true, dropped, ...relayShutdownStatePayload() };
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3139
3179
|
function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
|
|
3140
3180
|
const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
|
|
3141
3181
|
const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
|
|
@@ -3188,12 +3228,14 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
|
|
|
3188
3228
|
);
|
|
3189
3229
|
sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
|
|
3190
3230
|
void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
|
|
3231
|
+
emitRelayShutdownState();
|
|
3191
3232
|
};
|
|
3192
3233
|
|
|
3193
3234
|
if (!runtimeTimers.shutdownDrain) {
|
|
3194
3235
|
runtimeTimers.shutdownDrain = setInterval(tryShutdownWhenIdle, 1000);
|
|
3195
3236
|
}
|
|
3196
3237
|
tryShutdownWhenIdle();
|
|
3238
|
+
if (pendingRelayShutdownRequest) emitRelayShutdownState();
|
|
3197
3239
|
const counts = queueCounts();
|
|
3198
3240
|
return {
|
|
3199
3241
|
accepted: true,
|
|
@@ -4883,6 +4925,98 @@ const backgroundTaskStore = {
|
|
|
4883
4925
|
},
|
|
4884
4926
|
};
|
|
4885
4927
|
|
|
4928
|
+
// ─── Deferred host suspend ───────────────────────────────────────────────────
|
|
4929
|
+
// "💤 Suspend host" waits until nothing runs any more: no queued/processing
|
|
4930
|
+
// turn, no live background task of a live worker, no open CI run in a
|
|
4931
|
+
// workspace that was busy since the request. The relay-side signals are read
|
|
4932
|
+
// synchronously each tick; CI is refreshed in the background and read from
|
|
4933
|
+
// the last snapshot (a never-checked workspace counts as busy).
|
|
4934
|
+
const hostActivityWorkerStatuses = new Set(['starting', 'ready', 'processing']);
|
|
4935
|
+
function hostActivityPidAlive(pidValue) {
|
|
4936
|
+
const pid = Number(pidValue);
|
|
4937
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
4938
|
+
try {
|
|
4939
|
+
process.kill(pid, 0);
|
|
4940
|
+
return true;
|
|
4941
|
+
} catch (error) {
|
|
4942
|
+
return error?.code === 'EPERM';
|
|
4943
|
+
}
|
|
4944
|
+
}
|
|
4945
|
+
function hostActivityWorkerAlive(key) {
|
|
4946
|
+
const worker = sessionWorkerRegistry?.getWorker?.(key)
|
|
4947
|
+
|| sessionWorkerRegistry?.getWorkerByConversationId?.(key)
|
|
4948
|
+
|| null;
|
|
4949
|
+
if (!worker) return false;
|
|
4950
|
+
return hostActivityWorkerStatuses.has(String(worker.status || '').trim().toLowerCase())
|
|
4951
|
+
&& hostActivityPidAlive(worker.pid);
|
|
4952
|
+
}
|
|
4953
|
+
const hostSuspendWatchedConversationIds = new Set();
|
|
4954
|
+
const githubCiActivityService = createGitHubCiActivityService({ logger: console });
|
|
4955
|
+
function hostActivityTitle(conversationId, fallbackTitle = null) {
|
|
4956
|
+
const title = String(fallbackTitle || stmts.getConv?.get?.(conversationId)?.title || '').trim();
|
|
4957
|
+
return title || String(conversationId || '').slice(0, 8);
|
|
4958
|
+
}
|
|
4959
|
+
function collectHostActivity({ request = null } = {}) {
|
|
4960
|
+
const blockers = [];
|
|
4961
|
+
const turnsByConversation = stmts.listActiveQueueCountsByConversation?.all?.() || [];
|
|
4962
|
+
for (const row of turnsByConversation) {
|
|
4963
|
+
const conversationId = String(row.conversation_id || '');
|
|
4964
|
+
if (!conversationId) continue;
|
|
4965
|
+
hostSuspendWatchedConversationIds.add(conversationId);
|
|
4966
|
+
const count = Number(row.cnt || 0);
|
|
4967
|
+
blockers.push({
|
|
4968
|
+
kind: 'turn',
|
|
4969
|
+
conversationId,
|
|
4970
|
+
title: hostActivityTitle(conversationId, row.title),
|
|
4971
|
+
count,
|
|
4972
|
+
detail: count > 1 ? `${count} turns running` : 'turn running',
|
|
4973
|
+
});
|
|
4974
|
+
}
|
|
4975
|
+
for (const [key, entry] of backgroundTaskStore.sets) {
|
|
4976
|
+
const tasks = Array.isArray(entry?.tasks) ? entry.tasks : [];
|
|
4977
|
+
if (!tasks.length || !hostActivityWorkerAlive(key)) continue;
|
|
4978
|
+
const worker = sessionWorkerRegistry?.getWorker?.(key) || sessionWorkerRegistry?.getWorkerByConversationId?.(key) || null;
|
|
4979
|
+
const conversationId = String(worker?.conversationId || key);
|
|
4980
|
+
hostSuspendWatchedConversationIds.add(conversationId);
|
|
4981
|
+
const what = tasks.slice(0, 3).map((task) => String(task?.description || task?.taskType || 'task').slice(0, 80)).join('; ');
|
|
4982
|
+
blockers.push({
|
|
4983
|
+
kind: 'background',
|
|
4984
|
+
conversationId,
|
|
4985
|
+
title: hostActivityTitle(conversationId),
|
|
4986
|
+
count: tasks.length,
|
|
4987
|
+
detail: `${tasks.length} background agent${tasks.length === 1 ? '' : 's'}: ${what}`,
|
|
4988
|
+
});
|
|
4989
|
+
}
|
|
4990
|
+
const runningSubagents = Number(stmts.countRunningSubagentRuns?.get?.()?.cnt || 0);
|
|
4991
|
+
// CI is only worth a network round-trip for workspaces that were busy since
|
|
4992
|
+
// the request (or, for the modal's peek, that are busy right now).
|
|
4993
|
+
const roots = [...hostSuspendWatchedConversationIds]
|
|
4994
|
+
.map((conversationId) => {
|
|
4995
|
+
try { return resolveConversationWorkspaceState({ conversationId })?.currentWorkspaceRootPath || ''; } catch { return ''; }
|
|
4996
|
+
})
|
|
4997
|
+
.filter(Boolean);
|
|
4998
|
+
if (roots.length) void githubCiActivityService.describeRuns(roots).catch(() => {});
|
|
4999
|
+
const ci = roots.length ? githubCiActivityService.snapshot(roots) : { repos: [], unknown: [], blockers: [] };
|
|
5000
|
+
blockers.push(...ci.blockers);
|
|
5001
|
+
if (!request && !hostSuspendService.getState().pending) hostSuspendWatchedConversationIds.clear();
|
|
5002
|
+
return { blockers, runningSubagents, ci };
|
|
5003
|
+
}
|
|
5004
|
+
const hostSuspendService = createHostSuspendService({
|
|
5005
|
+
collectActivity: collectHostActivity,
|
|
5006
|
+
runSuspend: () => runHostSuspendToRam({ logger: console }),
|
|
5007
|
+
logger: console,
|
|
5008
|
+
onStateChange: (state) => {
|
|
5009
|
+
if (state.status === 'idle') hostSuspendWatchedConversationIds.clear();
|
|
5010
|
+
try { io.emit('host_suspend_state', state); } catch {}
|
|
5011
|
+
},
|
|
5012
|
+
onSuspended: () => {
|
|
5013
|
+
void pushDispatchService.notifyHostSuspend({ body: 'All agents finished; the host is suspending now.' });
|
|
5014
|
+
},
|
|
5015
|
+
onDropped: (state, reason) => {
|
|
5016
|
+
void pushDispatchService.notifyHostSuspend({ body: `Queued host suspend dropped: relay ${reason || 'restarting'}.` });
|
|
5017
|
+
},
|
|
5018
|
+
});
|
|
5019
|
+
|
|
4886
5020
|
// The live turn among a conversation's processing rows. Mid-turn steering can
|
|
4887
5021
|
// leave several processing at once — the running turn, steered messages the
|
|
4888
5022
|
// CLI folded into it (no output of their own), a handed-off row still settling
|
|
@@ -5391,6 +5525,13 @@ const app = express();
|
|
|
5391
5525
|
// via config.trustProxy (e.g. a hop count or subnet).
|
|
5392
5526
|
app.set('trust proxy', config.trustProxy ?? 'loopback');
|
|
5393
5527
|
const httpServer = http.createServer(app);
|
|
5528
|
+
// Node closes an idle keep-alive connection after 5 s. A client that pools
|
|
5529
|
+
// connections (a fronting proxy such as Caddy or cloudflared, an API client)
|
|
5530
|
+
// can pick such a socket in the very moment the relay closes it; its request
|
|
5531
|
+
// is then reset before the relay reads a byte, and a POST is not replayed.
|
|
5532
|
+
// Holding idle connections longer than those pools do closes that window.
|
|
5533
|
+
httpServer.keepAliveTimeout = 65_000;
|
|
5534
|
+
httpServer.headersTimeout = 66_000;
|
|
5394
5535
|
httpServer.prependListener('request', (req, _res) => {
|
|
5395
5536
|
rewriteSocketIoRequestPath(req, remotePath);
|
|
5396
5537
|
});
|
|
@@ -6040,6 +6181,7 @@ const runtimeState = {
|
|
|
6040
6181
|
set relayPaused(value) { relayPaused = value; },
|
|
6041
6182
|
get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
|
|
6042
6183
|
get relayShutdown() { return getRelayShutdownState(); },
|
|
6184
|
+
get hostSuspend() { return hostSuspendService.getState(); },
|
|
6043
6185
|
get activeBridgeOwner() { return relayBridgeOwnerService.getOwner(); },
|
|
6044
6186
|
get workerWebSocketStatus() { return sessionWorkerWebSocketService.status(); },
|
|
6045
6187
|
get tmuxInspectorStatus() { return tmuxInspectorSocketService.status(); },
|
|
@@ -6056,6 +6198,82 @@ const windowsBootAutostartService = createWindowsBootAutostartService({
|
|
|
6056
6198
|
});
|
|
6057
6199
|
|
|
6058
6200
|
const gitChangesService = createGitChangesService();
|
|
6201
|
+
|
|
6202
|
+
// ─── Remote Relays ────────────────────────────────────────────────────────────
|
|
6203
|
+
// Other OAR relays this one is paired with, so its agents can work there. Built
|
|
6204
|
+
// before the shared route deps: the message, session and question routes read
|
|
6205
|
+
// the registry for mentions, unlocks and the inbound switch.
|
|
6206
|
+
// Null when migration 0005 failed this boot (db-schema retries it next boot):
|
|
6207
|
+
// remotes still list and pair, nothing unlocks until then.
|
|
6208
|
+
const remoteRelayRepository = (() => {
|
|
6209
|
+
try {
|
|
6210
|
+
return createRemoteRelayRepository(db);
|
|
6211
|
+
} catch (error) {
|
|
6212
|
+
console.warn(`[remote-relays] unlock storage unavailable: ${error?.message || error}`);
|
|
6213
|
+
return null;
|
|
6214
|
+
}
|
|
6215
|
+
})();
|
|
6216
|
+
const remoteRelayClient = createRemoteRelayClient({
|
|
6217
|
+
// A closure: the registry owning the instance id is declared just below.
|
|
6218
|
+
getOwnRelayId: () => remoteRelayRegistry.instanceId(),
|
|
6219
|
+
getOwnToken: () => config.authToken,
|
|
6220
|
+
});
|
|
6221
|
+
const remoteRelayRegistry = createRemoteRelayRegistry({
|
|
6222
|
+
readSetting: readAppSettingValue,
|
|
6223
|
+
writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
|
|
6224
|
+
client: remoteRelayClient,
|
|
6225
|
+
repository: remoteRelayRepository,
|
|
6226
|
+
getOwnToken: () => config.authToken,
|
|
6227
|
+
getSelfName: getPwaAppName,
|
|
6228
|
+
hostname: os.hostname(),
|
|
6229
|
+
platform: process.platform,
|
|
6230
|
+
version: RUNNING_VERSION,
|
|
6231
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
6232
|
+
logger: console,
|
|
6233
|
+
});
|
|
6234
|
+
const remoteRelayPairing = createRemoteRelayPairing({
|
|
6235
|
+
registry: remoteRelayRegistry,
|
|
6236
|
+
client: remoteRelayClient,
|
|
6237
|
+
getOwnToken: () => config.authToken,
|
|
6238
|
+
});
|
|
6239
|
+
// What the inbound routes need: no tokens, only ids, names and state.
|
|
6240
|
+
const remoteRelayInbound = {
|
|
6241
|
+
listRelays: () => remoteRelayRegistry.list().map(({ id, name, url, lastStatus, version }) => ({ id, name, url, lastStatus, version })),
|
|
6242
|
+
selfNames: () => [remoteRelayRegistry.selfName()],
|
|
6243
|
+
inboundEnabled: () => remoteRelayRegistry.getSelfSettings().inboundEnabled,
|
|
6244
|
+
recordUnlock: (conversationId, relayId, messageId) => remoteRelayRepository?.recordUnlock(conversationId, relayId, messageId) ?? false,
|
|
6245
|
+
describeRelay: (id) => {
|
|
6246
|
+
const relay = remoteRelayRegistry.get(id);
|
|
6247
|
+
return relay ? { id: relay.id, name: relay.name, online: relay.lastStatus === 'online', version: relay.version } : null;
|
|
6248
|
+
},
|
|
6249
|
+
repository: remoteRelayRepository,
|
|
6250
|
+
};
|
|
6251
|
+
// The remote_relay tool: who is calling (live turn, provider, model, mode,
|
|
6252
|
+
// hops) and the Allow / Deny card write actions need in ask/plan mode.
|
|
6253
|
+
const remoteRelayCallerContext = createRemoteRelayCallerContext({
|
|
6254
|
+
db,
|
|
6255
|
+
repository: remoteRelayRepository,
|
|
6256
|
+
questions: stmts,
|
|
6257
|
+
resolveLiveTurnQueueRow,
|
|
6258
|
+
formatQuestionRow,
|
|
6259
|
+
normalizeRelayMode,
|
|
6260
|
+
defaultRelayMode: DEFAULT_RELAY_MODE,
|
|
6261
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
6262
|
+
notifyQuestion: (question) => pushDispatchService.notifyQuestion?.(question),
|
|
6263
|
+
uuid: uuidv4,
|
|
6264
|
+
logger: console,
|
|
6265
|
+
});
|
|
6266
|
+
const remoteRelayDispatcher = createRemoteRelayDispatcher({
|
|
6267
|
+
registry: remoteRelayRegistry,
|
|
6268
|
+
client: remoteRelayClient,
|
|
6269
|
+
repository: remoteRelayRepository,
|
|
6270
|
+
getCallerContext: (conversationId) => remoteRelayCallerContext.getCallerContext(conversationId),
|
|
6271
|
+
requestApproval: (request) => remoteRelayCallerContext.requestApproval(request),
|
|
6272
|
+
emitStatusEvent: ({ type, ...details }) => statusEventService.recordEvent(type, details),
|
|
6273
|
+
resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
|
|
6274
|
+
logger: console,
|
|
6275
|
+
});
|
|
6276
|
+
|
|
6059
6277
|
const sharedRouteDeps = {
|
|
6060
6278
|
auth,
|
|
6061
6279
|
gitChangesService,
|
|
@@ -6239,6 +6457,10 @@ const sharedRouteDeps = {
|
|
|
6239
6457
|
getPwaAppName,
|
|
6240
6458
|
setPwaAppName,
|
|
6241
6459
|
requestRelayShutdown,
|
|
6460
|
+
cancelRelayShutdown,
|
|
6461
|
+
relayShutdownStatePayload,
|
|
6462
|
+
hostSuspendService,
|
|
6463
|
+
collectHostActivity,
|
|
6242
6464
|
markSharedViewerPresence,
|
|
6243
6465
|
getSharedWatcherCount,
|
|
6244
6466
|
statusEventService,
|
|
@@ -6253,6 +6475,7 @@ const sharedRouteDeps = {
|
|
|
6253
6475
|
windowsAutostartService,
|
|
6254
6476
|
windowsBootAutostartService,
|
|
6255
6477
|
pushDispatchService,
|
|
6478
|
+
remoteRelayInbound,
|
|
6256
6479
|
};
|
|
6257
6480
|
registerMessagesRoutes(app, sharedRouteDeps);
|
|
6258
6481
|
registerSessionsRoutes(app, sharedRouteDeps);
|
|
@@ -6265,6 +6488,12 @@ registerPushRoutes(app, {
|
|
|
6265
6488
|
pushDispatchService,
|
|
6266
6489
|
getPushVapidPublicKey: () => pushVapidKeys.publicKey,
|
|
6267
6490
|
});
|
|
6491
|
+
registerRemoteRelayRoutes(app, {
|
|
6492
|
+
auth,
|
|
6493
|
+
registry: remoteRelayRegistry,
|
|
6494
|
+
pairing: remoteRelayPairing,
|
|
6495
|
+
dispatcher: remoteRelayDispatcher,
|
|
6496
|
+
});
|
|
6268
6497
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
6269
6498
|
cliLastSeen = null;
|
|
6270
6499
|
const wasOnline = cliOnline;
|
|
@@ -6619,6 +6848,9 @@ io.on('connection', (socket) => {
|
|
|
6619
6848
|
});
|
|
6620
6849
|
// Send current CLI status immediately on connect
|
|
6621
6850
|
socket.emit('cli_status', { online: cliOnline });
|
|
6851
|
+
// Pending host suspend / relay restart banners must survive a reconnect.
|
|
6852
|
+
try { socket.emit('host_suspend_state', hostSuspendService.getState()); } catch {}
|
|
6853
|
+
try { socket.emit('relay_shutdown_state', relayShutdownStatePayload()); } catch {}
|
|
6622
6854
|
// Visibility heartbeats for push suppression; also clears a stale
|
|
6623
6855
|
// deviceVisible flag restored by connectionStateRecovery.
|
|
6624
6856
|
activeDeviceTracker.registerSocket(socket);
|
|
@@ -6753,6 +6985,9 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
6753
6985
|
runtimeShutdownStarted = true;
|
|
6754
6986
|
console.log(`${runtimeLogPrefix()}Runtime shutdown started (${reason}, exitCode=${runtimeShutdownExitCode})`);
|
|
6755
6987
|
clearRuntimeTimers();
|
|
6988
|
+
// A queued host suspend is in-memory only; drop it loudly rather than let
|
|
6989
|
+
// the next runtime forget about it in silence.
|
|
6990
|
+
try { hostSuspendService.dispose({ reason }); } catch {}
|
|
6756
6991
|
try { updateCheckService?.stop(); } catch {}
|
|
6757
6992
|
// Started here (flips the importer's closing flag synchronously) but awaited
|
|
6758
6993
|
// below before the transports close: fire-and-forget disposal let a
|
|
@@ -6781,6 +7016,7 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
6781
7016
|
sshTunnelManager.stop();
|
|
6782
7017
|
cloudflaredTunnelManager.stop();
|
|
6783
7018
|
previewHealthProbe.stop();
|
|
7019
|
+
try { remoteRelayRegistry.stopHealthLoop(); } catch {}
|
|
6784
7020
|
// Previews are in-memory by design: a restart is the only implicit cleanup,
|
|
6785
7021
|
// so every public link dies with the process.
|
|
6786
7022
|
previewRegistry.clear();
|
|
@@ -6913,6 +7149,8 @@ httpServer.listen(config.port, listenHost, () => {
|
|
|
6913
7149
|
// Start SSH tunnel after server is listening
|
|
6914
7150
|
sshTunnelManager.start();
|
|
6915
7151
|
cloudflaredTunnelManager.start();
|
|
7152
|
+
// Status, version and renames of paired relays; a first pass right away.
|
|
7153
|
+
remoteRelayRegistry.startHealthLoop();
|
|
6916
7154
|
// A preview-lane failure must never take the relay with it: the listener is
|
|
6917
7155
|
// optional infrastructure, so a bind error is logged and the relay carries on
|
|
6918
7156
|
// without previews.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Open GitHub Actions runs for a set of workspace roots, via the `gh` CLI the
|
|
7
|
+
* host already has (same credentials the agents use). Nothing in the relay
|
|
8
|
+
* models CI, so this is a best-effort outside view: a repo whose runs cannot
|
|
9
|
+
* be read counts as "unknown" and blocks for a grace period, after which it is
|
|
10
|
+
* ignored rather than keeping the host awake forever over a GitHub outage.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CACHE_MS = 20 * 1000;
|
|
14
|
+
const DEFAULT_UNKNOWN_GRACE_MS = 15 * 60 * 1000;
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 20 * 1000;
|
|
16
|
+
const RUN_LIST_LIMIT = 30;
|
|
17
|
+
|
|
18
|
+
const OPEN_RUN_STATUSES = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
|
|
19
|
+
|
|
20
|
+
/** `git@github.com:o/r.git`, `https://github.com/o/r`, `ssh://git@github.com/o/r.git` → `o/r`. */
|
|
21
|
+
export function parseGitHubRepoSlug(remoteUrl) {
|
|
22
|
+
const text = String(remoteUrl || '').trim();
|
|
23
|
+
if (!text) return null;
|
|
24
|
+
const match = text.match(/github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
return `${match[1]}/${match[2]}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isOpenCiRunStatus(status) {
|
|
30
|
+
const value = String(status || '').trim().toLowerCase();
|
|
31
|
+
if (!value) return false;
|
|
32
|
+
return OPEN_RUN_STATUSES.has(value) || value !== 'completed';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function execText(execFileImpl, file, args, { cwd = undefined, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
try {
|
|
39
|
+
execFileImpl(file, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
if (error) {
|
|
43
|
+
const detail = String(stderr || error.message || '').trim().split('\n')[0] || 'command failed';
|
|
44
|
+
reject(new Error(detail.slice(0, 200)));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolve(String(stdout || ''));
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
reject(error);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createGitHubCiActivityService({
|
|
58
|
+
execFileImpl = execFile,
|
|
59
|
+
now = () => Date.now(),
|
|
60
|
+
cacheMs = DEFAULT_CACHE_MS,
|
|
61
|
+
unknownGraceMs = DEFAULT_UNKNOWN_GRACE_MS,
|
|
62
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
63
|
+
logger = console,
|
|
64
|
+
} = {}) {
|
|
65
|
+
// root → { slug: string|null, at }
|
|
66
|
+
const slugByRoot = new Map();
|
|
67
|
+
// slug → { runs, error, at, unknownSince, inFlight }
|
|
68
|
+
const runsBySlug = new Map();
|
|
69
|
+
|
|
70
|
+
async function resolveSlug(root) {
|
|
71
|
+
const key = String(root || '').trim();
|
|
72
|
+
if (!key) return null;
|
|
73
|
+
const cached = slugByRoot.get(key);
|
|
74
|
+
if (cached && cached.at !== null && now() - cached.at < 10 * cacheMs) return cached.slug;
|
|
75
|
+
let slug = null;
|
|
76
|
+
try {
|
|
77
|
+
const url = await execText(execFileImpl, 'git', ['-C', key, 'remote', 'get-url', 'origin'], { timeoutMs });
|
|
78
|
+
slug = parseGitHubRepoSlug(url);
|
|
79
|
+
} catch {
|
|
80
|
+
slug = null;
|
|
81
|
+
}
|
|
82
|
+
slugByRoot.set(key, { slug, at: now() });
|
|
83
|
+
return slug;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function fetchRuns(slug) {
|
|
87
|
+
const entry = runsBySlug.get(slug) || { runs: [], error: null, at: null, unknownSince: null, inFlight: null };
|
|
88
|
+
runsBySlug.set(slug, entry);
|
|
89
|
+
if (entry.inFlight) return entry.inFlight;
|
|
90
|
+
if (entry.at !== null && now() - entry.at < cacheMs) return entry;
|
|
91
|
+
entry.inFlight = (async () => {
|
|
92
|
+
try {
|
|
93
|
+
const out = await execText(execFileImpl, 'gh', [
|
|
94
|
+
'run', 'list', '-R', slug, '--limit', String(RUN_LIST_LIMIT),
|
|
95
|
+
'--json', 'status,name,headBranch,databaseId,event',
|
|
96
|
+
], { timeoutMs });
|
|
97
|
+
const parsed = JSON.parse(out || '[]');
|
|
98
|
+
entry.runs = (Array.isArray(parsed) ? parsed : [])
|
|
99
|
+
.filter((run) => run && isOpenCiRunStatus(run.status))
|
|
100
|
+
.map((run) => ({
|
|
101
|
+
id: run.databaseId ?? null,
|
|
102
|
+
name: String(run.name || 'workflow').slice(0, 120),
|
|
103
|
+
headBranch: String(run.headBranch || '').slice(0, 160),
|
|
104
|
+
status: String(run.status || '').slice(0, 40),
|
|
105
|
+
event: String(run.event || '').slice(0, 40),
|
|
106
|
+
}));
|
|
107
|
+
entry.error = null;
|
|
108
|
+
entry.unknownSince = null;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
entry.error = String(error?.message || error || 'gh failed').slice(0, 200);
|
|
111
|
+
if (entry.unknownSince === null) entry.unknownSince = now();
|
|
112
|
+
try { logger.warn?.(`[host-suspend] CI state for ${slug} unknown: ${entry.error}`); } catch {}
|
|
113
|
+
} finally {
|
|
114
|
+
entry.at = now();
|
|
115
|
+
entry.inFlight = null;
|
|
116
|
+
}
|
|
117
|
+
return entry;
|
|
118
|
+
})();
|
|
119
|
+
return entry.inFlight;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string[]} workspaceRoots
|
|
124
|
+
* @returns {Promise<{ repos: Array<{ repo, runs }>, unknown: Array<{ repo, error, since, ignored }>, blockers: Array<object> }>}
|
|
125
|
+
*/
|
|
126
|
+
async function describeRuns(workspaceRoots = []) {
|
|
127
|
+
const roots = [...new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((r) => String(r || '').trim()).filter(Boolean))];
|
|
128
|
+
const slugs = new Set();
|
|
129
|
+
for (const root of roots) {
|
|
130
|
+
const slug = await resolveSlug(root);
|
|
131
|
+
if (slug) slugs.add(slug);
|
|
132
|
+
}
|
|
133
|
+
const repos = [];
|
|
134
|
+
const unknown = [];
|
|
135
|
+
const blockers = [];
|
|
136
|
+
for (const slug of slugs) {
|
|
137
|
+
const entry = await fetchRuns(slug);
|
|
138
|
+
if (entry.error) {
|
|
139
|
+
const since = entry.unknownSince === null ? now() : entry.unknownSince;
|
|
140
|
+
const ignored = now() - since >= unknownGraceMs;
|
|
141
|
+
unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
|
|
142
|
+
if (!ignored) {
|
|
143
|
+
blockers.push({ kind: 'ci-unknown', detail: `CI state of ${slug} unknown (${entry.error})`, title: slug });
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (entry.runs.length) {
|
|
148
|
+
repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
|
|
149
|
+
const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
|
|
150
|
+
blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { repos, unknown, blockers };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Last known picture without touching git/gh — for synchronous collectors
|
|
158
|
+
* that kick describeRuns() off in the background. A root or repo that has
|
|
159
|
+
* never been resolved yet reports a `ci-checking` blocker so the host does
|
|
160
|
+
* not sleep during the very first lookup.
|
|
161
|
+
*/
|
|
162
|
+
function snapshot(workspaceRoots = []) {
|
|
163
|
+
const blockers = [];
|
|
164
|
+
const repos = [];
|
|
165
|
+
const unknown = [];
|
|
166
|
+
const slugs = new Set();
|
|
167
|
+
let checking = 0;
|
|
168
|
+
// Several busy conversations usually share one workspace; count it once.
|
|
169
|
+
const roots = new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((root) => String(root || '').trim()));
|
|
170
|
+
for (const key of roots) {
|
|
171
|
+
if (!key) continue;
|
|
172
|
+
const cached = slugByRoot.get(key);
|
|
173
|
+
if (!cached) { checking += 1; continue; }
|
|
174
|
+
if (cached.slug) slugs.add(cached.slug);
|
|
175
|
+
}
|
|
176
|
+
for (const slug of slugs) {
|
|
177
|
+
const entry = runsBySlug.get(slug);
|
|
178
|
+
if (!entry || entry.at === null) { checking += 1; continue; }
|
|
179
|
+
if (entry.error) {
|
|
180
|
+
const since = entry.unknownSince === null ? now() : entry.unknownSince;
|
|
181
|
+
const ignored = now() - since >= unknownGraceMs;
|
|
182
|
+
unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
|
|
183
|
+
if (!ignored) blockers.push({ kind: 'ci-unknown', title: slug, detail: `CI state of ${slug} unknown (${entry.error})` });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (entry.runs.length) {
|
|
187
|
+
repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
|
|
188
|
+
const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
|
|
189
|
+
blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (checking > 0) {
|
|
193
|
+
blockers.push({ kind: 'ci-checking', count: checking, detail: `Checking CI state of ${checking} workspace${checking === 1 ? '' : 's'}` });
|
|
194
|
+
}
|
|
195
|
+
return { repos, unknown, blockers };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { describeRuns, snapshot, parseGitHubRepoSlug };
|
|
199
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The actual suspend-to-RAM call. Windows only: `SetSuspendState 0,0,0` puts
|
|
7
|
+
* the box into S3 (hibernation is not enabled on the host, so this never
|
|
8
|
+
* hibernates). Detached so the relay does not wait on it.
|
|
9
|
+
*
|
|
10
|
+
* `OAR_HOST_SUSPEND_DRY_RUN=1` logs instead of sleeping the machine, which is
|
|
11
|
+
* how the deferred-suspend flow gets exercised on a dev box.
|
|
12
|
+
*/
|
|
13
|
+
export function runHostSuspendToRam({
|
|
14
|
+
platform = process.platform,
|
|
15
|
+
spawnImpl = spawn,
|
|
16
|
+
dryRun = /^(1|true|yes|on)$/i.test(String(process.env.OAR_HOST_SUSPEND_DRY_RUN || '')),
|
|
17
|
+
logger = console,
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (platform !== 'win32') {
|
|
20
|
+
return { ok: false, statusCode: 501, error: 'Host suspend is only supported on Windows' };
|
|
21
|
+
}
|
|
22
|
+
const command = 'rundll32.exe powrprof.dll,SetSuspendState 0,0,0';
|
|
23
|
+
if (dryRun) {
|
|
24
|
+
try { logger.log?.(`[host-suspend] DRY RUN — would run: ${command}`); } catch {}
|
|
25
|
+
return { ok: true, command, dryRun: true };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const child = spawnImpl('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: 'ignore',
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
});
|
|
33
|
+
child.unref?.();
|
|
34
|
+
return { ok: true, command };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { ok: false, statusCode: 500, error: error?.message || 'Failed to launch host suspend command' };
|
|
37
|
+
}
|
|
38
|
+
}
|