@oar-sh/oar 0.9.8 → 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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- 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 +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -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 +91 -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 +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -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 +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- 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/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -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-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -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 +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -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/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One STALE ATTEMPT warning per superseded attempt, not one per write. A
|
|
4
|
+
// worker whose turn was requeued under it keeps streaming for the old
|
|
5
|
+
// attempt until it learns of the refusal, several writes a second (live:
|
|
6
|
+
// 696 lines in four minutes after one recovery). The first refusal of an
|
|
7
|
+
// attempt is logged in full; the rest are counted and summarized in one
|
|
8
|
+
// line once the attempt has been quiet for `quietMs`.
|
|
9
|
+
|
|
10
|
+
export const STALE_ATTEMPT_SUMMARY_QUIET_MS = 60_000;
|
|
11
|
+
const MAX_TRACKED_ATTEMPTS = 500;
|
|
12
|
+
|
|
13
|
+
function short(value) {
|
|
14
|
+
return String(value || '').trim().slice(0, 8) || 'none';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `warn(line)` writes one line (without timestamp). `now`, `setTimer` and
|
|
19
|
+
* `clearTimer` are injectable for tests.
|
|
20
|
+
*/
|
|
21
|
+
export function createStaleAttemptLogLimiter({
|
|
22
|
+
warn = (line) => console.warn(line),
|
|
23
|
+
now = () => Date.now(),
|
|
24
|
+
setTimer = (fn, ms) => {
|
|
25
|
+
const timer = setTimeout(fn, ms);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
return timer;
|
|
28
|
+
},
|
|
29
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
30
|
+
quietMs = STALE_ATTEMPT_SUMMARY_QUIET_MS,
|
|
31
|
+
maxTracked = MAX_TRACKED_ATTEMPTS,
|
|
32
|
+
} = {}) {
|
|
33
|
+
// `${messageId}:${attemptId}` → { messageId, attemptId, count, firstAt, lastAt, routes, current, status, timer }
|
|
34
|
+
const entries = new Map();
|
|
35
|
+
|
|
36
|
+
function summarize(key) {
|
|
37
|
+
const entry = entries.get(key);
|
|
38
|
+
if (!entry) return;
|
|
39
|
+
entries.delete(key);
|
|
40
|
+
if (entry.timer) clearTimer(entry.timer);
|
|
41
|
+
const more = entry.count - 1;
|
|
42
|
+
if (more <= 0) return;
|
|
43
|
+
const seconds = Math.max(1, Math.round((entry.lastAt - entry.firstAt) / 1000));
|
|
44
|
+
warn(`STALE ATTEMPT ${short(entry.messageId)} attempt=${short(entry.attemptId)}: ${more} more write${more === 1 ? '' : 's'} refused over ${seconds}s (routes ${[...entry.routes].join(',')}; current=${short(entry.current)} status=${entry.status || 'unknown'})`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function arm(key, entry) {
|
|
48
|
+
if (entry.timer) clearTimer(entry.timer);
|
|
49
|
+
entry.timer = setTimer(() => {
|
|
50
|
+
entry.timer = null;
|
|
51
|
+
summarize(key);
|
|
52
|
+
}, quietMs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A write of `attemptId` for `messageId` was refused because the row's
|
|
57
|
+
* attempt is `currentAttemptId`. Returns `{ logged, count }`.
|
|
58
|
+
*/
|
|
59
|
+
function note({ messageId, attemptId, currentAttemptId = '', route = 'unknown', status = '' } = {}) {
|
|
60
|
+
const key = `${String(messageId || '')}:${String(attemptId || '')}`;
|
|
61
|
+
const at = now();
|
|
62
|
+
const existing = entries.get(key);
|
|
63
|
+
if (existing) {
|
|
64
|
+
existing.count += 1;
|
|
65
|
+
existing.lastAt = at;
|
|
66
|
+
existing.routes.add(String(route || 'unknown'));
|
|
67
|
+
existing.current = currentAttemptId;
|
|
68
|
+
existing.status = String(status || existing.status || '');
|
|
69
|
+
arm(key, existing);
|
|
70
|
+
return { logged: false, count: existing.count };
|
|
71
|
+
}
|
|
72
|
+
if (entries.size >= maxTracked) summarize(entries.keys().next().value);
|
|
73
|
+
const entry = {
|
|
74
|
+
messageId,
|
|
75
|
+
attemptId,
|
|
76
|
+
count: 1,
|
|
77
|
+
firstAt: at,
|
|
78
|
+
lastAt: at,
|
|
79
|
+
routes: new Set([String(route || 'unknown')]),
|
|
80
|
+
current: currentAttemptId,
|
|
81
|
+
status: String(status || ''),
|
|
82
|
+
timer: null,
|
|
83
|
+
};
|
|
84
|
+
entries.set(key, entry);
|
|
85
|
+
arm(key, entry);
|
|
86
|
+
warn(`STALE ATTEMPT ${short(messageId)} route=${route} claimed=${short(attemptId)} current=${short(currentAttemptId)} status=${status || 'unknown'} (further writes of this attempt are counted and summarized once it goes quiet)`);
|
|
87
|
+
return { logged: true, count: 1 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Writes every pending summary now (shutdown, tests). */
|
|
91
|
+
function flush() {
|
|
92
|
+
for (const key of [...entries.keys()]) summarize(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { note, flush, size: () => entries.size };
|
|
96
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Stale recovery after a wall-clock jump. The stale window is measured from
|
|
4
|
+
// the worker's last heartbeat: after the host slept for hours, every
|
|
5
|
+
// heartbeat is hours old the moment the relay's timer fires again, and the
|
|
6
|
+
// recovery requeued live turns (live: three messages recovered after a 6 h
|
|
7
|
+
// sleep, delivered a second time, and the live worker's replies refused as
|
|
8
|
+
// stale attempts, 696 warnings in 4 minutes).
|
|
9
|
+
//
|
|
10
|
+
// The guard notices the jump itself: the gap between two ticks of the
|
|
11
|
+
// recovery timer far larger than its interval (a sleep, a stopped VM, a
|
|
12
|
+
// blocked event loop, which stalls heartbeats just the same). After one, no
|
|
13
|
+
// recovery runs for a grace period, long enough for a live worker to
|
|
14
|
+
// heartbeat again; a dead worker's rows are recovered after it.
|
|
15
|
+
|
|
16
|
+
export const CLOCK_JUMP_FACTOR = 4;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `intervalMs` is the recovery timer's interval; a gap above
|
|
20
|
+
* `jumpThresholdMs` (default: four intervals) is a jump; `graceMs` is how
|
|
21
|
+
* long recovery then waits. `now()` is injectable for tests.
|
|
22
|
+
*/
|
|
23
|
+
export function createStaleRecoveryClockGuard({
|
|
24
|
+
now = () => Date.now(),
|
|
25
|
+
intervalMs,
|
|
26
|
+
jumpThresholdMs = null,
|
|
27
|
+
graceMs,
|
|
28
|
+
log = () => {},
|
|
29
|
+
} = {}) {
|
|
30
|
+
const interval = Math.max(1, Number(intervalMs) || 1);
|
|
31
|
+
const threshold = Number(jumpThresholdMs) > 0 ? Number(jumpThresholdMs) : interval * CLOCK_JUMP_FACTOR;
|
|
32
|
+
const grace = Math.max(0, Number(graceMs) || 0);
|
|
33
|
+
let lastTickMs = null;
|
|
34
|
+
let graceUntilMs = 0;
|
|
35
|
+
let inGrace = false;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One tick of the recovery timer. `{ skip, jumped, gapMs,
|
|
39
|
+
* graceRemainingMs }`: `skip` says recovery must not run now.
|
|
40
|
+
*/
|
|
41
|
+
function tick() {
|
|
42
|
+
const at = now();
|
|
43
|
+
const gapMs = lastTickMs === null ? 0 : Math.max(0, at - lastTickMs);
|
|
44
|
+
lastTickMs = at;
|
|
45
|
+
const jumped = gapMs > threshold;
|
|
46
|
+
if (jumped) {
|
|
47
|
+
graceUntilMs = Math.max(graceUntilMs, at + grace);
|
|
48
|
+
log(`Clock jump: ${Math.round(gapMs / 1000)}s passed between stale-recovery ticks (interval ${Math.round(interval / 1000)}s); stale recovery paused for ${Math.round(grace / 1000)}s so live workers can report`);
|
|
49
|
+
}
|
|
50
|
+
const skip = at < graceUntilMs;
|
|
51
|
+
if (inGrace && !skip) log('Stale recovery resumed after the clock jump');
|
|
52
|
+
inGrace = skip;
|
|
53
|
+
return { skip, jumped, gapMs, graceRemainingMs: Math.max(0, graceUntilMs - at) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { tick, thresholdMs: threshold, graceMs: grace };
|
|
57
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sub-agent runs left `running` for good. A run's end is written by its
|
|
4
|
+
// worker (or by the worker-death and terminal-turn reconciliations); a run
|
|
5
|
+
// whose worker is gone without either leaves its row `running` forever, and
|
|
6
|
+
// such rows count as running work everywhere: the activity tracker, stalled
|
|
7
|
+
// and all_idle for a subscription to all sessions, the suspend-when-idle
|
|
8
|
+
// watchdog (live: rows two months old still `running`).
|
|
9
|
+
//
|
|
10
|
+
// The sweep closes them: a `running` row older than `minAgeMs` whose
|
|
11
|
+
// conversation has no live worker becomes `cancelled`. The age bound keeps a
|
|
12
|
+
// run that just started, with its worker still registering, out of it; the
|
|
13
|
+
// liveness check keeps a long background agent of a live worker in. It runs
|
|
14
|
+
// shortly after boot (the workers of the previous runtime have reconnected
|
|
15
|
+
// by then) and periodically.
|
|
16
|
+
|
|
17
|
+
export const STALE_SUBAGENT_RUN_MIN_AGE_MS = 10 * 60_000;
|
|
18
|
+
export const STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS = 10 * 60_000;
|
|
19
|
+
export const STALE_SUBAGENT_RUN_BOOT_DELAY_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
function toText(value) {
|
|
22
|
+
return String(value ?? '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `isConversationWorkerAlive(conversationId)` says whether a live worker owns
|
|
27
|
+
* the conversation. `now()` is injectable for tests.
|
|
28
|
+
*/
|
|
29
|
+
export function createStaleSubagentRunSweep({
|
|
30
|
+
db,
|
|
31
|
+
isConversationWorkerAlive,
|
|
32
|
+
now = () => Date.now(),
|
|
33
|
+
minAgeMs = STALE_SUBAGENT_RUN_MIN_AGE_MS,
|
|
34
|
+
log = (line) => console.log(line),
|
|
35
|
+
warn = (line) => console.warn(line),
|
|
36
|
+
} = {}) {
|
|
37
|
+
if (!db) throw new Error('createStaleSubagentRunSweep requires db');
|
|
38
|
+
if (typeof isConversationWorkerAlive !== 'function') throw new Error('createStaleSubagentRunSweep requires isConversationWorkerAlive');
|
|
39
|
+
|
|
40
|
+
let statements = null;
|
|
41
|
+
function prepare() {
|
|
42
|
+
if (statements) return statements;
|
|
43
|
+
try {
|
|
44
|
+
statements = {
|
|
45
|
+
listOld: db.prepare(`
|
|
46
|
+
SELECT id, conversation_id, started_at, updated_at
|
|
47
|
+
FROM subagent_runs
|
|
48
|
+
WHERE status = 'running' AND COALESCE(updated_at, started_at) < ?
|
|
49
|
+
ORDER BY conversation_id, started_at
|
|
50
|
+
`),
|
|
51
|
+
close: db.prepare(`
|
|
52
|
+
UPDATE subagent_runs
|
|
53
|
+
SET status = 'cancelled', updated_at = ?, completed_at = COALESCE(completed_at, ?)
|
|
54
|
+
WHERE id = ? AND status = 'running'
|
|
55
|
+
`),
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
statements = null;
|
|
59
|
+
}
|
|
60
|
+
return statements;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One pass. Returns `{ checked, closed, conversations }`. */
|
|
64
|
+
function sweep() {
|
|
65
|
+
const prepared = prepare();
|
|
66
|
+
if (!prepared) return { checked: 0, closed: 0, conversations: 0, skipped: 'unsupported' };
|
|
67
|
+
const at = now();
|
|
68
|
+
const cutoff = new Date(at - minAgeMs).toISOString();
|
|
69
|
+
let rows = [];
|
|
70
|
+
try {
|
|
71
|
+
rows = prepared.listOld.all(cutoff);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
warn(`STALE SUBAGENT RUNS sweep failed: ${error?.message || error}`);
|
|
74
|
+
return { checked: 0, closed: 0, conversations: 0, error: String(error?.message || error) };
|
|
75
|
+
}
|
|
76
|
+
if (!rows.length) return { checked: 0, closed: 0, conversations: 0 };
|
|
77
|
+
const aliveByConversation = new Map();
|
|
78
|
+
const alive = (conversationId) => {
|
|
79
|
+
if (!aliveByConversation.has(conversationId)) {
|
|
80
|
+
let live = false;
|
|
81
|
+
try { live = isConversationWorkerAlive(conversationId) === true; } catch { live = false; }
|
|
82
|
+
aliveByConversation.set(conversationId, live);
|
|
83
|
+
}
|
|
84
|
+
return aliveByConversation.get(conversationId);
|
|
85
|
+
};
|
|
86
|
+
const nowIso = new Date(at).toISOString();
|
|
87
|
+
const closedConversations = new Set();
|
|
88
|
+
let closed = 0;
|
|
89
|
+
let oldest = null;
|
|
90
|
+
db.transaction(() => {
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
const conversationId = toText(row.conversation_id);
|
|
93
|
+
if (alive(conversationId)) continue;
|
|
94
|
+
if (prepared.close.run(nowIso, nowIso, row.id).changes > 0) {
|
|
95
|
+
closed += 1;
|
|
96
|
+
closedConversations.add(conversationId);
|
|
97
|
+
const since = toText(row.updated_at) || toText(row.started_at);
|
|
98
|
+
if (since && (!oldest || since < oldest)) oldest = since;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
if (closed) {
|
|
103
|
+
log(`STALE SUBAGENT RUNS closed ${closed} run${closed === 1 ? '' : 's'} still marked running in ${closedConversations.size} conversation${closedConversations.size === 1 ? '' : 's'} without a live worker (oldest ${oldest})`);
|
|
104
|
+
}
|
|
105
|
+
return { checked: rows.length, closed, conversations: closedConversations.size };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { sweep };
|
|
109
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Retention for sub-agent records. After the configured number of days a
|
|
4
|
+
// finished run's tool results lose their text and its tool calls keep only
|
|
5
|
+
// their one-line summary; both keep their row, marked
|
|
6
|
+
// `{ pruned: true, prunedAt, originalChars, retentionDays }` so the viewer
|
|
7
|
+
// can say what happened. Thoughts, reply text, notes, child rows and the
|
|
8
|
+
// runs stay. The sweep runs at relay start and once a day, a few thousand
|
|
9
|
+
// rows per transaction with the event loop free in between, and logs one
|
|
10
|
+
// line. No VACUUM: SQLite reuses the freed pages.
|
|
11
|
+
|
|
12
|
+
import { subagentToolCallSummary } from '../../shared/subagent-timeline.mjs';
|
|
13
|
+
|
|
14
|
+
export const SUBAGENT_RETENTION_BATCH_SIZE = 2000;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const METADATA_MAX_CHARS = 4096;
|
|
17
|
+
|
|
18
|
+
function parseMetadata(raw) {
|
|
19
|
+
if (!raw) return {};
|
|
20
|
+
try {
|
|
21
|
+
const value = JSON.parse(raw);
|
|
22
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What a stripped entry keeps: text and metadata, from its stored row. */
|
|
29
|
+
export function prunedTimelineEntry(row, { prunedAt, retentionDays }) {
|
|
30
|
+
const original = String(row?.text ?? '');
|
|
31
|
+
const text = row?.kind === 'tool_call' ? subagentToolCallSummary(original) : '';
|
|
32
|
+
const marker = { pruned: true, prunedAt, originalChars: original.length, retentionDays };
|
|
33
|
+
let metadataJson = JSON.stringify({ ...parseMetadata(row?.metadata_json), ...marker });
|
|
34
|
+
if (metadataJson.length > METADATA_MAX_CHARS) metadataJson = JSON.stringify(marker);
|
|
35
|
+
return { text, metadataJson, freedChars: Math.max(0, original.length - text.length) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function columnExists(db, table, column) {
|
|
39
|
+
try {
|
|
40
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `getRetentionDays()` reads the setting (0 = off). `now()` and
|
|
48
|
+
* `yieldToLoop()` are injectable for tests.
|
|
49
|
+
*/
|
|
50
|
+
export function createSubagentRecordRetention({
|
|
51
|
+
db,
|
|
52
|
+
getRetentionDays,
|
|
53
|
+
now = () => Date.now(),
|
|
54
|
+
batchSize = SUBAGENT_RETENTION_BATCH_SIZE,
|
|
55
|
+
yieldToLoop = () => new Promise((resolve) => setImmediate(resolve)),
|
|
56
|
+
isStopped = () => false,
|
|
57
|
+
log = (line) => console.log(line),
|
|
58
|
+
warn = (line) => console.warn(line),
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (!db) throw new Error('createSubagentRecordRetention requires db');
|
|
61
|
+
if (typeof getRetentionDays !== 'function') throw new Error('createSubagentRecordRetention requires getRetentionDays');
|
|
62
|
+
|
|
63
|
+
let statements = null;
|
|
64
|
+
let running = null;
|
|
65
|
+
let scheduled = null;
|
|
66
|
+
|
|
67
|
+
function prepare() {
|
|
68
|
+
if (statements) return statements;
|
|
69
|
+
if (!columnExists(db, 'subagent_timeline', 'pruned_at')) return null;
|
|
70
|
+
statements = {
|
|
71
|
+
// The first condition repeats the partial index's WHERE word for word.
|
|
72
|
+
// A run still running keeps its whole record, however old.
|
|
73
|
+
candidates: db.prepare(`
|
|
74
|
+
SELECT t.id, t.kind, t.text, t.metadata_json
|
|
75
|
+
FROM subagent_timeline t
|
|
76
|
+
WHERE t.pruned_at IS NULL AND t.kind IN ('tool_call', 'tool_result')
|
|
77
|
+
AND t.updated_at < @cutoff
|
|
78
|
+
AND NOT EXISTS (
|
|
79
|
+
SELECT 1 FROM subagent_runs r
|
|
80
|
+
WHERE r.id = t.run_id AND r.status IN ('running', 'pending', 'processing')
|
|
81
|
+
)
|
|
82
|
+
ORDER BY t.updated_at
|
|
83
|
+
LIMIT @limit
|
|
84
|
+
`),
|
|
85
|
+
prune: db.prepare(`
|
|
86
|
+
UPDATE subagent_timeline
|
|
87
|
+
SET text = @text, metadata_json = @metadataJson, pruned_at = @prunedAt
|
|
88
|
+
WHERE id = @id AND pruned_at IS NULL
|
|
89
|
+
`),
|
|
90
|
+
};
|
|
91
|
+
return statements;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pruneBatch(cutoff, prunedAt, retentionDays) {
|
|
95
|
+
const { candidates, prune } = statements;
|
|
96
|
+
return db.transaction(() => {
|
|
97
|
+
const rows = candidates.all({ cutoff, limit: batchSize });
|
|
98
|
+
let pruned = 0;
|
|
99
|
+
let freedChars = 0;
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
const next = prunedTimelineEntry(row, { prunedAt, retentionDays });
|
|
102
|
+
pruned += prune.run({ id: row.id, text: next.text, metadataJson: next.metadataJson, prunedAt }).changes;
|
|
103
|
+
freedChars += next.freedChars;
|
|
104
|
+
}
|
|
105
|
+
return { seen: rows.length, pruned, freedChars };
|
|
106
|
+
})();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runSweep() {
|
|
110
|
+
const retentionDays = Number(getRetentionDays()) || 0;
|
|
111
|
+
if (retentionDays <= 0) return { retentionDays: 0, pruned: 0, freedChars: 0, batches: 0, skipped: 'off' };
|
|
112
|
+
if (!prepare()) return { retentionDays, pruned: 0, freedChars: 0, batches: 0, skipped: 'unsupported' };
|
|
113
|
+
const startedAt = now();
|
|
114
|
+
const cutoff = new Date(startedAt - retentionDays * DAY_MS).toISOString();
|
|
115
|
+
const prunedAt = new Date(startedAt).toISOString();
|
|
116
|
+
let pruned = 0;
|
|
117
|
+
let freedChars = 0;
|
|
118
|
+
let batches = 0;
|
|
119
|
+
for (;;) {
|
|
120
|
+
if (isStopped()) break;
|
|
121
|
+
const batch = pruneBatch(cutoff, prunedAt, retentionDays);
|
|
122
|
+
if (!batch.seen) break;
|
|
123
|
+
batches += 1;
|
|
124
|
+
pruned += batch.pruned;
|
|
125
|
+
freedChars += batch.freedChars;
|
|
126
|
+
if (batch.seen < batchSize) break;
|
|
127
|
+
await yieldToLoop();
|
|
128
|
+
}
|
|
129
|
+
const elapsedMs = Math.max(0, now() - startedAt);
|
|
130
|
+
log(pruned
|
|
131
|
+
? `SUBAGENT RETENTION stripped ${pruned} tool entr${pruned === 1 ? 'y' : 'ies'} (${freedChars} chars) older than ${retentionDays} d in ${batches} batch${batches === 1 ? '' : 'es'}, ${elapsedMs} ms`
|
|
132
|
+
: `SUBAGENT RETENTION nothing older than ${retentionDays} d to strip`);
|
|
133
|
+
return { retentionDays, pruned, freedChars, batches, cutoff };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One sweep at a time; a call while one runs gets that one. */
|
|
137
|
+
function sweep() {
|
|
138
|
+
if (running) return running;
|
|
139
|
+
running = runSweep()
|
|
140
|
+
.catch((error) => {
|
|
141
|
+
warn(`SUBAGENT RETENTION sweep failed: ${error?.message || error}`);
|
|
142
|
+
return { error: String(error?.message || error) };
|
|
143
|
+
})
|
|
144
|
+
.finally(() => { running = null; });
|
|
145
|
+
return running;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A sweep in `delayMs` (the relay's start, a changed setting); later calls move it. */
|
|
149
|
+
function scheduleSweep(delayMs = 0) {
|
|
150
|
+
if (scheduled) clearTimeout(scheduled);
|
|
151
|
+
scheduled = setTimeout(() => {
|
|
152
|
+
scheduled = null;
|
|
153
|
+
if (!isStopped()) void sweep();
|
|
154
|
+
}, Math.max(0, delayMs));
|
|
155
|
+
if (typeof scheduled.unref === 'function') scheduled.unref();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stop() {
|
|
159
|
+
if (scheduled) clearTimeout(scheduled);
|
|
160
|
+
scheduled = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { sweep, scheduleSweep, stop, DAY_MS };
|
|
164
|
+
}
|