@ouro.bot/cli 0.1.0-alpha.478 → 0.1.0-alpha.479

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,14 @@
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.479",
6
+ "changes": [
7
+ "Hosted Outlook mailbox reads now stay on the visible recent slice instead of requesting 500 full hosted messages, so Slugger's live mailbox can load again after the large HEY archive import.",
8
+ "Hosted Blob mail reads and index backfills now use bounded fan-out plus per-blob timeouts, which keeps large Azure Blob sweeps from hanging forever when one request stalls.",
9
+ "Ouro Outlook now treats missing old private mail keys as recovery state instead of a full mailbox outage: readable mail still renders, undecryptable counts surface in recovery, and the wrapper stays version-synced for the hosted mail stability release."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.478",
6
14
  "changes": [
@@ -7,7 +7,7 @@ const file_store_1 = require("../../../mailroom/file-store");
7
7
  const reader_1 = require("../../../mailroom/reader");
8
8
  const core_1 = require("../../../mailroom/core");
9
9
  const OUTLOOK_MAIL_LIST_LIMIT = 50;
10
- const OUTLOOK_MAIL_COUNT_LIMIT = 500;
10
+ const OUTLOOK_MAIL_SUMMARY_LIMIT = OUTLOOK_MAIL_LIST_LIMIT;
11
11
  const OUTLOOK_MAIL_BODY_LIMIT = 12_000;
12
12
  function emptyFolders() {
13
13
  return [
@@ -22,7 +22,7 @@ function emptyFolders() {
22
22
  ];
23
23
  }
24
24
  function emptyRecovery() {
25
- return { discardedCount: 0, quarantineCount: 0 };
25
+ return { discardedCount: 0, quarantineCount: 0, undecryptableCount: 0, missingKeyIds: [] };
26
26
  }
27
27
  function unavailableMailView(agentName, status, error) {
28
28
  return {
@@ -81,6 +81,26 @@ function mailSummary(message) {
81
81
  provenance,
82
82
  };
83
83
  }
84
+ function missingPrivateMailKeyId(error) {
85
+ const match = /^(?:Error: )?Missing private mail key ([^\s]+)$/.exec(String(error));
86
+ return match?.[1] ?? null;
87
+ }
88
+ function decryptVisibleMessages(messages, privateKeys) {
89
+ const decrypted = [];
90
+ const skipped = [];
91
+ for (const message of messages) {
92
+ try {
93
+ decrypted.push((0, file_store_1.decryptMessages)([message], privateKeys)[0]);
94
+ }
95
+ catch (error) {
96
+ const keyId = missingPrivateMailKeyId(error);
97
+ if (!keyId)
98
+ throw error;
99
+ skipped.push({ messageId: message.id, keyId });
100
+ }
101
+ }
102
+ return { decrypted, skipped };
103
+ }
84
104
  function buildFolders(messages, outbound) {
85
105
  const folders = [
86
106
  { id: "imbox", label: "Imbox", count: messages.filter((message) => message.placement === "imbox").length },
@@ -188,10 +208,12 @@ function outboundRecord(record) {
188
208
  reason: record.reason,
189
209
  };
190
210
  }
191
- function buildRecovery(messages) {
211
+ function buildRecovery(messages, skipped = []) {
192
212
  return {
193
213
  discardedCount: messages.filter((message) => message.placement === "discarded").length,
194
214
  quarantineCount: messages.filter((message) => message.placement === "quarantine").length,
215
+ undecryptableCount: skipped.length,
216
+ missingKeyIds: [...new Set(skipped.map((entry) => entry.keyId))].sort(),
195
217
  };
196
218
  }
197
219
  function accessEntries(entries) {
@@ -240,9 +262,9 @@ async function readMailView(agentName) {
240
262
  return unavailableMailView(agentName, status, resolved.error);
241
263
  }
242
264
  try {
243
- const stored = await resolved.store.listMessages({ agentId: agentName, limit: OUTLOOK_MAIL_COUNT_LIMIT });
244
- const decrypted = (0, file_store_1.decryptMessages)(stored, resolved.config.privateKeys);
245
- const summaries = decrypted.map(mailSummary);
265
+ const stored = await resolved.store.listMessages({ agentId: agentName, limit: OUTLOOK_MAIL_SUMMARY_LIMIT });
266
+ const result = decryptVisibleMessages(stored, resolved.config.privateKeys);
267
+ const summaries = result.decrypted.map(mailSummary);
246
268
  const screener = (await resolved.store.listScreenerCandidates({ agentId: agentName, status: "pending", limit: 100 }))
247
269
  .map(screenerCandidate);
248
270
  const outbound = (await resolved.store.listMailOutbound(agentName)).map(outboundRecord);
@@ -266,7 +288,7 @@ async function readMailView(agentName) {
266
288
  messages: summaries.slice(0, OUTLOOK_MAIL_LIST_LIMIT),
267
289
  screener,
268
290
  outbound,
269
- recovery: buildRecovery(summaries),
291
+ recovery: buildRecovery(summaries, result.skipped),
270
292
  accessLog,
271
293
  error: null,
272
294
  };
@@ -8,7 +8,9 @@ const MESSAGE_INDEX_PREFIX = "message-index";
8
8
  const MESSAGE_INDEX_SORT_MAX_MS = 9_999_999_999_999;
9
9
  const MESSAGE_INDEX_SORT_WIDTH = 13;
10
10
  const MESSAGE_INDEX_NO_SOURCE = "~";
11
- const MESSAGE_INDEX_BACKFILL_CONCURRENCY = 16;
11
+ const DEFAULT_BLOB_OPERATION_TIMEOUT_MS = 20_000;
12
+ const DEFAULT_MESSAGE_FETCH_CONCURRENCY = 20;
13
+ const DEFAULT_MESSAGE_INDEX_BACKFILL_CONCURRENCY = 8;
12
14
  const MESSAGE_LIST_SCAN_CONCURRENCY = 32;
13
15
  function compareNewestFirst(left, right) {
14
16
  return Date.parse(right.receivedAt) - Date.parse(left.receivedAt);
@@ -19,10 +21,88 @@ function compareCandidatesNewestFirst(left, right) {
19
21
  function blobText(value) {
20
22
  return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf-8");
21
23
  }
22
- async function downloadJson(blob) {
24
+ function positiveInteger(value, fallback) {
25
+ if (typeof value !== "number" || !Number.isFinite(value))
26
+ return fallback;
27
+ const normalized = Math.floor(value);
28
+ return normalized > 0 ? normalized : fallback;
29
+ }
30
+ function blobClientName(blob) {
31
+ return typeof blob.name === "string" && blob.name.trim().length > 0 ? blob.name : "<unknown-blob>";
32
+ }
33
+ function timeoutSignal(timeoutMs) {
34
+ if (typeof AbortSignal.timeout === "function") {
35
+ return {
36
+ signal: AbortSignal.timeout(timeoutMs),
37
+ dispose() {
38
+ return undefined;
39
+ },
40
+ };
41
+ }
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(new Error(`The operation timed out after ${timeoutMs}ms`)), timeoutMs);
44
+ return {
45
+ signal: controller.signal,
46
+ dispose() {
47
+ clearTimeout(timer);
48
+ },
49
+ };
50
+ }
51
+ async function withBlobOperationTimeout(timeoutMs, operation) {
52
+ const timeout = timeoutSignal(timeoutMs);
53
+ try {
54
+ return await operation(timeout.signal);
55
+ }
56
+ finally {
57
+ timeout.dispose();
58
+ }
59
+ }
60
+ function normalizeBlobOperationError(action, blob, timeoutMs, error) {
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ if ((error instanceof Error && error.name === "AbortError") || message.toLowerCase().includes("aborted")) {
63
+ return new Error(`${action} ${blobClientName(blob)} timed out after ${timeoutMs}ms`);
64
+ }
65
+ return new Error(`${action} ${blobClientName(blob)} failed: ${message}`);
66
+ }
67
+ async function downloadJson(blob, timeoutMs) {
23
68
  if (!await blob.exists())
24
69
  return null;
25
- return JSON.parse((await blob.downloadToBuffer()).toString("utf-8"));
70
+ try {
71
+ const buffer = await withBlobOperationTimeout(timeoutMs, (abortSignal) => {
72
+ return blob.downloadToBuffer(undefined, undefined, { abortSignal });
73
+ });
74
+ return JSON.parse(buffer.toString("utf-8"));
75
+ }
76
+ catch (error) {
77
+ throw normalizeBlobOperationError("download", blob, timeoutMs, error);
78
+ }
79
+ }
80
+ async function uploadJson(blob, value, timeoutMs) {
81
+ try {
82
+ await withBlobOperationTimeout(timeoutMs, (abortSignal) => {
83
+ return blob.uploadData(blobText(value), { abortSignal });
84
+ });
85
+ }
86
+ catch (error) {
87
+ throw normalizeBlobOperationError("upload", blob, timeoutMs, error);
88
+ }
89
+ }
90
+ async function mapWithConcurrency(items, concurrency, worker) {
91
+ if (items.length === 0)
92
+ return [];
93
+ const results = new Array(items.length);
94
+ let nextIndex = 0;
95
+ const workerLoop = async () => {
96
+ while (true) {
97
+ const current = nextIndex;
98
+ nextIndex += 1;
99
+ if (current >= items.length)
100
+ return;
101
+ results[current] = await worker(items[current], current);
102
+ }
103
+ };
104
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => workerLoop()));
105
+ return results;
26
106
  }
27
107
  function encodeSourceToken(source) {
28
108
  return source ? encodeURIComponent(source.toLowerCase()) : MESSAGE_INDEX_NO_SOURCE;
@@ -94,10 +174,16 @@ function messageMatchesFilters(message, filters) {
94
174
  class AzureBlobMailroomStore {
95
175
  serviceClient;
96
176
  containerName;
177
+ blobOperationTimeoutMs;
178
+ messageFetchConcurrency;
179
+ backfillConcurrency;
97
180
  containerReady = null;
98
181
  constructor(options) {
99
182
  this.serviceClient = options.serviceClient;
100
183
  this.containerName = options.containerName;
184
+ this.blobOperationTimeoutMs = positiveInteger(options.blobOperationTimeoutMs, DEFAULT_BLOB_OPERATION_TIMEOUT_MS);
185
+ this.messageFetchConcurrency = positiveInteger(options.messageFetchConcurrency, DEFAULT_MESSAGE_FETCH_CONCURRENCY);
186
+ this.backfillConcurrency = positiveInteger(options.backfillConcurrency, DEFAULT_MESSAGE_INDEX_BACKFILL_CONCURRENCY);
101
187
  (0, runtime_1.emitNervesEvent)({
102
188
  component: "senses",
103
189
  event: "senses.mail_blob_store_init",
@@ -136,7 +222,7 @@ class AzureBlobMailroomStore {
136
222
  return this.container.getBlockBlobClient(`outbound/${id}.json`);
137
223
  }
138
224
  async putMessageIndex(message) {
139
- await this.messageIndexBlob(messageIndexBlobName(message)).uploadData(blobText(messageIndexRecord(message)));
225
+ await uploadJson(this.messageIndexBlob(messageIndexBlobName(message)), messageIndexRecord(message), this.blobOperationTimeoutMs);
140
226
  }
141
227
  async removeMessageIndex(message) {
142
228
  await this.messageIndexBlob(messageIndexBlobName(message)).deleteIfExists();
@@ -153,7 +239,7 @@ class AzureBlobMailroomStore {
153
239
  while (nextIndex < messageBlobNames.length) {
154
240
  const current = messageBlobNames[nextIndex];
155
241
  nextIndex += 1;
156
- const message = await downloadJson(this.container.getBlockBlobClient(current));
242
+ const message = await downloadJson(this.container.getBlockBlobClient(current), this.blobOperationTimeoutMs);
157
243
  if (!message || !messageMatchesFilters(message, filters))
158
244
  continue;
159
245
  matches.push(message);
@@ -179,7 +265,9 @@ class AzureBlobMailroomStore {
179
265
  }
180
266
  if (!sawIndex)
181
267
  return null;
182
- return (await Promise.all(messageIds.map(async (id) => downloadJson(this.messageBlob(id)))))
268
+ return (await mapWithConcurrency(messageIds, this.messageFetchConcurrency, async (id) => {
269
+ return downloadJson(this.messageBlob(id), this.blobOperationTimeoutMs);
270
+ }))
183
271
  .filter((message) => message !== null)
184
272
  .filter((message) => messageMatchesFilters(message, filters))
185
273
  .sort(compareNewestFirst)
@@ -192,21 +280,31 @@ class AzureBlobMailroomStore {
192
280
  messageBlobNames.push(item.name);
193
281
  }
194
282
  let indexed = 0;
283
+ const failures = [];
195
284
  let nextIndex = 0;
196
285
  const worker = async () => {
197
286
  while (nextIndex < messageBlobNames.length) {
198
287
  const current = messageBlobNames[nextIndex];
199
288
  nextIndex += 1;
200
- const message = await downloadJson(this.container.getBlockBlobClient(current));
201
- if (!message)
202
- continue;
203
- if (agentId && message.agentId !== agentId)
204
- continue;
205
- await this.messageIndexBlob(messageIndexBlobName(message)).uploadData(blobText(messageIndexRecord(message)));
206
- indexed += 1;
289
+ try {
290
+ const message = await downloadJson(this.container.getBlockBlobClient(current), this.blobOperationTimeoutMs);
291
+ if (!message)
292
+ continue;
293
+ if (agentId && message.agentId !== agentId)
294
+ continue;
295
+ await uploadJson(this.messageIndexBlob(messageIndexBlobName(message)), messageIndexRecord(message), this.blobOperationTimeoutMs);
296
+ indexed += 1;
297
+ }
298
+ catch (error) {
299
+ failures.push(error instanceof Error ? error.message : String(error));
300
+ }
207
301
  }
208
302
  };
209
- await Promise.all(Array.from({ length: Math.min(MESSAGE_INDEX_BACKFILL_CONCURRENCY, Math.max(messageBlobNames.length, 1)) }, async () => worker()));
303
+ await Promise.all(Array.from({ length: Math.min(this.backfillConcurrency, Math.max(messageBlobNames.length, 1)) }, async () => worker()));
304
+ if (failures.length > 0) {
305
+ const sample = failures.slice(0, 3).join("; ");
306
+ throw new Error(`hosted message index backfill incomplete after indexing ${indexed} message(s); ${failures.length} blob operation(s) failed. first failure(s): ${sample}. rerun the command to retry remaining messages.`);
307
+ }
210
308
  (0, runtime_1.emitNervesEvent)({
211
309
  component: "senses",
212
310
  event: "senses.mail_blob_index_backfilled",
@@ -218,7 +316,7 @@ class AzureBlobMailroomStore {
218
316
  async putRawMessage(input) {
219
317
  await this.ensureContainer();
220
318
  const { message, rawPayload, candidate } = await (0, core_1.buildStoredMailMessage)(input);
221
- const existing = await downloadJson(this.messageBlob(message.id));
319
+ const existing = await downloadJson(this.messageBlob(message.id), this.blobOperationTimeoutMs);
222
320
  if (existing) {
223
321
  await this.putMessageIndex(existing);
224
322
  (0, runtime_1.emitNervesEvent)({
@@ -245,7 +343,7 @@ class AzureBlobMailroomStore {
245
343
  }
246
344
  async getMessage(id) {
247
345
  await this.ensureContainer();
248
- const message = await downloadJson(this.messageBlob(id));
346
+ const message = await downloadJson(this.messageBlob(id), this.blobOperationTimeoutMs);
249
347
  (0, runtime_1.emitNervesEvent)({
250
348
  component: "senses",
251
349
  event: "senses.mail_blob_store_message_read",
@@ -273,7 +371,7 @@ class AzureBlobMailroomStore {
273
371
  async updateMessagePlacement(id, placement) {
274
372
  await this.ensureContainer();
275
373
  const blob = this.messageBlob(id);
276
- const message = await downloadJson(blob);
374
+ const message = await downloadJson(blob, this.blobOperationTimeoutMs);
277
375
  if (!message) {
278
376
  (0, runtime_1.emitNervesEvent)({
279
377
  component: "senses",
@@ -297,7 +395,7 @@ class AzureBlobMailroomStore {
297
395
  }
298
396
  async readRawPayload(objectName) {
299
397
  await this.ensureContainer();
300
- const payload = await downloadJson(this.rawBlob(objectName));
398
+ const payload = await downloadJson(this.rawBlob(objectName), this.blobOperationTimeoutMs);
301
399
  (0, runtime_1.emitNervesEvent)({
302
400
  component: "senses",
303
401
  event: "senses.mail_blob_store_raw_read",
@@ -324,7 +422,7 @@ class AzureBlobMailroomStore {
324
422
  await this.ensureContainer();
325
423
  const candidates = [];
326
424
  for await (const item of this.container.listBlobsFlat({ prefix: "candidates/" })) {
327
- const candidate = await downloadJson(this.container.getBlockBlobClient(item.name));
425
+ const candidate = await downloadJson(this.container.getBlockBlobClient(item.name), this.blobOperationTimeoutMs);
328
426
  if (candidate)
329
427
  candidates.push(candidate);
330
428
  }
@@ -351,7 +449,7 @@ class AzureBlobMailroomStore {
351
449
  createdAt: entry.createdAt ?? new Date().toISOString(),
352
450
  };
353
451
  const blob = this.decisionsBlob(entry.agentId);
354
- const existing = await downloadJson(blob).catch(() => null);
452
+ const existing = await downloadJson(blob, this.blobOperationTimeoutMs).catch(() => null);
355
453
  const entries = Array.isArray(existing) ? existing : [];
356
454
  entries.push(complete);
357
455
  await blob.uploadData(blobText(entries));
@@ -365,7 +463,7 @@ class AzureBlobMailroomStore {
365
463
  }
366
464
  async listMailDecisions(agentId) {
367
465
  await this.ensureContainer();
368
- const entries = await downloadJson(this.decisionsBlob(agentId));
466
+ const entries = await downloadJson(this.decisionsBlob(agentId), this.blobOperationTimeoutMs);
369
467
  const safeEntries = Array.isArray(entries) ? entries : [];
370
468
  (0, runtime_1.emitNervesEvent)({
371
469
  component: "senses",
@@ -388,7 +486,7 @@ class AzureBlobMailroomStore {
388
486
  }
389
487
  async getMailOutbound(id) {
390
488
  await this.ensureContainer();
391
- const record = await downloadJson(this.outboundBlob(id));
489
+ const record = await downloadJson(this.outboundBlob(id), this.blobOperationTimeoutMs);
392
490
  (0, runtime_1.emitNervesEvent)({
393
491
  component: "senses",
394
492
  event: "senses.mail_blob_outbound_record_read",
@@ -401,7 +499,7 @@ class AzureBlobMailroomStore {
401
499
  await this.ensureContainer();
402
500
  const records = [];
403
501
  for await (const item of this.container.listBlobsFlat({ prefix: "outbound/" })) {
404
- const record = await downloadJson(this.container.getBlockBlobClient(item.name));
502
+ const record = await downloadJson(this.container.getBlockBlobClient(item.name), this.blobOperationTimeoutMs);
405
503
  if (record)
406
504
  records.push(record);
407
505
  }
@@ -424,7 +522,7 @@ class AzureBlobMailroomStore {
424
522
  accessedAt: new Date().toISOString(),
425
523
  };
426
524
  const blob = this.accessLogBlob(entry.agentId);
427
- const existing = await downloadJson(blob).catch(() => null);
525
+ const existing = await downloadJson(blob, this.blobOperationTimeoutMs).catch(() => null);
428
526
  const entries = Array.isArray(existing) ? existing : [];
429
527
  entries.push(complete);
430
528
  await blob.uploadData(blobText(entries));
@@ -438,7 +536,7 @@ class AzureBlobMailroomStore {
438
536
  }
439
537
  async listAccessLog(agentId) {
440
538
  await this.ensureContainer();
441
- const entries = await downloadJson(this.accessLogBlob(agentId));
539
+ const entries = await downloadJson(this.accessLogBlob(agentId), this.blobOperationTimeoutMs);
442
540
  const safeEntries = Array.isArray(entries) ? entries : [];
443
541
  (0, runtime_1.emitNervesEvent)({
444
542
  component: "senses",