@ouro.bot/cli 0.1.0-alpha.322 → 0.1.0-alpha.324

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.324",
6
+ "changes": [
7
+ "fix(daemon): surface crashed worker error reasons and fix hints in `ouro status`, `ouro up --no-repair`, health-monitor alerts, and daemon-health snapshots so configuration failures point to the exact repair command instead of a bare warn/crashed state.",
8
+ "ci(release): verify the supported npm publish channels after release (`@ouro.bot/cli@alpha` and `ouro.bot@latest`) and remove the broken trusted-publishing `ouro.bot@alpha` dist-tag warning path."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.323",
13
+ "changes": [
14
+ "fix(bluebubbles): drain missed upstream BlueBubbles messages after an outage by querying recent messages when the upstream health probe recovers, deduping against the inbound sidecar, repairing candidates, and replaying them through the normal BlueBubbles turn path oldest-first.",
15
+ "fix(bluebubbles): make upstream catch-up bounded, paginated, and observable with nerves events for query start/end/skip/error, catch-up start/complete/error, runtime status updates, and explicit failure state if the bounded page limit is reached before the outage window is drained."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.322",
6
20
  "changes": [
@@ -1080,6 +1080,9 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
1080
1080
  deps.writeStdout("degraded agents:");
1081
1081
  for (const d of daemonResult.stability.degraded) {
1082
1082
  deps.writeStdout(` ${d.agent}: ${d.errorReason}`);
1083
+ if (d.fixHint) {
1084
+ deps.writeStdout(` fix: ${d.fixHint}`);
1085
+ }
1083
1086
  }
1084
1087
  (0, runtime_1.emitNervesEvent)({
1085
1088
  level: "warn",
@@ -345,6 +345,12 @@ function formatDaemonStatusOutput(response, fallback) {
345
345
  /* v8 ignore stop */
346
346
  const details = [pidStr, restartStr, exitStr].filter(Boolean).join(" ");
347
347
  lines.push(` ${name} ${dot} ${row.status.padEnd(10)} ${dim(details)}`);
348
+ if (row.errorReason) {
349
+ lines.push(` ${dim(`error: ${row.errorReason}`)}`);
350
+ }
351
+ if (row.fixHint) {
352
+ lines.push(` ${dim(`fix: ${row.fixHint}`)}`);
353
+ }
348
354
  }
349
355
  }
350
356
  lines.push("");
@@ -172,15 +172,40 @@ const daemon = new daemon_1.OuroDaemon({
172
172
  const daemonStartedAt = new Date().toISOString();
173
173
  const degradedComponents = [];
174
174
  function buildDaemonHealthState() {
175
+ const snapshots = processManager.listAgentSnapshots();
176
+ const agentDegradedComponents = snapshots
177
+ .filter((snapshot) => snapshot.status !== "running")
178
+ .map((snapshot) => {
179
+ const reasonParts = [
180
+ snapshot.errorReason ?? `${snapshot.channel} is ${snapshot.status}`,
181
+ snapshot.fixHint ? `Fix: ${snapshot.fixHint}` : null,
182
+ ].filter((part) => part !== null);
183
+ return {
184
+ component: `agent:${snapshot.name}`,
185
+ reason: reasonParts.join(" "),
186
+ since: snapshot.lastCrashAt ?? daemonStartedAt,
187
+ };
188
+ });
189
+ const degraded = [
190
+ ...degradedComponents.map((entry) => ({ ...entry })),
191
+ ...agentDegradedComponents,
192
+ ];
175
193
  return {
176
- status: degradedComponents.length > 0 ? "degraded" : "ok",
194
+ status: degraded.length > 0 ? "degraded" : "ok",
177
195
  mode,
178
196
  pid: process.pid,
179
197
  startedAt: daemonStartedAt,
180
198
  uptimeSeconds: Math.floor(process.uptime()),
181
199
  safeMode: null,
182
- degraded: degradedComponents.map((entry) => ({ ...entry })),
183
- agents: {},
200
+ degraded,
201
+ agents: Object.fromEntries(snapshots.map((snapshot) => [
202
+ snapshot.name,
203
+ {
204
+ status: snapshot.status,
205
+ pid: snapshot.pid,
206
+ crashes: snapshot.restartCount,
207
+ },
208
+ ])),
184
209
  habits: {},
185
210
  };
186
211
  }
@@ -75,6 +75,10 @@ exports.HEALTH_TRACKED_EVENTS = new Set([
75
75
  "daemon.habit_fire",
76
76
  "daemon.agent_exit",
77
77
  "daemon.agent_started",
78
+ "daemon.agent_config_invalid",
79
+ "daemon.agent_config_failure",
80
+ "daemon.agent_entry_missing",
81
+ "daemon.agent_spawn_failed",
78
82
  "daemon.agent_restart_exhausted",
79
83
  "daemon.agent_permanent_failure",
80
84
  "daemon.agent_cooldown_recovery",
@@ -43,10 +43,17 @@ class HealthMonitor {
43
43
  const snapshots = this.processManager.listAgentSnapshots();
44
44
  const unhealthy = snapshots.filter((snapshot) => snapshot.status !== "running");
45
45
  if (unhealthy.length > 0) {
46
+ const unhealthySummary = unhealthy.map((item) => {
47
+ const detail = [
48
+ item.errorReason ?? null,
49
+ item.fixHint ? `fix: ${item.fixHint}` : null,
50
+ ].filter((part) => part !== null).join("; ");
51
+ return detail.length > 0 ? `${item.name} (${detail})` : item.name;
52
+ }).join(", ");
46
53
  results.push({
47
54
  name: "agent-processes",
48
55
  status: "critical",
49
- message: `non-running agents: ${unhealthy.map((item) => item.name).join(", ")}`,
56
+ message: `non-running agents: ${unhealthySummary}`,
50
57
  });
51
58
  for (const agent of unhealthy) {
52
59
  try {
@@ -55,7 +62,12 @@ class HealthMonitor {
55
62
  component: "daemon",
56
63
  event: "daemon.health_check_recovery_attempted",
57
64
  message: "triggering recovery restart for non-running agent",
58
- meta: { agentName: agent.name, agentStatus: agent.status },
65
+ meta: {
66
+ agentName: agent.name,
67
+ agentStatus: agent.status,
68
+ errorReason: agent.errorReason ?? null,
69
+ fixHint: agent.fixHint ?? null,
70
+ },
59
71
  });
60
72
  this.onCriticalAgent(agent.name);
61
73
  }
@@ -58,6 +58,9 @@ async function parseJsonBody(response) {
58
58
  return null;
59
59
  }
60
60
  }
61
+ function describeCaughtValue(error) {
62
+ return error instanceof Error ? error.message : String(error);
63
+ }
61
64
  function buildRepairUrl(baseUrl, messageGuid, password) {
62
65
  const url = buildBlueBubblesApiUrl(baseUrl, `/api/v1/message/${encodeURIComponent(messageGuid)}`, password);
63
66
  const parsed = new URL(url);
@@ -102,6 +105,17 @@ function extractChatQueryRows(payload) {
102
105
  }
103
106
  return data.map((entry) => asRecord(entry)).filter((entry) => entry !== null);
104
107
  }
108
+ function extractMessageQueryRows(payload) {
109
+ const record = asRecord(payload);
110
+ const data = asRecord(record?.data);
111
+ const rows = Array.isArray(record?.data) ? record.data
112
+ : Array.isArray(data?.messages) ? data.messages
113
+ : Array.isArray(data?.results) ? data.results
114
+ : Array.isArray(record?.messages) ? record.messages
115
+ : Array.isArray(payload) ? payload
116
+ : [];
117
+ return rows.map((entry) => asRecord(entry)).filter((entry) => entry !== null);
118
+ }
105
119
  async function resolveChatGuidForIdentifier(config, channelConfig, chatIdentifier) {
106
120
  const trimmedIdentifier = chatIdentifier.trim();
107
121
  if (!trimmedIdentifier)
@@ -368,6 +382,71 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
368
382
  meta: { serverUrl: config.serverUrl },
369
383
  });
370
384
  },
385
+ async listRecentMessages(params = {}) {
386
+ const limit = Math.max(1, Math.min(100, Math.floor(params.limit ?? 50)));
387
+ const offset = Math.max(0, Math.floor(params.offset ?? 0));
388
+ const url = buildBlueBubblesApiUrl(config.serverUrl, "/api/v1/message/query", config.password);
389
+ (0, runtime_1.emitNervesEvent)({
390
+ component: "senses",
391
+ event: "senses.bluebubbles_query_recent_start",
392
+ message: "querying recent bluebubbles messages",
393
+ meta: { limit, offset },
394
+ });
395
+ const response = await fetch(url, {
396
+ method: "POST",
397
+ headers: { "Content-Type": "application/json" },
398
+ body: JSON.stringify({
399
+ limit,
400
+ offset,
401
+ sort: "DESC",
402
+ with: ["chats", "attachments", "payloadData", "messageSummaryInfo"],
403
+ }),
404
+ signal: AbortSignal.timeout(channelConfig.requestTimeoutMs),
405
+ });
406
+ if (!response.ok) {
407
+ const errorText = await response.text().catch(() => "");
408
+ (0, runtime_1.emitNervesEvent)({
409
+ level: "warn",
410
+ component: "senses",
411
+ event: "senses.bluebubbles_query_recent_error",
412
+ message: "bluebubbles recent message query failed",
413
+ meta: {
414
+ status: response.status,
415
+ reason: errorText || "unknown",
416
+ },
417
+ });
418
+ throw new Error(`BlueBubbles recent message query failed (${response.status}): ${errorText || "unknown"}`);
419
+ }
420
+ const payload = await parseJsonBody(response);
421
+ const rows = extractMessageQueryRows(payload);
422
+ const messages = [];
423
+ for (const row of rows) {
424
+ try {
425
+ messages.push((0, model_1.normalizeBlueBubblesEvent)({ type: "new-message", data: row }));
426
+ }
427
+ catch (error) {
428
+ (0, runtime_1.emitNervesEvent)({
429
+ level: "warn",
430
+ component: "senses",
431
+ event: "senses.bluebubbles_query_recent_skip",
432
+ message: "skipped unusable bluebubbles recent message row",
433
+ meta: {
434
+ reason: describeCaughtValue(error),
435
+ },
436
+ });
437
+ }
438
+ }
439
+ (0, runtime_1.emitNervesEvent)({
440
+ component: "senses",
441
+ event: "senses.bluebubbles_query_recent_end",
442
+ message: "queried recent bluebubbles messages",
443
+ meta: {
444
+ rows: rows.length,
445
+ normalized: messages.length,
446
+ },
447
+ });
448
+ return messages;
449
+ },
371
450
  async repairEvent(event) {
372
451
  if (!event.requiresRepair) {
373
452
  (0, runtime_1.emitNervesEvent)({
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.enrichReactionText = enrichReactionText;
37
37
  exports.createStatusBatcher = createStatusBatcher;
38
38
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
39
+ exports.catchUpMissedBlueBubblesMessages = catchUpMissedBlueBubblesMessages;
39
40
  exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
40
41
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
41
42
  exports.sendProactiveBlueBubblesMessageToSession = sendProactiveBlueBubblesMessageToSession;
@@ -135,6 +136,11 @@ const defaultDeps = {
135
136
  createServer: http.createServer,
136
137
  };
137
138
  const BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS = 30_000;
139
+ const BLUEBUBBLES_CATCHUP_PAGE_SIZE = 50;
140
+ const BLUEBUBBLES_CATCHUP_MAX_PAGES = 20;
141
+ const BLUEBUBBLES_HEALTHY_CATCHUP_OVERLAP_MS = 90_000;
142
+ const BLUEBUBBLES_RECOVERY_CATCHUP_LOOKBACK_MS = 24 * 60 * 60 * 1000;
143
+ const BLUEBUBBLES_FIRST_CATCHUP_LOOKBACK_MS = 10 * 60 * 1000;
138
144
  function resolveFriendParams(event) {
139
145
  if (event.chat.isGroup) {
140
146
  const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
@@ -921,24 +927,50 @@ function countPendingRecoveryCandidates(agentName) {
921
927
  .filter((entry) => !(0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, entry.sessionKey, entry.messageGuid))
922
928
  .length;
923
929
  }
930
+ function parseTimestampMs(value) {
931
+ if (!value)
932
+ return null;
933
+ const parsed = Date.parse(value);
934
+ return Number.isFinite(parsed) ? parsed : null;
935
+ }
936
+ function resolveBlueBubblesCatchUpSince(previousState, nowMs = Date.now()) {
937
+ if (previousState.upstreamStatus === "error") {
938
+ return nowMs - BLUEBUBBLES_RECOVERY_CATCHUP_LOOKBACK_MS;
939
+ }
940
+ const lastCheckedAt = parseTimestampMs(previousState.lastCheckedAt);
941
+ if (lastCheckedAt !== null) {
942
+ return Math.max(0, lastCheckedAt - BLUEBUBBLES_HEALTHY_CATCHUP_OVERLAP_MS);
943
+ }
944
+ return nowMs - BLUEBUBBLES_FIRST_CATCHUP_LOOKBACK_MS;
945
+ }
946
+ function formatRecoveredCount(count) {
947
+ return `caught up ${count} missed message(s)`;
948
+ }
924
949
  async function syncBlueBubblesRuntime(deps = {}) {
925
950
  const resolvedDeps = { ...defaultDeps, ...deps };
926
951
  const agentName = resolvedDeps.getAgentName();
927
952
  const client = resolvedDeps.createClient();
928
953
  const checkedAt = new Date().toISOString();
954
+ const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
929
955
  try {
930
956
  await client.checkHealth();
931
957
  const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
958
+ const catchUp = await catchUpMissedBlueBubblesMessages(resolvedDeps, previousState);
959
+ const failed = recovery.failed + catchUp.failed;
960
+ const recovered = recovery.recovered + catchUp.recovered;
932
961
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
933
- upstreamStatus: recovery.pending > 0 || recovery.failed > 0 ? "error" : "ok",
934
- detail: recovery.failed > 0
935
- ? `recovery failures: ${recovery.failed}`
962
+ upstreamStatus: recovery.pending > 0 || failed > 0 ? "error" : "ok",
963
+ detail: failed > 0
964
+ ? `recovery failures: ${failed}`
936
965
  : recovery.pending > 0
937
966
  ? `pending recovery: ${recovery.pending}`
938
- : "upstream reachable",
967
+ : catchUp.recovered > 0
968
+ ? formatRecoveredCount(catchUp.recovered)
969
+ : "upstream reachable",
939
970
  lastCheckedAt: checkedAt,
940
971
  pendingRecoveryCount: recovery.pending,
941
- lastRecoveredAt: recovery.recovered > 0 ? checkedAt : undefined,
972
+ lastRecoveredAt: recovered > 0 ? checkedAt : previousState.lastRecoveredAt,
973
+ lastRecoveredMessageGuid: catchUp.lastRecoveredMessageGuid ?? previousState.lastRecoveredMessageGuid,
942
974
  });
943
975
  }
944
976
  catch (error) {
@@ -950,6 +982,128 @@ async function syncBlueBubblesRuntime(deps = {}) {
950
982
  });
951
983
  }
952
984
  }
985
+ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState) {
986
+ const resolvedDeps = { ...defaultDeps, ...deps };
987
+ const agentName = resolvedDeps.getAgentName();
988
+ const client = resolvedDeps.createClient();
989
+ const result = { inspected: 0, recovered: 0, skipped: 0, failed: 0 };
990
+ const state = previousState ?? (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
991
+ const catchUpSince = resolveBlueBubblesCatchUpSince(state);
992
+ /* v8 ignore next -- older injected test doubles may omit the catch-up query method */
993
+ if (!client.listRecentMessages)
994
+ return result;
995
+ (0, runtime_1.emitNervesEvent)({
996
+ component: "senses",
997
+ event: "senses.bluebubbles_catchup_start",
998
+ message: "bluebubbles upstream catch-up pass started",
999
+ meta: {
1000
+ since: new Date(catchUpSince).toISOString(),
1001
+ pageSize: BLUEBUBBLES_CATCHUP_PAGE_SIZE,
1002
+ maxPages: BLUEBUBBLES_CATCHUP_MAX_PAGES,
1003
+ },
1004
+ });
1005
+ const recentEvents = [];
1006
+ for (let page = 0; page < BLUEBUBBLES_CATCHUP_MAX_PAGES; page++) {
1007
+ let pageEvents;
1008
+ try {
1009
+ pageEvents = await client.listRecentMessages({
1010
+ limit: BLUEBUBBLES_CATCHUP_PAGE_SIZE,
1011
+ offset: page * BLUEBUBBLES_CATCHUP_PAGE_SIZE,
1012
+ });
1013
+ }
1014
+ catch (error) {
1015
+ result.failed++;
1016
+ (0, runtime_1.emitNervesEvent)({
1017
+ level: "warn",
1018
+ component: "senses",
1019
+ event: "senses.bluebubbles_catchup_error",
1020
+ message: "bluebubbles upstream catch-up query failed",
1021
+ meta: {
1022
+ offset: page * BLUEBUBBLES_CATCHUP_PAGE_SIZE,
1023
+ reason: error instanceof Error ? error.message : String(error),
1024
+ },
1025
+ });
1026
+ break;
1027
+ }
1028
+ recentEvents.push(...pageEvents);
1029
+ if (pageEvents.length < BLUEBUBBLES_CATCHUP_PAGE_SIZE)
1030
+ break;
1031
+ const oldestMessageTimestamp = pageEvents
1032
+ .filter((event) => event.kind === "message")
1033
+ .reduce((oldest, event) => Math.min(oldest, event.timestamp), Number.POSITIVE_INFINITY);
1034
+ if (oldestMessageTimestamp <= catchUpSince)
1035
+ break;
1036
+ if (page === BLUEBUBBLES_CATCHUP_MAX_PAGES - 1) {
1037
+ result.failed++;
1038
+ (0, runtime_1.emitNervesEvent)({
1039
+ level: "warn",
1040
+ component: "senses",
1041
+ event: "senses.bluebubbles_catchup_error",
1042
+ message: "bluebubbles upstream catch-up reached the bounded page limit",
1043
+ meta: {
1044
+ inspectedPages: BLUEBUBBLES_CATCHUP_MAX_PAGES,
1045
+ reason: "catch-up page limit reached before the outage window cutoff",
1046
+ },
1047
+ });
1048
+ }
1049
+ }
1050
+ const seenMessageGuids = new Set();
1051
+ const candidates = recentEvents
1052
+ .filter((event) => event.kind === "message")
1053
+ .filter((event) => {
1054
+ if (seenMessageGuids.has(event.messageGuid))
1055
+ return false;
1056
+ seenMessageGuids.add(event.messageGuid);
1057
+ return true;
1058
+ })
1059
+ .sort((left, right) => left.timestamp - right.timestamp);
1060
+ for (const event of candidates) {
1061
+ result.inspected++;
1062
+ if (event.fromMe
1063
+ || event.timestamp < catchUpSince
1064
+ || (0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, event.chat.sessionKey, event.messageGuid)) {
1065
+ result.skipped++;
1066
+ continue;
1067
+ }
1068
+ try {
1069
+ const repaired = await client.repairEvent(event);
1070
+ if (repaired.kind !== "message") {
1071
+ result.skipped++;
1072
+ continue;
1073
+ }
1074
+ const handled = await handleBlueBubblesNormalizedEvent(repaired, resolvedDeps, "upstream-catchup");
1075
+ if (handled.reason === "already_processed") {
1076
+ result.skipped++;
1077
+ }
1078
+ else {
1079
+ result.recovered++;
1080
+ result.lastRecoveredMessageGuid = repaired.messageGuid;
1081
+ }
1082
+ }
1083
+ catch (error) {
1084
+ result.failed++;
1085
+ (0, runtime_1.emitNervesEvent)({
1086
+ level: "warn",
1087
+ component: "senses",
1088
+ event: "senses.bluebubbles_catchup_error",
1089
+ message: "bluebubbles upstream catch-up message failed",
1090
+ meta: {
1091
+ messageGuid: event.messageGuid,
1092
+ reason: error instanceof Error ? error.message : String(error),
1093
+ },
1094
+ });
1095
+ }
1096
+ }
1097
+ if (result.inspected > 0 || result.recovered > 0 || result.skipped > 0 || result.failed > 0) {
1098
+ (0, runtime_1.emitNervesEvent)({
1099
+ component: "senses",
1100
+ event: "senses.bluebubbles_catchup_complete",
1101
+ message: "bluebubbles upstream catch-up pass completed",
1102
+ meta: { ...result },
1103
+ });
1104
+ }
1105
+ return result;
1106
+ }
953
1107
  async function recoverMissedBlueBubblesMessages(deps = {}) {
954
1108
  const resolvedDeps = { ...defaultDeps, ...deps };
955
1109
  const agentName = resolvedDeps.getAgentName();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.322",
3
+ "version": "0.1.0-alpha.324",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",