@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
|
@@ -209,12 +209,66 @@ export function startCursorRun({
|
|
|
209
209
|
};
|
|
210
210
|
// A tool call is reported on both surfaces (the delta hook and the message
|
|
211
211
|
// stream) under the same id; either one opens and closes it.
|
|
212
|
+
//
|
|
213
|
+
// Not every tool call is reported. The SDK translates only the tools it has
|
|
214
|
+
// a type for and drops the rest on both surfaces: among them the wait on a
|
|
215
|
+
// background shell (`awaitToolCall`), web fetches and input to a running
|
|
216
|
+
// shell. All a dropped call leaves is the step's `tool-requests-listed`
|
|
217
|
+
// count, sent once the model has finished requesting tools; from there the
|
|
218
|
+
// run waits for them without a single event, for as long as the command it
|
|
219
|
+
// waits on takes. So the calls listed beyond those reported count as one
|
|
220
|
+
// open tool, which closes when the model writes again: the SDK tells
|
|
221
|
+
// nothing about such a call ending, and the next step is the first sign.
|
|
222
|
+
// A step without such calls keeps the model window once its reported
|
|
223
|
+
// tools have ended. Steps are told from the delta hook alone: it carries
|
|
224
|
+
// the count, the reported calls and the model's output in step order.
|
|
225
|
+
const stepCalls = new Set();
|
|
226
|
+
let stepListed = false;
|
|
227
|
+
let unreportedCalls = 0;
|
|
228
|
+
const UNREPORTED_TOOLS = 'unreported-tools';
|
|
229
|
+
const noteCallStarted = (callId) => {
|
|
230
|
+
const key = String(callId || '').trim();
|
|
231
|
+
if (!key || stepCalls.has(key)) return;
|
|
232
|
+
stepCalls.add(key);
|
|
233
|
+
// Reported after the count: it was one of the calls counted as unreported.
|
|
234
|
+
if (stepListed && unreportedCalls > 0 && --unreportedCalls === 0) liveness.end('tool', UNREPORTED_TOOLS);
|
|
235
|
+
};
|
|
236
|
+
const noteToolsListed = (callCount) => {
|
|
237
|
+
stepListed = true;
|
|
238
|
+
unreportedCalls = Math.max(0, (Number(callCount) || 0) - stepCalls.size);
|
|
239
|
+
if (unreportedCalls > 0) liveness.begin('tool', UNREPORTED_TOOLS, 'unreported tool');
|
|
240
|
+
};
|
|
241
|
+
const noteModelWriting = () => {
|
|
242
|
+
if (!stepListed) return;
|
|
243
|
+
stepListed = false;
|
|
244
|
+
unreportedCalls = 0;
|
|
245
|
+
stepCalls.clear();
|
|
246
|
+
liveness.end('tool', UNREPORTED_TOOLS);
|
|
247
|
+
};
|
|
212
248
|
const observe = (event) => {
|
|
213
249
|
if (event.source === 'delta') {
|
|
214
250
|
const update = event.update || {};
|
|
215
251
|
liveness.traffic(update.type);
|
|
216
|
-
|
|
217
|
-
|
|
252
|
+
switch (update.type) {
|
|
253
|
+
case 'tool-call-started':
|
|
254
|
+
noteCallStarted(update.callId);
|
|
255
|
+
liveness.begin('tool', update.callId, update.toolCall?.type);
|
|
256
|
+
break;
|
|
257
|
+
case 'tool-call-completed':
|
|
258
|
+
liveness.end('tool', update.callId);
|
|
259
|
+
break;
|
|
260
|
+
case 'tool-requests-listed':
|
|
261
|
+
noteToolsListed(update.callCount);
|
|
262
|
+
break;
|
|
263
|
+
case 'text-delta':
|
|
264
|
+
case 'thinking-delta':
|
|
265
|
+
case 'thinking-completed':
|
|
266
|
+
case 'partial-tool-call':
|
|
267
|
+
noteModelWriting();
|
|
268
|
+
break;
|
|
269
|
+
default:
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
218
272
|
return;
|
|
219
273
|
}
|
|
220
274
|
const message = event.message || {};
|
package/server/db-schema.mjs
CHANGED
|
@@ -13,6 +13,11 @@ import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-marker
|
|
|
13
13
|
import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
|
|
14
14
|
import { migrateWorkspaceRootSettings } from './migrations/0006-workspace-root-settings.mjs';
|
|
15
15
|
import { migrateClaudeCloud } from './migrations/0007-claude-cloud.mjs';
|
|
16
|
+
import { migrateMessagePins } from './migrations/0008-message-pins.mjs';
|
|
17
|
+
import { migrateSubagentTimeline } from './migrations/0009-subagent-timeline.mjs';
|
|
18
|
+
import { migrateSubagentTimelinePruning } from './migrations/0010-subagent-timeline-pruning.mjs';
|
|
19
|
+
import { migrateRelayEventSubscriptions } from './migrations/0011-relay-event-subscriptions.mjs';
|
|
20
|
+
import { migrateCrossRelayEventSubscriptions } from './migrations/0012-cross-relay-event-subscriptions.mjs';
|
|
16
21
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
17
22
|
|
|
18
23
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -734,6 +739,45 @@ try {
|
|
|
734
739
|
} catch (error) {
|
|
735
740
|
console.warn(`[claude-cloud] migration 0007 failed; retrying on next boot: ${error?.message || error}`);
|
|
736
741
|
}
|
|
742
|
+
// Pinned messages: one additive column and its partial index. Readers tolerate
|
|
743
|
+
// their absence (no pins), so a failure is logged and retried next boot like 0007.
|
|
744
|
+
try {
|
|
745
|
+
migrateMessagePins(db);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
console.warn(`[message-pins] migration 0008 failed; retrying on next boot: ${error?.message || error}`);
|
|
748
|
+
}
|
|
749
|
+
// Sub-agent viewer: the timeline table and the run header columns. Readers
|
|
750
|
+
// tolerate their absence (runs fall back to the activity, thought and stream
|
|
751
|
+
// rows), so a failure is logged and retried next boot like 0008.
|
|
752
|
+
try {
|
|
753
|
+
migrateSubagentTimeline(db);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
console.warn(`[subagent-timeline] migration 0009 failed; retrying on next boot: ${error?.message || error}`);
|
|
756
|
+
}
|
|
757
|
+
// Sub-agent record retention: the pruned marker and the sweep's index. The
|
|
758
|
+
// sweep checks for the column and does nothing without it, so a failure is
|
|
759
|
+
// logged and retried next boot like 0009.
|
|
760
|
+
try {
|
|
761
|
+
migrateSubagentTimelinePruning(db);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
console.warn(`[subagent-timeline] migration 0010 failed; retrying on next boot: ${error?.message || error}`);
|
|
764
|
+
}
|
|
765
|
+
// Relay event subscriptions: two new tables. The event service checks for
|
|
766
|
+
// them and stays off without them, so a failure is logged and retried next
|
|
767
|
+
// boot like 0010.
|
|
768
|
+
try {
|
|
769
|
+
migrateRelayEventSubscriptions(db);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
console.warn(`[relay-events] migration 0011 failed; retrying on next boot: ${error?.message || error}`);
|
|
772
|
+
}
|
|
773
|
+
// Relay event subscriptions across paired relays: columns on 0011's table and
|
|
774
|
+
// the link table. Without them the event service keeps to this relay's own
|
|
775
|
+
// sessions, so a failure is logged and retried next boot like 0011.
|
|
776
|
+
try {
|
|
777
|
+
migrateCrossRelayEventSubscriptions(db);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
console.warn(`[relay-events] migration 0012 failed; retrying on next boot: ${error?.message || error}`);
|
|
780
|
+
}
|
|
737
781
|
|
|
738
782
|
const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
|
|
739
783
|
if (runtimeSessionColumns.length) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: pinned messages.
|
|
5
|
+
*
|
|
6
|
+
* 1. `messages.pinned_at` — when the user pinned the message (ISO time); NULL
|
|
7
|
+
* means not pinned. A pin is a property of the message row, like
|
|
8
|
+
* `hidden_from_shares`, so it goes away with the row.
|
|
9
|
+
* 2. `idx_messages_pinned` — a partial index over the pinned rows only: the
|
|
10
|
+
* pin list travels with every conversation load.
|
|
11
|
+
*
|
|
12
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
13
|
+
*
|
|
14
|
+
* Usage: node server/migrations/0008-message-pins.mjs [path/to/copilot.db]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import process from 'process';
|
|
20
|
+
import Database from 'better-sqlite3';
|
|
21
|
+
|
|
22
|
+
function tableExists(db, name) {
|
|
23
|
+
return !!db
|
|
24
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
25
|
+
.get(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function indexExists(db, name) {
|
|
29
|
+
return !!db
|
|
30
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
31
|
+
.get(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function columnNames(db, table) {
|
|
35
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function migrateMessagePins(db) {
|
|
39
|
+
if (!tableExists(db, 'messages')) return { applied: false, columnsAdded: 0, indexesAdded: 0 };
|
|
40
|
+
let columnsAdded = 0;
|
|
41
|
+
let indexesAdded = 0;
|
|
42
|
+
if (!columnNames(db, 'messages').has('pinned_at')) {
|
|
43
|
+
db.exec(`ALTER TABLE messages ADD COLUMN pinned_at TEXT`);
|
|
44
|
+
columnsAdded += 1;
|
|
45
|
+
}
|
|
46
|
+
if (!indexExists(db, 'idx_messages_pinned')) {
|
|
47
|
+
db.exec(`
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_messages_pinned
|
|
49
|
+
ON messages(conversation_id, timestamp)
|
|
50
|
+
WHERE pinned_at IS NOT NULL
|
|
51
|
+
`);
|
|
52
|
+
indexesAdded += 1;
|
|
53
|
+
}
|
|
54
|
+
return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultDbPath() {
|
|
58
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
59
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function migrate(dbPath, options = {}) {
|
|
63
|
+
const db = new Database(dbPath, options);
|
|
64
|
+
try {
|
|
65
|
+
return migrateMessagePins(db);
|
|
66
|
+
} finally {
|
|
67
|
+
db.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
72
|
+
if (isMain) {
|
|
73
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
74
|
+
try {
|
|
75
|
+
const result = migrate(dbPath);
|
|
76
|
+
console.log(`Migration 0008 on ${dbPath}`);
|
|
77
|
+
console.log(result.applied
|
|
78
|
+
? ` message pins: columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
|
|
79
|
+
: ' skipped: nothing to change');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`Migration 0008 failed: ${err?.message || err}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: sub-agent timeline.
|
|
5
|
+
*
|
|
6
|
+
* 1. `subagent_timeline` — one row per entry of a sub-agent's record (a
|
|
7
|
+
* thought, a tool call, a tool result, reply text, a child agent, a note),
|
|
8
|
+
* in the order the relay first saw it (`seq`, per run). A writer names each
|
|
9
|
+
* entry with its own `entry_key`; a second write under the same key updates
|
|
10
|
+
* the entry in place (live text, a thought that is still streaming) and
|
|
11
|
+
* keeps its place in the order.
|
|
12
|
+
* 2. Header columns on `subagent_runs`: what the task panel shows (model,
|
|
13
|
+
* type, tokens) and what the viewer needs to know about the run (task id,
|
|
14
|
+
* background, whether the record was cut, who writes the timeline). The
|
|
15
|
+
* panel's own copy lives in memory only and is gone when the task settles.
|
|
16
|
+
*
|
|
17
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
18
|
+
*
|
|
19
|
+
* Usage: node server/migrations/0009-subagent-timeline.mjs [path/to/copilot.db]
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { fileURLToPath } from 'url';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import process from 'process';
|
|
25
|
+
import Database from 'better-sqlite3';
|
|
26
|
+
|
|
27
|
+
function tableExists(db, name) {
|
|
28
|
+
return !!db
|
|
29
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
30
|
+
.get(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function indexExists(db, name) {
|
|
34
|
+
return !!db
|
|
35
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
36
|
+
.get(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function columnNames(db, table) {
|
|
40
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const SUBAGENT_RUN_HEADER_COLUMNS = Object.freeze([
|
|
44
|
+
['model', 'TEXT'],
|
|
45
|
+
['model_inherited', 'INTEGER'],
|
|
46
|
+
['subagent_type', 'TEXT'],
|
|
47
|
+
['task_id', 'TEXT'],
|
|
48
|
+
['total_tokens', 'INTEGER'],
|
|
49
|
+
['background', 'INTEGER NOT NULL DEFAULT 0'],
|
|
50
|
+
['truncated', 'INTEGER NOT NULL DEFAULT 0'],
|
|
51
|
+
['timeline_owner', 'TEXT'],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export function migrateSubagentTimeline(db) {
|
|
55
|
+
if (!tableExists(db, 'subagent_runs')) return { applied: false, tablesAdded: 0, columnsAdded: 0, indexesAdded: 0 };
|
|
56
|
+
let tablesAdded = 0;
|
|
57
|
+
let columnsAdded = 0;
|
|
58
|
+
let indexesAdded = 0;
|
|
59
|
+
|
|
60
|
+
const runColumns = columnNames(db, 'subagent_runs');
|
|
61
|
+
for (const [name, type] of SUBAGENT_RUN_HEADER_COLUMNS) {
|
|
62
|
+
if (runColumns.has(name)) continue;
|
|
63
|
+
db.exec(`ALTER TABLE subagent_runs ADD COLUMN ${name} ${type}`);
|
|
64
|
+
columnsAdded += 1;
|
|
65
|
+
}
|
|
66
|
+
if (!indexExists(db, 'idx_subagent_runs_parent')) {
|
|
67
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_subagent_runs_parent ON subagent_runs(parent_subagent_id)`);
|
|
68
|
+
indexesAdded += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!tableExists(db, 'subagent_timeline')) {
|
|
72
|
+
db.exec(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS subagent_timeline (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
run_id TEXT NOT NULL,
|
|
76
|
+
conversation_id TEXT NOT NULL,
|
|
77
|
+
queue_message_id TEXT,
|
|
78
|
+
entry_key TEXT NOT NULL,
|
|
79
|
+
seq INTEGER NOT NULL,
|
|
80
|
+
kind TEXT NOT NULL,
|
|
81
|
+
text TEXT,
|
|
82
|
+
tool_name TEXT,
|
|
83
|
+
tool_use_id TEXT,
|
|
84
|
+
metadata_json TEXT,
|
|
85
|
+
done INTEGER NOT NULL DEFAULT 1,
|
|
86
|
+
created_at TEXT NOT NULL,
|
|
87
|
+
updated_at TEXT NOT NULL,
|
|
88
|
+
UNIQUE (run_id, entry_key),
|
|
89
|
+
UNIQUE (run_id, seq),
|
|
90
|
+
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
|
91
|
+
)
|
|
92
|
+
`);
|
|
93
|
+
tablesAdded += 1;
|
|
94
|
+
}
|
|
95
|
+
if (!indexExists(db, 'idx_subagent_timeline_conversation')) {
|
|
96
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_subagent_timeline_conversation ON subagent_timeline(conversation_id)`);
|
|
97
|
+
indexesAdded += 1;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
applied: tablesAdded + columnsAdded + indexesAdded > 0,
|
|
101
|
+
tablesAdded,
|
|
102
|
+
columnsAdded,
|
|
103
|
+
indexesAdded,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defaultDbPath() {
|
|
108
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
109
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function migrate(dbPath, options = {}) {
|
|
113
|
+
const db = new Database(dbPath, options);
|
|
114
|
+
try {
|
|
115
|
+
return migrateSubagentTimeline(db);
|
|
116
|
+
} finally {
|
|
117
|
+
db.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
122
|
+
if (isMain) {
|
|
123
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
124
|
+
try {
|
|
125
|
+
const result = migrate(dbPath);
|
|
126
|
+
console.log(`Migration 0009 on ${dbPath}`);
|
|
127
|
+
console.log(result.applied
|
|
128
|
+
? ` sub-agent timeline: tables added ${result.tablesAdded}, columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
|
|
129
|
+
: ' skipped: nothing to change');
|
|
130
|
+
process.exit(0);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(`Migration 0009 failed: ${err?.message || err}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: pruning old sub-agent records.
|
|
5
|
+
*
|
|
6
|
+
* After the retention period the relay strips a finished run's tool inputs
|
|
7
|
+
* and tool results (services/subagent-record-retention.mjs). `pruned_at`
|
|
8
|
+
* marks an entry it already stripped, and a partial index over the tool
|
|
9
|
+
* entries not stripped yet, by age, lets the daily sweep go straight to its
|
|
10
|
+
* candidates instead of reading the whole table every day.
|
|
11
|
+
*
|
|
12
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
13
|
+
*
|
|
14
|
+
* Usage: node server/migrations/0010-subagent-timeline-pruning.mjs [path/to/copilot.db]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import process from 'process';
|
|
20
|
+
import Database from 'better-sqlite3';
|
|
21
|
+
|
|
22
|
+
export const PRUNABLE_INDEX_NAME = 'idx_subagent_timeline_prunable';
|
|
23
|
+
|
|
24
|
+
function tableExists(db, name) {
|
|
25
|
+
return !!db
|
|
26
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
27
|
+
.get(name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function indexExists(db, name) {
|
|
31
|
+
return !!db
|
|
32
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
33
|
+
.get(name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function columnNames(db, table) {
|
|
37
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function migrateSubagentTimelinePruning(db) {
|
|
41
|
+
if (!tableExists(db, 'subagent_timeline')) return { applied: false, columnsAdded: 0, indexesAdded: 0 };
|
|
42
|
+
let columnsAdded = 0;
|
|
43
|
+
let indexesAdded = 0;
|
|
44
|
+
if (!columnNames(db, 'subagent_timeline').has('pruned_at')) {
|
|
45
|
+
db.exec(`ALTER TABLE subagent_timeline ADD COLUMN pruned_at TEXT`);
|
|
46
|
+
columnsAdded += 1;
|
|
47
|
+
}
|
|
48
|
+
if (!indexExists(db, PRUNABLE_INDEX_NAME)) {
|
|
49
|
+
// The sweep's WHERE repeats this condition word for word, so SQLite can
|
|
50
|
+
// use the partial index.
|
|
51
|
+
db.exec(`
|
|
52
|
+
CREATE INDEX IF NOT EXISTS ${PRUNABLE_INDEX_NAME}
|
|
53
|
+
ON subagent_timeline(updated_at)
|
|
54
|
+
WHERE pruned_at IS NULL AND kind IN ('tool_call', 'tool_result')
|
|
55
|
+
`);
|
|
56
|
+
indexesAdded += 1;
|
|
57
|
+
}
|
|
58
|
+
return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultDbPath() {
|
|
62
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function migrate(dbPath, options = {}) {
|
|
67
|
+
const db = new Database(dbPath, options);
|
|
68
|
+
try {
|
|
69
|
+
return migrateSubagentTimelinePruning(db);
|
|
70
|
+
} finally {
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
76
|
+
if (isMain) {
|
|
77
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
78
|
+
try {
|
|
79
|
+
const result = migrate(dbPath);
|
|
80
|
+
console.log(`Migration 0010 on ${dbPath}`);
|
|
81
|
+
console.log(result.applied
|
|
82
|
+
? ` sub-agent timeline pruning: columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
|
|
83
|
+
: ' skipped: nothing to change');
|
|
84
|
+
process.exit(0);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`Migration 0010 failed: ${err?.message || err}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: relay event subscriptions.
|
|
5
|
+
*
|
|
6
|
+
* 1. `relay_event_subscriptions` — what a conversation (the watcher) asked
|
|
7
|
+
* the relay to tell it about: which sessions (listed ids or all), which
|
|
8
|
+
* events, one-shot or not. A row stays after it ended (`ended_at`), so
|
|
9
|
+
* list_subscriptions can say why one is gone. `heartbeat_minutes` (NULL =
|
|
10
|
+
* off) and `last_heartbeat_at` drive the heartbeat while work runs.
|
|
11
|
+
* 2. `relay_events` — events recorded for a watcher and not yet written into
|
|
12
|
+
* its conversation (`delivered_at` NULL), then the ones that were, for a
|
|
13
|
+
* couple of hours. The relay rebuilds its flush timers from the
|
|
14
|
+
* undelivered rows at boot.
|
|
15
|
+
*
|
|
16
|
+
* `relay_id` names the relay a watched session lives on: 'self' today, a
|
|
17
|
+
* paired relay's registry id once events cross relays.
|
|
18
|
+
*
|
|
19
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
20
|
+
*
|
|
21
|
+
* Usage: node server/migrations/0011-relay-event-subscriptions.mjs [path/to/copilot.db]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import process from 'process';
|
|
27
|
+
import Database from 'better-sqlite3';
|
|
28
|
+
|
|
29
|
+
function tableExists(db, name) {
|
|
30
|
+
return !!db
|
|
31
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
32
|
+
.get(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function migrateRelayEventSubscriptions(db) {
|
|
36
|
+
let tablesAdded = 0;
|
|
37
|
+
if (!tableExists(db, 'relay_event_subscriptions')) {
|
|
38
|
+
db.exec(`
|
|
39
|
+
CREATE TABLE relay_event_subscriptions (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
watcher_conversation_id TEXT NOT NULL,
|
|
42
|
+
relay_id TEXT NOT NULL DEFAULT 'self',
|
|
43
|
+
scope TEXT NOT NULL,
|
|
44
|
+
session_ids_json TEXT,
|
|
45
|
+
events_json TEXT NOT NULL,
|
|
46
|
+
once INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
match TEXT,
|
|
48
|
+
stalled_minutes INTEGER NOT NULL DEFAULT 15,
|
|
49
|
+
heartbeat_minutes INTEGER,
|
|
50
|
+
last_heartbeat_at TEXT,
|
|
51
|
+
created_at TEXT NOT NULL,
|
|
52
|
+
ended_at TEXT,
|
|
53
|
+
end_reason TEXT,
|
|
54
|
+
fired_count INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
last_event_at TEXT
|
|
56
|
+
)
|
|
57
|
+
`);
|
|
58
|
+
tablesAdded += 1;
|
|
59
|
+
}
|
|
60
|
+
db.exec(`
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_relay_event_subscriptions_watcher
|
|
62
|
+
ON relay_event_subscriptions(watcher_conversation_id)
|
|
63
|
+
WHERE ended_at IS NULL
|
|
64
|
+
`);
|
|
65
|
+
if (!tableExists(db, 'relay_events')) {
|
|
66
|
+
db.exec(`
|
|
67
|
+
CREATE TABLE relay_events (
|
|
68
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
subscription_id TEXT NOT NULL,
|
|
70
|
+
watcher_conversation_id TEXT NOT NULL,
|
|
71
|
+
relay_id TEXT NOT NULL DEFAULT 'self',
|
|
72
|
+
session_id TEXT NOT NULL,
|
|
73
|
+
type TEXT NOT NULL,
|
|
74
|
+
urgent INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
payload_json TEXT NOT NULL,
|
|
76
|
+
created_at TEXT NOT NULL,
|
|
77
|
+
delivered_at TEXT,
|
|
78
|
+
message_id TEXT
|
|
79
|
+
)
|
|
80
|
+
`);
|
|
81
|
+
tablesAdded += 1;
|
|
82
|
+
}
|
|
83
|
+
db.exec(`
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_relay_events_undelivered
|
|
85
|
+
ON relay_events(watcher_conversation_id, created_at)
|
|
86
|
+
WHERE delivered_at IS NULL
|
|
87
|
+
`);
|
|
88
|
+
// Delivered events are pruned by age (relay-event-service pruneDelivered).
|
|
89
|
+
db.exec(`
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_relay_events_delivered
|
|
91
|
+
ON relay_events(delivered_at)
|
|
92
|
+
WHERE delivered_at IS NOT NULL
|
|
93
|
+
`);
|
|
94
|
+
return { applied: tablesAdded > 0, tablesAdded };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultDbPath() {
|
|
98
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
99
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function migrate(dbPath, options = {}) {
|
|
103
|
+
const db = new Database(dbPath, options);
|
|
104
|
+
try {
|
|
105
|
+
return migrateRelayEventSubscriptions(db);
|
|
106
|
+
} finally {
|
|
107
|
+
db.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
112
|
+
if (isMain) {
|
|
113
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
114
|
+
try {
|
|
115
|
+
const result = migrate(dbPath);
|
|
116
|
+
console.log(`Migration 0011 on ${dbPath}`);
|
|
117
|
+
console.log(result.applied
|
|
118
|
+
? ` relay event subscriptions: tables added ${result.tablesAdded}`
|
|
119
|
+
: ' skipped: nothing to change');
|
|
120
|
+
process.exit(0);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error(`Migration 0011 failed: ${err?.message || err}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: relay event subscriptions across paired relays.
|
|
5
|
+
*
|
|
6
|
+
* The watching relay (it holds the watcher) and the watched relay (it holds
|
|
7
|
+
* the sessions) both keep a row in `relay_event_subscriptions`:
|
|
8
|
+
*
|
|
9
|
+
* - On the watching relay `relay_id` is the paired relay's registry id,
|
|
10
|
+
* `remote_subscription_id` that relay's id for the subscription,
|
|
11
|
+
* `remote_meta_json` the watched sessions' titles, and `remote_sync` says
|
|
12
|
+
* that an end still has to reach the other relay ('unsubscribe').
|
|
13
|
+
* - On the watched relay `subscriber_relay_id` is the watching relay's own
|
|
14
|
+
* id (as it states it), `subscriber_watcher_id` / `_title` name its
|
|
15
|
+
* watcher, `last_pull_at` is its last fetch and `dropped_count` the events
|
|
16
|
+
* not kept for it since then.
|
|
17
|
+
*
|
|
18
|
+
* `relay_event_links` (watching relay): per paired relay the cursor of the
|
|
19
|
+
* events fetched there and the link's health, so a gap is noticed across
|
|
20
|
+
* restarts.
|
|
21
|
+
*
|
|
22
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
23
|
+
*
|
|
24
|
+
* Usage: node server/migrations/0012-cross-relay-event-subscriptions.mjs [path/to/copilot.db]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { fileURLToPath } from 'url';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import process from 'process';
|
|
30
|
+
import Database from 'better-sqlite3';
|
|
31
|
+
|
|
32
|
+
const SUBSCRIPTION_COLUMNS = Object.freeze([
|
|
33
|
+
['remote_subscription_id', 'TEXT'],
|
|
34
|
+
['remote_meta_json', 'TEXT'],
|
|
35
|
+
['remote_sync', 'TEXT'],
|
|
36
|
+
['subscriber_relay_id', 'TEXT'],
|
|
37
|
+
['subscriber_relay_name', 'TEXT'],
|
|
38
|
+
['subscriber_watcher_id', 'TEXT'],
|
|
39
|
+
['subscriber_watcher_title', 'TEXT'],
|
|
40
|
+
['last_pull_at', 'TEXT'],
|
|
41
|
+
['dropped_count', 'INTEGER NOT NULL DEFAULT 0'],
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function tableExists(db, name) {
|
|
45
|
+
return !!db
|
|
46
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
47
|
+
.get(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function migrateCrossRelayEventSubscriptions(db) {
|
|
51
|
+
if (!tableExists(db, 'relay_event_subscriptions')) {
|
|
52
|
+
throw new Error('relay_event_subscriptions is missing (migration 0011 has not run)');
|
|
53
|
+
}
|
|
54
|
+
const present = new Set(db.prepare(`PRAGMA table_info(relay_event_subscriptions)`).all().map((column) => column.name));
|
|
55
|
+
let columnsAdded = 0;
|
|
56
|
+
for (const [name, type] of SUBSCRIPTION_COLUMNS) {
|
|
57
|
+
if (present.has(name)) continue;
|
|
58
|
+
db.exec(`ALTER TABLE relay_event_subscriptions ADD COLUMN ${name} ${type}`);
|
|
59
|
+
columnsAdded += 1;
|
|
60
|
+
}
|
|
61
|
+
db.exec(`
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_relay_event_subscriptions_remote
|
|
63
|
+
ON relay_event_subscriptions(relay_id, remote_subscription_id)
|
|
64
|
+
WHERE remote_subscription_id IS NOT NULL
|
|
65
|
+
`);
|
|
66
|
+
db.exec(`
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_relay_event_subscriptions_subscriber
|
|
68
|
+
ON relay_event_subscriptions(subscriber_relay_id)
|
|
69
|
+
WHERE subscriber_relay_id IS NOT NULL
|
|
70
|
+
`);
|
|
71
|
+
let tablesAdded = 0;
|
|
72
|
+
if (!tableExists(db, 'relay_event_links')) {
|
|
73
|
+
db.exec(`
|
|
74
|
+
CREATE TABLE relay_event_links (
|
|
75
|
+
relay_id TEXT PRIMARY KEY,
|
|
76
|
+
cursor INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
last_ok_at TEXT,
|
|
78
|
+
first_fail_at TEXT,
|
|
79
|
+
lost_at TEXT,
|
|
80
|
+
last_error TEXT
|
|
81
|
+
)
|
|
82
|
+
`);
|
|
83
|
+
tablesAdded += 1;
|
|
84
|
+
}
|
|
85
|
+
return { applied: columnsAdded > 0 || tablesAdded > 0, columnsAdded, tablesAdded };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function defaultDbPath() {
|
|
89
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
90
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function migrate(dbPath, options = {}) {
|
|
94
|
+
const db = new Database(dbPath, options);
|
|
95
|
+
try {
|
|
96
|
+
return migrateCrossRelayEventSubscriptions(db);
|
|
97
|
+
} finally {
|
|
98
|
+
db.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
103
|
+
if (isMain) {
|
|
104
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
105
|
+
try {
|
|
106
|
+
const result = migrate(dbPath);
|
|
107
|
+
console.log(`Migration 0012 on ${dbPath}`);
|
|
108
|
+
console.log(result.applied
|
|
109
|
+
? ` cross-relay event subscriptions: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}`
|
|
110
|
+
: ' skipped: nothing to change');
|
|
111
|
+
process.exit(0);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(`Migration 0012 failed: ${err?.message || err}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|