@botbuddy/cli 1.64.13 → 1.64.15

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.
@@ -0,0 +1,269 @@
1
+ // BOT-2887: explicit feedback submission. The caller's file is the durable
2
+ // retry source; the idempotency key must be inside it before the first send.
3
+ import { lstat, mkdir, open, readFile, unlink, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { dirname, join } from "node:path";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { CALL_EXIT, callToolJson, resolveCallAuth } from "./api.mjs";
8
+ import { containsFeedbackSecret } from "./feedback-safety.mjs";
9
+ import { restrictWindowsAcl, restrictWindowsAclDir, sessionPathIsConfidential } from "./agent-state-fs.mjs";
10
+ import { cacheBelongsToHarness, findAgentStateRoot, harnessSessionIdFromEnv, readAgentState } from "./agent-state.mjs";
11
+ import { hasPendingFeedback, queueFeedback, withFeedbackOutbox } from "./feedback-outbox.mjs";
12
+ import { CONDITION_TYPES } from "./wait-core.mjs";
13
+
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
16
+ const OPTIONAL_TEXT = new Set(["goal", "steps", "expected", "actual", "blocked_reason", "blocking_step", "safe_next_action", "environment", "operation", "component_version", "error_code", "correlation_id", "workaround", "cause_confidence", "ticket_ref"]);
17
+ const USAGE = `Usage: bb feedback init --file <report.json>
18
+ bb feedback init --file <report.json> --from-run <run_id>
19
+ bb feedback submit --file <report.json>
20
+ bb feedback enrich --feedback-id <id> --file <report.json>
21
+ bb feedback status --feedback-id <id>
22
+ bb feedback updates --feedback-id <id> [--after <revision>]
23
+ bb feedback acknowledge --feedback-id <id> --revision <revision>
24
+ bb feedback flush
25
+ bb feedback recover
26
+
27
+ init creates a private starter file without overwriting an existing file.
28
+ --from-run adds an exact status command and a bounded status observation from
29
+ the original command run. It never copies raw logs or restarts the workload.
30
+ Fill in the fields before submitting it. The JSON report must contain a stable
31
+ UUID idempotency_key, kind, surface, title and impact. Set kind to bug,
32
+ feature_request, improvement, friction, docs_gap or unknown; surface to mcp, cli, stack,
33
+ wait, web, helper, docs or other; and impact to blocked, workaround, slowed,
34
+ cosmetic or unknown. Unknown values keep the report in needs_context. Include your goal, steps, expected and actual outcomes,
35
+ evidence, why you are blocked and what you can safely do next. Keep the file
36
+ and its idempotency_key unchanged if delivery is uncertain; retry using the
37
+ same reporter and tenant. Tenant identity sharing follows the server's policy;
38
+ a report cannot override it.
39
+
40
+ An unavailable service does not mean a report was recorded. A failed send is
41
+ queued privately only when the original agent and tenant can be bound to the
42
+ cached session. Run bb feedback flush to replay those drafts after reconnecting.
43
+ Unbound drafts stay in their original file for a deliberate retry.`;
44
+
45
+ export async function cmdFeedback(args, { call = callToolJson, resolveAuth = resolveCallAuth, read = readFile, write = writeFile, makeDir = mkdir, uuid = randomUUID, inspectStatus = async (id) => (await import("./run.mjs")).statusRun(id), cwd = process.cwd(), loadState = readAgentState, rootFor = findAgentStateRoot, queue = queueFeedback, outbox = withFeedbackOutbox, hasPending = hasPendingFeedback, waitTypes = CONDITION_TYPES, platform = process.platform, restrictAcl = restrictWindowsAcl, restrictAclDir = restrictWindowsAclDir, confidential = sessionPathIsConfidential, log = console.log, errorLog = console.error } = {}) {
46
+ if (args.length === 1 && ["help", "--help", "-h"].includes(args[0])) { log(USAGE); return CALL_EXIT.OK; }
47
+ const cachedBinding = async (resolved) => {
48
+ const state = await loadState(cwd);
49
+ if (!state || !cacheBelongsToHarness(state, harnessSessionIdFromEnv()) || resolved?.sessionToken !== true ||
50
+ resolved.auth?.["x-agent-api-key"] !== state.agent_session_token || !UUID.test(state.agent_id ?? "") ||
51
+ !/^[a-z0-9][a-z0-9-]{0,63}$/.test(state.tenant ?? "")) return null;
52
+ return { agentId: state.agent_id, tenant: state.tenant };
53
+ };
54
+ if (args.length === 1 && args[0] === "flush") {
55
+ let delivered = 0;
56
+ const receipts = [];
57
+ const remember = (id) => { delivered++; receipts.push({ feedback_id: id, status_command: `bb feedback status --feedback-id ${id}`, updates_command: `bb feedback updates --feedback-id ${id}` }); };
58
+ try {
59
+ const resolved = await resolveAuth({ toolName: "submit_feedback", preferSession: true });
60
+ const binding = await cachedBinding(resolved);
61
+ if (!binding) { errorLog("Feedback replay requires the original worktree's bound agent session and tenant. Drafts remain queued."); return CALL_EXIT.AUTH; }
62
+ const identity = await call("whoami", {}, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
63
+ if (!identity?.ok || identity.isError || identity.data?.credential_kind !== "agent_session" || identity.data?.agent?.id !== binding.agentId || identity.data?.tenant_id !== binding.tenant) {
64
+ errorLog("Feedback replay could not verify the original reporter and tenant. Drafts remain queued."); return CALL_EXIT.AUTH;
65
+ }
66
+ const root = await rootFor(cwd);
67
+ const result = await outbox(root, async (items, io) => {
68
+ for (const item of items) {
69
+ const record = await io.read(item);
70
+ if (record.agent_id !== binding.agentId || record.tenant !== binding.tenant) return { ok: false, error: "outbox_identity_mismatch", delivered, receipts, remaining: true };
71
+ const response = await call("submit_feedback", record.report, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
72
+ const data = response?.data;
73
+ if (response?.ok && !response.isError && (response.status == null || (response.status >= 200 && response.status < 300)) && data?.ok === true && data.source_duplicate === true && typeof data.feedback_id === "string" && UUID.test(data.feedback_id)) {
74
+ const current = await call("get_feedback", { feedback_id: data.feedback_id }, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
75
+ const intended = Object.entries(record.report).filter(([key]) => key !== "idempotency_key");
76
+ if (current?.ok && !current.isError && (current.status == null || (current.status >= 200 && current.status < 300)) && current.data?.ok === true && current.data.feedback_id === data.feedback_id &&
77
+ current.data.report && intended.every(([key, value]) => isDeepStrictEqual(current.data.report[key], value))) {
78
+ remember(data.feedback_id); await io.remove(item); continue;
79
+ }
80
+ const draft = await io.exportDraft(record);
81
+ const argv = ["bb", "feedback", "enrich", "--feedback-id", data.feedback_id, "--file", draft];
82
+ return { ok: false, error: "source_already_reported", delivered, receipts, remaining: true, feedback_id: data.feedback_id,
83
+ status_command: `bb feedback status --feedback-id ${data.feedback_id}`, enrich_argv: argv,
84
+ enrich_command: argv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") };
85
+ }
86
+ if (!response?.ok || response.isError || (response.status != null && (response.status < 200 || response.status >= 300)) || data?.ok !== true ||
87
+ typeof data.feedback_id !== "string" || !UUID.test(data.feedback_id) || typeof data.occurrence_id !== "string" || !UUID.test(data.occurrence_id) || !["complete", "needs_context"].includes(data.capture_state)) {
88
+ return { ok: false, error: "delivery_unconfirmed", delivered, receipts, remaining: true,
89
+ ...(typeof data?.feedback_id === "string" && UUID.test(data.feedback_id) ? { feedback_id: data.feedback_id, status_command: `bb feedback status --feedback-id ${data.feedback_id}` } : {}) };
90
+ }
91
+ remember(data.feedback_id);
92
+ await io.remove(item);
93
+ }
94
+ return { ok: true, delivered, receipts, remaining: io.more, ...(io.more ? { continue_command: "bb feedback flush" } : {}) };
95
+ });
96
+ log(JSON.stringify(result));
97
+ if (!result.ok) errorLog("Feedback replay stopped without deleting the unconfirmed draft.");
98
+ return result.ok ? CALL_EXIT.OK : CALL_EXIT.ERROR;
99
+ } catch {
100
+ log(JSON.stringify({ ok: false, error: "replay_unavailable", delivered, receipts, remaining: true, retry_command: "bb feedback flush" }));
101
+ errorLog("Feedback replay stopped. Check the receipts and retry: bb feedback flush");
102
+ return CALL_EXIT.ERROR;
103
+ }
104
+ }
105
+ if (args.length === 1 && args[0] === "recover") {
106
+ let outboxRecovery = null;
107
+ try {
108
+ if (await hasPending(await rootFor(cwd))) {
109
+ let receipt = null;
110
+ const code = await cmdFeedback(["flush"], { call, resolveAuth, read, write, makeDir, uuid, inspectStatus, cwd, loadState, rootFor, queue, outbox, hasPending,
111
+ log: (line) => { try { receipt = JSON.parse(line); } catch { /* retain unknown */ } }, errorLog: () => {} });
112
+ outboxRecovery = { ok: code === 0 && receipt?.ok === true, delivered: receipt?.delivered ?? null, receipts: receipt?.receipts ?? null,
113
+ remaining: code !== 0 || receipt?.remaining === true, ...(code !== 0 || receipt?.remaining === true ? { retry_command: "bb feedback flush" } : {}),
114
+ ...(receipt?.error === "source_already_reported" ? { error: receipt.error, feedback_id: receipt.feedback_id, status_command: receipt.status_command, enrich_argv: receipt.enrich_argv, enrich_command: receipt.enrich_command } : {}) };
115
+ }
116
+ } catch { outboxRecovery = { ok: false, delivered: null, receipts: null, remaining: true, retry_command: "bb feedback flush" }; }
117
+ let result;
118
+ try {
119
+ const resolved = await resolveAuth({ toolName: "recover_feedback_sources", preferSession: true });
120
+ result = resolved?.error || !resolved?.auth ? { ok: false, auth: true } : await call("recover_feedback_sources", {}, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
121
+ } catch { result = { ok: false, status: null }; }
122
+ const data = result?.data;
123
+ if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || data?.ok !== true || !Number.isInteger(data.captured) || data.captured < 0 || data.captured > 5 || typeof data.complete !== "boolean" || !["none", "more_sources", "retry_later"].includes(data.remaining) || data.complete !== (data.remaining === "none")) {
124
+ if (outboxRecovery) log(JSON.stringify({ ok: false, cloud_recovery: "unconfirmed", outbox_recovery: outboxRecovery, all_complete: false, retry_command: "bb feedback recover" }));
125
+ errorLog("Feedback recovery was not confirmed. Preserve the original run; retry bb feedback recover using the same reporter and tenant.");
126
+ return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
127
+ }
128
+ log(JSON.stringify({ ...data, ...(outboxRecovery ? { outbox_recovery: outboxRecovery, all_complete: data.complete && outboxRecovery.ok && !outboxRecovery.remaining } : {}), list_command: "bb call list_feedback", ...(data.remaining === "more_sources" ? { continue_command: "bb feedback recover" } : {}), ...(data.remaining === "retry_later" ? { retry_command: "bb feedback recover" } : {}) }));
129
+ return outboxRecovery && !outboxRecovery.ok ? CALL_EXIT.ERROR : CALL_EXIT.OK;
130
+ }
131
+ if (["status", "updates", "acknowledge"].includes(args[0])) {
132
+ const kind = args[0];
133
+ const validId = args[1] === "--feedback-id" && UUID.test(args[2] ?? "");
134
+ const cursorText = args[4];
135
+ const validCursor = typeof cursorText === "string" && /^(0|[1-9][0-9]*)$/.test(cursorText) && Number.isSafeInteger(Number(cursorText)) && Number(cursorText) <= 2147483647;
136
+ const valid = validId && (kind === "status" ? args.length === 3 : kind === "updates" ? args.length === 3 || (args.length === 5 && args[3] === "--after" && validCursor) : args.length === 5 && args[3] === "--revision" && validCursor && Number(cursorText) > 0);
137
+ if (!valid) { errorLog(USAGE); return 2; }
138
+ const tool = kind === "status" ? "get_feedback" : kind === "updates" ? "list_feedback_updates" : "acknowledge_feedback_update";
139
+ const input = { feedback_id: args[2], ...(kind === "updates" && args.length === 5 ? { after_revision: Number(cursorText) } : {}), ...(kind === "acknowledge" ? { revision: Number(cursorText) } : {}) };
140
+ let result;
141
+ try {
142
+ const resolved = await resolveAuth({ toolName: tool, preferSession: true });
143
+ result = resolved?.error || !resolved?.auth ? { ok: false, auth: true } : await call(tool, input, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
144
+ } catch { result = { ok: false, status: null }; }
145
+ const data = result?.data;
146
+ const receiptValid = data?.ok === true && typeof data.feedback_id === "string" && data.feedback_id.toLowerCase() === args[2].toLowerCase() && (kind === "status" || (kind === "updates" ? Array.isArray(data.items) && data.items.every((item) => Number.isInteger(item?.revision) && item.revision > 0 && item.revision <= 2147483647) && typeof data.has_more === "boolean" && Number.isInteger(data.next_after_revision) && data.next_after_revision >= 0 && data.next_after_revision <= 2147483647 : data.revision === Number(cursorText) && typeof data.acknowledged_at === "string"));
147
+ if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || !receiptValid) {
148
+ errorLog(kind === "acknowledge" ? "Acknowledgement was not confirmed; check updates before retrying." : "Feedback status is unavailable; retry using the same reporter and tenant.");
149
+ return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
150
+ }
151
+ if (kind === "updates") {
152
+ const items = data.items.map((item) => ({ ...item, acknowledge_command: `bb feedback acknowledge --feedback-id ${args[2]} --revision ${item.revision}` }));
153
+ const next = data.has_more ? `bb feedback updates --feedback-id ${args[2]} --after ${data.next_after_revision}` : null;
154
+ const waitCommand = !data.has_more && waitTypes.includes("feedback") ? `bb wait 'feedback:id=${args[2].toLowerCase()},after=${data.next_after_revision}'` : null;
155
+ log(JSON.stringify({ ...data, items, next_command: next, ...(waitCommand ? { after_consuming_wait_command: waitCommand } : {}) }));
156
+ } else log(JSON.stringify(data));
157
+ return CALL_EXIT.OK;
158
+ }
159
+ const enrich = args.length === 5 && args[0] === "enrich" && args[1] === "--feedback-id" && UUID.test(args[2] ?? "") && args[3] === "--file";
160
+ const fromRun = args.length === 5 && args[0] === "init" && args[1] === "--file" && args[3] === "--from-run" && RUN_ID.test(args[4] ?? "");
161
+ const filename = enrich ? args[4] : args[2];
162
+ if ((!enrich && !fromRun && (args.length !== 3 || !["init", "submit"].includes(args[0]) || args[1] !== "--file")) || !filename || filename.startsWith("-")) {
163
+ errorLog(USAGE); return 2;
164
+ }
165
+ if (args[0] === "init") {
166
+ const template = { idempotency_key: uuid(), kind: "", surface: "", title: "", impact: "", goal: "", steps: "", expected: "", actual: "", blocked_reason: "", blocking_step: "", can_continue: null, safe_next_action: "", ticket_ref: "", evidence: [] };
167
+ if (fromRun) {
168
+ let status;
169
+ try { status = await inspectStatus(args[4]); } catch { errorLog("The original run status is unavailable; no feedback file was created. Preserve the original run and retry."); return CALL_EXIT.TRANSPORT; }
170
+ const allowed = ["running", "completed", "completed_locally_remote_conflict", "completed_locally_remote_stale", "completed_locally_remote_unknown", "unknown"];
171
+ if (!status || status.run_id !== args[4] || !allowed.includes(status.classification)) { errorLog("The original run status could not be verified; no feedback file was created."); return CALL_EXIT.ERROR; }
172
+ template.surface = "cli";
173
+ template.operation = "bb run";
174
+ template.correlation_id = args[4];
175
+ const local = status.local;
176
+ const localOutcome = status.source_bound === true && ["success", "failure", "timed_out", "interrupted", "cancelled"].includes(local?.status)
177
+ ? ` Local outcome: ${local.status}${Number.isSafeInteger(local.exit_code) && local.exit_code >= 0 && local.exit_code <= 255 ? ` (exit ${local.exit_code})` : ""}.`
178
+ : "";
179
+ template.actual = `Original command run status: ${status.classification}.${localOutcome}`;
180
+ template.evidence = [{ type: "command", value: `bb run status ${args[4]}` }];
181
+ const counts = status.test_counts;
182
+ if (counts && typeof counts === "object" && [counts.passed, counts.failed, counts.remaining].every((value) => value === null || (Number.isSafeInteger(value) && value >= 0)) && (counts.passed !== null || counts.failed !== null)) {
183
+ template.evidence.push({ type: "text", value: `Observed test counts: passed=${counts.passed ?? "unknown"}, failed=${counts.failed ?? "unknown"}, remaining=${counts.remaining ?? "unknown"}. Counts are observations and retries may change them.` });
184
+ }
185
+ }
186
+ if (platform === "win32") {
187
+ // A mode bit does not protect Windows files. The parent must be private
188
+ // before path-based ACL checks, so another user cannot replace the file.
189
+ // Do not change ACLs of an existing user-selected directory.
190
+ let handle, created = false;
191
+ try {
192
+ const parent = dirname(filename);
193
+ const made = await makeDir(parent, { recursive: true, mode: 0o700 });
194
+ if (made) await restrictAclDir(parent);
195
+ const info = await lstat(parent);
196
+ if (!info.isDirectory() || info.isSymbolicLink() || !(await confidential(parent, info.mode, { platform }))) throw new Error("directory_not_private");
197
+ handle = await open(filename, "wx", 0o600); created = true;
198
+ await restrictAcl(filename);
199
+ const fileInfo = await handle.stat();
200
+ if (!(await confidential(filename, fileInfo.mode, { platform }))) throw new Error("file_not_private");
201
+ await handle.writeFile(JSON.stringify(template, null, 2) + "\n");
202
+ await handle.sync();
203
+ await handle.close(); handle = null;
204
+ } catch {
205
+ if (handle) await handle.close().catch(() => {});
206
+ if (created) await unlink(filename).catch(() => {});
207
+ const retryPath = join(".botbuddy", `feedback-${template.idempotency_key}`, "report.json");
208
+ const retry = `bb feedback init --file "${retryPath}"${fromRun ? ` --from-run ${args[4]}` : ""}`;
209
+ errorLog(`Could not create a private feedback file. Existing files are never overwritten. Retry in a new private directory: ${retry}`); return 2;
210
+ }
211
+ } else {
212
+ try { await makeDir(dirname(filename), { recursive: true, mode: 0o700 }); await write(filename, JSON.stringify(template, null, 2) + "\n", { flag: "wx", mode: 0o600 }); }
213
+ catch { errorLog("Could not create the feedback file; an existing file is never overwritten."); return 2; }
214
+ }
215
+ const argv = ["bb", "feedback", "submit", "--file", filename];
216
+ log(JSON.stringify({ ok: true, file: filename, state: "needs_context", submit_argv: argv, submit_command: argv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") }));
217
+ return CALL_EXIT.OK;
218
+ }
219
+ let report;
220
+ try { report = JSON.parse(await read(filename, "utf8")); }
221
+ catch { errorLog("Could not read the feedback file as JSON; the file was not submitted."); return 2; }
222
+ if (!report || typeof report !== "object" || Array.isArray(report) || typeof report.idempotency_key !== "string" || !UUID.test(report.idempotency_key)) {
223
+ errorLog("The feedback file must contain a stable UUID idempotency_key; the file was not submitted."); return 2;
224
+ }
225
+ // Blank template slots represent missing context. The server accepts omitted
226
+ // fields for partial capture, but rejects explicitly blank strings.
227
+ report = Object.fromEntries(Object.entries(report).filter(([key, value]) => !(OPTIONAL_TEXT.has(key) && typeof value === "string" && !value.trim()) && !(key === "can_continue" && value === null)));
228
+ if (containsFeedbackSecret(report)) {
229
+ errorLog("The feedback file contains a possible credential, sensitive header or signed URL; remove it before sending.");
230
+ return 2;
231
+ }
232
+ let result, resolved;
233
+ const tool = enrich ? "enrich_feedback" : "submit_feedback";
234
+ const patch = { ...report };
235
+ delete patch.idempotency_key;
236
+ try {
237
+ resolved = await resolveAuth({ toolName: tool, preferSession: true });
238
+ result = resolved?.error || !resolved?.auth ? { ok: false, auth: true, error: "not_authenticated" } : await call(tool, enrich ? { feedback_id: args[2], patch } : report, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
239
+ }
240
+ catch { result = { ok: false, status: null, error: "transport_unavailable" }; }
241
+ if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || result.data?.ok !== true || typeof result.data.feedback_id !== "string" || !UUID.test(result.data.feedback_id) ||
242
+ typeof result.data.occurrence_id !== "string" || !UUID.test(result.data.occurrence_id) || !["complete", "needs_context"].includes(result.data.capture_state)) {
243
+ const reported = result?.auth ? "authentication_failed" : result?.data?.error ?? result?.code ?? result?.error;
244
+ const code = typeof reported === "string" && /^[A-Za-z0-9_.:-]{1,80}$/.test(reported) ? reported : "feedback_receipt_unavailable";
245
+ const retryArgv = enrich ? null : ["bb", "feedback", "submit", "--file", filename];
246
+ const retryCommand = retryArgv ? retryArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") : null;
247
+ const existingId = code === "source_already_reported" && typeof result?.data?.existing_feedback_id === "string" && UUID.test(result.data.existing_feedback_id) ? result.data.existing_feedback_id : null;
248
+ const existingEnrichArgv = existingId ? ["bb", "feedback", "enrich", "--feedback-id", existingId, "--file", filename] : null;
249
+ let queued = null, outboxReason = null;
250
+ if (!enrich && !result?.auth && !result?.isError && (result?.status == null || result.status >= 500)) {
251
+ try { const binding = await cachedBinding(resolved); if (binding) queued = await queue({ root: await rootFor(cwd), ...binding, report }); else outboxReason = "reporter_identity_unavailable"; }
252
+ catch (error) { outboxReason = typeof error?.message === "string" && /^outbox_[a-z_]+$/.test(error.message) ? error.message : "outbox_unavailable"; }
253
+ }
254
+ log(JSON.stringify({ ok: false, error: code, recorded: existingId ? "not_changed" : "unknown", retry_source: filename, retry_argv: retryArgv, retry_command: retryCommand,
255
+ ...(queued ? { queued_locally: true, flush_command: queued.flush_command } : {}),
256
+ ...(outboxReason ? { queued_locally: false, outbox_reason: outboxReason } : {}),
257
+ ...(existingEnrichArgv ? { existing_feedback_id: existingId, enrich_argv: existingEnrichArgv, enrich_command: existingEnrichArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") } : {}),
258
+ ...(result?.auth ? { diagnostic_argv: ["bb", "doctor"], diagnostic_command: "bb doctor" } : {}) }));
259
+ errorLog(existingId ? "This source already has another report. The update was not applied; use the exact enrich command in the receipt to update that report." : enrich ? "The update was not confirmed. Read the feedback before deciding whether to apply it again." : "Feedback was not confirmed. Preserve the file and idempotency_key; retry using the same reporter and tenant.");
260
+ return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
261
+ }
262
+ const sourceDuplicate = result.data.source_duplicate === true && result.data.submitted_context_saved === false;
263
+ const enrichArgv = result.data.capture_state === "needs_context" || sourceDuplicate ? ["bb", "feedback", "enrich", "--feedback-id", result.data.feedback_id, "--file", filename] : null;
264
+ const statusArgv = ["bb", "feedback", "status", "--feedback-id", result.data.feedback_id];
265
+ const updatesArgv = ["bb", "feedback", "updates", "--feedback-id", result.data.feedback_id];
266
+ log(JSON.stringify({ ...result.data, ...(sourceDuplicate ? { ok: false, error: "source_already_reported" } : {}), status_argv: statusArgv, status_command: statusArgv.join(" "), updates_argv: updatesArgv, updates_command: updatesArgv.join(" "), ...(enrichArgv ? { enrich_argv: enrichArgv, enrich_command: enrichArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") } : {}) }));
267
+ if (sourceDuplicate) errorLog("This source already has feedback. The new file was not applied; use the exact enrich command in the receipt to add its context to the existing report.");
268
+ return sourceDuplicate ? CALL_EXIT.ERROR : CALL_EXIT.OK;
269
+ }
package/src/run.mjs CHANGED
@@ -102,6 +102,12 @@ export function parseRunArgs(argv, env = process.env) {
102
102
  return { opts, command, errors };
103
103
  }
104
104
 
105
+ const FOREGROUND_INSPECTION = Object.freeze({
106
+ run_kind: "foreground_telemetry",
107
+ telemetry_status_command: "bb telemetry status",
108
+ inspection_note: "Preserve the original terminal output. bb run inspect and bb run status inspect detached command runs; bb telemetry status checks delivery, not the command outcome.",
109
+ });
110
+
105
111
  function foregroundOutcome({ exitCode, signal, timedOut }) {
106
112
  if (timedOut) return "timed_out";
107
113
  if (signal) return "canceled";
@@ -179,7 +185,7 @@ export async function launchForegroundRun(argv, {
179
185
  child = spawnImpl(command[0], command.slice(1), { cwd, detached: process.platform !== "win32", stdio: "inherit", windowsHide: true });
180
186
  } catch (error) {
181
187
  await telemetry.append({ ...base, action: "terminal", producer_event_id: `${runId}:terminal`, producer_sequence: 1, outcome: "crashed", diagnostic: { code: "spawn_failed", category: "spawn" } }).catch(() => {});
182
- return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "crashed", run_id: runId, telemetry: "degraded", exit_code: EXIT.INTERNAL } };
188
+ return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "crashed", run_id: runId, telemetry: "degraded", exit_code: EXIT.INTERNAL, ...FOREGROUND_INSPECTION } };
183
189
  }
184
190
  let timedOut = false;
185
191
  let killTimer;
@@ -212,7 +218,7 @@ export async function launchForegroundRun(argv, {
212
218
  });
213
219
  } catch { telemetryState = "degraded"; }
214
220
  const exitCode = result.error ? EXIT.INTERNAL : timedOut ? signalExitCode("SIGTERM") : terminalSignal ? signalExitCode(terminalSignal) : result.exitCode ?? EXIT.INTERNAL;
215
- return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome, run_id: runId, telemetry: telemetryState, exit_code: exitCode } };
221
+ return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome, run_id: runId, telemetry: telemetryState, exit_code: exitCode, ...FOREGROUND_INSPECTION } };
216
222
  }
217
223
 
218
224
  export function terminalStatus({ exitCode, signal, timedOut = false, ownerLost = false }) {
@@ -292,6 +298,7 @@ async function inspectRunEvidence(runId, {
292
298
  processAlive = workerProcessGroupAlive,
293
299
  receiptFile = receiptPath,
294
300
  contextFile = contextPath,
301
+ remoteTimeoutMs = 3000,
295
302
  } = {}) {
296
303
  if (typeof runId !== "string" || !runId.trim()) {
297
304
  return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_required" };
@@ -310,9 +317,15 @@ async function inspectRunEvidence(runId, {
310
317
  // A durable run belongs to the agent that launched it. Prefer its sealed
311
318
  // session identity; older contexts must use a current worktree session and
312
319
  // never silently fall back to an unrelated durable machine credential.
313
- const remoteResult = await callWithContextIdentity(context, call)("get_command_run", { run_id: id }, {
314
- ...(context ? { serverUrl: contextServerUrl(context) } : {}),
315
- }).catch(() => null);
320
+ let remoteResult = null, remoteTimer;
321
+ try {
322
+ remoteResult = await Promise.race([
323
+ callWithContextIdentity(context, call)("get_command_run", { run_id: id }, {
324
+ ...(context ? { serverUrl: contextServerUrl(context) } : {}), signal: AbortSignal.timeout(remoteTimeoutMs),
325
+ }).catch(() => null),
326
+ new Promise((resolve) => { remoteTimer = setTimeout(() => resolve(null), remoteTimeoutMs); }),
327
+ ]);
328
+ } finally { if (remoteTimer) clearTimeout(remoteTimer); }
316
329
  const remote = publicRemoteSummary(remoteResult);
317
330
  const receiptTerminal = Boolean(receipt && TERMINAL_COMMAND_STATUSES.has(receipt.status));
318
331
  const receiptFingerprint = receipt && typeof receipt.command_hash === "string" && /^[a-f0-9]{64}$/i.test(receipt.command_hash)
@@ -421,6 +434,124 @@ export async function inspectRun(runId, options = {}) {
421
434
  },
422
435
  };
423
436
  }
437
+
438
+ function laneProgressFromText(raw) {
439
+ if (typeof raw !== "string" || !raw.trim()) return null;
440
+ const { cases, malformed, catalogComplete } = parseLaneEvents(raw, { warn: () => {} });
441
+ if (malformed !== 0 || (!cases.size && !catalogComplete)) return null;
442
+ const counts = laneCaseCounts(cases);
443
+ return { passed: counts.passed, failed: counts.failed, executed: counts.passed + counts.failed, total: catalogComplete ? counts.case_count : null, remaining: catalogComplete ? counts.pending : null, observed_pending: counts.pending, skipped: counts.skipped,
444
+ reason: catalogComplete ? "complete_reporter_catalog" : "observed_cases_only_catalog_may_be_incomplete" };
445
+ }
446
+
447
+ /** A compact status for the original owned run. Only count explicit case
448
+ * events; a partially written catalog does not establish total remaining. */
449
+ export async function statusRun(runId, options = {}) {
450
+ const inspection = await inspectRun(runId, options);
451
+ let testCounts = { passed: null, failed: null, executed: null, total: null, remaining: null, reason: "producer_progress_unavailable" };
452
+ let eventsArgv = null;
453
+ let logArgv = null;
454
+ let elapsedSeconds = null;
455
+ let progressObservedAt = null;
456
+ if (inspection.classification === "running") {
457
+ const read = options.read ?? readFile;
458
+ const contextFile = options.contextFile ?? contextPath;
459
+ const contextRaw = await read(contextFile(inspection.run_id), "utf8").catch(() => null);
460
+ const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, inspection.run_id) : null;
461
+ const alive = options.processAlive ?? workerProcessGroupAlive;
462
+ const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
463
+ if (context && freshContext(context, options.now ?? Date.now()) && pids.some((pid) => alive(pid))) {
464
+ const started = Date.parse(context.launched_at ?? "");
465
+ const observedNow = options.now ?? Date.now();
466
+ if (Number.isFinite(started) && observedNow >= started) elapsedSeconds = Math.floor((observedNow - started) / 1000);
467
+ if (typeof context.test_run?.events_path === "string") {
468
+ const raw = await read(context.test_run.events_path, "utf8").catch(() => null);
469
+ if (typeof raw === "string") eventsArgv = ["cat", context.test_run.events_path];
470
+ testCounts = laneProgressFromText(raw) ?? testCounts;
471
+ if (testCounts.passed !== null) {
472
+ const info = await (options.stat ?? stat)(context.test_run.events_path).catch(() => null);
473
+ if (Number.isFinite(info?.mtimeMs) && info.mtimeMs >= 0 && info.mtimeMs <= observedNow) progressObservedAt = new Date(info.mtimeMs).toISOString();
474
+ }
475
+ }
476
+ const logPath = `${contextFile(inspection.run_id)}.live-log.json`;
477
+ const rawLog = await read(logPath, "utf8").catch(() => null);
478
+ const log = typeof rawLog === "string" ? parseOwnedRunFile(rawLog, inspection.run_id) : null;
479
+ if (log && /^[a-f0-9]{64}$/i.test(context.command_hash ?? "") && /^[a-f0-9]{40,64}$/i.test(context.source_sha ?? "") && log.command_hash === context.command_hash && log.source_sha === context.source_sha && typeof log.bounded_log === "string") logArgv = ["cat", logPath];
480
+ }
481
+ } else if (inspection.source_bound === true && typeof inspection.local?.receipt_path === "string") {
482
+ const read = options.read ?? readFile;
483
+ const raw = await read(inspection.local.receipt_path, "utf8").catch(() => null);
484
+ const receipt = typeof raw === "string" ? parseOwnedRunFile(raw, inspection.run_id) : null;
485
+ const start = Date.parse(receipt?.started_at ?? ""), end = Date.parse(receipt?.ended_at ?? "");
486
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) elapsedSeconds = Math.floor((end - start) / 1000);
487
+ const progress = receipt?.test_counts;
488
+ if (receipt?.command_hash === inspection.local.command_hash && receipt?.source_sha === inspection.local.source_sha && progress && [progress.passed, progress.failed, progress.observed_pending, progress.skipped].every((n) => Number.isSafeInteger(n) && n >= 0) &&
489
+ (progress.remaining === null || (Number.isSafeInteger(progress.remaining) && progress.remaining >= 0)) &&
490
+ ["complete_reporter_catalog", "observed_cases_only_catalog_may_be_incomplete"].includes(progress.reason) &&
491
+ (progress.reason === "complete_reporter_catalog" ? progress.remaining === progress.observed_pending : progress.remaining === null)) {
492
+ testCounts = { passed: progress.passed, failed: progress.failed, executed: progress.passed + progress.failed, total: progress.reason === "complete_reporter_catalog" ? progress.passed + progress.failed + progress.observed_pending + progress.skipped : null, remaining: progress.remaining, observed_pending: progress.observed_pending, skipped: progress.skipped, reason: progress.reason };
493
+ }
494
+ }
495
+ const path = inspection.source_bound === true ? inspection.local?.receipt_path : null;
496
+ const receiptArgv = typeof path === "string" && path.length > 0 ? ["cat", path] : null;
497
+ const logReadArgv = (logArgv || receiptArgv) && inspection.run_id ? ["bb", "run", "logs", inspection.run_id, "--tail", "100"] : null;
498
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
499
+ return {
500
+ schema_version: RUN_SCHEMA_VERSION,
501
+ run_id: inspection.run_id ?? null,
502
+ classification: inspection.classification,
503
+ ...(inspection.error ? { error: inspection.error } : {}),
504
+ source_bound: inspection.source_bound === true,
505
+ local: inspection.local ?? null,
506
+ remote: inspection.remote ?? null,
507
+ test_counts: testCounts,
508
+ elapsed_seconds: elapsedSeconds,
509
+ progress_observed_at: progressObservedAt,
510
+ current_batch: null,
511
+ current_batch_reason: "producer_batch_unavailable",
512
+ counts_basis: testCounts.passed === null ? null : "latest_observed_case_verdicts_retries_may_change",
513
+ receipt_argv: receiptArgv,
514
+ receipt_command: receiptArgv ? receiptArgv.map(quote).join(" ") : null,
515
+ test_events_argv: eventsArgv,
516
+ test_events_command: eventsArgv ? eventsArgv.map(quote).join(" ") : null,
517
+ live_log_argv: logArgv,
518
+ live_log_command: logArgv ? logArgv.map(quote).join(" ") : null,
519
+ log_read_argv: logReadArgv,
520
+ log_read_command: logReadArgv ? logReadArgv.map(quote).join(" ") : null,
521
+ recovery: inspection.recovery,
522
+ };
523
+ }
524
+
525
+ /** Read only the original source-bound local snapshot. Remote logs are not
526
+ * assumed available, and no command is rerun to obtain output. */
527
+ export async function logsRun(runId, { tail = 100, ...options } = {}) {
528
+ if (!Number.isSafeInteger(tail) || tail < 1 || tail > 1000) return { schema_version: RUN_SCHEMA_VERSION, available: false, error: "invalid_tail", classification: "rejected" };
529
+ const status = await statusRun(runId, options);
530
+ const statusArgv = status.run_id ? ["bb", "run", "status", status.run_id] : null;
531
+ const base = { schema_version: RUN_SCHEMA_VERSION, run_id: status.run_id, status_argv: statusArgv, status_command: statusArgv?.join(" ") ?? null };
532
+ if (status.classification === "rejected") return { ...base, available: false, classification: "rejected", error: status.error };
533
+ const path = status.live_log_argv?.[1] ?? status.receipt_argv?.[1];
534
+ if (!path) return { ...base, available: false, reason: "owned_log_unavailable", remote_logs: "unavailable" };
535
+ const read = options.read ?? readFile;
536
+ const raw = await read(path, "utf8").catch(() => null);
537
+ const snapshot = typeof raw === "string" ? parseOwnedRunFile(raw, status.run_id) : null;
538
+ let bound = false;
539
+ if (status.live_log_argv) {
540
+ const contextFile = options.contextFile ?? contextPath;
541
+ const contextRaw = await read(contextFile(status.run_id), "utf8").catch(() => null);
542
+ const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, status.run_id) : null;
543
+ const alive = options.processAlive ?? workerProcessGroupAlive;
544
+ const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
545
+ bound = Boolean(context && freshContext(context, options.now ?? Date.now()) && pids.some((pid) => alive(pid)) &&
546
+ /^[a-f0-9]{64}$/i.test(context.command_hash ?? "") && /^[a-f0-9]{40,64}$/i.test(context.source_sha ?? "") &&
547
+ snapshot?.command_hash === context.command_hash && snapshot?.source_sha === context.source_sha);
548
+ } else bound = status.source_bound && snapshot?.command_hash === status.local?.command_hash && snapshot?.source_sha === status.local?.source_sha;
549
+ if (!bound || typeof snapshot?.bounded_log !== "string") return { ...base, available: false, reason: "owned_log_changed_or_unavailable", remote_logs: "unavailable" };
550
+ const text = snapshot.bounded_log.slice(-8192);
551
+ const lines = text ? text.split(/\r?\n/) : [];
552
+ if (lines.at(-1) === "") lines.pop();
553
+ return { ...base, available: true, source: status.live_log_argv ? "local_live_snapshot" : "local_terminal_receipt", lines: lines.slice(-tail), truncated: lines.length > tail, remote_logs: "not_read" };
554
+ }
424
555
  function cancellationPath(contextFile) { return `${contextFile}.cancelled`; }
425
556
 
426
557
  async function markCancellation(contextFile, runId) {
@@ -644,6 +775,7 @@ export async function runWorker(contextFile, {
644
775
  sleep, now, terminalBudgetMs, startDelivery,
645
776
  // BOT-2530: worker liveness lease renewal cadence (tests inject a fake timer).
646
777
  workerHeartbeatIntervalMs = WORKER_HEARTBEAT_INTERVAL_MS, setIntervalFn = setInterval,
778
+ liveLogIntervalMs = 1_000,
647
779
  } = {}) {
648
780
  const retry = { sleep, now, budgetMs: terminalBudgetMs, startDelivery };
649
781
  const context = JSON.parse(await readFile(contextFile, "utf8"));
@@ -653,6 +785,7 @@ export async function runWorker(contextFile, {
653
785
  const startedAt = new Date().toISOString();
654
786
  let timedOut = false;
655
787
  let child;
788
+ let liveLogTimer;
656
789
  let cancellationStateWrite = Promise.resolve();
657
790
  let contextWrite = Promise.resolve();
658
791
  const persistWorkerContext = (state) => {
@@ -726,6 +859,20 @@ export async function runWorker(contextFile, {
726
859
  const capture = boundedCapture();
727
860
  child.stdout?.on("data", (chunk) => capture.append(chunk));
728
861
  child.stderr?.on("data", (chunk) => capture.append(chunk));
862
+ // A bounded, mode-0600 snapshot gives the owner an exact local log command
863
+ // while the detached workload runs. It is advisory; failure to write it
864
+ // must not stop the workload or replace the terminal receipt.
865
+ let logWrites = Promise.resolve();
866
+ let loggedCharacters = -1;
867
+ const flushLiveLog = () => {
868
+ if (loggedCharacters === capture.characters) return;
869
+ loggedCharacters = capture.characters;
870
+ const snapshot = { run_id: context.run_id, command_hash: context.command_hash ?? null, source_sha: context.source_sha ?? null, observed_at: new Date().toISOString(), bounded_log: capture.output, output_characters: capture.characters };
871
+ logWrites = logWrites.then(() => writeJson(`${contextFile}.live-log.json`, snapshot)).catch(() => { loggedCharacters = -1; });
872
+ };
873
+ flushLiveLog();
874
+ liveLogTimer = setInterval(flushLiveLog, liveLogIntervalMs);
875
+ liveLogTimer.unref?.();
729
876
  await persistWorkerContext({ workload_pid: child.pid, worker_state: "running" });
730
877
  // BOT-1510: for a test lane, tail the reporter's NDJSON and flush per-case
731
878
  // verdicts while the child runs. The test process never touches the network.
@@ -739,6 +886,9 @@ export async function runWorker(contextFile, {
739
886
  const result = await completion;
740
887
  clearTimeout(timer);
741
888
  clearTimeout(killTimer);
889
+ clearInterval(liveLogTimer);
890
+ flushLiveLog();
891
+ await logWrites;
742
892
  await cancellationStateWrite;
743
893
  if (lane) await lane.stop();
744
894
  const endedAt = new Date().toISOString();
@@ -750,6 +900,7 @@ export async function runWorker(contextFile, {
750
900
  const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, callbackCall, retry);
751
901
  return receipt;
752
902
  } finally {
903
+ clearInterval(liveLogTimer);
753
904
  if (workerLease) await workerLease.stop();
754
905
  if (ownerHeartbeat) await ownerHeartbeat.stop();
755
906
  signalEmitter.removeListener("SIGTERM", cancellationHandler);
@@ -1049,6 +1200,11 @@ async function finalize(context, state, call, retry = {}) {
1049
1200
  const boundedLog = String(state.output ?? "").slice(-8192);
1050
1201
  const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
1051
1202
  const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, command_hash: context.command_hash ?? commandHash(context.command ?? []), source_sha: context.source_sha ?? null, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
1203
+ if (typeof context.test_run?.events_path === "string") {
1204
+ const events = await readFile(context.test_run.events_path, "utf8").catch(() => null);
1205
+ const progress = laneProgressFromText(events);
1206
+ if (progress) receipt.test_counts = progress;
1207
+ }
1052
1208
  await writeJson(context.receipt_path, receipt);
1053
1209
  const bytes = await stat(context.receipt_path);
1054
1210
  const sha256 = createHash("sha256").update(await readFile(context.receipt_path)).digest("hex");
@@ -1132,6 +1288,19 @@ export async function cmdRun(args) {
1132
1288
  process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
1133
1289
  return;
1134
1290
  }
1291
+ if (args[0] === "status") {
1292
+ const result = await statusRun(args[1] ?? "");
1293
+ oneLine(result, false);
1294
+ process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
1295
+ return;
1296
+ }
1297
+ if (args[0] === "logs") {
1298
+ const valid = args.length === 2 || (args.length === 4 && args[2] === "--tail" && /^(?:[1-9][0-9]{0,2}|1000)$/.test(args[3] ?? ""));
1299
+ const result = valid ? await logsRun(args[1] ?? "", { tail: args.length === 4 ? Number(args[3]) : 100 }) : { schema_version: RUN_SCHEMA_VERSION, available: false, classification: "rejected", error: "invalid_logs_arguments" };
1300
+ oneLine(result, false);
1301
+ process.exitCode = result.classification === "rejected" ? EXIT.INVALID : result.available ? EXIT.OK : EXIT.BACKEND;
1302
+ return;
1303
+ }
1135
1304
  if (args[0] === "cancel") {
1136
1305
  const result = await cancelRun(args[1] ?? "");
1137
1306
  oneLine(result.receipt, false);
@@ -1139,7 +1308,7 @@ export async function cmdRun(args) {
1139
1308
  return;
1140
1309
  }
1141
1310
  if (args[0] === "help" || args[0] === "--help") {
1142
- console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run inspect <run_id>\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
1311
+ console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run status <run_id> Show observed counts and exact local log, test-event and receipt commands when available; counts may be unknown. Live logs are bounded private snapshots.\nbotbuddy run logs <run_id> --tail 100 Read the original owned local log snapshot; remote logs may be unavailable.\nbotbuddy run inspect <run_id> Inspect a detached command run; foreground telemetry IDs use the original terminal receipt.\nbotbuddy run cancel <run_id>\n bb telemetry status Check foreground telemetry delivery, not the command outcome.\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
1143
1312
  return;
1144
1313
  }
1145
1314
  // BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
@@ -239,7 +239,9 @@ export async function reportStackRunOutcome({ fields, exitCode, runId, opts, env
239
239
  const call = Promise.resolve().then(() => channel.callTool(STACK_RUN_OUTCOME_TOOL, payload, { auth: channel.auth, signal: ac.signal }));
240
240
  const result = await Promise.race([call.catch(() => null), timeout]);
241
241
  if (result === TIMED_OUT) return { sent: false, reason: "timeout", payload };
242
- return { sent: Boolean(result?.ok && !result?.isError), reason: result?.ok ? null : "refused", payload };
242
+ const capture = result?.data?.feedback_capture;
243
+ const feedbackCapture = result?.ok && !result?.isError && capture?.status === "captured" && typeof capture.feedback_id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(capture.feedback_id) ? { status: "captured", feedback_id: capture.feedback_id } : result?.ok && !result?.isError && capture?.status === "pending" ? { status: "pending" } : null;
244
+ return { sent: Boolean(result?.ok && !result?.isError), reason: result?.ok ? null : "refused", payload, ...(feedbackCapture ? { feedback_capture: feedbackCapture } : {}) };
243
245
  } catch {
244
246
  return { sent: false, reason: "error", payload };
245
247
  } finally {