@oar-sh/oar 0.9.14 → 0.9.16

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 (59) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +525 -12
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  6. package/server/claude-worker/claude-session-process.mjs +4 -4
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  8. package/server/db-schema.mjs +52 -0
  9. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  10. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  11. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  12. package/server/migrations/0022-relay-schedules.mjs +142 -0
  13. package/server/migrations/0023-relay-asks.mjs +141 -0
  14. package/server/public/app/ask-user-view.js +3 -0
  15. package/server/public/app/bootstrap.js +61 -3
  16. package/server/public/app/context-usage-view.mjs +17 -0
  17. package/server/public/app/conversation-group-order.mjs +35 -0
  18. package/server/public/app/inbox-ui.js +356 -0
  19. package/server/public/app/inbox-view.mjs +370 -0
  20. package/server/public/app/journal-view.js +23 -2
  21. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  22. package/server/public/app/relay-event-view.mjs +33 -7
  23. package/server/public/app/relay-schedule-view.mjs +119 -0
  24. package/server/public/app/relay-schedules-ui.js +172 -0
  25. package/server/public/app/settings-modal.js +17 -3
  26. package/server/public/app/socket-handlers.js +27 -3
  27. package/server/public/app/store.js +18 -5
  28. package/server/public/index.html +159 -2
  29. package/server/relay-tools.md +18 -3
  30. package/server/repositories/conversation-group-repository.mjs +97 -11
  31. package/server/repositories/remote-relay-repository.mjs +20 -2
  32. package/server/repositories/session-repository.mjs +7 -1
  33. package/server/routes/ask-user-routes.mjs +57 -3
  34. package/server/routes/cache-routes.mjs +6 -0
  35. package/server/routes/conversation-group-routes.mjs +17 -2
  36. package/server/routes/messages-routes.mjs +3 -3
  37. package/server/routes/relay-ask-routes.mjs +387 -0
  38. package/server/routes/relay-event-routes.mjs +3 -1
  39. package/server/routes/relay-schedule-routes.mjs +57 -0
  40. package/server/routes/sessions-routes.mjs +613 -56
  41. package/server/server-runtime.mjs +80 -1
  42. package/server/services/cache-rebuild-service.mjs +19 -2
  43. package/server/services/conversation-preferences-service.mjs +17 -0
  44. package/server/services/oar-cli-helpers.mjs +12 -4
  45. package/server/services/relay-ask-link-service.mjs +174 -0
  46. package/server/services/relay-ask-service.mjs +604 -0
  47. package/server/services/relay-event-service.mjs +589 -73
  48. package/server/services/relay-schedule-service.mjs +596 -0
  49. package/server/services/remote-relay-caller-context.mjs +89 -20
  50. package/server/services/remote-relay-dispatcher.mjs +956 -30
  51. package/server/services/windows-autostart-service.mjs +88 -16
  52. package/server/services/windows-boot-autostart-service.mjs +775 -126
  53. package/shared/relay-asks.mjs +145 -0
  54. package/shared/relay-events.mjs +268 -18
  55. package/shared/relay-schedules.mjs +128 -0
  56. package/shared/remote-relay-contract.mjs +553 -25
  57. package/shared/remote-relay-tool-core.mjs +16 -5
  58. package/.github/extensions/web-relay/README.md +0 -190
  59. package/server/README.md +0 -2334
@@ -13,7 +13,9 @@ import {
13
13
  REMOTE_RELAY_MODES,
14
14
  REMOTE_RELAY_PROVIDERS,
15
15
  REMOTE_RELAY_SESSION_SCOPES,
16
+ REMOTE_RELAY_THINKING_DISPLAYS,
16
17
  } from '../../shared/remote-relay-contract.mjs';
