@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -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
+ }
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: persisted previews.
5
+ *
6
+ * `previews` holds every published preview (a public link to a dev-server port
7
+ * or to a folder the relay serves), so a restarted runtime can restore them and
8
+ * a link handed out before the restart keeps working. One row per token:
9
+ *
10
+ * - `mode` is 'port' or 'static'. A port row has `target_host` and
11
+ * `target_port`, a static row `root_dir` (the folder's realpath).
12
+ * - `conversation_id` is NULL for a preview registered without one.
13
+ * - `created_at` and `last_hit_at` are epoch milliseconds.
14
+ * - `host_boot_id` names the host boot the preview was registered in. A port
15
+ * preview is restored only in the same boot (a dev server does not survive a
16
+ * reboot, and the port may later belong to something else).
17
+ *
18
+ * No foreign key on `conversation_id`: deleting a conversation closes its
19
+ * previews through the registry, and a restore drops rows whose conversation
20
+ * is gone.
21
+ *
22
+ * Additive and idempotent. Safe to run on every boot, and from the command line
23
+ * against a database a running relay has open (one CREATE TABLE in WAL mode).
24
+ * An older build never reads the table.
25
+ *
26
+ * A `previews` table this migration did not create (other columns) is left
27
+ * alone and reported as not compatible; the relay then keeps previews in
28
+ * memory only, it never refuses to start over it.
29
+ *
30
+ * Usage: node server/migrations/0013-previews.mjs [path/to/copilot.db]
31
+ * The file must exist: a wrong path is refused rather than creating an empty
32
+ * database.
33
+ */
34
+
35
+ import fs from 'fs';
36
+ import { fileURLToPath } from 'url';
37
+ import path from 'path';
38
+ import process from 'process';
39
+ import Database from 'better-sqlite3';
40
+
41
+ function tableExists(db, name) {
42
+ return !!db
43
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
44
+ .get(name);
45
+ }
46
+
47
+ export const PREVIEWS_COLUMNS = Object.freeze([
48
+ 'token', 'mode', 'conversation_id', 'label', 'target_host', 'target_port', 'root_dir',
49
+ 'created_at', 'last_hit_at', 'host_boot_id',
50
+ ]);
51
+
52
+ /**
53
+ * Why the `previews` table cannot be used, or '' when it can: every column
54
+ * this build reads and writes is there, and any other column takes a NULL or
55
+ * has a default (an insert would fail otherwise). '' too when the table is
56
+ * missing; the caller checks that separately.
57
+ */
58
+ export function previewsTableProblem(db) {
59
+ const info = db.prepare(`PRAGMA table_info(previews)`).all();
60
+ if (info.length === 0) return '';
61
+ const names = new Set(info.map((column) => column.name));
62
+ const missing = PREVIEWS_COLUMNS.filter((name) => !names.has(name));
63
+ if (missing.length) return `the previews table lacks column(s) ${missing.join(', ')}`;
64
+ const blocking = info.filter((column) => !PREVIEWS_COLUMNS.includes(column.name)
65
+ && column.notnull && column.dflt_value === null && !column.pk);
66
+ if (blocking.length) {
67
+ return `the previews table has required column(s) this build does not write: ${blocking.map((column) => column.name).join(', ')}`;
68
+ }
69
+ return '';
70
+ }
71
+
72
+ export function migratePreviews(db) {
73
+ let tablesAdded = 0;
74
+ if (!tableExists(db, 'previews')) {
75
+ db.exec(`
76
+ CREATE TABLE previews (
77
+ token TEXT PRIMARY KEY,
78
+ mode TEXT NOT NULL,
79
+ conversation_id TEXT,
80
+ label TEXT NOT NULL,
81
+ target_host TEXT,
82
+ target_port INTEGER,
83
+ root_dir TEXT,
84
+ created_at INTEGER NOT NULL,
85
+ last_hit_at INTEGER,
86
+ host_boot_id TEXT
87
+ )
88
+ `);
89
+ tablesAdded += 1;
90
+ }
91
+ const problem = previewsTableProblem(db);
92
+ if (problem) return { applied: false, tablesAdded, compatible: false, problem };
93
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_previews_conversation ON previews(conversation_id)`);
94
+ return { applied: tablesAdded > 0, tablesAdded, compatible: true };
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
+ if (!fs.existsSync(dbPath)) throw new Error(`no database at ${dbPath}`);
104
+ const db = new Database(dbPath, { fileMustExist: true, ...options });
105
+ try {
106
+ return migratePreviews(db);
107
+ } finally {
108
+ db.close();
109
+ }
110
+ }
111
+
112
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
113
+ if (isMain) {
114
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
115
+ try {
116
+ const result = migrate(dbPath);
117
+ console.log(`Migration 0013 on ${dbPath}`);
118
+ if (!result.compatible) {
119
+ console.error(` not compatible: ${result.problem}; the relay will keep previews in memory only`);
120
+ process.exit(1);
121
+ }
122
+ console.log(result.applied
123
+ ? ` previews: tables added ${result.tablesAdded}`
124
+ : ' skipped: nothing to change');
125
+ process.exit(0);
126
+ } catch (err) {
127
+ console.error(`Migration 0013 failed: ${err?.message || err}`);
128
+ process.exit(1);
129
+ }
130
+ }
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: session groups in the sidebar.
5
+ *
6
+ * A group is the set of conversations that share a `group_id`. The table
7
+ * `conversation_groups` holds what the set itself needs: a name given by hand
8
+ * (NULL means "shown under the head's title") and the head, the session that
9
+ * created the others with the remote_relay tool (NULL for a group made by
10
+ * hand, or once the head is deleted). A conversation an agent creates on this
11
+ * relay joins its creator's group at bootstrap; everything else is done by
12
+ * hand in the sidebar. Nothing is backfilled.
13
+ *
14
+ * Additive and idempotent. Safe to run on every boot.
15
+ *
16
+ * Usage: node server/migrations/0014-conversation-groups.mjs [path/to/copilot.db]
17
+ */
18
+
19
+ import { fileURLToPath } from 'url';
20
+ import path from 'path';
21
+ import process from 'process';
22
+ import Database from 'better-sqlite3';
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
+ export function migrateConversationGroups(db) {
31
+ if (!tableExists(db, 'conversations')) {
32
+ throw new Error('conversations is missing (the base schema has not run)');
33
+ }
34
+ let tablesAdded = 0;
35
+ if (!tableExists(db, 'conversation_groups')) {
36
+ db.exec(`
37
+ CREATE TABLE conversation_groups (
38
+ id TEXT PRIMARY KEY,
39
+ name TEXT,
40
+ parent_conversation_id TEXT,
41
+ created_at TEXT NOT NULL,
42
+ updated_at TEXT NOT NULL
43
+ )
44
+ `);
45
+ tablesAdded += 1;
46
+ }
47
+ const present = new Set(db.prepare(`PRAGMA table_info(conversations)`).all().map((column) => column.name));
48
+ let columnsAdded = 0;
49
+ if (!present.has('group_id')) {
50
+ db.exec(`ALTER TABLE conversations ADD COLUMN group_id TEXT`);
51
+ columnsAdded += 1;
52
+ }
53
+ db.exec(`
54
+ CREATE INDEX IF NOT EXISTS idx_conversations_group
55
+ ON conversations(group_id)
56
+ WHERE group_id IS NOT NULL
57
+ `);
58
+ return { applied: columnsAdded > 0 || tablesAdded > 0, columnsAdded, tablesAdded };
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 migrateConversationGroups(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 0014 on ${dbPath}`);
81
+ console.log(result.applied
82
+ ? ` conversation groups: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}`
83
+ : ' skipped: nothing to change');
84
+ process.exit(0);
85
+ } catch (err) {
86
+ console.error(`Migration 0014 failed: ${err?.message || err}`);
87
+ process.exit(1);
88
+ }
89
+ }
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: a hand order inside a session group.
5
+ *
6
+ * `group_position` is a member's place in its group once the owner has
7
+ * dragged a row there: the members with one come first, by position, the
8
+ * others after them, newest first. The head keeps NULL (it always leads),
9
+ * and so does every member of a group nobody reordered. A row that leaves
10
+ * its group loses its position.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
13
+ * data change: older code ignores the column; a row it moves out of a group
14
+ * keeps a stale place, which the next run here clears.
15
+ *
16
+ * Usage: node server/migrations/0015-conversation-group-position.mjs [path/to/copilot.db]
17
+ */
18
+
19
+ import { fileURLToPath } from 'url';
20
+ import path from 'path';
21
+ import process from 'process';
22
+ import Database from 'better-sqlite3';
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
+ export function migrateConversationGroupPosition(db) {
31
+ if (!tableExists(db, 'conversations')) {
32
+ throw new Error('conversations is missing (the base schema has not run)');
33
+ }
34
+ const present = new Set(db.prepare(`PRAGMA table_info(conversations)`).all().map((column) => column.name));
35
+ let columnsAdded = 0;
36
+ if (!present.has('group_position')) {
37
+ db.exec(`ALTER TABLE conversations ADD COLUMN group_position INTEGER`);
38
+ columnsAdded += 1;
39
+ }
40
+ // A place without a group: an older release (which ignores the column)
41
+ // moved the row out of its group after a roll back. Cleared on every run.
42
+ const placesCleared = present.has('group_id')
43
+ ? db.prepare(`UPDATE conversations SET group_position = NULL WHERE group_id IS NULL AND group_position IS NOT NULL`).run().changes
44
+ : 0;
45
+ return { applied: columnsAdded > 0 || placesCleared > 0, columnsAdded, placesCleared };
46
+ }
47
+
48
+ function defaultDbPath() {
49
+ const here = path.dirname(fileURLToPath(import.meta.url));
50
+ return path.resolve(here, '..', 'data', 'copilot.db');
51
+ }
52
+
53
+ export function migrate(dbPath, options = {}) {
54
+ const db = new Database(dbPath, options);
55
+ try {
56
+ return migrateConversationGroupPosition(db);
57
+ } finally {
58
+ db.close();
59
+ }
60
+ }
61
+
62
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
63
+ if (isMain) {
64
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
65
+ try {
66
+ const result = migrate(dbPath);
67
+ console.log(`Migration 0015 on ${dbPath}`);
68
+ console.log(result.applied
69
+ ? ` group position: columns added ${result.columnsAdded}, stale places cleared ${result.placesCleared}`
70
+ : ' skipped: nothing to change');
71
+ process.exit(0);
72
+ } catch (err) {
73
+ console.error(`Migration 0015 failed: ${err?.message || err}`);
74
+ process.exit(1);
75
+ }
76
+ }
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: share lineages and the share access log.
5
+ *
6
+ * A share link can be renewed: the conversation gets a new token and the old
7
+ * one stops at once. What the link has been through (its opens) belongs to the
8
+ * share, not to one token, so every row of `conversation_shares` gets a
9
+ * `lineage_id`: the token the share started with, carried over to every
10
+ * renewed token. Existing rows start their own lineage (`lineage_id = token`).
11
+ *
12
+ * `conversation_share_opens` holds one row per open of a shared page:
13
+ *
14
+ * - `lineage_id` names the share; the log survives a renew.
15
+ * - `opened_at` is an ISO timestamp.
16
+ * - `ip` is NULL when the relay does not store viewer addresses (a setting).
17
+ * - `country` is the edge's two-letter country, NULL when unknown.
18
+ * - `viewer_key` is a keyed hash of address and browser, so distinct viewers
19
+ * can be counted when the address itself is not stored.
20
+ *
21
+ * Stopping a share deletes its log; deleting the conversation does too (the
22
+ * foreign key, and the delete route by hand). Nothing else prunes it.
23
+ *
24
+ * Additive and idempotent. Safe to run on every boot. An older build never
25
+ * reads the new table, and a share it creates has no lineage, which this build
26
+ * reads as the share's own token.
27
+ *
28
+ * Usage: node server/migrations/0016-share-access-log.mjs [path/to/copilot.db]
29
+ */
30
+
31
+ import { fileURLToPath } from 'url';
32
+ import path from 'path';
33
+ import process from 'process';
34
+ import Database from 'better-sqlite3';
35
+
36
+ function tableExists(db, name) {
37
+ return !!db
38
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
39
+ .get(name);
40
+ }
41
+
42
+ export function migrateShareAccessLog(db) {
43
+ if (!tableExists(db, 'conversation_shares')) {
44
+ throw new Error('conversation_shares is missing (the base schema has not run)');
45
+ }
46
+ let columnsAdded = 0;
47
+ let tablesAdded = 0;
48
+ const present = new Set(db.prepare(`PRAGMA table_info(conversation_shares)`).all().map((column) => column.name));
49
+ if (!present.has('lineage_id')) {
50
+ db.exec(`ALTER TABLE conversation_shares ADD COLUMN lineage_id TEXT`);
51
+ columnsAdded += 1;
52
+ }
53
+ // Every run: a share an older build created after this migration has none.
54
+ const lineagesSet = db.prepare(`UPDATE conversation_shares SET lineage_id = token WHERE lineage_id IS NULL OR lineage_id = ''`).run().changes;
55
+ if (!tableExists(db, 'conversation_share_opens')) {
56
+ db.exec(`
57
+ CREATE TABLE conversation_share_opens (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ lineage_id TEXT NOT NULL,
60
+ conversation_id TEXT NOT NULL,
61
+ opened_at TEXT NOT NULL,
62
+ ip TEXT,
63
+ user_agent TEXT,
64
+ country TEXT,
65
+ viewer_key TEXT NOT NULL,
66
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
67
+ )
68
+ `);
69
+ tablesAdded += 1;
70
+ }
71
+ db.exec(`
72
+ CREATE INDEX IF NOT EXISTS idx_conversation_share_opens_lineage
73
+ ON conversation_share_opens(lineage_id, opened_at DESC)
74
+ `);
75
+ db.exec(`
76
+ CREATE INDEX IF NOT EXISTS idx_conversation_share_opens_conversation
77
+ ON conversation_share_opens(conversation_id)
78
+ `);
79
+ return { applied: columnsAdded > 0 || tablesAdded > 0 || lineagesSet > 0, columnsAdded, tablesAdded, lineagesSet };
80
+ }
81
+
82
+ function defaultDbPath() {
83
+ const here = path.dirname(fileURLToPath(import.meta.url));
84
+ return path.resolve(here, '..', 'data', 'copilot.db');
85
+ }
86
+
87
+ export function migrate(dbPath, options = {}) {
88
+ const db = new Database(dbPath, options);
89
+ try {
90
+ return migrateShareAccessLog(db);
91
+ } finally {
92
+ db.close();
93
+ }
94
+ }
95
+
96
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
97
+ if (isMain) {
98
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
99
+ try {
100
+ const result = migrate(dbPath);
101
+ console.log(`Migration 0016 on ${dbPath}`);
102
+ console.log(result.applied
103
+ ? ` share access log: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}, lineages set ${result.lineagesSet}`
104
+ : ' skipped: nothing to change');
105
+ process.exit(0);
106
+ } catch (err) {
107
+ console.error(`Migration 0016 failed: ${err?.message || err}`);
108
+ process.exit(1);
109
+ }
110
+ }