@oar-sh/oar 0.9.10 → 0.9.12
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 +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Windows system programs by absolute path, and the bits of a process
|
|
2
|
+
// environment that decide whether a child can start at all.
|
|
3
|
+
//
|
|
4
|
+
// A relay started by the boot-time Scheduled Task (S4U, before anyone signs
|
|
5
|
+
// in) gets an environment built without a full profile: user PATH entries
|
|
6
|
+
// that refer to other variables can arrive unexpanded, and a bare `node`
|
|
7
|
+
// that a signed-in shell finds was not found there. Node looks a bare name
|
|
8
|
+
// up on PATH only, so the programs Windows ships are called by their full
|
|
9
|
+
// path under %SystemRoot%; one that is not there falls back to the bare
|
|
10
|
+
// name, which is what every call site did before.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const WINDOWS_DEFAULT_ROOT = 'C:\\Windows';
|
|
16
|
+
|
|
17
|
+
function text(value) {
|
|
18
|
+
return String(value == null ? '' : value).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The value of an environment variable, matched without regard to case.
|
|
23
|
+
* Windows names are case-insensitive, and a copied environment (`{ ...env }`)
|
|
24
|
+
* keeps whatever case it came with: `Path`, not `PATH`, on most machines.
|
|
25
|
+
*/
|
|
26
|
+
export function readEnvValue(env, name) {
|
|
27
|
+
if (!env || typeof env !== 'object') return '';
|
|
28
|
+
if (Object.prototype.hasOwnProperty.call(env, name)) return text(env[name]);
|
|
29
|
+
const wanted = String(name).toLowerCase();
|
|
30
|
+
for (const key of Object.keys(env)) {
|
|
31
|
+
if (key.toLowerCase() === wanted) return text(env[key]);
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The key PATH is stored under in `env` (`Path` on most Windows machines). */
|
|
37
|
+
export function envPathKey(env, platform = process.platform) {
|
|
38
|
+
if (env && typeof env === 'object') {
|
|
39
|
+
if (Object.prototype.hasOwnProperty.call(env, 'PATH')) return 'PATH';
|
|
40
|
+
if (platform === 'win32') {
|
|
41
|
+
const found = Object.keys(env).find((key) => key.toLowerCase() === 'path');
|
|
42
|
+
if (found) return found;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return platform === 'win32' ? 'Path' : 'PATH';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function windowsSystemRoot(env = process.env) {
|
|
49
|
+
return readEnvValue(env, 'SystemRoot') || readEnvValue(env, 'windir') || WINDOWS_DEFAULT_ROOT;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TOOL_SUBPATHS = Object.freeze({
|
|
53
|
+
'powershell.exe': ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Full path of a program that ships with Windows (`schtasks.exe`,
|
|
58
|
+
* `powershell.exe`, `taskkill.exe`, ...), or the bare name when the file is
|
|
59
|
+
* not where Windows keeps it.
|
|
60
|
+
*/
|
|
61
|
+
export function windowsSystemToolPath(name, {
|
|
62
|
+
env = process.env,
|
|
63
|
+
existsImpl = fs.existsSync,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const bare = text(name);
|
|
66
|
+
const fileName = /\.exe$/i.test(bare) ? bare : `${bare}.exe`;
|
|
67
|
+
const parts = TOOL_SUBPATHS[fileName.toLowerCase()] || ['System32', fileName];
|
|
68
|
+
const full = path.win32.join(windowsSystemRoot(env), ...parts);
|
|
69
|
+
try {
|
|
70
|
+
if (existsImpl(full)) return full;
|
|
71
|
+
} catch {}
|
|
72
|
+
return bare;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The command interpreter: %ComSpec%, else cmd.exe under %SystemRoot%. */
|
|
76
|
+
export function windowsCmdPath({ env = process.env, existsImpl = fs.existsSync } = {}) {
|
|
77
|
+
return readEnvValue(env, 'ComSpec') || windowsSystemToolPath('cmd.exe', { env, existsImpl });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One log line's worth of the environment a child was started with: PATH in
|
|
82
|
+
* full (the entries are what a failed lookup needs), SystemRoot and ComSpec.
|
|
83
|
+
*/
|
|
84
|
+
export function describeSpawnEnvironment(env = process.env, platform = process.platform) {
|
|
85
|
+
const pathValue = readEnvValue(env, 'PATH');
|
|
86
|
+
const parts = [`PATH=${pathValue || '(not set)'}`];
|
|
87
|
+
if (platform === 'win32') {
|
|
88
|
+
parts.push(`SystemRoot=${readEnvValue(env, 'SystemRoot') || '(not set)'}`);
|
|
89
|
+
parts.push(`ComSpec=${readEnvValue(env, 'ComSpec') || '(not set)'}`);
|
|
90
|
+
}
|
|
91
|
+
return parts.join(' ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What a failed execFile/execFileSync reports, on one line. */
|
|
95
|
+
export function describeExecError(error) {
|
|
96
|
+
if (!error) return 'unknown error';
|
|
97
|
+
const parts = [];
|
|
98
|
+
const code = error.code ?? error.status;
|
|
99
|
+
if (code !== undefined && code !== null) parts.push(`code=${code}`);
|
|
100
|
+
if (error.signal) parts.push(`signal=${error.signal}`);
|
|
101
|
+
const stderr = text(Buffer.isBuffer(error.stderr) ? error.stderr.toString('utf8') : error.stderr);
|
|
102
|
+
const message = text(error.message).split(/\r?\n/)[0];
|
|
103
|
+
if (message) parts.push(message);
|
|
104
|
+
if (stderr) parts.push(`stderr=${stderr.replace(/\s+/g, ' ').slice(0, 500)}`);
|
|
105
|
+
return parts.join(' ') || 'unknown error';
|
|
106
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The digest of a background-task set, as a worker's heartbeat carries it and
|
|
5
|
+
* the relay computes it for the set it holds: `{ count, digest }` over the
|
|
6
|
+
* sorted task ids only. Descriptions, summaries and token counts change every
|
|
7
|
+
* few seconds and are no reason to send the set again; membership is.
|
|
8
|
+
*/
|
|
9
|
+
export function backgroundTaskDigest(taskIds) {
|
|
10
|
+
const ids = [...new Set((Array.isArray(taskIds) ? taskIds : [])
|
|
11
|
+
.map((id) => String(id ?? "").trim())
|
|
12
|
+
.filter(Boolean))].sort();
|
|
13
|
+
return {
|
|
14
|
+
count: ids.length,
|
|
15
|
+
digest: createHash("sha256").update(ids.join("\n")).digest("hex").slice(0, 16),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A digest off the wire, or null when the sender gave none (it has no opinion). */
|
|
20
|
+
export function normalizeBackgroundTaskDigest(raw) {
|
|
21
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
22
|
+
const digest = String(raw.digest ?? "").trim();
|
|
23
|
+
const count = Number(raw.count);
|
|
24
|
+
if (!/^[0-9a-f]{16}$/.test(digest) || !Number.isInteger(count) || count < 0) return null;
|
|
25
|
+
return { count, digest };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sameBackgroundTaskDigest(a, b) {
|
|
29
|
+
return !!a && !!b && a.count === b.count && a.digest === b.digest;
|
|
30
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { backgroundTaskDigest } from "./background-task-digest.mjs";
|
|
2
|
+
|
|
1
3
|
export function createHeartbeatController({
|
|
2
4
|
api,
|
|
3
5
|
pollMs,
|
|
@@ -19,6 +21,18 @@ export function createHeartbeatController({
|
|
|
19
21
|
// `settleFailedHandled`, which `onSettleFailedHandled` receives.
|
|
20
22
|
getSettleFailed,
|
|
21
23
|
onSettleFailedHandled = () => {},
|
|
24
|
+
// Optional: the ids of the worker's live background tasks (an array, empty
|
|
25
|
+
// for none). The body then carries their digest, and a relay whose own set
|
|
26
|
+
// for this worker differs (it restarted, or a post was lost) answers
|
|
27
|
+
// `resyncBackgroundTasks`: `onResyncBackgroundTasks` publishes the full
|
|
28
|
+
// set again. It is asked again by every beat until the sets agree, so a
|
|
29
|
+
// failed publish needs no retry of its own. Workers that publish no task
|
|
30
|
+
// sets leave both unset; the relay reads a missing digest as no opinion.
|
|
31
|
+
getBackgroundTaskIds,
|
|
32
|
+
onResyncBackgroundTasks = () => {},
|
|
33
|
+
// Optional: called after every beat the relay answered, for work that
|
|
34
|
+
// waits for a reachable relay (posts kept after a failure).
|
|
35
|
+
onAnswered = () => {},
|
|
22
36
|
// requestPulse's window: one turn transition can flip the steering
|
|
23
37
|
// snapshot several times in quick succession, and each flip only needs to
|
|
24
38
|
// reach the relay once.
|
|
@@ -43,11 +57,15 @@ export function createHeartbeatController({
|
|
|
43
57
|
const settleFailed = typeof getSettleFailed === "function"
|
|
44
58
|
? (getSettleFailed() || []).filter((entry) => entry && String(entry.id || "").trim())
|
|
45
59
|
: [];
|
|
60
|
+
const backgroundTaskIds = typeof getBackgroundTaskIds === "function"
|
|
61
|
+
? getBackgroundTaskIds()
|
|
62
|
+
: null;
|
|
46
63
|
const body = {
|
|
47
64
|
...(activeQueueMessageId ? { activeQueueMessageId } : {}),
|
|
48
65
|
...(activeQueueMessageIds.length ? { activeQueueMessageIds } : {}),
|
|
49
66
|
...(steering && typeof steering === "object" ? { steering } : {}),
|
|
50
67
|
...(settleFailed.length ? { settleFailed } : {}),
|
|
68
|
+
...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
|
|
51
69
|
};
|
|
52
70
|
const response = await api("POST", "/api/heartbeat", body);
|
|
53
71
|
// Failed by the relay, or skipped because this worker has no claim on
|
|
@@ -59,6 +77,12 @@ export function createHeartbeatController({
|
|
|
59
77
|
if (handled.length) {
|
|
60
78
|
try { onSettleFailedHandled(handled); } catch {}
|
|
61
79
|
}
|
|
80
|
+
// Kept posts first: an end the relay has not heard of yet goes before
|
|
81
|
+
// the set that no longer lists the task.
|
|
82
|
+
try { await onAnswered(); } catch {}
|
|
83
|
+
if (response?.resyncBackgroundTasks === true) {
|
|
84
|
+
try { await onResyncBackgroundTasks(); } catch {}
|
|
85
|
+
}
|
|
62
86
|
return true;
|
|
63
87
|
} catch {
|
|
64
88
|
return false;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Posts that must not be lost to a relay that is away for a moment: a post
|
|
3
|
+
* that fails is kept and sent again once the relay answers (the worker's
|
|
4
|
+
* heartbeat calls `flush`). Bounded both ways, the newest `max` posts for
|
|
5
|
+
* `ttlMs`, because what they report is only worth telling for a while and a
|
|
6
|
+
* relay that stays away must not grow the worker.
|
|
7
|
+
*
|
|
8
|
+
* A post the relay refused (a 4xx answer) is not kept: sending it again
|
|
9
|
+
* would be refused again.
|
|
10
|
+
*/
|
|
11
|
+
export const KEPT_POSTS_MAX = 20;
|
|
12
|
+
export const KEPT_POSTS_TTL_MS = 10 * 60_000;
|
|
13
|
+
// A post that gets no answer is given up after this long and kept: fetch
|
|
14
|
+
// alone waits five minutes, and the heartbeat's resync waits for the flush.
|
|
15
|
+
export const KEPT_POSTS_TIMEOUT_MS = 10_000;
|
|
16
|
+
|
|
17
|
+
export function createKeptPosts({
|
|
18
|
+
api,
|
|
19
|
+
max = KEPT_POSTS_MAX,
|
|
20
|
+
ttlMs = KEPT_POSTS_TTL_MS,
|
|
21
|
+
timeoutMs = KEPT_POSTS_TIMEOUT_MS,
|
|
22
|
+
now = () => Date.now(),
|
|
23
|
+
dbg = () => {},
|
|
24
|
+
} = {}) {
|
|
25
|
+
let kept = [];
|
|
26
|
+
let flushing = null;
|
|
27
|
+
|
|
28
|
+
function worthKeeping(error) {
|
|
29
|
+
const status = Number(error?.status);
|
|
30
|
+
return !(status >= 400 && status < 500);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function send(routePath, body) {
|
|
34
|
+
return api("POST", routePath, body, timeoutMs > 0 ? { signal: AbortSignal.timeout(timeoutMs) } : {});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function keep(entry) {
|
|
38
|
+
kept.push(entry);
|
|
39
|
+
if (kept.length > max) kept = kept.slice(-max);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Post now; on a failure keep it for `flush`. Never rejects. */
|
|
43
|
+
async function post(routePath, body) {
|
|
44
|
+
try {
|
|
45
|
+
await send(routePath, body);
|
|
46
|
+
return true;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
dbg("post failed", routePath, error?.message || String(error));
|
|
49
|
+
if (worthKeeping(error)) keep({ routePath, body, at: now() });
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Send the kept posts again, oldest first; what fails again stays. */
|
|
55
|
+
function flush() {
|
|
56
|
+
if (flushing) return flushing;
|
|
57
|
+
if (!kept.length) return Promise.resolve(0);
|
|
58
|
+
flushing = (async () => {
|
|
59
|
+
const pending = kept;
|
|
60
|
+
kept = [];
|
|
61
|
+
let sent = 0;
|
|
62
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
63
|
+
const entry = pending[index];
|
|
64
|
+
if (now() - entry.at > ttlMs) continue;
|
|
65
|
+
try {
|
|
66
|
+
await send(entry.routePath, entry.body);
|
|
67
|
+
sent += 1;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
dbg("kept post failed again", entry.routePath, error?.message || String(error));
|
|
70
|
+
if (!worthKeeping(error)) continue;
|
|
71
|
+
// The relay is still away: the rest waits too, in order, ahead of
|
|
72
|
+
// whatever was kept meanwhile.
|
|
73
|
+
kept = [...pending.slice(index).filter((later) => now() - later.at <= ttlMs), ...kept].slice(-max);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return sent;
|
|
78
|
+
})().finally(() => { flushing = null; });
|
|
79
|
+
return flushing;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { post, flush, size: () => kept.length };
|
|
83
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The spool a session worker writes a background task's output into when the
|
|
2
|
+
// provider keeps none in a file of its own (Copilot's attached shells, Grok's
|
|
3
|
+
// terminals): `<os.tmpdir()>/oar-task-output/<sessionId>/<taskId>.log`,
|
|
4
|
+
// append-only. The worker names the file as the task row's `outputPath`
|
|
5
|
+
// (`outputKind: 'spool'`) and the relay reads it like any other output file,
|
|
6
|
+
// so the console works the same for every provider and outlives a worker
|
|
7
|
+
// restart.
|
|
8
|
+
//
|
|
9
|
+
// Bounded: a file stops growing at 50 MB with one "output capped" line, and
|
|
10
|
+
// `sweep()` (at worker start and hourly) deletes files that ended more than an
|
|
11
|
+
// hour ago. Nothing is deleted at exit: the relay keeps showing an ended
|
|
12
|
+
// task's output for about an hour.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const TASK_OUTPUT_SPOOL_DIR = 'oar-task-output';
|
|
19
|
+
export const TASK_OUTPUT_SPOOL_MAX_BYTES = 50 * 1024 * 1024;
|
|
20
|
+
export const TASK_OUTPUT_SPOOL_MAX_AGE_MS = 60 * 60 * 1000;
|
|
21
|
+
// A dead worker's session folder is left for its own next start to sweep;
|
|
22
|
+
// another session's worker removes it only once it is a day old.
|
|
23
|
+
export const TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
const CAPPED_LINE = '\n[output capped at 50 MB: later output is not kept]\n';
|
|
26
|
+
|
|
27
|
+
/** A file-name-safe form of an id: [A-Za-z0-9._-], at most 128 characters, never `.` or `..`. */
|
|
28
|
+
export function spoolSafeName(value) {
|
|
29
|
+
const text = String(value ?? '').trim().replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
|
|
30
|
+
return /^\.+$/.test(text) ? text.replace(/\./g, '_') : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `createTaskOutputSpool({ sessionId })` →
|
|
35
|
+
* `{ root, pathFor(taskId), append(taskId, text), end(taskId), sweep(maxAgeMs) }`.
|
|
36
|
+
*
|
|
37
|
+
* - `pathFor(taskId)`: the absolute file path for the task's row.
|
|
38
|
+
* - `append(taskId, text)`: `{ ok, bytes, capped }`; never throws. The cap
|
|
39
|
+
* writes one marker line and drops what comes after it.
|
|
40
|
+
* - `end(taskId)`: the task finished; its file stays for the relay until a
|
|
41
|
+
* sweep finds it older than the age limit.
|
|
42
|
+
* - `sweep(maxAgeMs = 1 h)`: deletes this session's files not written to for
|
|
43
|
+
* that long (a task still appending is never swept), and other sessions'
|
|
44
|
+
* folders a day old. Returns the number of files removed.
|
|
45
|
+
*
|
|
46
|
+
* `baseDir` (default the OS temp dir) and `now` are for tests.
|
|
47
|
+
*/
|
|
48
|
+
export function createTaskOutputSpool({
|
|
49
|
+
sessionId,
|
|
50
|
+
now = () => Date.now(),
|
|
51
|
+
baseDir = os.tmpdir(),
|
|
52
|
+
maxBytes = TASK_OUTPUT_SPOOL_MAX_BYTES,
|
|
53
|
+
fsImpl = fs,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const sessionName = spoolSafeName(sessionId);
|
|
56
|
+
if (!sessionName) throw new Error('createTaskOutputSpool requires sessionId');
|
|
57
|
+
const top = path.join(path.resolve(baseDir), TASK_OUTPUT_SPOOL_DIR);
|
|
58
|
+
const root = path.join(top, sessionName);
|
|
59
|
+
// taskId → { bytes, capped }; a task listed here is live (still appending).
|
|
60
|
+
const live = new Map();
|
|
61
|
+
let rootReady = false;
|
|
62
|
+
|
|
63
|
+
// The shared folder is ours to write and sweep only when it is a real
|
|
64
|
+
// folder (not a symlink someone else put into a shared temp dir) owned by
|
|
65
|
+
// this user where the platform says who owns it.
|
|
66
|
+
function topIsOurs() {
|
|
67
|
+
let stats;
|
|
68
|
+
try { stats = fsImpl.lstatSync(top); } catch { return false; }
|
|
69
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
|
|
70
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
71
|
+
return uid === null || typeof stats.uid !== 'number' || stats.uid === uid;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureRoot() {
|
|
75
|
+
if (rootReady) return;
|
|
76
|
+
// Owner-only: the temp dir is shared on a multi-user host.
|
|
77
|
+
try { fsImpl.mkdirSync(top, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
78
|
+
if (!topIsOurs()) throw new Error(`${top} is not a folder of this user`);
|
|
79
|
+
fsImpl.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
80
|
+
rootReady = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pathFor(taskId) {
|
|
84
|
+
const name = spoolSafeName(taskId);
|
|
85
|
+
if (!name) throw new Error('task id is empty');
|
|
86
|
+
return path.join(root, `${name}.log`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stateOf(taskId, file) {
|
|
90
|
+
let state = live.get(taskId);
|
|
91
|
+
if (!state) {
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
try { bytes = fsImpl.statSync(file).size; } catch {}
|
|
94
|
+
state = { bytes, capped: bytes >= maxBytes };
|
|
95
|
+
live.set(taskId, state);
|
|
96
|
+
}
|
|
97
|
+
return state;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function append(taskId, text) {
|
|
101
|
+
const id = String(taskId ?? '').trim();
|
|
102
|
+
const value = typeof text === 'string' ? text : (Buffer.isBuffer(text) ? text.toString('utf8') : String(text ?? ''));
|
|
103
|
+
if (!id) return { ok: false, bytes: 0, capped: false };
|
|
104
|
+
try {
|
|
105
|
+
ensureRoot();
|
|
106
|
+
const file = pathFor(id);
|
|
107
|
+
const state = stateOf(id, file);
|
|
108
|
+
if (state.capped || !value) return { ok: true, bytes: state.bytes, capped: state.capped };
|
|
109
|
+
let data = Buffer.from(value, 'utf8');
|
|
110
|
+
const room = maxBytes - state.bytes;
|
|
111
|
+
if (data.length > room) {
|
|
112
|
+
// Cut on a character boundary, then say so once.
|
|
113
|
+
let end = Math.max(0, room);
|
|
114
|
+
while (end > 0 && (data[end] & 0xc0) === 0x80) end -= 1;
|
|
115
|
+
data = Buffer.concat([data.subarray(0, end), Buffer.from(CAPPED_LINE, 'utf8')]);
|
|
116
|
+
state.capped = true;
|
|
117
|
+
}
|
|
118
|
+
fsImpl.appendFileSync(file, data, { mode: 0o600 });
|
|
119
|
+
state.bytes += data.length;
|
|
120
|
+
return { ok: true, bytes: state.bytes, capped: state.capped };
|
|
121
|
+
} catch {
|
|
122
|
+
return { ok: false, bytes: live.get(id)?.bytes || 0, capped: !!live.get(id)?.capped };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function end(taskId) {
|
|
127
|
+
live.delete(String(taskId ?? '').trim());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function liveNames() {
|
|
131
|
+
return new Set([...live.keys()].map((id) => `${spoolSafeName(id)}.log`));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sweepDir(dir, maxAge, skip = new Set()) {
|
|
135
|
+
let removed = 0;
|
|
136
|
+
let left = 0;
|
|
137
|
+
let entries = [];
|
|
138
|
+
try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return { removed, left: 0, missing: true }; }
|
|
139
|
+
const cutoff = now() - maxAge;
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!entry.isFile() || skip.has(entry.name)) {
|
|
142
|
+
left += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const file = path.join(dir, entry.name);
|
|
146
|
+
try {
|
|
147
|
+
if (fsImpl.statSync(file).mtimeMs < cutoff) {
|
|
148
|
+
fsImpl.unlinkSync(file);
|
|
149
|
+
removed += 1;
|
|
150
|
+
} else {
|
|
151
|
+
left += 1;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
left += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { removed, left, missing: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sweep(maxAgeMs = TASK_OUTPUT_SPOOL_MAX_AGE_MS) {
|
|
161
|
+
const maxAge = Number.isFinite(Number(maxAgeMs)) && Number(maxAgeMs) >= 0 ? Number(maxAgeMs) : TASK_OUTPUT_SPOOL_MAX_AGE_MS;
|
|
162
|
+
// Never through a symlinked or foreign shared folder: its "session
|
|
163
|
+
// folders" could be anything.
|
|
164
|
+
if (!topIsOurs()) return 0;
|
|
165
|
+
let removed = sweepDir(root, maxAge, liveNames()).removed;
|
|
166
|
+
let folders = [];
|
|
167
|
+
try { folders = fsImpl.readdirSync(top, { withFileTypes: true }); } catch {}
|
|
168
|
+
for (const folder of folders) {
|
|
169
|
+
if (!folder.isDirectory() || folder.name === sessionName) continue;
|
|
170
|
+
const dir = path.join(top, folder.name);
|
|
171
|
+
const result = sweepDir(dir, Math.max(maxAge, TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS));
|
|
172
|
+
removed += result.removed;
|
|
173
|
+
if (!result.missing && result.left === 0) {
|
|
174
|
+
try { fsImpl.rmdirSync(dir); } catch {}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return removed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { root, pathFor, append, end, sweep };
|
|
181
|
+
}
|
|
@@ -84,6 +84,8 @@ export function createWorkerWebSocketLink({
|
|
|
84
84
|
let readyRefreshTimer = null;
|
|
85
85
|
let reconnectAttempt = 0;
|
|
86
86
|
let reconnectDelayMs = 0;
|
|
87
|
+
// Ends the current socket's connection (see connect).
|
|
88
|
+
let endCurrentSocket = null;
|
|
87
89
|
// Insertion-ordered: the first entry is the oldest in-flight delivery, which
|
|
88
90
|
// is what a legacy worker's coalesced delivery waits on.
|
|
89
91
|
const deliveriesInFlight = new Set();
|
|
@@ -246,7 +248,11 @@ export function createWorkerWebSocketLink({
|
|
|
246
248
|
function closeStaleSocket(reason) {
|
|
247
249
|
if (!isSocketOpen()) return false;
|
|
248
250
|
dbg("worker ws stale connection closing", reason);
|
|
249
|
-
|
|
251
|
+
const socket = ws;
|
|
252
|
+
// A dead peer may never complete the close handshake: the reconnect does
|
|
253
|
+
// not wait for the close event.
|
|
254
|
+
endCurrentSocket?.(`stale:${reason}`);
|
|
255
|
+
try { socket?.close?.(); } catch {}
|
|
250
256
|
return true;
|
|
251
257
|
}
|
|
252
258
|
|
|
@@ -286,7 +292,9 @@ export function createWorkerWebSocketLink({
|
|
|
286
292
|
|
|
287
293
|
function scheduleReconnect() {
|
|
288
294
|
if (stopped) return;
|
|
289
|
-
|
|
295
|
+
// One timer at a time: a second end reported for the same outage must not
|
|
296
|
+
// stack another attempt or push the backoff up.
|
|
297
|
+
if (reconnectTimer) return;
|
|
290
298
|
reconnectAttempt += 1;
|
|
291
299
|
const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
|
|
292
300
|
const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
|
|
@@ -309,10 +317,34 @@ export function createWorkerWebSocketLink({
|
|
|
309
317
|
dbg("worker ws unavailable: global WebSocket is missing");
|
|
310
318
|
return;
|
|
311
319
|
}
|
|
320
|
+
clearReconnectTimer();
|
|
312
321
|
const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
|
|
313
|
-
|
|
322
|
+
const socket = new WebSocketImpl(url);
|
|
323
|
+
ws = socket;
|
|
314
324
|
let serverHelloSeen = false;
|
|
315
|
-
|
|
325
|
+
// Set once this socket's end has been handled, by whichever of error and
|
|
326
|
+
// close comes first; every later event from it is ignored. Node 22 fires
|
|
327
|
+
// no close for a socket closed while still connecting, and closing it
|
|
328
|
+
// from its own error handler raised the error again, so the reconnect
|
|
329
|
+
// has to be scheduled from the error as well, and only once.
|
|
330
|
+
let ended = false;
|
|
331
|
+
function endSocket(reason) {
|
|
332
|
+
if (ended) return false;
|
|
333
|
+
ended = true;
|
|
334
|
+
if (ws === socket) {
|
|
335
|
+
ws = null;
|
|
336
|
+
clearReadyRefreshTimer();
|
|
337
|
+
// The next connection may reach a different (restarted) relay.
|
|
338
|
+
serverCapabilities = new Set();
|
|
339
|
+
}
|
|
340
|
+
if (stopped) return true;
|
|
341
|
+
dbg("worker ws disconnected", reason);
|
|
342
|
+
scheduleReconnect();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
endCurrentSocket = endSocket;
|
|
346
|
+
socket.addEventListener("open", () => {
|
|
347
|
+
if (ended) return;
|
|
316
348
|
const openedAt = getNowMs();
|
|
317
349
|
reconnectAttempt = 0;
|
|
318
350
|
reconnectDelayMs = 0;
|
|
@@ -323,7 +355,8 @@ export function createWorkerWebSocketLink({
|
|
|
323
355
|
void notifyReady("ws-open");
|
|
324
356
|
startReadyRefreshTimer();
|
|
325
357
|
});
|
|
326
|
-
|
|
358
|
+
socket.addEventListener("message", (event) => {
|
|
359
|
+
if (ended) return;
|
|
327
360
|
lastMessageAt = getNowMs();
|
|
328
361
|
let payload = null;
|
|
329
362
|
try {
|
|
@@ -355,7 +388,7 @@ export function createWorkerWebSocketLink({
|
|
|
355
388
|
}
|
|
356
389
|
if (payload?.type === "server.draining") {
|
|
357
390
|
dbg("worker ws draining", payload.reason || "relay-shutdown");
|
|
358
|
-
try {
|
|
391
|
+
try { socket.close?.(); } catch {}
|
|
359
392
|
return;
|
|
360
393
|
}
|
|
361
394
|
if (payload?.type === "queue.changed") {
|
|
@@ -375,17 +408,16 @@ export function createWorkerWebSocketLink({
|
|
|
375
408
|
dbg("worker ws blocked", payload.reason || "blocked");
|
|
376
409
|
}
|
|
377
410
|
});
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// The next connection may reach a different (restarted) relay.
|
|
381
|
-
serverCapabilities = new Set();
|
|
382
|
-
if (stopped) return;
|
|
383
|
-
dbg("worker ws disconnected");
|
|
384
|
-
scheduleReconnect();
|
|
411
|
+
socket.addEventListener("close", () => {
|
|
412
|
+
endSocket("close");
|
|
385
413
|
});
|
|
386
|
-
|
|
414
|
+
socket.addEventListener("error", (error) => {
|
|
415
|
+
if (ended) return;
|
|
387
416
|
dbg("worker ws error", error?.message || String(error));
|
|
388
|
-
|
|
417
|
+
// Ended before the close call, so an error or close it raises
|
|
418
|
+
// (synchronously on some runtimes) finds the socket already handled.
|
|
419
|
+
endSocket("error");
|
|
420
|
+
try { socket.close?.(); } catch {}
|
|
389
421
|
});
|
|
390
422
|
}
|
|
391
423
|
|