@botbuddy/cli 1.34.3 → 1.35.0

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.
@@ -1,14 +1,115 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
- import { homedir } from "node:os";
1
+ // BOT-1318 — production wiring for bb pw browser-acceptance telemetry.
2
+ //
3
+ // Replaces the pre-BOT-1318 lane-number spool + close-only record_lane_event
4
+ // rollup. A repository opts in by committing `.botbuddy/config.json` (BOT-1316);
5
+ // once opted in every rule fails closed: the checked-in repository must match
6
+ // origin, the local credential binding must belong to the expected tenant, and
7
+ // that credential is attested once against the server (cached by fingerprint).
8
+ // A repository without the checked-in config stays untracked, exactly as before.
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync } from "node:fs";
3
11
  import { join } from "node:path";
4
- import { deriveSession } from "./readiness.mjs";
5
- const spoolDir = (env) => env.BB_PW_SPOOL_DIR || join(homedir(), ".botbuddy", "pw-spool");
6
- export function makeTelemetry({ env = process.env } = {}) {
7
- const path = (lane) => join(spoolDir(env), `lane-${lane}.jsonl`);
8
- const readEvents = (lane) => existsSync(path(lane)) ? readFileSync(path(lane), "utf8").split("\n").filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } }) : [];
12
+ import { fileURLToPath } from "node:url";
13
+ import { VERSION } from "../version.mjs";
14
+ import { createTelemetryOutbox } from "../telemetry-outbox.mjs";
15
+ import { deliverExecutionEvent } from "../telemetry-delivery.mjs";
16
+ import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "../telemetry-config.mjs";
17
+ import { defaultGitInfo, findGitRoot } from "../test-lane.mjs";
18
+ import { createBrowserTelemetryResolver } from "./session.mjs";
19
+ import { resolveAgentSessionCredential } from "../agent-session.mjs";
20
+ import { homedir } from "node:os";
21
+
22
+ const HEARTBEAT_ENTRY = fileURLToPath(new URL("./heartbeat.mjs", import.meta.url));
23
+
24
+ export function telemetryRoot(env = process.env) {
25
+ return env.BB_PW_TELEMETRY_ROOT || join(homedir(), ".botbuddy");
26
+ }
27
+
28
+ // Ticket/PR/branch attribution reuses the `botbuddy test` helper (BOT-1510):
29
+ // explicit BB_PW_TICKET_ID / BB_PW_PR_ID win, else the branch's `bot-<n>` segment
30
+ // and `gh pr view`; anything unresolved stays null — never a guess.
31
+ export function gitInfoFor({ cwd, env }) {
32
+ let cached = null;
33
+ return () => {
34
+ if (!cached) {
35
+ const prOverride = env.BB_PW_PR_ID ? Number.parseInt(env.BB_PW_PR_ID, 10) : null;
36
+ cached = defaultGitInfo({ cwd, ticket: env.BB_PW_TICKET_ID || null, pr: Number.isInteger(prOverride) ? prOverride : null });
37
+ }
38
+ return cached;
39
+ };
40
+ }
41
+
42
+ // The session credential the foreground authenticated with (e.g. an explicit
43
+ // --agent-session-token that is neither exported nor cached) is handed to the
44
+ // detached heartbeat through its environment — readable only by the same user,
45
+ // never on the command line — so the heartbeat re-resolves the same principal.
46
+ export function spawnHeartbeatProcess({ cwd, env, sessionCredential = null, spawnImpl = spawn }) {
47
+ return (info) => {
48
+ const childEnv = { ...env, BB_PW_TELEMETRY_ROOT: info.root, ...(sessionCredential ? { BOTBUDDY_AGENT_SESSION_TOKEN: sessionCredential } : {}) };
49
+ const child = spawnImpl(process.execPath, [HEARTBEAT_ENTRY, JSON.stringify({ ...info, cwd })], {
50
+ cwd, detached: true, stdio: "ignore", env: childEnv,
51
+ });
52
+ child.on("error", () => {});
53
+ child.unref();
54
+ return child.pid ?? null;
55
+ };
56
+ }
57
+
58
+ // The worktree's already-resolved session credential (exported or the fresh `bb setup`
59
+ // cache). Never self-heals: the detached heartbeat must not mint a session.
60
+ async function cachedSessionCredential({ env, cwd }) {
61
+ try {
62
+ const credential = await resolveAgentSessionCredential({ argv: [], env, cwd, sessionId: null, flagToken: null, selfHeal: async () => null, warn: () => {} });
63
+ return credential?.token ?? null;
64
+ } catch { return null; }
65
+ }
66
+
67
+ /**
68
+ * Resolve the verified telemetry identity for this worktree and return a
69
+ * lane-tracker resolver, `{ tracked:false }` when the repository has not opted
70
+ * in, or `{ error }` (fail closed) when the opted-in identity is invalid. The
71
+ * durable mcp_env key is preferred; without one, the session credential the lane
72
+ * gate authenticated with (`sessionCredential`), else the cached session, is used.
73
+ */
74
+ export async function resolveBrowserTelemetry({
75
+ env = process.env, cwd = process.cwd(), sessionCredential = null,
76
+ identityOptions = {}, resolveSession = cachedSessionCredential, attest = attestTelemetryCredential,
77
+ } = {}) {
78
+ const gitRoot = findGitRoot(cwd);
79
+ if (!gitRoot || !existsSync(join(gitRoot, ".botbuddy", "config.json"))) return { tracked: false };
80
+ let identity;
81
+ try {
82
+ await loadTelemetryLocation(cwd, identityOptions);
83
+ identity = await loadTelemetryIdentity(cwd, { ...identityOptions, fallbackCredential: async () => sessionCredential || (await resolveSession({ env, cwd })) });
84
+ } catch (error) {
85
+ return { error: `bb-pw: browser telemetry identity rejected (${error?.message ?? "invalid"}); nothing was run. Fix .botbuddy/config.json or the worktree credential binding.` };
86
+ }
87
+ const root = telemetryRoot(env);
88
+ try {
89
+ const outbox = await createTelemetryOutbox({ root: join(root, "telemetry"), tenant: identity.tenant, repository: identity.repository, producerVersion: VERSION });
90
+ await attest(identity, outbox);
91
+ } catch (error) {
92
+ return { error: `bb-pw: browser telemetry credential rejected (${error?.message ?? "unattested"}); nothing was run.` };
93
+ }
94
+ return {
95
+ tracked: true,
96
+ resolver: createBrowserTelemetryResolver({
97
+ root, tenant: identity.tenant, repository: identity.repository, producerVersion: VERSION,
98
+ deliver: (event) => deliverExecutionEvent(event, { credential: identity.credential }),
99
+ gitInfo: gitInfoFor({ cwd, env }),
100
+ spawnHeartbeat: spawnHeartbeatProcess({ cwd, env, sessionCredential }),
101
+ }),
102
+ };
103
+ }
104
+
105
+ /** The runPw `deps.telemetry` default: `{ resolve({host, lane}) }`. */
106
+ export function makeTelemetry({ env = process.env, cwd = process.cwd(), sessionCredential = null } = {}) {
107
+ let resolved = null;
9
108
  return {
10
- append(lane, event) { mkdirSync(spoolDir(env), { recursive: true }); appendFileSync(path(lane), JSON.stringify({ ts: event.ts, method: event.method, url: event.url ?? null }) + "\n"); },
11
- count(lane) { return readEvents(lane).length; },
12
- async rollup({ lane, coordinator, host }) { const events = readEvents(lane); if (!events.length) return { emitted: false, count: 0 }; const session = deriveSession(events, { meta: { host, slot: String(lane), ticket_id: env.BB_PW_TICKET_ID, ticket_url: env.BB_PW_TICKET_URL, pr_id: env.BB_PW_PR_ID, pr_url: env.BB_PW_PR_URL, branch: env.BB_PW_BRANCH, commit_sha: env.BB_PW_COMMIT, environment: env.BB_PW_ENV } }); const event = { ...session, started_at: new Date(session.started_at).toISOString(), ended_at: new Date(session.ended_at).toISOString(), ended_reason: "close" }; try { if (!coordinator?.emit) return { emitted: false, count: events.length, session: event }; await coordinator.emit(event); rmSync(path(lane), { force: true }); return { emitted: true, count: events.length, session: event }; } catch { return { emitted: false, count: events.length, session: event }; } },
109
+ async resolve({ host, lane }) {
110
+ resolved ??= await resolveBrowserTelemetry({ env, cwd, sessionCredential });
111
+ if (!resolved.tracked) return resolved;
112
+ return resolved.resolver.resolve({ host, lane });
113
+ },
13
114
  };
14
115
  }
