@oar-sh/oar 0.9.10 → 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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -18,6 +18,10 @@ import { migrateSubagentTimeline } from './migrations/0009-subagent-timeline.mjs
18
18
  import { migrateSubagentTimelinePruning } from './migrations/0010-subagent-timeline-pruning.mjs';
19
19
  import { migrateRelayEventSubscriptions } from './migrations/0011-relay-event-subscriptions.mjs';
20
20
  import { migrateCrossRelayEventSubscriptions } from './migrations/0012-cross-relay-event-subscriptions.mjs';
21
+ import { migratePreviews } from './migrations/0013-previews.mjs';
22
+ import { migrateConversationGroups } from './migrations/0014-conversation-groups.mjs';
23
+ import { migrateConversationGroupPosition } from './migrations/0015-conversation-group-position.mjs';
24
+ import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
21
25
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
22
26
 
23
27
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -426,8 +430,8 @@ export const SCHEMA_SQL = `
426
430
  ON workflow_runs(conversation_id);
427
431
 
428
432
  -- Previews published mid-turn, pinned to the transcript. Snapshot JSON, not a
429
- -- registry reference: the registry is in-memory and the card must still
430
- -- render after the preview closes or the relay restarts. Written with the
433
+ -- registry reference: the card must still render after the preview closes
434
+ -- (or after a restart dropped it as no longer valid). Written with the
431
435
  -- queue id (the response id does not exist yet mid-turn) and linked to the
432
436
  -- response at finalize, exactly like relay_activity.
433
437
  CREATE TABLE IF NOT EXISTS preview_cards (
@@ -445,6 +449,24 @@ export const SCHEMA_SQL = `
445
449
  CREATE INDEX IF NOT EXISTS idx_preview_cards_queue
446
450
  ON preview_cards(queue_message_id);
447
451
 