18
+ import { RELAY_ASK_URGENCIES } from '../../shared/relay-asks.mjs';
17
19
  import {
18
20
  REMOTE_RELAY_CALL_FAILED,
19
21
  REMOTE_RELAY_TOOL_DESCRIPTION,
@@ -67,7 +69,21 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
67
69
  mode: choice('mode', REMOTE_RELAY_MODES),
68
70
  effort: text('effort'),
69
71
  title: text('title'),
72
+ // The one exception to the parity with the JSON schema: Claude may send
73
+ // null for update_session's group (out of the group), as "none" does on
74
+ // the providers whose strict schemas take a string only.
75
+ group: z.string().nullable().optional().describe(FIELDS.group.description),
76
+ auto_compact_window: number('auto_compact_window'),
77
+ thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
78
+ thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
70
79
  question_id: text('question_id'),
80
+ // ask: the contract's validator refuses a question or detail that is too
81
+ // long, or more than eight options, with a message the model can act on.
82
+ question: text('question'),
83
+ options: z.array(z.string()).optional().describe(FIELDS.options.description),
84
+ recommendation: text('recommendation'),
85
+ detail: text('detail'),
86
+ urgency: choice('urgency', RELAY_ASK_URGENCIES),
71
87
  answer: text('answer'),
72
88
  choices: z.array(z.string()).optional().describe(FIELDS.choices.description),
73
89
  if_busy: choice('if_busy', REMOTE_RELAY_IF_BUSY),
@@ -78,18 +94,37 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
78
94
  match: text('match'),
79
95
  stalled_minutes: number('stalled_minutes'),
80
96
  heartbeat_minutes: number('heartbeat_minutes'),
97
+ digest_minutes: number('digest_minutes'),
98
+ // Loose and every field optional: the contract's validator checks the
99
+ // times and the zone and says what is wrong; an empty one is no option.
100
+ quiet_hours: z.looseObject({
101
+ from: z.string().optional().describe(FIELDS.quiet_hours.properties.from.description),
102
+ to: z.string().optional().describe(FIELDS.quiet_hours.properties.to.description),
103
+ tz: z.string().optional().describe(FIELDS.quiet_hours.properties.tz.description),
104
+ }).optional().describe(FIELDS.quiet_hours.description),
105
+ // A list, or the comma-separated text the contract takes as well.
106
+ exclude: z.union([z.array(z.string()), z.string()]).optional().describe(FIELDS.exclude.description),
81
107
  subscription_id: text('subscription_id'),
82
108
  watcher: text('watcher'),
83
109
  from: text('from'),
110
+ at: text('at'),
111
+ every_minutes: number('every_minutes'),
112
+ schedule_id: text('schedule_id'),
84
113
  task_id: text('task_id'),
85
114
  lines: number('lines'),
86
115
  };
87
116
 
117
+ // What the tool registers: the shape as a loose object. A raw shape becomes a
118
+ // strict-stripping object in the SDK's MCP layer, which would drop a key the
119
+ // schema lacks before the contract's validator could refuse it (create_session)
120
+ // or name it in `ignored` (the other actions). The loose object lets it through.
121
+ export const REMOTE_RELAY_TOOL_ZOD_SCHEMA = z.looseObject(REMOTE_RELAY_TOOL_ZOD_SHAPE);
122
+
88
123
  export function createRemoteRelayToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
89
124
  return {
90
125
  name: REMOTE_RELAY_TOOL_NAME,
91
126
  description: REMOTE_RELAY_TOOL_DESCRIPTION,
92
- inputSchema: REMOTE_RELAY_TOOL_ZOD_SHAPE,
127
+ inputSchema: REMOTE_RELAY_TOOL_ZOD_SCHEMA,
93
128
  // `extra.signal` is the MCP request's: the SDK aborts it when the CLI
94
129
  // cancels the call (a Stop interrupting the turn) or the process's MCP
95
130
  // transport closes, and the pending relay request goes with it.
@@ -53,7 +53,7 @@ import {
53
53
  STEER_STOPPED_TEXT,
54
54
  } from '../../shared/steer-settle-markers.mjs';
55
55
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
56
- import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
56
+ import { isRelayAutomatedDelivery } from '../../shared/relay-events.mjs';
57
57
  import {
58
58
  stripSlashCommandGuard,
59
59
  stripSlashCommandGuardContent,
@@ -4209,9 +4209,9 @@ export function createClaudeSessionRunner({
4209
4209
  // noted steer no longer starts with "/" and is pushed as noted. The
4210
4210
  // replay signatures strip the guard too, so expectedText stays the
4211
4211
  // user's own text here as well.
4212
- // Nor into an event message the relay wrote: the note speaks as the
4213
- // user, the event must not (shared/relay-events.mjs).
4214
- const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayEventDelivery(message)
4212
+ // Nor into a message the relay wrote (an event, a scheduled prompt):
4213
+ // the note speaks as the user, that message must not (shared/relay-events.mjs).
4214
+ const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayAutomatedDelivery(message)
4215
4215
  ? withSteerNoteContent(content)
4216
4216
  : content);
4217
4217
  procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });
@@ -112,7 +112,7 @@ import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT, STEER_TURN_FAILED_TEXT } from '.
112
112
  import { buildSteerSettleFailure } from '../../shared/steer-settle-failure.mjs';
