larkin 0.5.2 → 0.5.4

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.
@@ -33,29 +33,35 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MAX_INBOX_AUDIT_TARGETS = exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = void 0;
36
+ exports.MAX_INBOX_AUDIT_REGISTRY_ROWS = exports.MAX_INBOX_AUDIT_REGISTRY_BYTES = exports.MAX_INBOX_AUDIT_TARGETS = exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = void 0;
37
37
  exports.inboxAuditRegistryFile = inboxAuditRegistryFile;
38
+ exports.inboxAuditRetryFile = inboxAuditRetryFile;
38
39
  exports.observeInboxAuditTarget = observeInboxAuditTarget;
40
+ exports.enqueueInboxAuditTargetRetry = enqueueInboxAuditTargetRetry;
41
+ exports.discardInboxAuditTargetRetry = discardInboxAuditTargetRetry;
42
+ exports.reconcileInboxAuditTargetRetries = reconcileInboxAuditTargetRetries;
43
+ exports.hasPendingInboxAuditTargets = hasPendingInboxAuditTargets;
39
44
  exports.readInboxAuditTargets = readInboxAuditTargets;
45
+ exports.completeInboxAuditTarget = completeInboxAuditTarget;
46
+ const crypto = __importStar(require("node:crypto"));
40
47
  const fs = __importStar(require("node:fs"));
41
48
  const path = __importStar(require("node:path"));
42
- /**
43
- * #169's host-owned heartbeat no longer creates per-target missed-outbound loops.
44
- * Pre-v0.4.21 records have no unique ownership marker and can collide exactly
45
- * with user reminders, so existing indistinguishable loops are never migrated
46
- * or deleted automatically.
47
- */
49
+ const process_state_js_1 = require("../platform/process-state.cjs");
50
+ /** Historical reminder loops are deliberately unrelated to this bounded audit registry. */
48
51
  exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = "New versions no longer create missed-outbound loops; existing indistinguishable historical loops are not migrated or deleted automatically.";
49
- // Audits return every retained target. Storage remains bounded so a heartbeat
50
- // cannot accumulate an unbounded work list.
51
52
  exports.MAX_INBOX_AUDIT_TARGETS = 96;
52
53
  const MAX_STORED_TARGETS = exports.MAX_INBOX_AUDIT_TARGETS;
54
+ exports.MAX_INBOX_AUDIT_REGISTRY_BYTES = 64 * 1024;
55
+ exports.MAX_INBOX_AUDIT_REGISTRY_ROWS = exports.MAX_INBOX_AUDIT_TARGETS;
56
+ const LOCK_WAIT_MS = 5_000;
57
+ const LOCK_RETRY_MS = 25;
53
58
  const CHAT = /^oc_[A-Za-z0-9]+$/;
54
59
  const THREAD = /^omt_[A-Za-z0-9]+$/;
55
60
  const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
