@oar-sh/oar 0.9.15 → 0.9.17
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/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -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/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- 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 +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- 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 +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- 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 +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- 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 +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- 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 +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Relay schedules: a prompt the relay queues into a session at a set time,
|
|
5
|
+
* once or every N minutes (remote_relay's `schedule`). An agent's own timers
|
|
6
|
+
* die with the relay; these live in the database (migrations/0022-relay-schedules.mjs)
|
|
7
|
+
* and their timers are rebuilt at boot.
|
|
8
|
+
*
|
|
9
|
+
* A firing is an ordinary queued message of the relay's (`messages.kind =
|
|
10
|
+
* 'relay-schedule'`, a pending queue row whose `next_attempt_at` is the time
|
|
11
|
+
* it was due), like the usage-limit follow-up: the worker takes it as it
|
|
12
|
+
* takes any message, a new turn when the session is idle, the next one when
|
|
13
|
+
* it is busy. The row, the schedule's next time and its end are written in
|
|
14
|
+
* one transaction, so a restart neither loses a firing nor repeats it.
|
|
15
|
+
*
|
|
16
|
+
* - A time that passed while the relay was down fires once at boot, not
|
|
17
|
+
* once per missed interval; a repeating schedule then keeps to its grid
|
|
18
|
+
* (the first time plus whole intervals).
|
|
19
|
+
* - A repeat is skipped while the message of the firing before it is still
|
|
20
|
+
* waiting in the queue: a stuck session gets one, not a pile.
|
|
21
|
+
* - A schedule ends after firing when `once`, on unschedule, and when the
|
|
22
|
+
* session it fires into or the conversation that set it is archived or
|
|
23
|
+
* deleted (checked again when it comes due). A compaction carries it to
|
|
24
|
+
* the successor instead, in both directions (carryToCompacted).
|
|
25
|
+
* - A conversation holds at most RELAY_SCHEDULE_LIMITS.perConversation
|
|
26
|
+
* active schedules firing into it, and may set as many.
|
|
27
|
+
* - A firing into another session is a prompt of the conversation that set
|
|
28
|
+
* it, counted by the agents' loop guard (`admitFiring`). When that guard
|
|
29
|
+
* wants the user first, the firing is held (`held_since`) and looked at
|
|
30
|
+
* again every minute; it goes once the user wrote in that conversation.
|
|
31
|
+
* - Its messages carry the provenance of the turn that set it
|
|
32
|
+
* (`origin_json`, with that turn's hops): a schedule passes no prompt on
|
|
33
|
+
* with fewer hops than it came with.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
RELAY_SCHEDULE_LIMITS,
|
|
40
|
+
RELAY_SCHEDULE_MESSAGE_KIND,
|
|
41
|
+
RELAY_SCHEDULE_SOCKET_EVENT,
|
|
42
|
+
buildRelayScheduleMessageText,
|
|
43
|
+
firstRelayScheduleFire,
|
|
44
|
+
nextRelayScheduleFire,
|
|
45
|
+
normalizeRelayScheduleTitle,
|
|
46
|
+
} from '../../shared/relay-schedules.mjs';
|
|
47
|
+
import { relaySchedulesTableProblem } from '../migrations/0022-relay-schedules.mjs';
|
|
48
|
+
|
|
49
|
+
const LOG_PREFIX = '[relay-schedules]';
|
|
50
|
+
// setTimeout holds at most ~24.8 days; a schedule further out is looked at
|
|
51
|
+
// again after this long.
|
|
52
|
+
const MAX_TIMER_MS = 6 * 60 * 60_000;
|
|
53
|
+
// Ended schedules are kept this long (list_schedules shows the last hour).
|
|
54
|
+
const ENDED_KEEP_MS = 7 * 24 * 60 * 60_000;
|
|
55
|
+
const ENDED_LISTED_MS = 60 * 60_000;
|
|
56
|
+
const TEXT_PREVIEW_CHARS = 200;
|
|
57
|
+
// A firing held for the user's approval is looked at again this often.
|
|
58
|
+
export const RELAY_SCHEDULE_HOLD_RECHECK_MS = 60_000;
|
|
59
|
+
|
|
60
|
+
function parseJson(value) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(String(value ?? ''));
|
|
63
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toText(value) {
|
|
70
|
+
return String(value ?? '').trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shortId(value) {
|
|
74
|
+
return toText(value).slice(0, 8);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function msOf(value) {
|
|
78
|
+
const ms = Date.parse(toText(value));
|
|
79
|
+
return Number.isFinite(ms) ? ms : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class RelayScheduleError extends Error {
|
|
83
|
+
constructor(code, message, extra = {}) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.code = code;
|
|
86
|
+
this.extra = extra;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createRelayScheduleService({
|
|
91
|
+
db,
|
|
92
|
+
stmts,
|
|
93
|
+
emit = () => {},
|
|
94
|
+
// A message was queued: the primer starts a stopped worker, the socket
|
|
95
|
+
// service hands it to a running one.
|
|
96
|
+
wake = () => {},
|
|
97
|
+
now = () => Date.now(),
|
|
98
|
+
setTimer = (fn, ms) => {
|
|
99
|
+
const timer = setTimeout(fn, ms);
|
|
100
|
+
timer.unref?.();
|
|
101
|
+
return timer;
|
|
102
|
+
},
|
|
103
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
104
|
+
newId = () => `sch_${randomBytes(6).toString('hex')}`,
|
|
105
|
+
// `({ scheduleId, conversationId, createdBy })` → true when a firing into
|
|
106
|
+
// another session may go: the agents' loop guard of the conversation that
|
|
107
|
+
// set it counts it (remote-relay-dispatcher.mjs admitScheduledFiring).
|
|
108
|
+
admitFiring = () => true,
|
|
109
|
+
logger = console,
|
|
110
|
+
} = {}) {
|
|
111
|
+
if (!db) throw new Error('db is required');
|
|
112
|
+
if (!stmts) throw new Error('stmts is required');
|
|
113
|
+
|
|
114
|
+
const problem = (() => {
|
|
115
|
+
try {
|
|
116
|
+
return relaySchedulesTableProblem(db);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return toText(error?.message || error) || 'unreadable';
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
const available = !problem;
|
|
122
|
+
|
|
123
|
+
const sql = available ? {
|
|
124
|
+
insert: db.prepare(`
|
|
125
|
+
INSERT INTO relay_schedules
|
|
126
|
+
(id, conversation_id, created_by_conversation_id, title, text, at, every_minutes, once, next_fire_at, created_at, origin_json)
|
|
127
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
128
|
+
`),
|
|
129
|
+
get: db.prepare(`SELECT * FROM relay_schedules WHERE id = ?`),
|
|
130
|
+
listActive: db.prepare(`SELECT * FROM relay_schedules WHERE ended_at IS NULL ORDER BY created_at ASC, rowid ASC`),
|
|
131
|
+
listOf: db.prepare(`
|
|
132
|
+
SELECT * FROM relay_schedules
|
|
133
|
+
WHERE (conversation_id = ? OR created_by_conversation_id = ?) AND (ended_at IS NULL OR ended_at >= ?)
|
|
134
|
+
ORDER BY created_at ASC, rowid ASC
|
|
135
|
+
`),
|
|
136
|
+
listActiveOf: db.prepare(`
|
|
137
|
+
SELECT * FROM relay_schedules
|
|
138
|
+
WHERE (conversation_id = ? OR created_by_conversation_id = ?) AND ended_at IS NULL
|
|
139
|
+
`),
|
|
140
|
+
countFiringInto: db.prepare(`SELECT COUNT(*) AS n FROM relay_schedules WHERE conversation_id = ? AND ended_at IS NULL`),
|
|
141
|
+
countSetBy: db.prepare(`SELECT COUNT(*) AS n FROM relay_schedules WHERE created_by_conversation_id = ? AND ended_at IS NULL`),
|
|
142
|
+
end: db.prepare(`UPDATE relay_schedules SET ended_at = ?, end_reason = ?, next_fire_at = NULL WHERE id = ? AND ended_at IS NULL`),
|
|
143
|
+
markFired: db.prepare(`
|
|
144
|
+
UPDATE relay_schedules
|
|
145
|
+
SET last_fired_at = ?, fired_count = fired_count + 1, last_message_id = ?, next_fire_at = ?,
|
|
146
|
+
ended_at = ?, end_reason = ?, held_since = NULL
|
|
147
|
+
WHERE id = ? AND ended_at IS NULL
|
|
148
|
+
`),
|
|
149
|
+
markHeld: db.prepare(`UPDATE relay_schedules SET held_since = COALESCE(held_since, ?) WHERE id = ? AND ended_at IS NULL`),
|
|
150
|
+
// A compaction: the successor takes over both sides.
|
|
151
|
+
carryTarget: db.prepare(`UPDATE relay_schedules SET conversation_id = ? WHERE conversation_id = ? AND ended_at IS NULL`),
|
|
152
|
+
carryCreator: db.prepare(`UPDATE relay_schedules SET created_by_conversation_id = ? WHERE created_by_conversation_id = ? AND ended_at IS NULL`),
|
|
153
|
+
markSkipped: db.prepare(`
|
|
154
|
+
UPDATE relay_schedules SET skipped_count = skipped_count + 1, next_fire_at = ? WHERE id = ? AND ended_at IS NULL
|
|
155
|
+
`),
|
|
156
|
+
pruneEnded: db.prepare(`DELETE FROM relay_schedules WHERE ended_at IS NOT NULL AND ended_at < ?`),
|
|
157
|
+
getConversation: db.prepare(`
|
|
158
|
+
SELECT id, title, archived, status, preferred_relay_mode, preferred_reasoning_effort
|
|
159
|
+
FROM conversations WHERE id = ?
|
|
160
|
+
`),
|
|
161
|
+
queueStatus: db.prepare(`SELECT status FROM queue WHERE id = ?`),
|
|
162
|
+
setNextAttempt: db.prepare(`UPDATE queue SET next_attempt_at = ? WHERE id = ? AND status = 'pending'`),
|
|
163
|
+
// Migration 0005's provenance column; without it a message carries none.
|
|
164
|
+
setMessageOrigin: db.prepare(`PRAGMA table_info(messages)`).all().some((column) => column.name === 'origin_json')
|
|
165
|
+
? db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`)
|
|
166
|
+
: null,
|
|
167
|
+
} : null;
|
|
168
|
+
|
|
169
|
+
// schedule id → timer
|
|
170
|
+
const timers = new Map();
|
|
171
|
+
let started = false;
|
|
172
|
+
|
|
173
|
+
function isoNow() {
|
|
174
|
+
return new Date(now()).toISOString();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function log(line) {
|
|
178
|
+
try { logger?.log?.(`${LOG_PREFIX} ${line}`); } catch {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function warn(line) {
|
|
182
|
+
try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function requireAvailable() {
|
|
186
|
+
if (!available) {
|
|
187
|
+
throw new RelayScheduleError('unavailable', 'Schedules are not available on this relay right now (see its server log).');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function conversation(id) {
|
|
192
|
+
const key = toText(id);
|
|
193
|
+
if (!key) return null;
|
|
194
|
+
try {
|
|
195
|
+
return sql.getConversation.get(key) || null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isLive(row) {
|
|
202
|
+
return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function titleOf(id) {
|
|
206
|
+
return toText(conversation(id)?.title) || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A schedule as agents and the browser see it. */
|
|
210
|
+
function shape(row) {
|
|
211
|
+
const own = row.conversation_id === row.created_by_conversation_id;
|
|
212
|
+
const text = String(row.text || '');
|
|
213
|
+
return {
|
|
214
|
+
id: row.id,
|
|
215
|
+
session: { id: row.conversation_id, title: titleOf(row.conversation_id) },
|
|
216
|
+
...(own ? {} : { setBy: { id: row.created_by_conversation_id, title: titleOf(row.created_by_conversation_id) } }),
|
|
217
|
+
title: toText(row.title) || null,
|
|
218
|
+
text: text.length > TEXT_PREVIEW_CHARS ? `${text.slice(0, TEXT_PREVIEW_CHARS - 1)}…` : text,
|
|
219
|
+
at: row.at || null,
|
|
220
|
+
everyMinutes: row.every_minutes ? Number(row.every_minutes) : null,
|
|
221
|
+
once: Number(row.once) === 1,
|
|
222
|
+
nextFireAt: row.next_fire_at || null,
|
|
223
|
+
lastFiredAt: row.last_fired_at || null,
|
|
224
|
+
firedCount: Number(row.fired_count || 0),
|
|
225
|
+
...(Number(row.skipped_count || 0) ? { skippedCount: Number(row.skipped_count) } : {}),
|
|
226
|
+
// Due, but held until the user writes in the conversation that set it.
|
|
227
|
+
...(row.held_since && !row.ended_at ? { heldForApproval: true, heldSince: row.held_since } : {}),
|
|
228
|
+
createdAt: row.created_at,
|
|
229
|
+
...(row.ended_at ? { endedAt: row.ended_at, endReason: row.end_reason || null } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function listActive() {
|
|
234
|
+
if (!available) return [];
|
|
235
|
+
return sql.listActive.all().map(shape);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function broadcast() {
|
|
239
|
+
try { emit(RELAY_SCHEDULE_SOCKET_EVENT, { schedules: listActive() }); } catch {}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Timers ────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
function disarm(id) {
|
|
245
|
+
const timer = timers.get(id);
|
|
246
|
+
if (timer) clearTimer(timer);
|
|
247
|
+
timers.delete(id);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function arm(row) {
|
|
251
|
+
disarm(row.id);
|
|
252
|
+
if (!started || row.ended_at) return;
|
|
253
|
+
const dueMs = msOf(row.next_fire_at);
|
|
254
|
+
if (dueMs === null) return;
|
|
255
|
+
// A held firing is due already: it is looked at again after a while.
|
|
256
|
+
const delay = row.held_since
|
|
257
|
+
? RELAY_SCHEDULE_HOLD_RECHECK_MS
|
|
258
|
+
: Math.max(0, Math.min(dueMs - now(), MAX_TIMER_MS));
|
|
259
|
+
timers.set(row.id, setTimer(() => {
|
|
260
|
+
timers.delete(row.id);
|
|
261
|
+
try {
|
|
262
|
+
fireIfDue(row.id);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
warn(`schedule ${row.id} failed to fire: ${toText(error?.message || error).slice(0, 200)}`);
|
|
265
|
+
}
|
|
266
|
+
}, delay));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Changes ───────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function checkCapacity(target, creator) {
|
|
272
|
+
const max = RELAY_SCHEDULE_LIMITS.perConversation;
|
|
273
|
+
if (Number(sql.countFiringInto.get(target)?.n || 0) >= max) {
|
|
274
|
+
throw new RelayScheduleError('limit', `Session ${target} already has ${max} schedules; unschedule one first (list_schedules lists them).`, { limit: max });
|
|
275
|
+
}
|
|
276
|
+
if (creator !== target && Number(sql.countSetBy.get(creator)?.n || 0) >= max) {
|
|
277
|
+
throw new RelayScheduleError('limit', `This conversation has set ${max} schedules already; unschedule one first (list_schedules lists them).`, { limit: max });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A new schedule. `conversationId` is the session the prompt goes to,
|
|
283
|
+
* `createdBy` the conversation whose agent set it, `origin` the provenance
|
|
284
|
+
* its messages carry (a remote-relay origin with the setting turn's hops,
|
|
285
|
+
* or null). Returns the schedule.
|
|
286
|
+
*/
|
|
287
|
+
function create({ conversationId, createdBy, text, at = null, everyMinutes = null, once = null, title = '', origin = null } = {}) {
|
|
288
|
+
requireAvailable();
|
|
289
|
+
const target = toText(conversationId);
|
|
290
|
+
const creator = toText(createdBy) || target;
|
|
291
|
+
const body = String(text ?? '').trim();
|
|
292
|
+
if (!body) throw new RelayScheduleError('invalid', 'A schedule needs text.');
|
|
293
|
+
if (body.length > RELAY_SCHEDULE_LIMITS.textMax) {
|
|
294
|
+
throw new RelayScheduleError('invalid', `text is longer than ${RELAY_SCHEDULE_LIMITS.textMax} characters`);
|
|
295
|
+
}
|
|
296
|
+
const every = everyMinutes === null || everyMinutes === undefined ? null : Number(everyMinutes);
|
|
297
|
+
if (every !== null && (!Number.isInteger(every) || every < RELAY_SCHEDULE_LIMITS.everyMinutesMin || every > RELAY_SCHEDULE_LIMITS.everyMinutesMax)) {
|
|
298
|
+
throw new RelayScheduleError('invalid', `every_minutes must be ${RELAY_SCHEDULE_LIMITS.everyMinutesMin} to ${RELAY_SCHEDULE_LIMITS.everyMinutesMax}`);
|
|
299
|
+
}
|
|
300
|
+
const atMs = msOf(at);
|
|
301
|
+
if (atMs === null && every === null) throw new RelayScheduleError('invalid', 'A schedule needs at or every_minutes.');
|
|
302
|
+
const isOnce = once === null || once === undefined ? every === null : once === true;
|
|
303
|
+
if (!isOnce && every === null) throw new RelayScheduleError('invalid', 'A schedule that repeats needs every_minutes.');
|
|
304
|
+
if (!isLive(conversation(target))) {
|
|
305
|
+
throw new RelayScheduleError('notFound', `No live session ${target} on this relay; use an id from list_sessions (archived sessions take no schedules).`, { session: target });
|
|
306
|
+
}
|
|
307
|
+
if (creator !== target && !isLive(conversation(creator))) {
|
|
308
|
+
throw new RelayScheduleError('notFound', 'The conversation setting the schedule was not found or is archived.');
|
|
309
|
+
}
|
|
310
|
+
const at0 = atMs === null ? null : new Date(atMs).toISOString();
|
|
311
|
+
const createdAt = isoNow();
|
|
312
|
+
const next = firstRelayScheduleFire({ at: at0, everyMinutes: every, now: now() });
|
|
313
|
+
let id = '';
|
|
314
|
+
db.transaction(() => {
|
|
315
|
+
checkCapacity(target, creator);
|
|
316
|
+
id = newId();
|
|
317
|
+
const originJson = origin && typeof origin === 'object' ? JSON.stringify(origin) : null;
|
|
318
|
+
sql.insert.run(id, target, creator, normalizeRelayScheduleTitle(title) || null, body, at0, every, isOnce ? 1 : 0, next, createdAt, originJson);
|
|
319
|
+
})();
|
|
320
|
+
const row = sql.get.get(id);
|
|
321
|
+
arm(row);
|
|
322
|
+
log(`${id} set for conv=${shortId(target)}${creator !== target ? ` by conv=${shortId(creator)}` : ''}: next ${next}${every ? `, every ${every} min` : ''}${isOnce ? ', once' : ''}`);
|
|
323
|
+
broadcast();
|
|
324
|
+
return shape(row);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The schedules that fire into a conversation or that it set: the active
|
|
329
|
+
* ones and the ones that ended within the last hour.
|
|
330
|
+
*/
|
|
331
|
+
function list({ conversationId } = {}) {
|
|
332
|
+
requireAvailable();
|
|
333
|
+
const id = toText(conversationId);
|
|
334
|
+
return sql.listOf.all(id, id, new Date(now() - ENDED_LISTED_MS).toISOString()).map(shape);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function get(scheduleId) {
|
|
338
|
+
if (!available) return null;
|
|
339
|
+
const row = sql.get.get(toText(scheduleId));
|
|
340
|
+
return row ? shape(row) : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function endRow(row, reason) {
|
|
344
|
+
const changed = sql.end.run(isoNow(), reason, row.id).changes > 0;
|
|
345
|
+
disarm(row.id);
|
|
346
|
+
return changed;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* End a schedule. `byConversationId` (an agent's call) may end only one
|
|
351
|
+
* that fires into it or that it set; without it (the user) any.
|
|
352
|
+
*/
|
|
353
|
+
function unschedule({ scheduleId, byConversationId = null } = {}) {
|
|
354
|
+
requireAvailable();
|
|
355
|
+
const id = toText(scheduleId);
|
|
356
|
+
const row = id ? sql.get.get(id) : null;
|
|
357
|
+
const by = toText(byConversationId);
|
|
358
|
+
if (!row || (by && row.conversation_id !== by && row.created_by_conversation_id !== by)) {
|
|
359
|
+
throw new RelayScheduleError('notFound', `No schedule ${id} of this conversation; list_schedules lists them.`, { schedule_id: id });
|
|
360
|
+
}
|
|
361
|
+
if (row.ended_at) return shape(row);
|
|
362
|
+
endRow(row, 'unscheduled');
|
|
363
|
+
log(`${id} unscheduled${by ? ` by conv=${shortId(by)}` : ' by the user'}`);
|
|
364
|
+
broadcast();
|
|
365
|
+
return shape(sql.get.get(id));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A conversation was archived or deleted: the schedules that fire into it or that it set end. */
|
|
369
|
+
function noteConversationGone(conversationId, { deleted = false } = {}) {
|
|
370
|
+
if (!available) return 0;
|
|
371
|
+
const id = toText(conversationId);
|
|
372
|
+
if (!id) return 0;
|
|
373
|
+
let ended = 0;
|
|
374
|
+
for (const row of sql.listActiveOf.all(id, id)) {
|
|
375
|
+
if (endRow(row, deleted ? 'deleted' : 'archived')) ended += 1;
|
|
376
|
+
}
|
|
377
|
+
if (ended) {
|
|
378
|
+
log(`conv=${shortId(id)} ${deleted ? 'deleted' : 'archived'}: ${ended} schedule${ended === 1 ? '' : 's'} ended`);
|
|
379
|
+
broadcast();
|
|
380
|
+
}
|
|
381
|
+
return ended;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A conversation was compacted into a new one: its schedules go on there,
|
|
386
|
+
* the ones that fire into it and the ones it set (a nightly schedule of its
|
|
387
|
+
* own survives the compaction). Returns how many moved.
|
|
388
|
+
*/
|
|
389
|
+
function carryToCompacted(fromConversationId, toConversationId) {
|
|
390
|
+
if (!available) return 0;
|
|
391
|
+
const from = toText(fromConversationId);
|
|
392
|
+
const to = toText(toConversationId);
|
|
393
|
+
if (!from || !to || from === to) return 0;
|
|
394
|
+
const moved = db.transaction(() => sql.carryTarget.run(to, from).changes + sql.carryCreator.run(to, from).changes)();
|
|
395
|
+
if (moved) {
|
|
396
|
+
log(`conv=${shortId(from)} compacted into conv=${shortId(to)}: its schedules moved along`);
|
|
397
|
+
broadcast();
|
|
398
|
+
}
|
|
399
|
+
return moved;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Firing ────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
function queueMessage(target, text, dueAt, origin) {
|
|
405
|
+
const messageId = randomUUID();
|
|
406
|
+
const at = isoNow();
|
|
407
|
+
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(target.id) || null;
|
|
408
|
+
const latestModel = stmts.getLatestConversationModel?.get?.(target.id)?.model || null;
|
|
409
|
+
// The model the conversation last ran on, as a relay event picks it.
|
|
410
|
+
const model = toText(latestModel) || toText(runtimeSession?.provider_model) || toText(runtimeSession?.model) || null;
|
|
411
|
+
const relayMode = toText(target.preferred_relay_mode) || 'agent';
|
|
412
|
+
const owner = toText(runtimeSession?.sdk_session_id) || null;
|
|
413
|
+
stmts.insertMsg.run(
|
|
414
|
+
messageId,
|
|
415
|
+
target.id,
|
|
416
|
+
'user',
|
|
417
|
+
text,
|
|
418
|
+
model,
|
|
419
|
+
relayMode,
|
|
420
|
+
null,
|
|
421
|
+
at,
|
|
422
|
+
model,
|
|
423
|
+
null,
|
|
424
|
+
toText(model).toLowerCase() === 'auto' ? 'auto' : 'manual',
|
|
425
|
+
);
|
|
426
|
+
stmts.setMessageKind?.run?.(RELAY_SCHEDULE_MESSAGE_KIND, messageId);
|
|
427
|
+
// The provenance of the turn that set the schedule: its hops go on.
|
|
428
|
+
if (origin) sql.setMessageOrigin?.run(JSON.stringify(origin), messageId);
|
|
429
|
+
stmts.updateConvTime?.run?.(at, target.id);
|
|
430
|
+
const queueArgs = [
|
|
431
|
+
messageId,
|
|
432
|
+
target.id,
|
|
433
|
+
runtimeSession?.id || null,
|
|
434
|
+
0,
|
|
435
|
+
model,
|
|
436
|
+
model,
|
|
437
|
+
toText(target.preferred_reasoning_effort) || null,
|
|
438
|
+
null,
|
|
439
|
+
relayMode,
|
|
440
|
+
text,
|
|
441
|
+
null,
|
|
442
|
+
at,
|
|
443
|
+
owner,
|
|
444
|
+
owner ? at : null,
|
|
445
|
+
null,
|
|
446
|
+
null,
|
|
447
|
+
];
|
|
448
|
+
if (stmts.queueHasImageOperationId || stmts.insertQ?.source?.includes?.('image_operation_id')) queueArgs.push(null);
|
|
449
|
+
stmts.insertQ.run(...queueArgs);
|
|
450
|
+
// Due since the schedule's time: the delivery takes it at once.
|
|
451
|
+
sql.setNextAttempt.run(dueAt, messageId);
|
|
452
|
+
return { messageId, model, relayMode, owner, timestamp: at, text, origin };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function goneReason(row) {
|
|
456
|
+
return toText(row?.status) === 'deleted' || !row ? 'deleted' : 'archived';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Fire a schedule whose time has come; anything else is left (re-armed).
|
|
461
|
+
* Returns `{ fired, skipped, held, ended, messageId }` or null.
|
|
462
|
+
*/
|
|
463
|
+
function fireIfDue(scheduleId) {
|
|
464
|
+
if (!available) return null;
|
|
465
|
+
const row = sql.get.get(toText(scheduleId));
|
|
466
|
+
if (!row || row.ended_at) return null;
|
|
467
|
+
const dueMs = msOf(row.next_fire_at);
|
|
468
|
+
if (dueMs === null) return null;
|
|
469
|
+
if (dueMs > now()) {
|
|
470
|
+
arm(row);
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const target = conversation(row.conversation_id);
|
|
474
|
+
const forOther = row.created_by_conversation_id !== row.conversation_id;
|
|
475
|
+
// The session it fires into, and the one that set it, must still be
|
|
476
|
+
// there (a compaction carried them over to the successor).
|
|
477
|
+
const creator = forOther ? conversation(row.created_by_conversation_id) : target;
|
|
478
|
+
if (!isLive(target) || !isLive(creator)) {
|
|
479
|
+
endRow(row, goneReason(!isLive(target) ? target : creator));
|
|
480
|
+
broadcast();
|
|
481
|
+
return { fired: false, skipped: false, held: false, ended: true, messageId: null };
|
|
482
|
+
}
|
|
483
|
+
const dueAt = new Date(dueMs).toISOString();
|
|
484
|
+
const every = row.every_minutes ? Number(row.every_minutes) : null;
|
|
485
|
+
const once = Number(row.once) === 1;
|
|
486
|
+
const next = once ? null : nextRelayScheduleFire({ dueAt, everyMinutes: every, now: now() });
|
|
487
|
+
const waiting = row.last_message_id ? toText(sql.queueStatus.get(row.last_message_id)?.status) : '';
|
|
488
|
+
// The firing before still waits for the session: this one is skipped
|
|
489
|
+
// (a once schedule has none before it).
|
|
490
|
+
const skip = !once && (waiting === 'pending' || waiting === 'parked');
|
|
491
|
+
// A firing into another session is a prompt of the conversation that set
|
|
492
|
+
// it: the agents' loop guard counts it, and holds it once it wants the
|
|
493
|
+
// user first (it goes after the user wrote in that conversation).
|
|
494
|
+
let admitted = true;
|
|
495
|
+
if (!skip && forOther) {
|
|
496
|
+
try {
|
|
497
|
+
admitted = admitFiring({ scheduleId: row.id, conversationId: row.conversation_id, createdBy: row.created_by_conversation_id }) !== false;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
warn(`schedule ${row.id}: the loop guard could not be asked (${toText(error?.message || error).slice(0, 200)}); held`);
|
|
500
|
+
admitted = false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (!admitted) {
|
|
504
|
+
const first = !row.held_since;
|
|
505
|
+
sql.markHeld.run(isoNow(), row.id);
|
|
506
|
+
arm(sql.get.get(row.id));
|
|
507
|
+
if (first) {
|
|
508
|
+
log(`${row.id} held for conv=${shortId(row.conversation_id)}: conv=${shortId(row.created_by_conversation_id)} has prompted other agents as often as it may before the user writes`);
|
|
509
|
+
broadcast();
|
|
510
|
+
}
|
|
511
|
+
return { fired: false, skipped: false, held: true, ended: false, messageId: null };
|
|
512
|
+
}
|
|
513
|
+
const origin = parseJson(row.origin_json);
|
|
514
|
+
let queued = null;
|
|
515
|
+
db.transaction(() => {
|
|
516
|
+
if (skip) {
|
|
517
|
+
sql.markSkipped.run(next, row.id);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const setBy = row.created_by_conversation_id !== row.conversation_id
|
|
521
|
+
? { id: row.created_by_conversation_id, title: titleOf(row.created_by_conversation_id) }
|
|
522
|
+
: null;
|
|
523
|
+
const text = buildRelayScheduleMessageText({ id: row.id, title: row.title, setBy, text: row.text });
|
|
524
|
+
queued = queueMessage(target, text, dueAt, origin);
|
|
525
|
+
sql.markFired.run(isoNow(), queued.messageId, next, once ? isoNow() : null, once ? 'fired' : null, row.id);
|
|
526
|
+
})();
|
|
527
|
+
const after = sql.get.get(row.id);
|
|
528
|
+
if (after && !after.ended_at) arm(after);
|
|
529
|
+
else disarm(row.id);
|
|
530
|
+
if (skip) {
|
|
531
|
+
log(`${row.id} skipped for conv=${shortId(row.conversation_id)}: the message before still waits; next ${next}`);
|
|
532
|
+
broadcast();
|
|
533
|
+
return { fired: false, skipped: true, held: false, ended: false, messageId: null };
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
emit('user_message', {
|
|
537
|
+
conversationId: target.id,
|
|
538
|
+
messageId: queued.messageId,
|
|
539
|
+
senderClientId: null,
|
|
540
|
+
message: {
|
|
541
|
+
role: 'user',
|
|
542
|
+
text: queued.text,
|
|
543
|
+
model: queued.model,
|
|
544
|
+
mode: queued.relayMode,
|
|
545
|
+
attachments: [],
|
|
546
|
+
timestamp: queued.timestamp,
|
|
547
|
+
kind: RELAY_SCHEDULE_MESSAGE_KIND,
|
|
548
|
+
...(queued.origin ? { origin: queued.origin } : {}),
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
} catch {}
|
|
552
|
+
try { emit('message_status', { messageId: queued.messageId, conversationId: target.id, status: 'pending' }); } catch {}
|
|
553
|
+
try { wake({ conversationId: target.id, ownerSessionId: queued.owner, messageId: queued.messageId }); } catch {}
|
|
554
|
+
log(`${row.id} fired into conv=${shortId(target.id)}${once ? ' (ended: once)' : `; next ${next}`}`);
|
|
555
|
+
broadcast();
|
|
556
|
+
return { fired: true, skipped: false, held: false, ended: once, messageId: queued.messageId };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function pruneEnded() {
|
|
560
|
+
if (!available) return 0;
|
|
561
|
+
return sql.pruneEnded.run(new Date(now() - ENDED_KEEP_MS).toISOString()).changes;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Boot: every active schedule gets its timer; one that was due while the relay was down fires now, once. */
|
|
565
|
+
function start() {
|
|
566
|
+
if (!available) {
|
|
567
|
+
if (problem) warn(`schedules are off: ${problem}`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (started) return;
|
|
571
|
+
started = true;
|
|
572
|
+
try { pruneEnded(); } catch {}
|
|
573
|
+
for (const row of sql.listActive.all()) arm(row);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function stop() {
|
|
577
|
+
started = false;
|
|
578
|
+
for (const timer of timers.values()) clearTimer(timer);
|
|
579
|
+
timers.clear();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
isAvailable: () => available,
|
|
584
|
+
create,
|
|
585
|
+
list,
|
|
586
|
+
listActive,
|
|
587
|
+
get,
|
|
588
|
+
unschedule,
|
|
589
|
+
noteConversationGone,
|
|
590
|
+
carryToCompacted,
|
|
591
|
+
fireIfDue,
|
|
592
|
+
pruneEnded,
|
|
593
|
+
start,
|
|
594
|
+
stop,
|
|
595
|
+
};
|
|
596
|
+
}
|