@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.
- package/bin/botbuddy.mjs +14 -2
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/commands.mjs +10 -2
- package/src/docker-hygiene.mjs +2 -1
- package/src/feedback-outbox.mjs +119 -0
- package/src/feedback-reminder.mjs +16 -0
- package/src/feedback-safety.mjs +31 -0
- package/src/feedback.integration.fixture.mjs +103 -0
- package/src/feedback.mjs +269 -0
- package/src/run.mjs +175 -6
- package/src/stack-run-outcome.mjs +3 -1
- package/src/stack.mjs +32 -5
- package/src/test/playwright-reporter.mjs +3 -1
- package/src/test-lane-events.mjs +8 -1
- package/src/wait-core.mjs +17 -0
- package/src/wait-feedback-capture.mjs +62 -0
- package/src/wait.mjs +45 -0
package/src/feedback.mjs
ADDED
|
@@ -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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|