@oar-sh/oar 0.9.13 → 0.9.14
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/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +302 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +296 -149
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +4 -2
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `preinstall` of @oar-sh/oar. Nothing of OAR loads before the `try`:
|
|
3
|
+
* a module of this package that cannot load (a broken file, a missing one)
|
|
4
|
+
* must not fail the install on any platform. Only a copy of an old
|
|
5
|
+
* database that failed (RescueCopyError) exits 1, so npm stops the update
|
|
6
|
+
* and puts the old package, data and all, back in place; anything else is
|
|
7
|
+
* one line and exit 0, as this script only ever copies. See
|
|
8
|
+
* package-data-rescue.mjs.
|
|
9
|
+
*/
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const { rescuePackageData } = await import('./package-data-rescue.mjs');
|
|
14
|
+
rescuePackageData({ packageRoot: fileURLToPath(new URL('../..', import.meta.url)) });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error?.rescueCopyFailed === true) {
|
|
17
|
+
console.error(`[oar] ${error.message}`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
} else {
|
|
20
|
+
console.error(`[oar] Did not copy the data of the version being replaced: ${error?.message || error}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preinstall's work (package-data-rescue-preinstall.mjs), on every
|
|
3
|
+
* platform. Relays before 0.9.14 could run on the database and uploads
|
|
4
|
+
* inside the package (Windows autostart, a plain `gh copilot`), and
|
|
5
|
+
* `npm i -g` deletes the old package. While install scripts run, npm keeps
|
|
6
|
+
* it as its retired folder (npmRetiredPath; a scan for npm-shaped names only
|
|
7
|
+
* when that is missing): its database (-wal, -shm), Cursor stores, uploads
|
|
8
|
+
* and config.json are copied, copy only, to
|
|
9
|
+
* `<data folder>/rescued-package-data/<time>/` (written as `.partial`, then
|
|
10
|
+
* renamed), and the relay takes them over at its next start. Any of them
|
|
11
|
+
* makes the old package a source (its uploads beyond the README.md and
|
|
12
|
+
* image_input.md it ships). Nothing to copy, or not an installed package:
|
|
13
|
+
* nothing happens, nothing is printed. A failed copy throws
|
|
14
|
+
* RescueCopyError: exit 1, npm puts the old package back. npm hides a successful script's output, hence the README.txt.
|
|
15
|
+
*/
|
|
16
|
+
import crypto from 'crypto';
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
import { RESCUE_DIR_NAME, copyMissing, isRetiredName } from './earlier-database-service.mjs';
|
|
21
|
+
import { resolveRelayStateDirs, resolveStateLayout } from './oar-state-layout.mjs';
|
|
22
|
+
|
|
23
|
+
const DB_FILES = ['copilot.db', 'copilot.db-wal', 'copilot.db-shm'];
|
|
24
|
+
|
|
25
|
+
// What a relay left in a package's server folder; a folder that cannot be
|
|
26
|
+
// read throws (no copy failure: nothing was detected yet).
|
|
27
|
+
function hasRelayData(fsImpl, serverDir) {
|
|
28
|
+
const has = (...parts) => fsImpl.existsSync(path.join(serverDir, ...parts));
|
|
29
|
+
const anyUpload = (...parts) => has(...parts) && fsImpl.readdirSync(path.join(serverDir, ...parts)).some((name) => /^[a-f0-9]{64}$/.test(name));
|
|
30
|
+
return has('data', 'copilot.db') || has('data', 'cursor-agents') || has('config.json') || anyUpload('uploads') || anyUpload('data', 'uploads');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where npm's reify moves a package it replaces (@npmcli/arborist retire-path.js). */
|
|
34
|
+
export function npmRetiredPath(packageDir) {
|
|
35
|
+
const hash = crypto.createHash('sha1').update(packageDir).digest('base64').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
|
|
36
|
+
return path.resolve(path.dirname(packageDir), `.${path.basename(packageDir)}-${hash}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A copy of an old database failed: the one error that stops the install. */
|
|
40
|
+
export class RescueCopyError extends Error {
|
|
41
|
+
constructor(message, options) {
|
|
42
|
+
super(message, options);
|
|
43
|
+
this.name = 'RescueCopyError';
|
|
44
|
+
this.rescueCopyFailed = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns { status: 'not-installed' | 'nothing' | 'rescued', rescued: [{ from,
|
|
50
|
+
* to }] }; throws a RescueCopyError, naming what to save, when a copy fails.
|
|
51
|
+
*/
|
|
52
|
+
export function rescuePackageData({
|
|
53
|
+
packageRoot,
|
|
54
|
+
env = process.env,
|
|
55
|
+
platform = process.platform,
|
|
56
|
+
now = () => new Date(),
|
|
57
|
+
fsImpl = fs,
|
|
58
|
+
logger = console,
|
|
59
|
+
} = {}) {
|
|
60
|
+
const resolvedRoot = path.resolve(String(packageRoot || ''));
|
|
61
|
+
const scope = path.dirname(resolvedRoot);
|
|
62
|
+
if (path.basename(scope) !== '@oar-sh' || path.basename(path.dirname(scope)) !== 'node_modules') {
|
|
63
|
+
return { status: 'not-installed', rescued: [] };
|
|
64
|
+
}
|
|
65
|
+
// Detection: npm's retired name, else a scan for npm-shaped names (a path
|
|
66
|
+
// in another case hashes differently). Failing here is no copy failure.
|
|
67
|
+
let sources;
|
|
68
|
+
try {
|
|
69
|
+
const exact = npmRetiredPath(resolvedRoot);
|
|
70
|
+
const retired = fsImpl.existsSync(exact)
|
|
71
|
+
? [exact]
|
|
72
|
+
: fsImpl.readdirSync(scope).filter((name) => isRetiredName(name, path.basename(resolvedRoot))).map((name) => path.join(scope, name));
|
|
73
|
+
sources = retired.filter((dir) => hasRelayData(fsImpl, path.join(dir, 'server')));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`Could not look for the data of the version being replaced: ${error?.message || error}`, { cause: error });
|
|
76
|
+
}
|
|
77
|
+
if (!sources.length) return { status: 'nothing', rescued: [] };
|
|
78
|
+
|
|
79
|
+
// Old data is there: any failure from here stops the install.
|
|
80
|
+
const exists = (file) => fsImpl.existsSync(file);
|
|
81
|
+
const kept = path.join(resolvedRoot, 'server');
|
|
82
|
+
const copyFailed = (from, to, error) => new RescueCopyError(
|
|
83
|
+
`Could not copy the data of the version being replaced (${from}) to ${to}: ${error?.message || error}. `
|
|
84
|
+
+ `The update stops and the version installed stays as it is; copy ${path.join(kept, 'data')} and ${path.join(kept, 'uploads')} somewhere safe by hand, then update again.`,
|
|
85
|
+
{ cause: error },
|
|
86
|
+
);
|
|
87
|
+
let rescueRoot;
|
|
88
|
+
try {
|
|
89
|
+
const layout = resolveStateLayout({ packageRoot: resolvedRoot, env, platform });
|
|
90
|
+
rescueRoot = path.join(resolveRelayStateDirs({ packageRoot: resolvedRoot, env, platform, layout }).dataDir, RESCUE_DIR_NAME);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw copyFailed(path.join(sources[0], 'server'), 'the OAR data folder', error);
|
|
93
|
+
}
|
|
94
|
+
const stamp = now().toISOString().replace(/[:.]/g, '-');
|
|
95
|
+
const rescued = [];
|
|
96
|
+
for (const [index, source] of sources.entries()) {
|
|
97
|
+
const serverDir = path.join(source, 'server');
|
|
98
|
+
let target = path.join(rescueRoot, sources.length > 1 ? `${stamp}-${index}` : stamp);
|
|
99
|
+
// Written under a .partial name and renamed when complete: a half copy
|
|
100
|
+
// is never taken for a rescue.
|
|
101
|
+
try {
|
|
102
|
+
for (let n = 1; exists(target) || exists(`${target}.partial`); n += 1) target = `${path.join(rescueRoot, stamp)}-${n}`;
|
|
103
|
+
const partial = `${target}.partial`;
|
|
104
|
+
fsImpl.mkdirSync(rescueRoot, { recursive: true });
|
|
105
|
+
fsImpl.mkdirSync(partial);
|
|
106
|
+
fsImpl.mkdirSync(path.join(partial, 'data'));
|
|
107
|
+
for (const name of DB_FILES) {
|
|
108
|
+
const from = path.join(serverDir, 'data', name);
|
|
109
|
+
if (exists(from)) fsImpl.copyFileSync(from, path.join(partial, 'data', name), fs.constants.COPYFILE_EXCL);
|
|
110
|
+
}
|
|
111
|
+
if (exists(path.join(serverDir, 'data', 'cursor-agents'))) {
|
|
112
|
+
copyMissing(fsImpl, path.join(serverDir, 'data', 'cursor-agents'), path.join(partial, 'data', 'cursor-agents'));
|
|
113
|
+
}
|
|
114
|
+
// Uploads of server/uploads and server/data/uploads, content-addressed.
|
|
115
|
+
for (const uploads of [path.join(serverDir, 'uploads'), path.join(serverDir, 'data', 'uploads')]) {
|
|
116
|
+
if (exists(uploads)) copyMissing(fsImpl, uploads, path.join(partial, 'uploads'));
|
|
117
|
+
}
|
|
118
|
+
if (exists(path.join(serverDir, 'config.json'))) {
|
|
119
|
+
fsImpl.copyFileSync(path.join(serverDir, 'config.json'), path.join(partial, 'config.json'), fs.constants.COPYFILE_EXCL);
|
|
120
|
+
}
|
|
121
|
+
fsImpl.writeFileSync(path.join(partial, 'README.txt'), [
|
|
122
|
+
`Copied by the @oar-sh/oar update on ${now().toISOString()} from`,
|
|
123
|
+
`${serverDir}`,
|
|
124
|
+
'before npm deleted the version it replaced. The relay takes the',
|
|
125
|
+
'database in data\\ over at its next start if it is newer than the',
|
|
126
|
+
'one it has. Nothing here is deleted by OAR.',
|
|
127
|
+
'',
|
|
128
|
+
].join('\r\n'));
|
|
129
|
+
fsImpl.renameSync(partial, target);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw copyFailed(serverDir, target, error);
|
|
132
|
+
}
|
|
133
|
+
rescued.push({ from: serverDir, to: target });
|
|
134
|
+
logger.log?.(`[oar] Copied the data of the version being replaced from ${serverDir} to ${target}; the relay takes it over at its next start.`);
|
|
135
|
+
}
|
|
136
|
+
return { status: 'rescued', rescued };
|
|
137
|
+
}
|
|
@@ -687,8 +687,9 @@ export function createPreviewRegistry({
|
|
|
687
687
|
ok: false,
|
|
688
688
|
status: 429,
|
|
689
689
|
error: `Too many live previews (max ${settings.maxLive}); previews stay until closed. `
|
|
690
|
-
+ `Oldest: ${oldest.join('; ')}. Close ${toClose === 1 ? 'one' : toClose} in Settings → Previews
|
|
691
|
-
+ `close action (list shows
|
|
690
|
+
+ `Oldest: ${oldest.join('; ')}. Close ${toClose === 1 ? 'one' : toClose} in Settings → Previews; an agent can close `
|
|
691
|
+
+ `its own conversation's with the preview tool's close action (list shows their tokens) or with `
|
|
692
|
+
+ `DELETE /api/previews/:token?conversationId=<conv>.`,
|
|
692
693
|
};
|
|
693
694
|
}
|
|
694
695
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { providerDisplayName } from '../public/app/conversation-provider-indicator.mjs';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Web Push dispatch: decides whether a push should go out at all (suppressed
|
|
5
7
|
* while any device is actively foregrounded), renders the notification per
|
|
@@ -29,7 +31,8 @@ const FAILURE_PRUNE_THRESHOLD = 8;
|
|
|
29
31
|
const PUSH_TTL_SECONDS = 12 * 60 * 60;
|
|
30
32
|
|
|
31
33
|
const EVENT_LABELS = Object.freeze({
|
|
32
|
-
|
|
34
|
+
// A question's title names the provider that asks (questionTitle).
|
|
35
|
+
question: { title: 'An agent needs your input', generic: 'The agent asked a question.' },
|
|
33
36
|
turnComplete: { title: 'Turn completed', generic: 'The agent finished its turn.' },
|
|
34
37
|
turnFailed: { title: 'Turn failed', generic: 'The agent turn failed.' },
|
|
35
38
|
board: { title: 'Plan ready for review', generic: 'The agent posted a board for review.' },
|
|
@@ -38,6 +41,12 @@ const EVENT_LABELS = Object.freeze({
|
|
|
38
41
|
relayRestart: { title: 'Relay restart waiting', generic: 'The queued relay restart has waited its maximum time.' },
|
|
39
42
|
});
|
|
40
43
|
|
|
44
|
+
function eventTitle(type, providerName) {
|
|
45
|
+
const name = String(providerName || '').trim();
|
|
46
|
+
if (type === 'question' && name) return `${name} needs your input`;
|
|
47
|
+
return EVENT_LABELS[type].title;
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
// System events carry their own body: no conversation text to preview, and
|
|
42
51
|
// the body is the message itself regardless of the preview preference.
|
|
43
52
|
const SYSTEM_BODY_TYPES = new Set(['hostSuspend', 'relayRestart']);
|
|
@@ -79,7 +88,7 @@ export function normalizePushPreferences(raw) {
|
|
|
79
88
|
* Render the notification payload for one subscription. Pure so every content
|
|
80
89
|
* preference combination is unit-testable.
|
|
81
90
|
*
|
|
82
|
-
* @param {{ type: string, conversationTitle?: string|null, body?: string|null, data?: object }} event
|
|
91
|
+
* @param {{ type: string, conversationTitle?: string|null, providerName?: string|null, body?: string|null, data?: object }} event
|
|
83
92
|
* @param {object} preferences canonical shape from normalizePushPreferences
|
|
84
93
|
*/
|
|
85
94
|
export function renderPushNotification(event, preferences) {
|
|
@@ -87,10 +96,11 @@ export function renderPushNotification(event, preferences) {
|
|
|
87
96
|
const prefs = normalizePushPreferences(preferences);
|
|
88
97
|
const labels = EVENT_LABELS[type];
|
|
89
98
|
|
|
90
|
-
|
|
99
|
+
const baseTitle = eventTitle(type, event?.providerName);
|
|
100
|
+
let title = baseTitle;
|
|
91
101
|
const conversationTitle = String(event?.conversationTitle || '').trim();
|
|
92
102
|
if (prefs.content.includeTitle && conversationTitle) {
|
|
93
|
-
title = `${
|
|
103
|
+
title = `${baseTitle} — ${conversationTitle}`.slice(0, MAX_TITLE_CHARS);
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
let body = labels.generic;
|
|
@@ -116,7 +126,7 @@ export function renderPushNotification(event, preferences) {
|
|
|
116
126
|
return {
|
|
117
127
|
title,
|
|
118
128
|
body,
|
|
119
|
-
tag: `
|
|
129
|
+
tag: `oar-${type}-${tagSeed}`,
|
|
120
130
|
timestamp: Date.now(),
|
|
121
131
|
data,
|
|
122
132
|
};
|
|
@@ -341,6 +351,21 @@ export function createPushDispatchService({
|
|
|
341
351
|
}
|
|
342
352
|
}
|
|
343
353
|
|
|
354
|
+
// The provider of a question's conversation, for the push title. Prepared
|
|
355
|
+
// lazily: a schema without runtime sessions just means "not known".
|
|
356
|
+
let providerTypeStatement = null;
|
|
357
|
+
function conversationProviderName(conversationId) {
|
|
358
|
+
const id = String(conversationId || '').trim();
|
|
359
|
+
if (!id) return null;
|
|
360
|
+
try {
|
|
361
|
+
providerTypeStatement ??= db.prepare(`SELECT provider_type FROM runtime_sessions WHERE conversation_id = ?`);
|
|
362
|
+
// Named as the question card's head names it; unknown stays neutral.
|
|
363
|
+
return providerDisplayName(providerTypeStatement.get(id)?.provider_type) || null;
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
344
369
|
async function sendToSubscription(row, event) {
|
|
345
370
|
const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
|
|
346
371
|
const payload = renderPushNotification(event, preferences);
|
|
@@ -384,7 +409,7 @@ export function createPushDispatchService({
|
|
|
384
409
|
* Fire-and-forget push for one event. Never throws; suppression failures
|
|
385
410
|
* fail closed (no push when the active-device state is unknown).
|
|
386
411
|
*/
|
|
387
|
-
async function dispatch({ type, conversationId = null, body = null, data = {} }) {
|
|
412
|
+
async function dispatch({ type, conversationId = null, body = null, data = {}, providerName = null }) {
|
|
388
413
|
try {
|
|
389
414
|
if (!PUSH_EVENT_TYPES.includes(type)) return { outcome: 'invalid-type' };
|
|
390
415
|
|
|
@@ -410,6 +435,7 @@ export function createPushDispatchService({
|
|
|
410
435
|
const event = {
|
|
411
436
|
type,
|
|
412
437
|
conversationTitle: conversationTitle(conversationId),
|
|
438
|
+
providerName,
|
|
413
439
|
body,
|
|
414
440
|
data: { ...data, conversationId: conversationId || undefined },
|
|
415
441
|
};
|
|
@@ -445,6 +471,7 @@ export function createPushDispatchService({
|
|
|
445
471
|
return dispatch({
|
|
446
472
|
type: 'question',
|
|
447
473
|
conversationId: question?.conversationId || null,
|
|
474
|
+
providerName: conversationProviderName(question?.conversationId),
|
|
448
475
|
body: question?.prompt || null,
|
|
449
476
|
data: {
|
|
450
477
|
questionId: question?.id || undefined,
|
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
* boot. A subscription ends on unsubscribe, when its watcher is archived or
|
|
33
33
|
* deleted, when its last listed session is, or after firing (`once`).
|
|
34
34
|
*
|
|
35
|
+
* relay_restarted is the relay's own event, opt-in by name: each start of
|
|
36
|
+
* the runtime (noteRelayStarted) wakes the subscriptions of this relay's
|
|
37
|
+
* watchers that name it, once, with how the run before ended
|
|
38
|
+
* (noteRelayExit). A conversation whose turn answered relay_restarted when
|
|
39
|
+
* the restart was requested (noteRestartRequested) is not woken by it: a
|
|
40
|
+
* woken turn that restarts again ends the chain.
|
|
41
|
+
*
|
|
35
42
|
* Across paired relays (migrations/0012-cross-relay-event-subscriptions.mjs)
|
|
36
43
|
* this service plays both sides:
|
|
37
44
|
*
|
|
@@ -57,6 +64,8 @@ import {
|
|
|
57
64
|
RELAY_EVENT_LINK_LOST_TYPE,
|
|
58
65
|
RELAY_EVENT_LINK_RESTORED_TYPE,
|
|
59
66
|
RELAY_EVENT_MESSAGE_KIND,
|
|
67
|
+
RELAY_EVENT_RELAY_LEVEL_TYPES,
|
|
68
|
+
RELAY_EVENT_RELAY_RESTARTED_TYPE,
|
|
60
69
|
RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
|
|
61
70
|
RELAY_EVENT_SUBSCRIBABLE_TYPES,
|
|
62
71
|
RELAY_EVENT_TYPES,
|
|
@@ -66,6 +75,7 @@ import {
|
|
|
66
75
|
effectiveRelayEventTypes,
|
|
67
76
|
isLegacyDefaultRelayEventTypes,
|
|
68
77
|
isRelayEventType,
|
|
78
|
+
isRelayLevelOnly,
|
|
69
79
|
isUrgentRelayEventType,
|
|
70
80
|
relayEventMatchText,
|
|
71
81
|
relayEventTypesTake,
|
|
@@ -77,6 +87,12 @@ export const RELAY_EVENT_SELF_RELAY_ID = 'self';
|
|
|
77
87
|
// conversation id, so nothing a paired relay asks for is ever written into
|
|
78
88
|
// one of this relay's conversations.
|
|
79
89
|
export const RELAY_EVENT_SERVED_WATCHER_PREFIX = 'remote:';
|
|
90
|
+
// app_settings: how the runtime before this one ended, written on its way
|
|
91
|
+
// out (noteRelayExit) and taken by the next start (noteRelayStarted).
|
|
92
|
+
export const RELAY_LAST_EXIT_SETTING_KEY = 'relay_last_exit';
|
|
93
|
+
// Older than this, a record is not of the run just before this one. Long:
|
|
94
|
+
// a relay stopped for a weekend still says how; the line shows the stop time.
|
|
95
|
+
export const RELAY_LAST_EXIT_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
|
|
80
96
|
|
|
81
97
|
const HOUR_MS = 60 * 60_000;
|
|
82
98
|
const DAY_MS = 24 * HOUR_MS;
|
|
@@ -291,6 +307,19 @@ export function createRelayEventService({
|
|
|
291
307
|
AND COALESCE(m.kind, '') <> ?
|
|
292
308
|
`),
|
|
293
309
|
deleteDeliveredBefore: db.prepare(RELAY_EVENT_PRUNE_SQL.deleteDeliveredBefore),
|
|
310
|
+
// The loop guard: conversations whose running turn answers relay_restarted.
|
|
311
|
+
listRestartWokenRunning: db.prepare(`
|
|
312
|
+
SELECT DISTINCT q.conversation_id FROM queue q JOIN relay_events e ON e.message_id = q.id
|
|
313
|
+
WHERE q.status = 'processing' AND e.type = ?
|
|
314
|
+
`),
|
|
315
|
+
} : null;
|
|
316
|
+
const exitSql = available && tableExists('app_settings') ? {
|
|
317
|
+
get: db.prepare(`SELECT value FROM app_settings WHERE key = ?`),
|
|
318
|
+
put: db.prepare(`
|
|
319
|
+
INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
320
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
321
|
+
`),
|
|
322
|
+
take: db.prepare(`DELETE FROM app_settings WHERE key = ?`),
|
|
294
323
|
} : null;
|
|
295
324
|
// Without migration 0012 the table has no remote_sync column.
|
|
296
325
|
const deleteEndedSubscriptionsBefore = available
|
|
@@ -381,6 +410,12 @@ export function createRelayEventService({
|
|
|
381
410
|
let sweepTimer = null;
|
|
382
411
|
let started = false;
|
|
383
412
|
let lastPruneAt = 0;
|
|
413
|
+
// relay_restarted: the conversations a requested restart does not wake
|
|
414
|
+
// (the loop guard), whether this runtime's end is written, and whether its
|
|
415
|
+
// start was noted.
|
|
416
|
+
const restartNoWake = new Set();
|
|
417
|
+
let exitNoted = false;
|
|
418
|
+
let startNoted = false;
|
|
384
419
|
|
|
385
420
|
function isoNow() {
|
|
386
421
|
return new Date(now()).toISOString();
|
|
@@ -500,12 +535,25 @@ export function createRelayEventService({
|
|
|
500
535
|
/**
|
|
501
536
|
* What a subscribe asks for, checked: `{ all, ids, types, matchText,
|
|
502
537
|
* stalled, heartbeat }`. Throws RelayEventError (`invalid`, `notFound`).
|
|
503
|
-
* `exclude` is the watcher, never watched by itself.
|
|
538
|
+
* `exclude` is the watcher, never watched by itself. `relayLevel` allows
|
|
539
|
+
* relay_restarted (this relay's own watchers only); a subscription that
|
|
540
|
+
* names nothing else needs no sessions.
|
|
504
541
|
*/
|
|
505
|
-
function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '' }) {
|
|
542
|
+
function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '', relayLevel = false }) {
|
|
506
543
|
const all = scope === 'all';
|
|
507
544
|
const ids = all ? [] : [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map(toText).filter(Boolean))];
|
|
508
|
-
|
|
545
|
+
const named = Array.isArray(events) ? events.map(toText).filter(Boolean) : [];
|
|
546
|
+
const relayLevelNamed = named.filter((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
|
|
547
|
+
if (relayLevelNamed.length && !relayLevel) {
|
|
548
|
+
throw new RelayEventError('invalid', `${relayLevelNamed.join(', ')} is about the relay the watcher runs on and cannot be watched on another relay (an outage there shows as link_lost and link_restored).`);
|
|
549
|
+
}
|
|
550
|
+
if (!all && !ids.length && !isRelayLevelOnly(named)) {
|
|
551
|
+
throw new RelayEventError('invalid', `subscribe needs sessions (ids from list_sessions) or "all"${relayLevel ? ', unless events names only relay_restarted' : ''}.`);
|
|
552
|
+
}
|
|
553
|
+
// No session, no work that could run: a heartbeat would never come.
|
|
554
|
+
if (!all && !ids.length && heartbeatMinutes !== null && heartbeatMinutes !== undefined && heartbeatMinutes !== '') {
|
|
555
|
+
throw new RelayEventError('invalid', 'heartbeat_minutes needs sessions to watch; relay_restarted alone has none.');
|
|
556
|
+
}
|
|
509
557
|
if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
|
|
510
558
|
throw new RelayEventError('invalid', `subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use "all" for more.`);
|
|
511
559
|
}
|
|
@@ -555,7 +603,7 @@ export function createRelayEventService({
|
|
|
555
603
|
if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
|
|
556
604
|
const watcher = toText(watcherConversationId);
|
|
557
605
|
if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
|
|
558
|
-
const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher });
|
|
606
|
+
const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher, relayLevel: true });
|
|
559
607
|
const { all, ids, types } = prepared;
|
|
560
608
|
checkWatcherCapacity(watcher);
|
|
561
609
|
const id = `sub_${randomBytes(5).toString('hex')}`;
|
|
@@ -577,7 +625,8 @@ export function createRelayEventService({
|
|
|
577
625
|
// The watched set starts from what it is doing now: all_idle fires when
|
|
578
626
|
// work that is running now (or starts later) has ended.
|
|
579
627
|
noteBusy(sql.getSubscription.get(id));
|
|
580
|
-
|
|
628
|
+
const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'this relay');
|
|
629
|
+
log(`conv=${shortId(watcher)} subscribed ${id} to ${what} (${types.join(',')})${once ? ' once' : ''}`);
|
|
581
630
|
broadcast();
|
|
582
631
|
return subscription;
|
|
583
632
|
}
|
|
@@ -1617,6 +1666,131 @@ export function createRelayEventService({
|
|
|
1617
1666
|
return ended;
|
|
1618
1667
|
}
|
|
1619
1668
|
|
|
1669
|
+
// ─── The relay's own start: relay_restarted ────────────────────────────────
|
|
1670
|
+
|
|
1671
|
+
/** Conversations whose running turn answers a relay_restarted event. */
|
|
1672
|
+
function restartWokenConversations() {
|
|
1673
|
+
if (!available) return [];
|
|
1674
|
+
try {
|
|
1675
|
+
return sql.listRestartWokenRunning.all(RELAY_EVENT_RELAY_RESTARTED_TYPE).map((row) => toText(row.conversation_id)).filter(Boolean);
|
|
1676
|
+
} catch {
|
|
1677
|
+
return [];
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* A restart or shutdown was requested: a conversation whose turn answers
|
|
1683
|
+
* relay_restarted right now is not woken by it (the request may be that
|
|
1684
|
+
* turn's). Called on every request while one is queued; returns them.
|
|
1685
|
+
*/
|
|
1686
|
+
function noteRestartRequested() {
|
|
1687
|
+
const woken = restartWokenConversations();
|
|
1688
|
+
for (const id of woken) restartNoWake.add(id);
|
|
1689
|
+
if (woken.length) log(`restart requested while ${woken.map(shortId).join(', ')} answer${woken.length === 1 ? 's' : ''} relay_restarted: not woken by it`);
|
|
1690
|
+
return [...restartNoWake];
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** The queued request was withdrawn (or never queued): nothing is held back. */
|
|
1694
|
+
function noteRestartCancelled() {
|
|
1695
|
+
restartNoWake.clear();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* This runtime ends cleanly: how, for the next start to tell. The first
|
|
1700
|
+
* call wins (a queued request's details before the generic exit's); none
|
|
1701
|
+
* before noteRelayStarted. `requestedAt` set means the end was a queued (or immediate) request.
|
|
1702
|
+
*/
|
|
1703
|
+
function noteRelayExit({ action = 'shutdown', reason = '', requestedBy = null, requestedAt = null } = {}) {
|
|
1704
|
+
// A runtime stopped before it listened (and took the record) leaves the
|
|
1705
|
+
// record of the run before it for the next start.
|
|
1706
|
+
if (!exitSql || exitNoted || !startNoted) return false;
|
|
1707
|
+
exitNoted = true;
|
|
1708
|
+
const requested = !!toText(requestedAt);
|
|
1709
|
+
// The runtime's own reasons may carry a process id (`owner-pid-missing:<pid>`):
|
|
1710
|
+
// not for an agent's eyes.
|
|
1711
|
+
const ownReason = requested ? toText(reason) : toText(reason).replace(/:\d+\b/g, '');
|
|
1712
|
+
const record = {
|
|
1713
|
+
v: 1,
|
|
1714
|
+
requested,
|
|
1715
|
+
action: action === 'restart' ? 'restart' : 'shutdown',
|
|
1716
|
+
reason: ownReason.slice(0, 140) || null,
|
|
1717
|
+
requestedBy: toText(requestedBy).slice(0, 80) || null,
|
|
1718
|
+
requestedAt: toText(requestedAt) || null,
|
|
1719
|
+
stoppedAt: isoNow(),
|
|
1720
|
+
noWake: [...restartNoWake],
|
|
1721
|
+
};
|
|
1722
|
+
try {
|
|
1723
|
+
exitSql.put.run(RELAY_LAST_EXIT_SETTING_KEY, JSON.stringify(record), record.stoppedAt);
|
|
1724
|
+
return true;
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
warn(`how this run ended was not stored: ${toText(error?.message || error).slice(0, 200)}`);
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function restartedDetail(exit, startedAt) {
|
|
1732
|
+
if (!exit) return { stop: 'unknown', startedAt };
|
|
1733
|
+
const base = { startedAt, stoppedAt: toText(exit.stoppedAt) || null, reason: toText(exit.reason) || null };
|
|
1734
|
+
if (exit.requested !== true) return { stop: 'stopped', ...base };
|
|
1735
|
+
return {
|
|
1736
|
+
stop: 'requested',
|
|
1737
|
+
...base,
|
|
1738
|
+
action: exit.action === 'shutdown' ? 'shutdown' : 'restart',
|
|
1739
|
+
requestedBy: toText(exit.requestedBy) || null,
|
|
1740
|
+
requestedAt: toText(exit.requestedAt) || null,
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* The runtime started (it listens). Takes how the run before ended (read
|
|
1746
|
+
* and deleted with the events written, so a start is told once) and gives
|
|
1747
|
+
* every active subscription of this relay's live watchers that names
|
|
1748
|
+
* relay_restarted one event; a `once` one ends with it. Watchers the
|
|
1749
|
+
* restart's request held back (noteRestartRequested) are left out, and
|
|
1750
|
+
* their `once` subscriptions end too (`held-back`): that restart was the
|
|
1751
|
+
* one they were for, and a later one would wake them with a stale "carry
|
|
1752
|
+
* on". A record older than RELAY_LAST_EXIT_MAX_AGE_MS is not the run just
|
|
1753
|
+
* before (an older OAR, which neither writes nor takes it, may have run in
|
|
1754
|
+
* between): it is dropped unread. Once per runtime. Returns `{ recorded,
|
|
1755
|
+
* skipped }`.
|
|
1756
|
+
*/
|
|
1757
|
+
function noteRelayStarted() {
|
|
1758
|
+
if (!available || startNoted) return { recorded: 0, skipped: [] };
|
|
1759
|
+
startNoted = true;
|
|
1760
|
+
const startedAt = isoNow();
|
|
1761
|
+
let recorded = 0;
|
|
1762
|
+
let skipped = [];
|
|
1763
|
+
const heldBack = [];
|
|
1764
|
+
db.transaction(() => {
|
|
1765
|
+
let exit = null;
|
|
1766
|
+
if (exitSql) {
|
|
1767
|
+
exit = parseJson(exitSql.get.get(RELAY_LAST_EXIT_SETTING_KEY)?.value, null);
|
|
1768
|
+
exitSql.take.run(RELAY_LAST_EXIT_SETTING_KEY);
|
|
1769
|
+
const stoppedAt = Date.parse(String(exit?.stoppedAt || ''));
|
|
1770
|
+
if (exit && !(Number.isFinite(stoppedAt) && now() - stoppedAt <= RELAY_LAST_EXIT_MAX_AGE_MS)) exit = null;
|
|
1771
|
+
}
|
|
1772
|
+
const noWake = new Set((Array.isArray(exit?.noWake) ? exit.noWake : []).map(toText));
|
|
1773
|
+
const rows = activeRows().filter((row) => !isMirror(row)
|
|
1774
|
+
&& !isServedKey(row.watcher_conversation_id)
|
|
1775
|
+
&& wants(row, { type: RELAY_EVENT_RELAY_RESTARTED_TYPE })
|
|
1776
|
+
&& isLive(conversation(row.watcher_conversation_id)));
|
|
1777
|
+
const held = rows.filter((row) => noWake.has(row.watcher_conversation_id));
|
|
1778
|
+
skipped = [...new Set(held.map((row) => row.watcher_conversation_id))];
|
|
1779
|
+
for (const row of held) {
|
|
1780
|
+
if (Number(row.once || 0) === 1 && endSubscription(row.id, 'held-back')) heldBack.push(row.id);
|
|
1781
|
+
}
|
|
1782
|
+
const woken = rows.filter((row) => !noWake.has(row.watcher_conversation_id));
|
|
1783
|
+
if (!woken.length) return;
|
|
1784
|
+
recorded = enqueueForRows(woken, buildEvent(RELAY_EVENT_RELAY_RESTARTED_TYPE, '', restartedDetail(exit, startedAt)), '');
|
|
1785
|
+
})();
|
|
1786
|
+
if (recorded || skipped.length) {
|
|
1787
|
+
log(`relay started: ${recorded} watcher${recorded === 1 ? '' : 's'} told${skipped.length ? `; not woken (answering relay_restarted when this restart was requested): ${skipped.map(shortId).join(', ')}` : ''}`
|
|
1788
|
+
+ `${heldBack.length ? `; one-shot ${heldBack.join(', ')} ended (held-back)` : ''}`);
|
|
1789
|
+
}
|
|
1790
|
+
if (heldBack.length) broadcast();
|
|
1791
|
+
return { recorded, skipped };
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1620
1794
|
// ─── Hooks ─────────────────────────────────────────────────────────────────
|
|
1621
1795
|
|
|
1622
1796
|
function isRelayEventTurn(queueMessageId) {
|
|
@@ -1962,6 +2136,10 @@ export function createRelayEventService({
|
|
|
1962
2136
|
noteQuestion,
|
|
1963
2137
|
noteTaskEdge,
|
|
1964
2138
|
noteConversationGone,
|
|
2139
|
+
noteRestartRequested,
|
|
2140
|
+
noteRestartCancelled,
|
|
2141
|
+
noteRelayExit,
|
|
2142
|
+
noteRelayStarted,
|
|
1965
2143
|
afterStateChange,
|
|
1966
2144
|
sweep,
|
|
1967
2145
|
start,
|
|
@@ -93,8 +93,8 @@ export function createRelayRestartQueue({
|
|
|
93
93
|
// ({ action }) => { waitingFor, held, backgroundTasks, survival }; see
|
|
94
94
|
// relay-restart-picture.mjs. Optional: without it the state names nothing.
|
|
95
95
|
collectPicture = null,
|
|
96
|
-
// ({ action, reason, requestedBy, immediate, counts }) => void:
|
|
97
|
-
// runtime. Called at most once per request.
|
|
96
|
+
// ({ action, reason, requestedBy, requestedAt, immediate, counts }) => void:
|
|
97
|
+
// exits the runtime. Called at most once per request.
|
|
98
98
|
startShutdown,
|
|
99
99
|
// () => the maximum wait in ms, 0 for no limit.
|
|
100
100
|
getMaxWaitMs = () => DEFAULT_RELAY_RESTART_MAX_WAIT_MINUTES * 60_000,
|
|
@@ -231,7 +231,14 @@ export function createRelayRestartQueue({
|
|
|
231
231
|
);
|
|
232
232
|
}
|
|
233
233
|
try {
|
|
234
|
-
startShutdown({
|
|
234
|
+
startShutdown({
|
|
235
|
+
action,
|
|
236
|
+
reason: info?.reason || 'manual-request',
|
|
237
|
+
requestedBy: info?.requestedBy || 'unknown',
|
|
238
|
+
requestedAt: iso(info?.requestedAt),
|
|
239
|
+
immediate,
|
|
240
|
+
counts: queue,
|
|
241
|
+
});
|
|
235
242
|
} catch (error) {
|
|
236
243
|
log(`Relay ${action} failed to start: ${error?.message || error}`);
|
|
237
244
|
}
|