@@ -153,6 +153,55 @@ export async function heartbeatHarnessSession({
153
153
  return response.data;
154
154
  }
155
155
 
156
+ // BOT-1945: a worktree cache that this bootstrap path did not create — written by
157
+ // the self-heal / `bb setup` / MCP registration path (no `harness_origin` marker),
158
+ // or a legacy bootstrap cache with no stored generation — is not a harness
159
+ // principal this bootstrap owns and cannot be retired through the fenced durable
160
+ // close (the server refuses a generated row without its generation:
161
+ // `session_generation_required`). Retire it best-effort and NEVER fail the
162
+ // bootstrap because of it:
163
+ // 1. its own scoped bearer self-closes with if_stale (the server derives the
164
+ // generation from the token, and still refuses a live holder);
165
+ // 2. the durable credential confirms (with the generation when cached): an
166
+ // ended row answers already_closed, an active generated row stays refused;
167
+ // 3. otherwise leave it: a session that is never closed simply ages out of the
168
+ // server's holder-liveness window and its short-lived token expires.
169
+ // Returns a non-secret receipt `{ session_id, retired }`.
170
+ export function cacheOwnedByBootstrap(cached) {
171
+ return cached?.harness_origin === "bootstrap_agent_session" && !!cached.session_token_generation;
172
+ }
173
+
174
+ async function retireUnownedCachedSession({ cached, call, callOptions, resolveCleanupOptions }) {
175
+ const receipt = { session_id: cached.session_id, retired: false };
176
+ const ok = (response) => !!response?.ok && !response.isError;
177
+ try {
178
+ if (cached.session_token) {
179
+ const self = await call("close_session", { session_id: cached.session_id, if_stale: true }, {
180
+ auth: { "x-agent-api-key": cached.session_token },
181
+ }).catch(() => null);
182
+ if (ok(self)) return { ...receipt, retired: true };
183
+ // A live holder is refused by if_stale; the durable credential would be
184
+ // refused the same way, so do not widen to it.
185
+ if ((self?.data?.error || self?.error) === "session_still_active") return receipt;
186
+ }
187
+ // Codex #986 P2: confirm with the durable credential even without a cached
188
+ // generation. A bearer that fails auth often means the row was already ended
189
+ // (e.g. superseded by registration reconciliation) — the durable close then
190
+ // answers idempotent `already_closed`; an ACTIVE generated row is still
191
+ // refused (`session_generation_required`) and `if_stale` protects a live one.
192
+ const durable = callOptions ?? await resolveCleanupOptions().catch(() => null);
193
+ if (!durable) return receipt;
194
+ const confirmed = await call("close_session", {
195
+ session_id: cached.session_id,
196
+ ...(cached.session_token_generation ? { session_token_generation: cached.session_token_generation } : {}),
197
+ if_stale: true,
198
+ }, durable).catch(() => null);
199
+ return { ...receipt, retired: ok(confirmed) };
200
+ } catch {
201
+ return receipt;
202
+ }
203
+ }
204
+
156
205
  export async function bootstrapHarnessSession({
157
206
  name,
158
207
  type = "codex",
@@ -231,7 +280,10 @@ export async function bootstrapHarnessSession({
231
280
  // to retire the cached session *only if its authoritative holder-liveness
232
281
  // window elapsed*. The file's timestamp is intentionally not authority:
233
282
  // relay heartbeats can extend it after the snapshot was written.
234
- if (cached && cached.session_id !== session.session_id) {
283
+ let replacedCachedSession = null;
284
+ if (cached && cached.session_id !== session.session_id && !cacheOwnedByBootstrap(cached)) {
285
+ replacedCachedSession = await retireUnownedCachedSession({ cached, call, callOptions, resolveCleanupOptions });
286
+ } else if (cached && cached.session_id !== session.session_id) {
235
287
  const cleanupOptions = callOptions ?? await resolveCleanupOptions();
236
288
  if (!cleanupOptions) {
237
289
  // BOT-1650 (Codex round-27): the new session was admitted but there is no
@@ -370,6 +422,29 @@ export async function bootstrapHarnessSession({
370
422
  }
371
423
  throw error;
372
424
  }
373
- return { ...state, ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
425
+ return { ...state, ...(replacedCachedSession ? { replaced_cached_session: replacedCachedSession } : {}), ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
374
426
  });
375
427
  }
428
+
429
+ /** BOT-1945 (AC-3): non-secret `bb doctor` view of whether this worktree's cache
430
+ * is a harness session linked by `bb bootstrap` (the SessionStart hook's job).
431
+ * Only ids and presence flags — never the bearer. */
432
+ export async function doctorBootstrapState({ cwd = process.cwd(), readState = readAgentState } = {}) {
433
+ let root;
434
+ try { root = await worktreeRoot(cwd); } catch { root = cwd; }
435
+ const state = await readState(root).catch(() => null);
436
+ if (!state) return { status: "not_linked", cache: "none" };
437
+ const bootstrap = state.harness_origin === "bootstrap_agent_session";
438
+ return {
439
+ status: bootstrap ? "linked" : "not_linked",
440
+ cache: bootstrap ? "bootstrap" : "self_heal",
441
+ session_id: state.session_id ?? null,
442
+ generation: state.session_token_generation ? "cached" : "missing",
443
+ };
444
+ }
445
+
446
+ export function formatBootstrapLine(report) {
447
+ if (report?.status === "linked") return `bootstrap: linked (session ${report.session_id}, generation ${report.generation})`;
448
+ if (!report || report.cache === "none") return "bootstrap: not linked — no session cache in this worktree (the SessionStart hook runs bb bootstrap)";
449
+ return `bootstrap: not linked — cache is a self-heal session ${report.session_id} (generation ${report.generation}); the SessionStart hook's bb bootstrap replaces it`;
450
+ }
@@ -36,18 +36,23 @@ export async function repositoryFromRemote(cwd = process.cwd()) {
36
36
 
37
37
  export async function verifyTelemetryIdentity(config, {
38
38
  cwd = process.cwd(), repositoryFromRemote: remote = repositoryFromRemote, resolveBinding = resolveAgentBinding, call = callToolJson, verifyCredential = false,
39
+ // BOT-1318: a `bb setup`-only worktree holds no mcp_env key, only its cached
40
+ // session token. The durable key still wins; the fallback is used only without it,
41
+ // and is attested against the expected tenant like any other credential.
42
+ fallbackCredential = async () => null,
39
43
  } = {}) {
40
44
  const repository = await remote(cwd);
41
45
  if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
42
46
  const binding = await resolveBinding({ cwd });
43
- if (!binding?.token) throw new Error("telemetry requires a locally stored tenant-bound credential");
44
- if (binding.tenant !== config.expectedTenant) throw new Error("telemetry tenant mismatch with checked-in config");
47
+ if (binding?.tenant !== config.expectedTenant) throw new Error("telemetry tenant mismatch with checked-in config");
48
+ const credential = binding.token || (await fallbackCredential()) || null;
49
+ if (!credential) throw new Error("telemetry requires a locally stored tenant-bound credential");
45
50
  // A binding file names the intended tenant; it is not proof that a rotated
46
51
  // environment credential still belongs there. Ask the server's read-only
47
52
  // identity endpoint before allowing a foreground child to run.
48
53
  if (verifyCredential) {
49
54
  const verified = await call("whoami", {}, {
50
- auth: { "x-agent-api-key": binding.token },
55
+ auth: { "x-agent-api-key": credential },
51
56
  tenant: config.expectedTenant,
52
57
  signal: AbortSignal.timeout(2_000),
53
58
  });
@@ -55,7 +60,7 @@ export async function verifyTelemetryIdentity(config, {
55
60
  throw new Error("telemetry credential is not attested to the expected tenant");
56
61
  }
57
62
  }
58
- return { repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile, credential: binding.token };
63
+ return { repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile, credential };
59
64
  }
60
65
 
61
66
  export async function loadTelemetryLocation(cwd = process.cwd(), {
@@ -21,11 +21,29 @@ export async function deliverExecutionEvent(event, {
21
21
  });
22
22
  let body = {};
23
23
  try { body = await response.json(); } catch { /* typed HTTP status still controls retry */ }
24
- if (response.status === 200 || response.status === 202) return { accepted: true, receipt: body.receipt ?? "accepted" };
24
+ // BOT-1318 (Codex R1 P2): carry the canonical execution UUID from the real
25
+ // execution-ingest receipt so producers can report it (never source_run_id).
26
+ if (response.status === 200 || response.status === 202) {
27
+ return {
28
+ accepted: true, receipt: body.receipt ?? "accepted",
29
+ ...(typeof body.execution_id === "string" && body.execution_id ? { execution_id: body.execution_id } : {}),
30
+ // BOT-1318 (Codex R13): a terminal outcome on a non-terminal event means the
31
+ // server already ended the run (e.g. reaped as abandoned) — producers reconcile.
32
+ ...(typeof body.outcome === "string" && body.outcome ? { outcome: body.outcome } : {}),
33
+ };
34
+ }
25
35
  // A reaper may have terminalized a run after a successful upload whose
26
36
  // response was lost. BOT-1317 correctly refuses a contradictory terminal;
27
37
  // retaining that record would otherwise block every later local event.
28
- if (response.status === 409 && body.error === "terminal_conflict") return { accepted: true, receipt: "terminal_conflict_retired" };
38
+ // The 409 carries the canonical outcome and execution id; keep them so the producer
39
+ // reconciles its local terminal to the server's (Codex R27).
40
+ if (response.status === 409 && body.error === "terminal_conflict") {
41
+ return {
42
+ accepted: true, receipt: "terminal_conflict_retired",
43
+ ...(typeof body.outcome === "string" && body.outcome ? { outcome: body.outcome } : {}),
44
+ ...(typeof body.execution_id === "string" && body.execution_id ? { execution_id: body.execution_id } : {}),
45
+ };
46
+ }
29
47
  // The queue cannot repair an event rejected by the server's contract.
30
48
  // Quarantine these client errors so one malformed historical line does not
31
49
  // starve later valid lifecycle events. Authentication is deliberately left
@@ -3,7 +3,7 @@
3
3
  // append a small, allowlisted lifecycle event before spawning a child; delivery
4
4
  // is a separate, idempotent step against execution-ingest.
5
5
 
6
- import { createHash } from "node:crypto";
6
+ import { createHash, randomUUID } from "node:crypto";
7
7
  import { chmod, link, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
8
8
  import { homedir } from "node:os";
9
9
  import { dirname, join } from "node:path";
@@ -24,10 +24,26 @@ const CORRELATION_KEYS = new Set([
24
24
  ]);
25
25
  const PROGRESS_KEYS = new Set(["completed", "total", "phase", "current_item", "worker", "shard"]);
26
26
  const DIAGNOSTIC_KEYS = new Set(["code", "message", "category", "signal", "retryable"]);
27
+ // BOT-1318: the optional, bounded browser-acceptance object. Mirrors the
28
+ // execution-ingest contract; anything else (typed values, raw output, invented
29
+ // metrics) is stripped here rather than shipped for the server to reject.
30
+ const BROWSER_KEYS = new Set(["lane", "host", "action", "summary"]);
31
+ const BROWSER_ACTION_KEYS = new Set(["action_id", "verb", "category", "phase", "started_at", "finished_at", "command_ms", "result", "error_class", "route"]);
32
+ const BROWSER_SUMMARY_KEYS = new Set([
33
+ "actions_total", "actions_succeeded", "actions_failed", "actions_interrupted", "command_ms", "active_ms", "action_span_ms",
34
+ "first_action_at", "last_action_at", "navigations", "screenshots", "snapshots", "interactions", "distinct_routes", "acceptance_verdict",
35
+ ]);
36
+ const pick = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
27
37
 
28
38
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
39
  const hash = (value) => createHash("sha256").update(value).digest("hex");
30
40
 
41
+ /** True when a string carries a credential the outbox would redact. */
42
+ export function containsSensitive(value) {
43
+ SENSITIVE.lastIndex = 0;
44
+ return typeof value === "string" && SENSITIVE.test(value);
45
+ }
46
+
31
47
  function redactString(value) {
32
48
  SENSITIVE.lastIndex = 0;
33
49
  return value.replace(SENSITIVE, "[REDACTED]");
@@ -55,7 +71,7 @@ function eventPayload(event) {
55
71
  if (!clean || typeof clean !== "object" || Array.isArray(clean)) throw new Error("telemetry event must be an object");
56
72
  // execution-ingest is intentionally strict. Keep this client-side allowlist so
57
73
  // a reporter cannot accidentally turn arbitrary process state into telemetry.
58
- const allowed = new Set(["schema_version", "action", "producer_kind", "source_run_id", "attempt", "producer_event_id", "producer_sequence", "occurred_at", "context", "progress", "outcome", "diagnostic", "evidence_refs"]);
74
+ const allowed = new Set(["schema_version", "action", "producer_kind", "source_run_id", "attempt", "producer_event_id", "producer_sequence", "occurred_at", "context", "progress", "outcome", "diagnostic", "evidence_refs", "browser"]);
59
75
  for (const key of Object.keys(clean)) if (!allowed.has(key)) delete clean[key];
60
76
  // Mirror the server contract at the producer boundary. A malformed reporter
61
77
  // must not create a permanently retrying record merely because it included
@@ -69,6 +85,11 @@ function eventPayload(event) {
69
85
  if (clean.progress && typeof clean.progress === "object" && !Array.isArray(clean.progress)) {
70
86
  clean.progress = Object.fromEntries(Object.entries(clean.progress).filter(([key]) => PROGRESS_KEYS.has(key)));
71
87
  }
88
+ if (clean.browser && typeof clean.browser === "object" && !Array.isArray(clean.browser)) {
89
+ clean.browser = pick(clean.browser, BROWSER_KEYS);
90
+ if (clean.browser.action && typeof clean.browser.action === "object" && !Array.isArray(clean.browser.action)) clean.browser.action = pick(clean.browser.action, BROWSER_ACTION_KEYS);
91
+ if (clean.browser.summary && typeof clean.browser.summary === "object" && !Array.isArray(clean.browser.summary)) clean.browser.summary = pick(clean.browser.summary, BROWSER_SUMMARY_KEYS);
92
+ }
72
93
  if (clean.diagnostic && typeof clean.diagnostic === "object" && !Array.isArray(clean.diagnostic)) {
73
94
  clean.diagnostic = Object.fromEntries(Object.entries(clean.diagnostic).filter(([key]) => DIAGNOSTIC_KEYS.has(key)));
74
95
  }
@@ -88,7 +109,7 @@ async function durableWrite(path, value, { append = false } = {}) {
88
109
  }
89
110
 
90
111
  async function atomicJson(path, value) {
91
- const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
112
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
92
113
  await durableWrite(temp, `${JSON.stringify(value)}\n`);
93
114
  await rename(temp, path);
94
115
  // Persist the directory entry, not just the file data, before reporting the
@@ -137,7 +158,7 @@ export async function createTelemetryOutbox({
137
158
  // Write a complete, fsynced owner record first, then atomically publish
138
159
  // it with link(2). A crash can leave an unreferenced temp file but can
139
160
  // never strand the shared lock name with missing PID metadata.
140
- const candidate = `${lockPath}.${process.pid}.${Date.now()}.tmp`;
161
+ const candidate = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
141
162
  await durableWrite(candidate, JSON.stringify({ pid: process.pid, created_at: now().toISOString() }));
142
163
  try { await link(candidate, lockPath); } finally { await unlink(candidate).catch(() => {}); }
143
164
  return async () => { try { await unlink(lockPath); } catch { /* a process may have cleaned its own stale lock */ } };
@@ -257,7 +278,7 @@ export async function createTelemetryOutbox({
257
278
  }
258
279
  offset += Buffer.byteLength(line) + 1;
259
280
  }
260
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
281
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
261
282
  await durableWrite(tmp, remaining.map((record) => `${JSON.stringify(record)}\n`).join(""));
262
283
  await rename(tmp, path);
263
284
  let dir;
@@ -266,20 +287,25 @@ export async function createTelemetryOutbox({
266
287
  } finally { await release(); }
267
288
  }
268
289
 
269
- async function status() {
290
+ /** `ownerOf(event)` (optional) also counts the queue per owner key, e.g. a browser lane. */
291
+ async function status({ ownerOf = null } = {}) {
270
292
  const lines = await readLines();
271
293
  let oldest = null;
272
294
  let queued = 0;
295
+ const byOwner = {};
273
296
  for (const line of lines) {
274
297
  try {
275
298
  const record = parseRecord(line);
276
299
  queued += 1;
300
+ const owner = ownerOf ? ownerOf(record.event) : null;
301
+ if (owner) byOwner[owner] = (byOwner[owner] ?? 0) + 1;
277
302
  if (!oldest || record.created_at < oldest) oldest = record.created_at;
278
303
  } catch { queued += 1; }
279
304
  }
280
305
  const persisted = await readStatus();
281
306
  return {
282
307
  queued_count: queued,
308
+ ...(ownerOf ? { queued_by_owner: byOwner } : {}),
283
309
  oldest_queued_at: oldest,
284
310
  last_successful_delivery_at: persisted.last_successful_delivery_at ?? null,
285
311
  last_error_class: persisted.last_error_class ?? null,