452
+ -- Published previews, restored at boot so their public links survive a
453
+ -- restart. Existing databases get the table from migration 0013 (same
454
+ -- columns; a test keeps the two declarations equal). Times are epoch ms.
455
+ -- Its index comes from the migration only, which skips a previews table of
456
+ -- other columns instead of failing the boot over it.
457
+ CREATE TABLE IF NOT EXISTS previews (
458
+ token TEXT PRIMARY KEY,
459
+ mode TEXT NOT NULL,
460
+ conversation_id TEXT,
461
+ label TEXT NOT NULL,
462
+ target_host TEXT,
463
+ target_port INTEGER,
464
+ root_dir TEXT,
465
+ created_at INTEGER NOT NULL,
466
+ last_hit_at INTEGER,
467
+ host_boot_id TEXT
468
+ );
469
+
448
470
  CREATE TABLE IF NOT EXISTS uploaded_files (
449
471
  sha256 TEXT PRIMARY KEY,
450
472
  original_name TEXT,
@@ -778,6 +800,41 @@ try {
778
800
  } catch (error) {
779
801
  console.warn(`[relay-events] migration 0012 failed; retrying on next boot: ${error?.message || error}`);
780
802
  }
803
+ // Persisted previews: one new table. The preview store checks for it and keeps
804
+ // previews in memory only without it, so a failure is logged and retried next
805
+ // boot like 0012.
806
+ try {
807
+ migratePreviews(db);
808
+ } catch (error) {
809
+ console.warn(`[previews] migration 0013 failed; retrying on next boot: ${error?.message || error}`);
810
+ }
811
+
812
+ // Session groups: the group table and the conversations' group column. The
813
+ // group repository checks for them and sees no groups without them, so a
814
+ // failure is logged and retried next boot like 0012.
815
+ try {
816
+ migrateConversationGroups(db);
817
+ } catch (error) {
818
+ console.warn(`[session-groups] migration 0014 failed; retrying on next boot: ${error?.message || error}`);
819
+ }
820
+
821
+ // A hand order inside a session group: one column. The group repository
822
+ // checks for it and keeps the activity order without it, so a failure is
823
+ // logged and retried next boot like 0014.
824
+ try {
825
+ migrateConversationGroupPosition(db);
826
+ } catch (error) {
827
+ console.warn(`[session-groups] migration 0015 failed; retrying on next boot: ${error?.message || error}`);
828
+ }
829
+
830
+ // Share lineages and the share access log: one column, one table. The share
831
+ // repository checks for them; without them a renew starts a fresh lineage and
832
+ // no open is logged, so a failure is logged and retried next boot like 0015.
833
+ try {
834
+ migrateShareAccessLog(db);
835
+ } catch (error) {
836
+ console.warn(`[shares] migration 0016 failed; retrying on next boot: ${error?.message || error}`);
837
+ }
781
838
 
782
839
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
783
840
  if (runtimeSessionColumns.length) {
@@ -12,6 +12,7 @@ import {
12
12
  createTurnStalledError,
13
13
  resolveTurnStallWindows,
14
14
  } from '../../shared/worker-runtime/turn-liveness.mjs';
15
+ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
15
16
 
16
17
  function whichCommand(command = 'grok') {
17
18
  const value = String(command || 'grok').trim() || 'grok';
@@ -522,7 +523,7 @@ export class AcpClient extends EventEmitter {
522
523
  this.proc.kill();
523
524
  if (process.platform === 'win32' && this.proc.pid) {
524
525
  try {
525
- this.spawnImpl('taskkill', ['/pid', String(this.proc.pid), '/t', '/f'], {
526
+ this.spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(this.proc.pid), '/t', '/f'], {
526
527
  stdio: 'ignore',
527
528
  windowsHide: true,
528
529
  });
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { spawn, spawnSync } from 'node:child_process';
14
14
  import fs from 'node:fs/promises';
15
+ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
15
16
 
16
17
  const DEFAULT_OUTPUT_BYTE_LIMIT = 1024 * 1024;
17
18
  const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024;
@@ -55,9 +56,9 @@ export function createAcpHostServices({
55
56
  timeout: 10_000,
56
57
  stdio: 'ignore',
57
58
  });
58
- cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : 'powershell';
59
+ cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : windowsSystemToolPath('powershell');
59
60
  } catch {
60
- cachedWindowsShell = 'powershell';
61
+ cachedWindowsShell = windowsSystemToolPath('powershell');
61
62
  }
62
63
  return cachedWindowsShell;
63
64
  }
@@ -178,7 +179,7 @@ export function createAcpHostServices({
178
179
  if (platform === 'win32' && entry.proc.pid) {
179
180
  // proc.kill() only reaches the shell; shells leave grandchildren behind.
180
181
  try {
181
- spawnImpl('taskkill', ['/pid', String(entry.proc.pid), '/t', '/f'], {
182
+ spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
182
183
  stdio: 'ignore',
183
184
  windowsHide: true,
184
185
  });
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ // ⋯ → About OAR: the menu entry's label and the modal (about-view.mjs builds
2
+ // its HTML from GET /api/about).
3
+
4
+ import {
5
+ closeSummaryModal,
6
+ escHtml,
7
+ getUpdateState,
8
+ openSummaryModal,
9
+ renderSummaryModalContent,
10
+ setSummaryModalLoading,
11
+ } from './store.js';
12
+ import { apiFetchOrThrow } from './api-client.js';
13
+ import { openSettingsModal } from './settings-modal.js';
14
+ import { aboutMenuLabel, buildAboutModalHtml } from './about-view.mjs';
15
+
16
+ const ABOUT_KIND = 'about';
17
+ const ABOUT_TITLE = 'About OAR';
18
+
19
+ /** The entry carries the running version; /api/status brings it on load. */
20
+ export function syncAboutMenuLabel(version = getUpdateState()?.runningVersion) {
21
+ const button = document.getElementById('chat-menu-about');
22
+ if (button) button.textContent = aboutMenuLabel(version);
23
+ }
24
+
25
+ function bindAboutModalActions() {
26
+ const body = document.getElementById('summary-modal-body');
27
+ const button = body?.querySelector('[data-about-open-settings]');
28
+ if (!button) return;
29
+ button.addEventListener('click', (event) => {
30
+ event.preventDefault();
31
+ closeSummaryModal();
32
+ openSettingsModal('general');
33
+ document.getElementById('update-auto-check-toggle')?.scrollIntoView?.({ block: 'center' });
34
+ });
35
+ }
36
+
37
+ async function loadAboutAndRender() {
38
+ const about = await apiFetchOrThrow('/api/about');
39
+ if (!about) throw new Error('The relay is not reachable right now.');
40
+ renderSummaryModalContent({
41
+ title: ABOUT_TITLE,
42
+ subtitle: about.version ? `Version ${about.version}` : '',
43
+ bodyHtml: buildAboutModalHtml(about),
44
+ refresh: loadAboutAndRender,
45
+ kind: ABOUT_KIND,
46
+ });
47
+ bindAboutModalActions();
48
+ syncAboutMenuLabel(about.version);
49
+ }
50
+
51
+ export async function showAbout() {
52
+ openSummaryModal({
53
+ title: ABOUT_TITLE,
54
+ subtitle: 'Loading…',
55
+ bodyHtml: '<div class="summary-loading">Reading the relay…</div>',
56
+ refresh: loadAboutAndRender,
57
+ kind: ABOUT_KIND,
58
+ });
59
+ setSummaryModalLoading(true);
60
+ try {
61
+ await loadAboutAndRender();
62
+ } catch (error) {
63
+ renderSummaryModalContent({
64
+ title: ABOUT_TITLE,
65
+ subtitle: 'Unable to load',
66
+ bodyHtml: `<div class="summary-error">${escHtml(error?.message || 'Unknown error')}</div>`,
67
+ refresh: loadAboutAndRender,
68
+ kind: ABOUT_KIND,
69
+ });
70
+ } finally {
71
+ setSummaryModalLoading(false);
72
+ }
73
+ }