113
113
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
114
114
  import { withSteerNote } from '../../shared/steer-note.mjs';
115
- import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
115
+ import { isRelayAutomatedDelivery } from '../../shared/relay-events.mjs';
116
116
  import { isTransientRelayError } from '../../shared/worker-runtime/relay-errors.mjs';
117
117
  import {
118
118
  createTurnLiveness,
@@ -4456,7 +4456,7 @@ export function createCopilotSdkSessionRunner({
4456
4456
  // Not in a continuation: there the running work is the runtime's own
4457
4457
  // follow-up, not a request of the user's to keep going with.
4458
4458
  // Nor in an event message the relay wrote: the note speaks as the user.
4459
- const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' || isRelayEventDelivery(message) ? body : withSteerNote(body));
4459
+ const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' || isRelayAutomatedDelivery(message) ? body : withSteerNote(body));
4460
4460
  // Re-checked AFTER the awaits and before anything is sent or registered.
4461
4461
  // Nothing has been sent yet, so handing the row back to the normal path is
4462
4462
  // free.
@@ -24,6 +24,11 @@ import { migrateConversationGroupPosition } from './migrations/0015-conversation
24
24
  import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
25
25
  import { migrateConversationHistoryIndexes } from './migrations/0017-conversation-history-indexes.mjs';
26
26
  import { migrateBackgroundTaskRows } from './migrations/0018-background-task-rows.mjs';
27
+ import { migrateRelayEventSubscriptionOptions } from './migrations/0019-relay-event-subscription-options.mjs';
28
+ import { migrateConversationMayCreateSessions } from './migrations/0020-conversation-may-create-sessions.mjs';
29
+ import { migrateConversationGroupAgentMarks } from './migrations/0021-conversation-group-agent-marks.mjs';
30
+ import { migrateRelaySchedules } from './migrations/0022-relay-schedules.mjs';
31
+ import { migrateRelayAsks } from './migrations/0023-relay-asks.mjs';
27
32
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
28
33
 
29
34
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -855,6 +860,53 @@ try {
855
860
  console.warn(`[background-tasks] migration 0018 failed; retrying on next boot: ${error?.message || error}`);
856
861
  }
857
862
 
863
+ // Options of relay event subscriptions (digest, quiet hours, exclude) and the
864
+ // watcher's own prompts: columns on 0011's table and one table. The event
865
+ // service checks for them and refuses the options without them, so a failure
866
+ // is logged and retried next boot like 0018.
867
+ try {
868
+ migrateRelayEventSubscriptionOptions(db);
869
+ } catch (error) {
870
+ console.warn(`[relay-events] migration 0019 failed; retrying on next boot: ${error?.message || error}`);
871
+ }
872
+
873
+ // The owner's "agents in this conversation may start sessions": one column.
874
+ // Without it nobody can turn the switch on (the preferences route refuses
875
+ // it) and the one-level rule holds for every session, so a failure is logged
876
+ // and retried next boot like 0019.
877
+ try {
878
+ migrateConversationMayCreateSessions(db);
879
+ } catch (error) {
880
+ console.warn(`[remote-relays] migration 0020 failed; retrying on next boot: ${error?.message || error}`);
881
+ }
882
+
883
+ // What an agent's update_session may do to a group: two columns. Without
884
+ // them an owner's group an agent emptied is kept until the next boot repair
885
+ // only, so a failure is logged and retried next boot like 0020.
886
+ try {
887
+ migrateConversationGroupAgentMarks(db);
888
+ } catch (error) {
889
+ console.warn(`[session-groups] migration 0021 failed; retrying on next boot: ${error?.message || error}`);
890
+ }
891
+
892
+ // Relay schedules: one new table. The schedule service checks for it and
893
+ // refuses the schedule actions without it, so a failure is logged and
894
+ // retried next boot like 0021.
895
+ try {
896
+ migrateRelaySchedules(db);
897
+ } catch (error) {
898
+ console.warn(`[relay-schedules] migration 0022 failed; retrying on next boot: ${error?.message || error}`);
899
+ }
900
+
901
+ // Questions that don't block (remote_relay ask): one new table. The ask
902
+ // service checks for it and refuses the ask actions without it, so a failure
903
+ // is logged and retried next boot like 0022.
904
+ try {
905
+ migrateRelayAsks(db);
906
+ } catch (error) {
907
+ console.warn(`[relay-asks] migration 0023 failed; retrying on next boot: ${error?.message || error}`);
908
+ }
909
+
858
910
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
859
911
  if (runtimeSessionColumns.length) {
860
912
  if (!runtimeSessionColumns.includes('strategy')) {
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: options of relay event subscriptions.
5
+ *
6
+ * Columns on `relay_event_subscriptions` (all NULL = off, as before):
7
+ *
8
+ * - `digest_minutes`: the subscription's non-urgent events are held and
9
+ * delivered as one row every N minutes; `digest_last_at` is when its last
10
+ * such row went out (the next one is due N minutes after it).
11
+ * - `quiet_hours_json`: `{ from, to, tz }`; non-urgent events in the window
12
+ * are held until it ends.
13
+ * - `exclude_json`: session ids an "all" subscription leaves out.
14
+ *
15
+ * `relay_event_own_prompts`: the prompts a watcher sent through send or
16
+ * create_session (`message_id` is the prompt's), and when it received the
17
+ * finished reply (`received_at`): the turn_end of such a turn is not
18
+ * delivered to it again. Rows go after a day.
19
+ *
20
+ * Held events are ordinary undelivered `relay_events` rows: they and the
21
+ * timers rebuilt from them survive a restart without anything more here.
22
+ *
23
+ * Additive and idempotent. Safe to run on every boot.
24
+ *
25
+ * Usage: node server/migrations/0019-relay-event-subscription-options.mjs [path/to/copilot.db]
26
+ */
27
+
28
+ import { fileURLToPath } from 'url';
29
+ import path from 'path';
30
+ import process from 'process';
31
+ import Database from 'better-sqlite3';
32
+
33
+ export const RELAY_EVENT_OPTION_COLUMNS = Object.freeze([
34
+ ['digest_minutes', 'INTEGER'],
35
+ ['digest_last_at', 'TEXT'],
36
+ ['quiet_hours_json', 'TEXT'],
37
+ ['exclude_json', 'TEXT'],
38
+ ]);
39
+
40
+ function tableExists(db, name) {
41
+ return !!db
42
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
43
+ .get(name);
44
+ }
45
+
46
+ export function migrateRelayEventSubscriptionOptions(db) {
47
+ if (!tableExists(db, 'relay_event_subscriptions')) {
48
+ throw new Error('relay_event_subscriptions is missing (migration 0011 has not run)');
49
+ }
50
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_event_subscriptions)`).all().map((column) => column.name));
51
+ let columnsAdded = 0;
52
+ for (const [name, type] of RELAY_EVENT_OPTION_COLUMNS) {
53
+ if (present.has(name)) continue;
54
+ db.exec(`ALTER TABLE relay_event_subscriptions ADD COLUMN ${name} ${type}`);
55
+ columnsAdded += 1;
56
+ }
57
+ let tablesAdded = 0;
58
+ if (!tableExists(db, 'relay_event_own_prompts')) {
59
+ db.exec(`
60
+ CREATE TABLE relay_event_own_prompts (
61
+ message_id TEXT PRIMARY KEY,
62
+ watcher_conversation_id TEXT NOT NULL,
63
+ relay_id TEXT NOT NULL DEFAULT 'self',
64
+ session_id TEXT NOT NULL,
65
+ created_at TEXT NOT NULL,
66
+ received_at TEXT
67
+ )
68
+ `);
69
+ tablesAdded += 1;
70
+ }
71
+ db.exec(`
72
+ CREATE INDEX IF NOT EXISTS idx_relay_event_own_prompts_created
73
+ ON relay_event_own_prompts(created_at)
74
+ `);
75
+ return { applied: columnsAdded > 0 || tablesAdded > 0, columnsAdded, tablesAdded };
76
+ }
77
+
78
+ function defaultDbPath() {
79
+ const here = path.dirname(fileURLToPath(import.meta.url));
80
+ return path.resolve(here, '..', 'data', 'copilot.db');
81
+ }
82
+
83
+ export function migrate(dbPath, options = {}) {
84
+ const db = new Database(dbPath, options);
85
+ try {
86
+ return migrateRelayEventSubscriptionOptions(db);
87
+ } finally {
88
+ db.close();
89
+ }
90
+ }
91
+
92
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
93
+ if (isMain) {
94
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
95
+ try {
96
+ const result = migrate(dbPath);
97
+ console.log(`Migration 0019 on ${dbPath}`);
98
+ console.log(result.applied
99
+ ? ` relay event subscription options: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}`
100
+ : ' skipped: nothing to change');
101
+ process.exit(0);
102
+ } catch (err) {
103
+ console.error(`Migration 0019 failed: ${err?.message || err}`);
104
+ process.exit(1);
105
+ }
106
+ }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the owner's "agents in this conversation may start sessions".
5
+ *
6
+ * `may_create_sessions` is 1 when the owner allowed the agents of a session
7
+ * that an agent started to start sessions on this relay themselves (the
8
+ * remote_relay tool's one-level rule is lifted for it); NULL or 0 otherwise.
9
+ * Only the owner sets it, in the conversation's settings or through the
10
+ * preferences route; no agent can.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
13
+ * data change: older code ignores the column and keeps the one-level rule.
14
+ *
15
+ * Usage: node server/migrations/0020-conversation-may-create-sessions.mjs [path/to/copilot.db]
16
+ */
17
+
18
+ import { fileURLToPath } from 'url';
19
+ import path from 'path';
20
+ import process from 'process';
21
+ import Database from 'better-sqlite3';
22
+
23
+ function tableExists(db, name) {
24
+ return !!db
25
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
26
+ .get(name);
27
+ }
28
+
29
+ export function migrateConversationMayCreateSessions(db) {
30
+ if (!tableExists(db, 'conversations')) {
31
+ throw new Error('conversations is missing (the base schema has not run)');
32
+ }
33
+ const present = new Set(db.prepare(`PRAGMA table_info(conversations)`).all().map((column) => column.name));
34
+ if (present.has('may_create_sessions')) return { applied: false, columnsAdded: 0 };
35
+ db.exec(`ALTER TABLE conversations ADD COLUMN may_create_sessions INTEGER`);
36
+ return { applied: true, columnsAdded: 1 };
37
+ }
38
+
39
+ function defaultDbPath() {
40
+ const here = path.dirname(fileURLToPath(import.meta.url));
41
+ return path.resolve(here, '..', 'data', 'copilot.db');
42
+ }
43
+
44
+ export function migrate(dbPath, options = {}) {
45
+ const db = new Database(dbPath, options);
46
+ try {
47
+ return migrateConversationMayCreateSessions(db);
48
+ } finally {
49
+ db.close();
50
+ }
51
+ }
52
+
53
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
54
+ if (isMain) {
55
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
56
+ try {
57
+ const result = migrate(dbPath);
58
+ console.log(`Migration 0020 on ${dbPath}`);
59
+ console.log(result.applied
60
+ ? ` may create sessions: columns added ${result.columnsAdded}`
61
+ : ' skipped: nothing to change');
62
+ process.exit(0);
63
+ } catch (err) {
64
+ console.error(`Migration 0020 failed: ${err?.message || err}`);
65
+ process.exit(1);
66
+ }
67
+ }
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: what an agent's update_session may do to a session group.
5
+ *
6
+ * - `conversation_groups.agent_made` is 1 for a group update_session made
7
+ * (a name the relay did not have). Only such a group goes once an agent
8
+ * takes its last member out.
9
+ * - `conversation_groups.kept_empty_at` is set when an agent took the last
10
+ * member out of a group the owner made: the group stays (empty), the boot
11
+ * repair and the sweep leave it alone, and agents can still name it. The
12
+ * mark goes as soon as a member joins again.
13
+ *
14
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
15
+ * data change: older code ignores both columns (its boot repair removes an
16
+ * empty group, as it always did).
17
+ *
18
+ * Usage: node server/migrations/0021-conversation-group-agent-marks.mjs [path/to/copilot.db]
19
+ */
20
+
21
+ import { fileURLToPath } from 'url';
22
+ import path from 'path';
23
+ import process from 'process';
24
+ import Database from 'better-sqlite3';
25
+
26
+ function tableExists(db, name) {
27
+ return !!db
28
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
29
+ .get(name);
30
+ }
31
+
32
+ export function migrateConversationGroupAgentMarks(db) {
33
+ if (!tableExists(db, 'conversation_groups')) {
34
+ throw new Error('conversation_groups is missing (migration 0014 has not run)');
35
+ }
36
+ const present = new Set(db.prepare(`PRAGMA table_info(conversation_groups)`).all().map((column) => column.name));
37
+ let columnsAdded = 0;
38
+ if (!present.has('agent_made')) {
39
+ db.exec(`ALTER TABLE conversation_groups ADD COLUMN agent_made INTEGER`);
40
+ columnsAdded += 1;
41
+ }
42
+ if (!present.has('kept_empty_at')) {
43
+ db.exec(`ALTER TABLE conversation_groups ADD COLUMN kept_empty_at TEXT`);
44
+ columnsAdded += 1;
45
+ }
46
+ return { applied: columnsAdded > 0, columnsAdded };
47
+ }
48
+
49
+ function defaultDbPath() {
50
+ const here = path.dirname(fileURLToPath(import.meta.url));
51
+ return path.resolve(here, '..', 'data', 'copilot.db');
52
+ }
53
+
54
+ export function migrate(dbPath, options = {}) {
55
+ const db = new Database(dbPath, options);
56
+ try {
57
+ return migrateConversationGroupAgentMarks(db);
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
62
+
63
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
64
+ if (isMain) {
65
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
66
+ try {
67
+ const result = migrate(dbPath);
68
+ console.log(`Migration 0021 on ${dbPath}`);
69
+ console.log(result.applied
70
+ ? ` group agent marks: columns added ${result.columnsAdded}`
71
+ : ' skipped: nothing to change');
72
+ process.exit(0);
73
+ } catch (err) {
74
+ console.error(`Migration 0021 failed: ${err?.message || err}`);
75
+ process.exit(1);
76
+ }
77
+ }
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: relay schedules (services/relay-schedule-service.mjs).
5
+ *
6
+ * `relay_schedules`: one row per schedule an agent set with remote_relay's
7
+ * `schedule`.
8
+ *
9
+ * - `conversation_id`: the session the prompt goes to.
10
+ * - `created_by_conversation_id`: the conversation whose agent set it (the
11
+ * same one for a schedule of its own).
12
+ * - `at`: the first time asked for, or NULL; `every_minutes`: the interval
13
+ * of a repeating schedule, or NULL; `once`: 1 when it ends after firing.
14
+ * - `next_fire_at`: when it fires next. Written in the same transaction as
15
+ * the queue row a firing creates, so a restart neither loses a firing nor
16
+ * repeats it; the timers are rebuilt from it at boot.
17
+ * - `last_fired_at`, `fired_count`, `last_message_id` (the message its last
18
+ * firing queued: a repeat waits while that one is still pending),
19
+ * `skipped_count`.
20
+ * - `origin_json`: the provenance its messages carry (the turn that set it,
21
+ * with that turn's hops), so a schedule cannot reset the hop count;
22
+ * NULL when a user's own turn set it for its own conversation.
23
+ * - `held_since`: a firing into another session waits for the user (the
24
+ * agents' loop guard of the conversation that set it is used up).
25
+ * - `ended_at`, `end_reason`: unscheduled, fired (once), archived, deleted.
26
+ *
27
+ * Additive and idempotent. Safe to run on every boot.
28
+ *
29
+ * Usage: node server/migrations/0022-relay-schedules.mjs [path/to/copilot.db]
30
+ */
31
+
32
+ import { fileURLToPath } from 'url';
33
+ import path from 'path';
34
+ import process from 'process';
35
+ import Database from 'better-sqlite3';
36
+
37
+ export const RELAY_SCHEDULE_COLUMNS = Object.freeze([
38
+ 'id',
39
+ 'conversation_id',
40
+ 'created_by_conversation_id',
41
+ 'title',
42
+ 'text',
43
+ 'at',
44
+ 'every_minutes',
45
+ 'once',
46
+ 'next_fire_at',
47
+ 'last_fired_at',
48
+ 'fired_count',
49
+ 'skipped_count',
50
+ 'last_message_id',
51
+ 'created_at',
52
+ 'ended_at',
53
+ 'end_reason',
54
+ 'origin_json',
55
+ 'held_since',
56
+ ]);
57
+
58
+ // Added after the table's first form: added by ALTER where it lacks them.
59
+ const LATER_COLUMNS = Object.freeze([['origin_json', 'TEXT'], ['held_since', 'TEXT']]);
60
+
61
+ function tableExists(db, name) {
62
+ return !!db
63
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
64
+ .get(name);
65
+ }
66
+
67
+ /** What keeps the service from using the table: '' when nothing does. */
68
+ export function relaySchedulesTableProblem(db) {
69
+ if (!tableExists(db, 'relay_schedules')) return 'no relay_schedules table';
70
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_schedules)`).all().map((column) => column.name));
71
+ const missing = RELAY_SCHEDULE_COLUMNS.filter((name) => !present.has(name));
72
+ return missing.length ? `relay_schedules lacks ${missing.join(', ')}` : '';
73
+ }
74
+
75
+ export function migrateRelaySchedules(db) {
76
+ let tablesAdded = 0;
77
+ if (!tableExists(db, 'relay_schedules')) {
78
+ db.exec(`
79
+ CREATE TABLE relay_schedules (
80
+ id TEXT PRIMARY KEY,
81
+ conversation_id TEXT NOT NULL,
82
+ created_by_conversation_id TEXT NOT NULL,
83
+ title TEXT,
84
+ text TEXT NOT NULL,
85
+ at TEXT,
86
+ every_minutes INTEGER CHECK (every_minutes IS NULL OR every_minutes > 0),
87
+ once INTEGER NOT NULL DEFAULT 1 CHECK (once IN (0, 1)),
88
+ next_fire_at TEXT,
89
+ last_fired_at TEXT,
90
+ fired_count INTEGER NOT NULL DEFAULT 0,
91
+ skipped_count INTEGER NOT NULL DEFAULT 0,
92
+ last_message_id TEXT,
93
+ created_at TEXT NOT NULL,
94
+ ended_at TEXT,
95
+ end_reason TEXT,
96
+ origin_json TEXT,
97
+ held_since TEXT,
98
+ CHECK (once = 1 OR every_minutes IS NOT NULL)
99
+ )
100
+ `);
101
+ tablesAdded += 1;
102
+ }
103
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_schedules)`).all().map((column) => column.name));
104
+ let columnsAdded = 0;
105
+ for (const [name, type] of LATER_COLUMNS) {
106
+ if (present.has(name)) continue;
107
+ db.exec(`ALTER TABLE relay_schedules ADD COLUMN ${name} ${type}`);
108
+ columnsAdded += 1;
109
+ }
110
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_schedules_active ON relay_schedules(ended_at, next_fire_at)`);
111
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_schedules_conversation ON relay_schedules(conversation_id, ended_at)`);
112
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_schedules_creator ON relay_schedules(created_by_conversation_id, ended_at)`);
113
+ return { applied: tablesAdded > 0 || columnsAdded > 0, tablesAdded, columnsAdded };
114
+ }
115
+
116
+ function defaultDbPath() {
117
+ const here = path.dirname(fileURLToPath(import.meta.url));
118
+ return path.resolve(here, '..', 'data', 'copilot.db');
119
+ }
120
+
121
+ export function migrate(dbPath, options = {}) {
122
+ const db = new Database(dbPath, options);
123
+ try {
124
+ return migrateRelaySchedules(db);
125
+ } finally {
126
+ db.close();
127
+ }
128
+ }
129
+
130
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
131
+ if (isMain) {
132
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
133
+ try {
134
+ const result = migrate(dbPath);
135
+ console.log(`Migration 0022 on ${dbPath}`);
136
+ console.log(result.applied ? ` relay schedules: tables added ${result.tablesAdded}, columns added ${result.columnsAdded}` : ' skipped: nothing to change');
137
+ process.exit(0);
138
+ } catch (err) {
139
+ console.error(`Migration 0022 failed: ${err?.message || err}`);
140
+ process.exit(1);
141
+ }
142
+ }