@oar-sh/oar 0.9.9 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. package/shared/subagent-timeline.mjs +79 -0
@@ -14,6 +14,10 @@ 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
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';
17
21
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
18
22
 
19
23
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -742,6 +746,38 @@ try {
742
746
  } catch (error) {
743
747
  console.warn(`[message-pins] migration 0008 failed; retrying on next boot: ${error?.message || error}`);
744
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
+ }
745
781
 
746
782
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
747
783
  if (runtimeSessionColumns.length) {
@@ -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
+ }