56
- function inboxAuditRegistryFile(larkinHome) {
57
- return path.join(larkinHome, "inbox-audit.json");
58
- }
61
+ const GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
62
+ const OUTCOMES = new Set(["no-finding", "handled"]);
63
+ function inboxAuditRegistryFile(larkinHome) { return path.join(larkinHome, "inbox-audit.json"); }
64
+ function inboxAuditRetryFile(larkinHome) { return path.join(larkinHome, "inbox-audit-retry.json"); }
59
65
  function parseTarget(event) {
60
66
  const chatId = String(event.chat_id || "");
61
67
  const threadId = event.thread_id ? String(event.thread_id) : "";
@@ -64,70 +70,336 @@ function parseTarget(event) {
64
70
  return null;
65
71
  return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
66
72
  }
73
+ function emptyRegistry() { return { version: 4, targets: [] }; }
74
+ function assertSafeExistingFile(file, label) {
75
+ try {
76
+ const stat = fs.lstatSync(file);
77
+ if (!stat.isFile() || stat.isSymbolicLink())
78
+ throw new Error(`${label} is not a regular file`);
79
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
80
+ throw new Error(`${label} is not owned by the current user`);
81
+ }
82
+ catch (error) {
83
+ if (error.code !== "ENOENT")
84
+ throw error;
85
+ }
86
+ }
87
+ function prepareRegistryDirectory(file) {
88
+ const directory = path.dirname(file);
89
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
90
+ const stat = fs.lstatSync(directory);
91
+ if (!stat.isDirectory() || stat.isSymbolicLink())
92
+ throw new Error("inbox audit registry directory is unsafe");
93
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
94
+ throw new Error("inbox audit registry directory is not owned by the current user");
95
+ }
67
96
  function load(file) {
68
- let value;
97
+ let fd;
98
+ let bytes;
69
99
  try {
70
- value = JSON.parse(fs.readFileSync(file, "utf8"));
100
+ assertSafeExistingFile(file, "inbox audit registry");
101
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
102
+ const stat = fs.fstatSync(fd);
103
+ if (!stat.isFile())
104
+ throw new Error("inbox audit registry is not a regular file");
105
+ const buffer = Buffer.alloc(exports.MAX_INBOX_AUDIT_REGISTRY_BYTES + 1);
106
+ let offset = 0;
107
+ while (offset < buffer.length) {
108
+ const read = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
109
+ if (read === 0)
110
+ break;
111
+ offset += read;
112
+ }
113
+ if (offset > exports.MAX_INBOX_AUDIT_REGISTRY_BYTES)
114
+ throw new Error("inbox audit registry exceeds the bounded byte limit");
115
+ bytes = buffer.subarray(0, offset);
71
116
  }
72
117
  catch (error) {
73
118
  if (error.code === "ENOENT")
74
- return { version: 1, targets: [] };
119
+ return emptyRegistry();
75
120
  throw error;
76
121
  }
77
- if (value?.version !== 1 || !Array.isArray(value.targets))
78
- return { version: 1, targets: [] };
79
- return { version: 1, targets: value.targets.flatMap((row) => {
122
+ finally {
123
+ if (fd !== undefined)
124
+ fs.closeSync(fd);
125
+ }
126
+ const value = JSON.parse(bytes.toString("utf8"));
127
+ // v1 had no wake provenance; v2 had no durable observation generation.
128
+ // Neither can safely issue a completion receipt after this lifecycle upgrade.
129
+ if (value?.version !== 4 || !Array.isArray(value.targets))
130
+ return emptyRegistry();
131
+ if (value.targets.length > exports.MAX_INBOX_AUDIT_REGISTRY_ROWS)
132
+ throw new Error("inbox audit registry exceeds the bounded row limit");
133
+ return { version: 4, targets: value.targets.flatMap((row) => {
80
134
  if (!row || typeof row !== "object")
81
135
  return [];
82
136
  const candidate = row;
83
137
  if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
84
138
  return [];
139
+ const parts = candidate.target.split(":");
85
140
  const parsed = parseTarget({
86
- chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : candidate.target.split(":")[1],
87
- thread_id: candidate.target.startsWith("thread:") ? candidate.target.split(":")[2] : null,
141
+ chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : parts[1],
142
+ thread_id: candidate.target.startsWith("thread:") ? parts[2] : null,
88
143
  message_id: candidate.anchor,
89
144
  });
90
- return typeof candidate.agent_id === "string" && candidate.agent_id && parsed && candidate.target === parsed.target
91
- && typeof candidate.observed_at === "string" && Number.isFinite(Date.parse(candidate.observed_at))
92
- ? [{ agent_id: candidate.agent_id, ...parsed, observed_at: candidate.observed_at }] : [];
145
+ const status = candidate.status === "completed" ? "completed" : candidate.status === "pending" ? "pending" : null;
146
+ if (!status || typeof candidate.agent_id !== "string" || !candidate.agent_id || typeof candidate.generation !== "string" || !GENERATION.test(candidate.generation) || !parsed || candidate.target !== parsed.target
147
+ || !Number.isSafeInteger(candidate.source_seq) || Number(candidate.source_seq) < 1
148
+ || typeof candidate.observed_at !== "string" || !Number.isFinite(Date.parse(candidate.observed_at)))
149
+ return [];
150
+ const completed_at = typeof candidate.completed_at === "string" && Number.isFinite(Date.parse(candidate.completed_at)) ? candidate.completed_at : undefined;
151
+ const completed_anchor = typeof candidate.completed_anchor === "string" && ANCHOR.test(candidate.completed_anchor) ? candidate.completed_anchor : undefined;
152
+ const completed_outcome = OUTCOMES.has(String(candidate.completed_outcome)) ? candidate.completed_outcome : undefined;
153
+ return [{ agent_id: candidate.agent_id, generation: candidate.generation, ...parsed, source_seq: Number(candidate.source_seq), observed_at: candidate.observed_at, status,
154
+ ...(completed_at ? { completed_at } : {}), ...(completed_anchor ? { completed_anchor } : {}),
155
+ ...(completed_outcome ? { completed_outcome } : {}) }];
93
156
  }) };
94
157
  }
95
158
  function save(file, registry) {
96
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
97
- const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
98
- fs.writeFileSync(temporary, `${JSON.stringify(registry)}\n`, { mode: 0o600 });
99
- fs.renameSync(temporary, file);
100
- fs.chmodSync(file, 0o600);
159
+ if (registry.targets.length > exports.MAX_INBOX_AUDIT_REGISTRY_ROWS)
160
+ throw new Error("inbox audit registry exceeds the bounded row limit");
161
+ const serialized = `${JSON.stringify(registry)}\n`;
162
+ if (Buffer.byteLength(serialized) > exports.MAX_INBOX_AUDIT_REGISTRY_BYTES)
163
+ throw new Error("inbox audit registry exceeds the bounded byte limit");
164
+ prepareRegistryDirectory(file);
165
+ assertSafeExistingFile(file, "inbox audit registry");
166
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
167
+ const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
168
+ try {
169
+ fs.writeFileSync(fd, serialized);
170
+ fs.fsyncSync(fd);
171
+ }
172
+ finally {
173
+ fs.closeSync(fd);
174
+ }
175
+ try {
176
+ fs.renameSync(temporary, file);
177
+ fs.chmodSync(file, 0o600);
178
+ }
179
+ catch (error) {
180
+ try {
181
+ fs.unlinkSync(temporary);
182
+ }
183
+ catch { /* best effort */ }
184
+ throw error;
185
+ }
186
+ }
187
+ function emptyRetryJournal() { return { version: 2, targets: [] }; }
188
+ function loadRetryJournal(file) {
189
+ let value;
190
+ try {
191
+ assertSafeExistingFile(file, "inbox audit retry journal");
192
+ const stat = fs.statSync(file);
193
+ if (stat.size > exports.MAX_INBOX_AUDIT_REGISTRY_BYTES)
194
+ throw new Error("inbox audit retry journal exceeds the bounded byte limit");
195
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
196
+ }
197
+ catch (error) {
198
+ if (error.code === "ENOENT")
199
+ return emptyRetryJournal();
200
+ throw error;
201
+ }
202
+ const raw = value;
203
+ if (raw.version !== 2 || !Array.isArray(raw.targets) || raw.targets.length > exports.MAX_INBOX_AUDIT_REGISTRY_ROWS)
204
+ return emptyRetryJournal();
205
+ return { version: 2, targets: raw.targets.flatMap((candidate) => {
206
+ if (!candidate || typeof candidate !== "object")
207
+ return [];
208
+ const row = candidate;
209
+ const parsed = parseTarget({ chat_id: row.chat_id, thread_id: row.thread_id, message_id: row.anchor });
210
+ if (typeof row.agent_id !== "string" || !row.agent_id || typeof row.target !== "string" || !parsed || parsed.target !== row.target
211
+ || typeof row.chat_id !== "string" || (row.thread_id !== null && typeof row.thread_id !== "string")
212
+ || !Number.isSafeInteger(row.source_seq) || Number(row.source_seq) < 1
213
+ || typeof row.queued_at !== "string" || !Number.isFinite(Date.parse(row.queued_at)))
214
+ return [];
215
+ return [{ agent_id: row.agent_id, target: row.target, anchor: parsed.anchor, chat_id: row.chat_id, thread_id: row.thread_id ?? null, source_seq: Number(row.source_seq), queued_at: row.queued_at }];
216
+ }) };
217
+ }
218
+ function saveRetryJournal(file, journal) {
219
+ if (journal.targets.length > exports.MAX_INBOX_AUDIT_REGISTRY_ROWS)
220
+ throw new Error("inbox audit retry journal exceeds the bounded row limit");
221
+ const serialized = `${JSON.stringify(journal)}\n`;
222
+ if (Buffer.byteLength(serialized) > exports.MAX_INBOX_AUDIT_REGISTRY_BYTES)
223
+ throw new Error("inbox audit retry journal exceeds the bounded byte limit");
224
+ prepareRegistryDirectory(file);
225
+ assertSafeExistingFile(file, "inbox audit retry journal");
226
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
227
+ const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
228
+ try {
229
+ fs.writeFileSync(fd, serialized);
230
+ fs.fsyncSync(fd);
231
+ }
232
+ finally {
233
+ fs.closeSync(fd);
234
+ }
235
+ try {
236
+ fs.renameSync(temporary, file);
237
+ fs.chmodSync(file, 0o600);
238
+ }
239
+ catch (error) {
240
+ try {
241
+ fs.unlinkSync(temporary);
242
+ }
243
+ catch { /* best effort */ }
244
+ throw error;
245
+ }
246
+ }
247
+ function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
248
+ /** Serialize Host observers and CLI completion without replacing a newer registry snapshot. */
249
+ function withAuditLock(file, operation) {
250
+ const deadline = Date.now() + LOCK_WAIT_MS;
251
+ const lockFile = `${file}.mutation-lock`;
252
+ prepareRegistryDirectory(file);
253
+ while (Date.now() <= deadline) {
254
+ let lock;
255
+ try {
256
+ assertSafeExistingFile(lockFile, "inbox audit mutation lock");
257
+ lock = (0, process_state_js_1.acquireProcessLock)(lockFile, path.basename(process.execPath), { malformedGraceMs: 1_000 });
258
+ return operation();
259
+ }
260
+ catch (error) {
261
+ if (lock || !/lock 已被|无法取得 lock|正在创建|暂不能接管|并发重试耗尽/.test(error instanceof Error ? error.message : String(error)))
262
+ throw error;
263
+ sleep(LOCK_RETRY_MS);
264
+ }
265
+ finally {
266
+ lock?.release();
267
+ }
268
+ }
269
+ throw new Error("inbox audit registry busy: bounded lock contention");
270
+ }
271
+ function mutate(file, operation) {
272
+ return withAuditLock(file, () => {
273
+ const registry = load(file);
274
+ const result = operation(registry);
275
+ save(file, registry);
276
+ return result;
277
+ });
278
+ }
279
+ function mutateRetryJournal(file, operation) {
280
+ return withAuditLock(file, () => {
281
+ const journal = loadRetryJournal(file);
282
+ const result = operation(journal);
283
+ saveRetryJournal(file, journal);
284
+ return result;
285
+ });
286
+ }
287
+ function revision(row) {
288
+ return `sha256:${crypto.createHash("sha256").update(JSON.stringify({
289
+ v: 2, agent_id: row.agent_id, target: row.target, anchor: row.anchor, generation: row.generation, status: row.status,
290
+ })).digest("hex")}`;
291
+ }
292
+ function receipt(row) {
293
+ const payload = { v: 1, agent_id: row.agent_id, target: row.target, anchor: row.anchor, revision: revision(row) };
294
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
295
+ }
296
+ function parseReceipt(value) {
297
+ try {
298
+ const raw = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
299
+ if (raw.v !== 1 || typeof raw.agent_id !== "string" || !raw.agent_id || typeof raw.target !== "string" || typeof raw.anchor !== "string"
300
+ || typeof raw.revision !== "string" || !raw.revision.startsWith("sha256:"))
301
+ return null;
302
+ const parts = raw.target.split(":");
303
+ const parsed = parseTarget({ chat_id: raw.target.startsWith("chat:") ? raw.target.slice(5) : parts[1], thread_id: raw.target.startsWith("thread:") ? parts[2] : null, message_id: raw.anchor });
304
+ return parsed?.target === raw.target ? raw : null;
305
+ }
306
+ catch {
307
+ return null;
308
+ }
101
309
  }
102
- /** Record only an authoritative human group/topic source; DMs and bots are ignored. */
103
310
  function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
104
- if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
311
+ if (event.wake !== true || event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
105
312
  return false;
106
313
  const parsed = parseTarget(event);
107
- if (!parsed)
314
+ const sourceSeq = typeof event.source_seq === "number" ? event.source_seq : Number.NaN;
315
+ if (!parsed || !Number.isSafeInteger(sourceSeq) || sourceSeq < 1)
316
+ return false;
317
+ return mutate(file, (registry) => {
318
+ const existing = registry.targets.find((row) => row.agent_id === agentId && row.target === parsed.target);
319
+ if (existing && sourceSeq <= existing.source_seq)
320
+ return false;
321
+ registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
322
+ registry.targets.unshift({ agent_id: agentId, generation: crypto.randomUUID(), ...parsed, source_seq: sourceSeq, observed_at: now.toISOString(), status: "pending" });
323
+ registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
324
+ return true;
325
+ });
326
+ }
327
+ /** Durable recovery intent, recorded only after the same original wake gate. */
328
+ function enqueueInboxAuditTargetRetry(file, agentId, event, now = new Date()) {
329
+ if (event.wake !== true || event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
108
330
  return false;
109
- const registry = load(file);
110
- const observed_at = now.toISOString();
111
- registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
112
- registry.targets.unshift({ agent_id: agentId, ...parsed, observed_at });
113
- registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
114
- save(file, registry);
115
- return true;
116
- }
117
- function instruction(target) {
118
- const history = target.target.startsWith("thread:")
119
- ? "Use larkin im +threads-messages-list for this exact thread."
120
- : "Use larkin im +chat-messages-list for this exact chat.";
121
- return `${history} If a real missed outbound finding requires a response, use the existing guarded larkin im reply path anchored at ${target.anchor}; otherwise stay silent.`;
122
- }
123
- /** Content-free, bounded audit work list consumed by the Agent CLI. */
331
+ const parsed = parseTarget(event);
332
+ const sourceSeq = typeof event.source_seq === "number" ? event.source_seq : Number.NaN;
333
+ if (!parsed || !Number.isSafeInteger(sourceSeq) || sourceSeq < 1)
334
+ return false;
335
+ return mutateRetryJournal(file, (journal) => {
336
+ const existing = journal.targets.find((row) => row.agent_id === agentId && row.target === parsed.target);
337
+ if (existing && sourceSeq <= existing.source_seq)
338
+ return false;
339
+ journal.targets = journal.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
340
+ journal.targets.unshift({ agent_id: agentId, ...parsed, source_seq: sourceSeq, chat_id: String(event.chat_id), thread_id: event.thread_id ? String(event.thread_id) : null, queued_at: now.toISOString() });
341
+ journal.targets = journal.targets.slice(0, MAX_STORED_TARGETS);
342
+ return true;
343
+ });
344
+ }
345
+ /** Remove only the exact recovered/newer target so a late retry cannot erase a new anchor. */
346
+ function discardInboxAuditTargetRetry(file, agentId, event) {
347
+ const parsed = parseTarget(event);
348
+ if (!parsed)
349
+ return;
350
+ mutateRetryJournal(file, (journal) => { journal.targets = journal.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target || row.source_seq > Number(event.source_seq ?? 0)); });
351
+ }
352
+ /** Replay persisted, already-authorized observer intents. Failures remain durable for a later bounded retry/startup. */
353
+ function reconcileInboxAuditTargetRetries(registryFile, retryFile, agentId) {
354
+ const rows = loadRetryJournal(retryFile).targets.filter((row) => !agentId || row.agent_id === agentId);
355
+ let recovered = 0;
356
+ for (const row of rows) {
357
+ try {
358
+ observeInboxAuditTarget(registryFile, row.agent_id, { chat_id: row.chat_id, chat_type: "group", thread_id: row.thread_id, message_id: row.anchor, source_seq: row.source_seq, wake: true, _sender_is_bot: false, _scan_authority: true });
359
+ discardInboxAuditTargetRetry(retryFile, row.agent_id, { chat_id: row.chat_id, thread_id: row.thread_id, message_id: row.anchor, source_seq: row.source_seq });
360
+ recovered += 1;
361
+ }
362
+ catch {
363
+ break;
364
+ }
365
+ }
366
+ return { recovered, remaining: loadRetryJournal(retryFile).targets.filter((row) => !agentId || row.agent_id === agentId).length };
367
+ }
368
+ function instruction(target, receiptValue) {
369
+ const history = target.target.startsWith("thread:") ? "Use larkin im +threads-messages-list for this exact thread." : "Use larkin im +chat-messages-list for this exact chat.";
370
+ return `${history} Inspect first. When finished, run larkin inbox audit complete --receipt ${receiptValue} --outcome <no-finding|handled> --json. If a real finding needs a response, use the guarded reply path anchored at ${target.anchor}; otherwise stay silent.`;
371
+ }
372
+ function pendingRows(file, agentId) {
373
+ return load(file).targets.filter((row) => row.agent_id === agentId && row.status === "pending").sort((left, right) => right.observed_at.localeCompare(left.observed_at));
374
+ }
375
+ function hasPendingInboxAuditTargets(file, agentId) { return pendingRows(file, agentId).length > 0; }
376
+ /** Public read is intentionally completion-free: a caller crash leaves the target pending. */
124
377
  function readInboxAuditTargets(file, agentId) {
125
- const rows = load(file).targets.filter((row) => row.agent_id === agentId)
126
- .sort((left, right) => right.observed_at.localeCompare(left.observed_at));
127
- return {
128
- version: 1,
129
- targets: rows.slice(0, exports.MAX_INBOX_AUDIT_TARGETS).map(({ agent_id: _agentId, ...target }) => ({ ...target, instruction: instruction(target) })),
130
- has_more: rows.length > exports.MAX_INBOX_AUDIT_TARGETS,
131
- no_finding: "stay_silent",
132
- };
378
+ const rows = pendingRows(file, agentId);
379
+ return { version: 4, targets: rows.slice(0, exports.MAX_INBOX_AUDIT_TARGETS).map((row) => {
380
+ const target = { target: row.target, anchor: row.anchor, observed_at: row.observed_at, source_seq: row.source_seq };
381
+ const receiptValue = receipt(row);
382
+ return { ...target, revision: revision(row), receipt: receiptValue, instruction: instruction(target, receiptValue) };
383
+ }), has_more: rows.length > exports.MAX_INBOX_AUDIT_TARGETS, no_finding: "stay_silent" };
384
+ }
385
+ function completeInboxAuditTarget(file, agentId, receiptValue, outcome, now = new Date()) {
386
+ if (!OUTCOMES.has(outcome))
387
+ return { completed: false, reason: "invalid_outcome" };
388
+ const decoded = parseReceipt(receiptValue);
389
+ if (!decoded || decoded.agent_id !== agentId)
390
+ return { completed: false, reason: "invalid_receipt" };
391
+ return mutate(file, (registry) => {
392
+ const row = registry.targets.find((candidate) => candidate.agent_id === decoded.agent_id && candidate.target === decoded.target);
393
+ if (!row || row.anchor !== decoded.anchor)
394
+ return { completed: false, reason: "stale" };
395
+ if (row.status === "completed")
396
+ return { completed: false, reason: "already_completed" };
397
+ if (revision(row) !== decoded.revision)
398
+ return { completed: false, reason: "stale" };
399
+ row.status = "completed";
400
+ row.completed_at = now.toISOString();
401
+ row.completed_anchor = row.anchor;
402
+ row.completed_outcome = outcome;
403
+ return { completed: true, reason: "completed" };
404
+ });
133
405
  }