@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.
Files changed (72) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +302 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +296 -149
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +39 -13
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  14. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  15. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  16. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  17. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  18. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  19. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  20. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  21. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  22. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  24. package/server/public/app/api-client.js +3 -1
  25. package/server/public/app/ask-user-view.js +29 -4
  26. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  27. package/server/public/app/question-answer-rule.mjs +15 -0
  28. package/server/public/app/question-multi-select.mjs +9 -2
  29. package/server/public/app/relay-event-view.mjs +5 -1
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +4 -2
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +6 -1
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +329 -31
  61. package/server/services/windows-boot-autostart-service.mjs +188 -60
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. 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, with the preview tool's `
691
- + `close action (list shows the tokens), or with DELETE /api/previews/:token.`,
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
- question: { title: 'Copilot needs your input', generic: 'The agent asked a question.' },
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
- let title = labels.title;
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 = `${labels.title} — ${conversationTitle}`.slice(0, MAX_TITLE_CHARS);
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: `copilot-${type}-${tagSeed}`,
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
- if (!all && !ids.length) throw new RelayEventError('invalid', 'subscribe needs sessions (ids from list_sessions) or "all".');
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
- log(`conv=${shortId(watcher)} subscribed ${id} to ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${types.join(',')})${once ? ' once' : ''}`);
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: exits the
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({ action, reason: info?.reason || 'manual-request', requestedBy: info?.requestedBy || 'unknown', immediate, counts: queue });
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
  }