@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.
- package/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +613 -56
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/.github/extensions/web-relay/README.md +0 -190
- 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:
|
|
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 {
|
|
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
|
|
4213
|
-
// user,
|
|
4214
|
-
const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !
|
|
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 {
|
|
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' ||
|
|
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.
|
package/server/db-schema.mjs
CHANGED
|
@@ -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
|
+
}
|