cccc console

bluebut#52done

343e9673-af10-49d8-82b9-c6f5fa2c3380 · pbox · Jul 24, 03:10:30 PM26h ago

team
lead + 1
turns
tools
88
tokens
spend
took
14m 18s
model
errors
1
claude --resume 343e9673-af10-49d8-82b9-c6f5fa2c3380

lead agent

model not reported

done
GOAL: fix GitLab issue #52 in devdashco/bluebut in an isolated worktree, push, LAND IT ON main, and close. SUBAGENTS: spawn AT MOST 4 parallel subagent(s) (Task tool) at a time. NEVER fan heavy commands (installs, full test suites, builds) out across subagents in parallel — that is what melts the box. You are in the devdashco/bluebut repository (you are already assigned to this issue). This repo lives on our self-hosted GitLab (gitlab.hostbun.cc) — use the `glab` CLI for all issue / MR work (it is installed and a

Jul 24, 03:10:30 PM

Bash{"command": "PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 52'; $PROG read start \"checking existing state\"; pwd; git branch --show-current; git fetch origin -q; git log --oneline origin/main..fix/issue-52 2>/dev/null; echo \"---MRs---\"; glab mr list --repo devdashco/bluebut --source-branch fix/issue-52 2>/dev/null", "description": "Check branch and MR state"}
/home/philip/Documents/GitHub/bluebut-issue-52
fix/issue-52
---MRs---
No open merge requests match your search in devdashco/bluebut.
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs"}
1	// runtime/scheduler — the in-process recurring scheduler (job_schedule tick) + the
2	// runtime recognizer loop, relocated VERBATIM from runtime.mjs (issue #21 §4). The
3	// tick fns and the SCHED_ENABLED gate live here; the BOOT triggers (the setInterval /
4	// setTimeout that start them) are owned by the runtime.mjs barrel so their firing
5	// order is deterministic — this module only defines, it does not self-start.
6	
7	import { spawn } from "node:child_process";
8	import { rest as dbRest } from "../../../adapters/_lib/db/db.ts";
9	import { groupsRepo } from "../../../adapters/_lib/repos/index.ts";
10	import { mintJobId } from "../../jobs/job-id.ts";
11	import { isDue, nextRunAt } from "../../../adapters/_lib/schedule.ts";
12	import { resolveScheduleActor } from "../../../adapters/_lib/schedule-actor.ts";
13	import { workflowCmd } from "../../../adapters/_lib/workflow/workflow.ts";
14	import { API_TOKEN } from "../lib.mjs";
15	import { RUNNER, NODE, adapterPath, childEnv, parseLoose, envFile } from "./spawn-env.mjs";
16	import { newJob, pushLog, withAccountLock } from "./job-registry.mjs";
17	import { sweep, usableDrivableFleet } from "./run-core.mjs";
18	import { workflowStart, resolveWorkflowPlan } from "./workflow.mjs";
19	import { numEnv } from "../../../adapters/_lib/num-env.ts";
20	
21	// ── Recurring jobs (job_schedule) — the in-process scheduler tick ─────────────
22	// The bridge is the always-on process that already spawns runner jobs and holds
23	// the per-account lock, so recurring jobs tick RIGHT HERE — not as an external
24	// Coolify cron. Every SCHED_TICK_MS we read the enabled job_schedule rules, fire
25	// each one that's DUE as a real job through the SAME machinery /exec/stream uses
26	// (newJob → account lock → childEnv spawn), and stamp run-state back to the DB.
27	// State lives in the DB, so a bridge restart loses nothing. The due-ness math is
28	// pure + unit-tested in adapters/_lib/schedule.js (isDue/nextRunAt). No HTTP hop,
29	// no bridge token, no fresh node process per minute — the killed-off cron's cost.
30	const SCHED_TICK_MS = numEnv('BLUEBUT_SCHED_TICK_MS', 60_000);
31	const SCHED_TIMEOUT_MS = numEnv('BLUEBUT_SCHED_TIMEOUT_MS', 600_000);
32	const _schedInflight = new Set();   // schedule_ids currently firing — guards tick overlap
33	let _schedTicking = false;
34	// ANTI-DETECT: at most ONE browser-driving workflow SWEEP (scrape-new-members /
35	// get-group-feed / any needs_actor workflow) runs at a time. Firing both together would
36	// double the whole fleet's live FB activity in one window — a fleet-correlation tell. This
37	// holds the batch_id of the active sweep (or the "starting" sentinel between fire and
38	// batch id), cleared when that batch drains (workflowRunExec finally). A deferred sweep
39	// simply re-fires next tick. Broadcast batches serialize separately (_activeBatches).
40	// The reservation slot itself is sweep.batch (run-core), shared with the run loops.
41	
42	// ANTI-CLOCKWORK fire jitter: a deterministic per-cycle random hold (0..maxJitter) added
43	// on top of a schedule's interval, so an FB-touching sweep never fires at the same
44	// wall-clock every cycle (the "fires at 11:00:2x daily" robot tell). Deterministic —
45	// hashed from schedule_id + the current interval bucket — so it's STABLE within one
46	// pending window (no early/late flip-flop as the 60s tick re-checks) yet shifts every
47	// cycle. Browserless DB/LLM schedules don't jitter (no FB detection surface).
48	function schedFireJitterMs(s) {
49	  const intMs = Number(s.interval_minutes) * 60_000;
50	  if (!(intMs > 0)) return 0;
51	  if (!(s.workflow || s.campaign) && s.needs_actor === false) return 0;   // browserless → no jitter (#56.1: read EITHER flag)
52	  const maxJ = Math.min(intMs * 0.5, 60 * 60_000);               // ≤ half the interval, ≤ 60 min
53	  const last = s.last_run_at ? new Date(s.last_run_at).getTime() : 0;
54	  const bucket = Math.floor(last / intMs);
55	  let h = 2166136261;                                            // FNV-1a(schedule_id:bucket)
56	  const key = `${s.schedule_id}:${bucket}`;
57	  for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 16777619); }
58	  return Math.floor(((h >>> 0) % 100000) / 100000 * maxJ);
59	}
60	
61	// Build the adapter argv for a schedule: the JIT-resolved account, optional
62	// --groups csv, verbatim extras. `acct` is resolved at fire time (never s.account
63	// directly) so a device-less / dark account is never dispatched.
64	function schedArgs(s, acct) {
65	  const out = [];
66	  if (acct) out.push("--account", acct);
67	  const groups = Array.isArray(s.groups) ? s.groups.filter(Boolean) : [];
68	  if (groups.length) out.push("--groups", groups.join(","));
69	  for (const a of Array.isArray(s.args) ? s.args : []) out.push(String(a));
70	  return out;
71	}
72	
73	// Fire ONE schedule as a real job — the headless twin of /exec/stream's task():
74	// same newJob + withAccountLock + childEnv spawn, minus the SSE response. The
75	// runner writes the durable ledger row (jobId = BLUEBUT_JOB_ID) for free.
76	function fireSchedule(s, jobId, acct) {
77	  const site = s.site || "facebook";
78	  const cmd = s.cmd;
79	  const adapter = adapterPath(site, cmd);
80	  if (!adapter) return Promise.resolve({ ok: false, jobId, error: `no adapter for ${site}/${cmd}` });
81	  const args = schedArgs(s, acct);
82	  const job = newJob({ id: jobId, account: acct, kind: "adapter", cmd: `${site}/${cmd}`, label: `⏰ ${s.label} · ${site}/${cmd}${acct ? ` (${acct})` : ""}` });
83	  job.traceJobId = jobId;   // == job.id (id-collapse); kept for the UI/analyze-job field
84	  job.scheduled = true;   // mute routine schedule ticks in Telegram (BLUEBUT_TG_SCHEDULED=1 re-enables)
85	  const cliArgs = [RUNNER, adapter, ...args.filter((a) => a != null).map(String)];
86	  return new Promise((resolve) => {
87	    const task = () => new Promise((done) => {
88	      // batch_id = schedule_id → every run this schedule fires is linkable in the ledger
89	      // (job.batch_id = schedule_id), so the UI can open each run from the schedule.
90	      const child = spawn(NODE, cliArgs, { env: childEnv(acct, jobId, s.schedule_id || null, site) });
91	      const killTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } }, SCHED_TIMEOUT_MS + 15_000);
92	      let out = "", ebuf = "";
93	      child.stdout.on("data", (d) => (out += d));
94	      child.stderr.on("data", (d) => {
95	        ebuf += d; let i;
96	        while ((i = ebuf.indexOf("\n")) >= 0) { const line = ebuf.slice(0, i).replace(/\x1b\[[0-9;]*m/g, ""); ebuf = ebuf.slice(i + 1); if (line.trim()) pushLog(job, line); }
97	      });
98	      child.on("close", () => { clearTimeout(killTimer); const parsed = parseLoose(out); job.ok = !!parsed && parsed.ok !== false; if (parsed?.diagnostic) job.diagnostic = parsed.diagnostic; resolve({ ok: job.ok, jobId, result: parsed }); done(); });
99	      child.on("error", (e) => { clearTimeout(killTimer); job.ok = false; resolve({ ok: false, jobId, error: e.message }); done(); });
100	    });
101	    withAccountLock(acct, job, task, null);
102	  });
103	}
104	
105	// Resolve the TARGET SET for a workflow schedule. A per-account-discover cmd (the
106	// fleet sweeps / utilities — get-user-groups, list-pages, whoami…) needs NO group
107	// set (the target IS the account). A per-account-batch / per-target group cmd
108	// (get-group-feed, scrape-new-members, get-group…) needs a group id list: honour an
109	// explicit s.groups; else auto-resolve the natural set for the intent — every group
110	// we're a MEMBER of for a feed scrape, every group we OWN for an admin scrape — so
111	// a newly-joined group self-includes on the next tick (no per-group schedule churn).
112	async function resolveScheduleGroupIds(s) {
113	  const entry = workflowCmd(s.cmd);
114	  if (!entry || entry.fanMode === "per-account-discover") return [];
115	  const explicit = (Array.isArray(s.groups) ? s.groups : []).map(String).filter(Boolean);
116	  if (explicit.length) return explicit;
117	  // BIGGEST GROUPS FIRST: order candidates by member_count desc so the highest-value
118	  // groups get scraped before a sweep can be cut short (block / stop / actor blip). The
119	  // groupIds order is preserved all the way to batch_target.idx (resolveWorkflowTargets
120	  // maps over groupIds in order) → each lane processes its biggest groups first.
121	  const dedupInOrder = (rows, key = "fb_group_id") => {
122	    const seen = new Set(); const out = [];
123	    for (const r of rows) { const id = String(r?.[key] ?? ""); if (id && !seen.has(id)) { seen.add(id); out.push(id); } }
124	    return out;
125	  };
126	  try {
127	    if (entry.intent === "scrape-admin" || entry.intent === "insights") {
128	      const rows = (await groupsRepo.select("select=fb_group_id&owned_by_us=is.true&order=member_count.desc.nullslast")) || [];
129	      return dedupInOrder(rows);
130	    }
131	    // feed-scrape / group sync — every group any of our accounts is a member of, biggest
132	    // first (member_count lives on fb_group → embed + order by the embedded column).
133	    const rows = (await dbRest("GET", "fb_account_group?select=fb_group_id,fb_group!inner(member_count)&is_member=is.true&order=fb_group(member_count).desc.nullslast", null, { silent: true })) || [];
134	    return dedupInOrder(rows);
135	  } catch { return []; }
136	}
137	
138	// Fire ONE workflow schedule: resolve the target set, PARTITION the intent across the
139	// USABLE fleet (usync-alive ∩ device-runnable; an optional single pin narrows it),
140	// then workflowStart — the durable, parallel-lane executor owns the rest. This is the
141	// agnostic model: a scheduled job is "all accounts that fit criteria", not one profile.
142	// workflowStart MINTS the batch id (passing one makes it a resume → PATCH-not-INSERT →
143	// batch_target FK fail), so we let it and return the id. Returns { ok, targets, lanes, batchId, reason }.
144	async function fireWorkflowSchedule(s) {
145	  const entry = workflowCmd(s.cmd);
146	  if (!entry) return { ok: false, targets: 0, reason: `'${s.cmd}' is not a fannable workflow cmd` };
147	  const groupIds = await resolveScheduleGroupIds(s);
148	  // Resolve WHO can run it: usync-alive fleet (fail-open to device pool on a checker
149	  // outage). A single-account pin narrows the set; null accounts ⇒ pass the whole
150	  // usable fleet as the explicit subset so resolveWorkflowPlan fans across exactly it.
151	  const usable = await usableDrivableFleet();
152	  const pin = (s.account || "").trim();
153	  let accounts;
154	  if (!pin) accounts = usable == null ? null : [...usable];             // null = fall back to device pool
155	  else if (usable == null || usable.has(pin)) accounts = [pin];         // pin alive (or checker down) → honor it
156	  else {
157	    // #56.2: a pin that isn't usync-alive must NOT brick the flow forever (it would
158	    // re-fire every tick, fail identically, and — because it returns `skipped` not
159	    // `failed` — never trip the stall monitor). Mirror resolveScheduleActor: treat the
160	    // pin as a preference, autopick the usable fleet instead of returning [].
161	    accounts = [...usable];
162	    console.log(`[backend] workflow schedule ${s.schedule_id}: pinned '${pin}' not usync-alive → autopick usable fleet (${accounts.length}) [pinned_not_alive_autopicked]`);
163	  }
164	  if (Array.isArray(accounts) && accounts.length === 0) {
165	    return { ok: false, targets: 0, reason: "no usync-alive accounts" };
166	  }
167	  let plan;
168	  try {
169	    plan = await resolveWorkflowPlan({ cmd: s.cmd, accounts, groupIds, capPerAccount: 500 });
170	  } catch (e) { return { ok: false, targets: 0, reason: `plan failed: ${e?.message || e}` }; }
171	  if (!plan.targets.length) {
172	    return { ok: false, targets: 0, reason: plan.drivableSize === 0 ? "no drivable accounts" : "no eligible targets" };
173	  }
174	  const lanes = plan.partitions.size;
175	  const batchId = await workflowStart({
176	    cmd: s.cmd, targets: plan.targets,
177	    kind: entry.intent, scheduleId: s.schedule_id,
178	    label: `⏰ ${s.label} · ${plan.targets.length} target${plan.targets.length === 1 ? "" : "s"} · ${lanes} account${lanes === 1 ? "" : "s"}`,
179	    pacing: { maxConcurrentAccounts: entry.defaultConcurrency ?? 3, capPerAccount: 500 },
180	  });
181	  return { ok: true, targets: plan.targets.length, lanes, batchId, reason: entry.intent };
182	}
183	
184	// Atomic compare-and-swap claim on a schedule's interval slot (issue #43.F3). The
185	// in-memory guards (_schedInflight/_schedTicking) only serialize WITHIN one process;
186	// across a rolling redeploy two containers overlap and both pass isDue, and the old
187	// stamp was a blind fire-and-forget PATCH with no WHERE guard → both fire the same
188	// `*/5` sweep (the ~1400-keepalive runaway). This stamps last_run_at ONLY if the row
189	// still holds the exact value we read (eq guard, or is.null for a never-run row) and
190	// returns whether WE won — the loser skips instead of double-firing. It also closes
191	// the single-process re-fire storm: a DB blip that dropped the old blind stamp left
192	// last_run_at un-advanced so the schedule re-fired every tick; here the winning CAS
193	// advance is awaited and confirmed before we fire.
194	async function casClaimSchedule(scheduleId, oldLra, body) {
195	  const guard = oldLra ? `last_run_at=eq.${encodeURIComponent(oldLra)}` : "last_run_at=is.null";
196	  try {
197	    const rows = await dbRest("PATCH",
198	      `run_schedule?schedule_id=eq.${encodeURIComponent(scheduleId)}&${guard}`,
199	      body, { silent: true, headers: { Prefer: "return=representation" } });
200	    return Array.isArray(rows) && rows.length > 0;
201	  } catch (e) {
202	    // A failed claim PATCH must NOT be treated as a win — better to skip this tick
203	    // (the schedule stays due and re-CASes next tick) than to fire un-stamped.
204	    console.error(`[backend] schedule CAS claim ${scheduleId} failed: ${e?.message || e}`);
205	    return false;
206	  }
207	}
208	
209	// epic #6 cutover: the UI/exec dispatch path moved to the durable queue worker
210	// (bridge.ts /exec is gated dormant under BLUEBUT_QUEUE_WORKER=1). The in-process
211	// SCHEDULER stays live for now: it fires recurring run_schedule rows via fireSchedule()
212	// (spawn), a lane the queue worker does NOT cover — there is no schedule→queue producer
213	// yet, so gating this dormant would silently stop every recurring job. Migrating the
214	// scheduler onto the queue is tracked as a follow-up; until then it keeps driving.
215	async function tickSchedules() {
216	  if (_schedTicking) return;
217	  _schedTicking = true;
218	  try {
219	    const rows = (await dbRest("GET", "run_schedule?enabled=eq.true")) || [];
220	    const now = new Date();
221	    for (const s of rows) {
222	      if (_schedInflight.has(s.schedule_id)) continue;   // still running from a prior tick
223	      if (!isDue(s, now, schedFireJitterMs(s))) continue;   // interval + anti-clockwork jitter
224	      // Serialize browser-driving sweeps: if one is already draining, defer this one to a
225	      // later tick (don't stamp last_run — its jitter re-rolls and it re-fires when free).
226	      const isBrowserSweep = (s.workflow || s.campaign) && s.needs_actor !== false;
227	      if (isBrowserSweep && sweep.batch) continue;
228	      _schedInflight.add(s.schedule_id);
229	      if (isBrowserSweep) sweep.batch = "starting";   // reserve synchronously (closes the same-tick race)
230	
231	      // ── WORKFLOW schedule — fan the intent across the whole drivable fleet ──────
232	      // The agnostic model: "run this for ALL accounts that fit the criteria", not
233	      // one pinned profile. We DON'T resolve a single actor here (workflowRunExec
234	      // partitions the targets across every drivable account itself); we just launch
235	      // the durable batch and let it drain in parallel lanes. last_run_at is stamped
236	      // at START so the interval gate holds across a restart; last_run_job_id = the
237	      // workflow batch id (the run history links via batch.meta->>schedule_id).
238	      // #56.1: read EITHER flag during the campaign→workflow expand/contract window.
239	      // Both are NOT NULL default false (schema.ts:922-923) so `??` would be a no-op;
240	      // OR fires the fleet-fan path if either is set, surviving the `campaign` drop.
241	      if (s.workflow || s.campaign) {
242	        const startIso = new Date().toISOString();
243	        const next = nextRunAt({ ...s, last_run_at: startIso }, now);
244	        // CAS-claim the interval slot BEFORE firing (issue #43.F3): stamp last_run_at
245	        // at START (holds the interval gate across a restart) only if no other tick/
246	        // process already advanced it. Lost the race → release the sweep reservation
247	        // + inflight guard and skip, so the sibling never double-fires the sweep.
248	        const won = await casClaimSchedule(s.schedule_id, s.last_run_at ?? null,
249	          { last_run_at: startIso, next_run_at: next ? next.toISOString() : null, last_run_status: "running", last_run_account: null, updated_at: startIso });
250	        if (!won) {
251	          if (isBrowserSweep && sweep.batch === "starting") sweep.batch = null;
252	          _schedInflight.delete(s.schedule_id);
253	          continue;
254	        }
255	        fireWorkflowSchedule(s)
256	          .then((res) => {
257	            console.log(`[backend] workflow schedule fire: ${s.schedule_id} (${s.label}) → ${s.cmd} · ${res.ok ? `${res.targets} target(s) across ${res.lanes} lane(s) · batch ${res.batchId}` : `SKIPPED (${res.reason})`}`);
258	            // Hand the sweep reservation the real batch id (cleared when it drains), or
259	            // RELEASE it here if the sweep never started, so a deferred sibling can run.
260	            if (isBrowserSweep) sweep.batch = res.ok ? res.batchId : null;
261	            // A workflow that resolved 0 targets isn't a run. On the FIRST miss, restore
262	            // last_run so it re-fires next tick once accounts/targets appear (no dead
263	            // 'failed' churn). But if the PRIOR fire was already a 0-target miss
264	            // (last_run_status === "skipped"), DON'T restore — keep the CAS-stamped
265	            // startIso interval gate so the schedule backs off to its normal cadence
266	            // instead of re-resolving the whole fleet every single tick forever (a
267	            // never-satisfiable schedule would otherwise hot-loop, #43.F3 follow-up).
268	            const wasMiss = s.last_run_status === "skipped";
269	            return dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
270	              res.ok ? { last_run_status: "ok", last_run_job_id: res.batchId, updated_at: new Date().toISOString() }
271	                     : wasMiss ? { last_run_status: "skipped", updated_at: new Date().toISOString() }
272	                               : { last_run_at: s.last_run_at ?? null, next_run_at: s.next_run_at ?? null, last_run_status: "skipped", updated_at: new Date().toISOString() },
273	              { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {});
274	          })
275	          .catch((e) => { if (isBrowserSweep && sweep.batch === "starting") sweep.batch = null; console.error(`[backend] workflow schedule ${s.schedule_id} error: ${e?.message || e}`); })
276	          .finally(() => _schedInflight.delete(s.schedule_id));
277	        continue;
278	      }
279	
280	      // Actor selection depends on whether the cmd drives a browser. BROWSERLESS
281	      // schedules (needs_actor=false — classify-posts / llm-analyze-and-extract, the
282	      // Strategy.PUBLIC DB+LLM funnel jobs) need NO cloakbox account: resolving one
283	      // would (a) burn a drivable-account rotation slot on a job that never opens a
284	      // browser and (b) get SKIPPED whenever the 5-account drivable pool blips —
285	      // starving the funnel for no reason. They fire with acct=null (no --account).
286	      // BROWSER schedules keep JIT actor selection — resolve WHICH drivable account
287	      // runs this now, never the pinned s.account (a device-less account is doomed at
288	      // cdp_load). A null actor → skip WITHOUT stamping last_run_at, so the schedule
289	      // re-fires the moment an account becomes runnable (no DB churn, no dead job).
290	      let acct = null;
291	      let actorReason = "browserless";
292	      if (s.needs_actor !== false) {
293	        let actor;
294	        try { actor = await resolveScheduleActor(s, { rest: dbRest, now }); }
295	        catch (e) { actor = { account: null, reason: `resolve_error: ${e?.message || e}` }; }
296	        if (!actor.account) {
297	          console.log(`[backend] schedule ${s.schedule_id} (${s.label}): no runnable actor (${actor.reason}) — skipping`);
298	          _schedInflight.delete(s.schedule_id);
299	          continue;
300	        }
301	        acct = actor.account;
302	        actorReason = actor.reason;
303	      }
304	      const jobId = mintJobId();
305	      const startIso = new Date().toISOString();
306	      const next = nextRunAt({ ...s, last_run_at: startIso }, now);
307	      console.log(`[backend] schedule fire: ${s.schedule_id} (${s.label}) → ${s.site || "facebook"}/${s.cmd} as ${acct || "(browserless)"} [${actorReason}] job ${jobId}`);
308	      // CAS-claim the interval slot at START (issue #43.F3) — only if no other tick/
309	      // process already advanced last_run_at. Holds the interval gate across a bridge
310	      // restart mid-run AND prevents a redeploy-overlap double-fire; a crash can't
311	      // cause a re-fire storm. Lost the race → skip (a sibling already fired this
312	      // interval). The terminal status/ok is patched when the job finishes.
313	      const won = await casClaimSchedule(s.schedule_id, s.last_run_at ?? null,
314	        { last_run_at: startIso, next_run_at: next ? next.toISOString() : null, last_run_status: "running", last_run_job_id: jobId, last_run_account: acct, updated_at: startIso });
315	      if (!won) { _schedInflight.delete(s.schedule_id); continue; }
316	      fireSchedule(s, jobId, acct)
317	        .then((res) => dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
318	          { last_run_status: res.ok ? "ok" : "failed", updated_at: new Date().toISOString() },
319	          { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {}))
320	        .catch((e) => console.error(`[backend] schedule ${s.schedule_id} error: ${e?.message || e}`))
321	        .finally(() => _schedInflight.delete(s.schedule_id));
322	    }
323	  } catch (e) { console.error(`[backend] schedule tick failed: ${e?.message || e}`); }
324	  finally { _schedTicking = false; }
325	}
326	// HOSTED-ONLY. The recurring scheduler fires prod job_schedule rows — including
327	// BROWSER-driven ones that cdp_load a live cloakbox account. Both the prod bridge
328	// (bluebut-bridge.blpk.cc) and a laptop `npm run backend` read the SAME table, so
329	// without this gate a local dev bridge ALSO fires them, opening a cloakbox from
330	// your machine and duplicating prod's runs. API_TOKEN is set on the hosted
331	// bridge and unset locally, so it's the hosted/local discriminator; force it on
332	// locally with BLUEBUT_FORCE_SCHEDULER=1 if you really mean to test the tick.
333	// API_NO_LISTEN=1 means this is a smoke/import-check (or CI), NOT a serving bridge — it
334	// must NEVER tick the scheduler, which fires REAL prod job_schedule rows the instant it
335	// evaluates (tickSchedules() below runs immediately). Without this guard, `npm run
336	// smoke:backend` / `npm run check` drive prod schedules on a bare import (inv #23).
337	// BLUEBUT_NO_SCH
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== BLUEBUT_QUEUE_WORKER refs ===\"; grep -rn \"BLUEBUT_QUEUE_WORKER\" --include=*.ts --include=*.mjs -l; echo \"=== queue worker files ===\"; grep -rln \"queue\" server --include=*.ts --include=*.mjs -i | grep -i queue | head; echo \"=== updateRunStatus ===\"; grep -rn \"updateRunStatus\" --include=*.ts --include=*.mjs -l", "description": "Find queue worker and updateRunStatus"}
=== BLUEBUT_QUEUE_WORKER refs ===
server/http/routes/bridge.ts
server/http/index.mjs
server/http/runtime/scheduler.mjs
=== queue worker files ===
server/jobs/queue.ts
server/jobs/queue-status.test.mjs
server/jobs/enqueue-validation.test.mjs
server/jobs/enqueue-broadcast.ts
=== updateRunStatus ===
adapters/_lib/taxonomy.ts
server/jobs/queue.ts
server/jobs/queue-status.test.mjs
ontology/ontology.ts
server/contract/job.ts
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue.ts"}
1	// queue — the job queue, built on the `job` ledger table via the db.js seam ONLY.
2	//
3	// enqueue() inserts a `queued` row; claim() calls the claim_job RPC (atomic
4	// FOR UPDATE SKIP LOCKED + per-account unique-index lock, migration 0001). No new
5	// DB client, no in-memory Map: the table IS the queue and the job identity, so it
6	// survives any process restart. The runner (spawned by the worker) merge-upserts
7	// the same job_id to running→terminal, so a claimed row upgrades in place.
8	// db.ts is the untyped legacy _lib seam — treat `rest` as loose until it's typed on touch (invariant #20)
9	import { rest as _rest } from '../../adapters/_lib/db/db.ts'
10	const rest = _rest as (method: string, path: string, body?: unknown, opts?: Record<string, unknown>) => Promise<any>
11	import { getVerb } from './catalog.ts'
12	import { mintJobId } from './job-id.ts'
13	import { numEnv } from '../../adapters/_lib/num-env.ts'
14	
15	// How long a row may sit in claimed/running before the reaper considers it stale
16	// (worker process died without ever reaching the child.on('exit') handler that
17	// normally sweeps it via markFailed — see reapStale below). Env-tunable, matching
18	// this repo's numEnv convention (adapters/_lib/num-env.ts).
19	const REAP_STALE_MS = numEnv('BLUEBUT_QUEUE_REAP_STALE_MS', 15 * 60 * 1000)
20	
21	// The durable `job` ledger's status vocabulary — the ONE source for "is this run
22	// still going / done?" across every ledger reader (SSE guard, reaper, worker).
23	// (Distinct from taxonomy.ts RunStatus, the Skyvern vocab the ledger has not yet
24	// migrated onto — see docs/skyvern-gap-analysis.md §2. Until that lands, this is
25	// the single writer/reader of the ledger's own terms — no re-hardcoding per site.)
26	//
27	// TERMINAL must list EVERY status the runner banks on a finished row, or a reader
28	// treats a done job as still-running. `adapter-finalize.jobLifecycleStatus` writes
29	// SUCCESS as 'ok' (not 'done') and the benign terminals 'skipped'/'partial'/
30	// 'content_unavailable' — omitting them made `/jobs/:id/events` poll a succeeded
31	// job for the full 600s instead of closing. 'done' is kept for the in-mem bridge
32	// map; the taxonomy terminals are included so a future write of them still reads
33	// terminal. (LEDGER_ACTIVE also scopes markFailed's non-terminal sweep, below.)
34	export const LEDGER_ACTIVE = ['queued', 'claimed', 'running'] as const
35	export const LEDGER_TERMINAL = [
36	  'ok', 'done', 'completed', 'failed', 'blocked', 'skipped', 'partial',
37	  'content_unavailable', 'terminated', 'timed_out', 'canceled',
38	] as const
39	export const isLedgerTerminal = (status: string): boolean =>
40	  (LEDGER_TERMINAL as readonly string[]).includes(status)
41	
42	// A job row as PostgREST returns it (loosely typed — the DB is the schema).
43	export interface JobRow {
44	  id?: string          // the `run` PK (epic #200 rename of job_id); normalized → job_id below
45	  job_id: string
46	  verb?: string | null
47	  cmd?: string | null
48	  account?: string | null
49	  status: string
50	  input?: unknown
51	  batch_id?: string | null
52	  ok?: boolean | null
53	  [k: string]: unknown
54	}
55	
56	// epic #200 renamed the ledger table job→run: the PK column is now `id` (was `job_id`)
57	// and the batch FK is `workflow_run_id` (was `batch_id`). The queue's public JobRow shape
58	// keeps job_id/batch_id, so EVERY row read back from `run` is normalized HERE at the seam
59	// — worker.ts, the /api/queue routes, and the tests are untouched by the column rename.
60	function normalizeRun(row: Record<string, unknown>): JobRow {
61	  return {
62	    ...row,
63	    job_id: String(row.id ?? row.job_id ?? ''),
64	    batch_id: (row.workflow_run_id ?? row.batch_id ?? null) as string | null,
65	  } as JobRow
66	}
67	
68	/** Optional ledger-level knobs an enqueue can set (issue #217): a batch grouping, the
69	 *  `not_before` due-time (pacing — the row is invisible to claim_job_v2 until due), and
70	 *  `priority` (claim order). All optional; omitting them = legacy FIFO immediate-claim. */
71	export interface EnqueueOpts {
72	  batch_id?: string | null
73	  not_before?: string | null   // ISO timestamptz; a queued row won't be claimed before this
74	  priority?: number            // higher = claimed first (default 0)
75	}
76	
77	/** enqueue — validate input against the verb, insert a queued ledger row. */
78	export async function enqueue(
79	  verbName: string,
80	  rawInput: unknown,
81	  opts: EnqueueOpts = {},
82	): Promise<{ job_id: string; status: 'queued' }> {
83	  const verb = getVerb(verbName)          // unknown verb → ValidationError → 400 (app.ts onError)
84	  // Bad args throw a ZodError, which the app.ts onError maps to 422 'invalid_input'
85	  // with structured `issues` — richer than a flattened string, and the design's
86	  // intended split (unknown verb → 400, bad args → 422). Do NOT wrap it.
87	  const input = verb.input.parse(rawInput) as Record<string, unknown>
88	  const tgt = verb.target(input)
89	  const job_id = mintJobId()
90	  await rest('POST', 'run', {
91	    id: job_id,                                // `run` PK (epic #200 rename of job_id)
92	    verb: verb.name,
93	    cmd: verb.name,                            // keep `cmd` populated for existing ledger consumers
94	    site: verb.site,
95	    account: input.account ?? null,
96	    target_type: tgt.type,
97	    target_id: tgt.id,
98	    status: 'queued',
99	    input,
100	    workflow_run_id: opts.batch_id ?? null,    // batch FK (epic #200 rename of batch_id)
101	    not_before: opts.not_before ?? null,
102	    priority: opts.priority ?? 0,
103	    created_at: new Date().toISOString(),
104	  }, { headers: { Prefer: 'return=minimal' }, label: 'queue' })
105	  return { job_id, status: 'queued' }
106	}
107	
108	// Pull --account (or the send-path aliases) out of a raw argv so a raw job still
109	// takes the per-account claim lane, exactly like the typed path's input.account.
110	function accountFromArgv(argv: string[]): string | null {
111	  for (const f of ['--account', '--from-account', '--to-account']) {
112	    const i = argv.indexOf(f)
113	    if (i >= 0 && argv[i + 1]) return argv[i + 1]
114	  }
115	  return null
116	}
117	
118	/** enqueueRaw — enqueue an arbitrary (site, cmd, argv) job WITHOUT the typed-verb
119	 *  catalog. The UI's runCli() path speaks argv, not typed input; this carries the raw
120	 *  argv on the run row (input._raw) so the worker spawns the runner IDENTICALLY to the
121	 *  legacy /exec dispatcher (same adapterPath + argv). It is the durable-queue equivalent
122	 *  of a POST /exec/stream call — the missing capability the UI cutover needs, so the
123	 *  queue can run ANY adapter the dashboard fires, not only the 64 cataloged verbs. (#295)
124	 *  A `job_id` may be supplied so the UI's own job identity threads through unchanged. */
125	export async function enqueueRaw(
126	  site: string,
127	  cmd: string,
128	  argv: string[],
129	  opts: EnqueueOpts & { job_id?: string; account?: string | null } = {},
130	): Promise<{ job_id: string; status: 'queued' }> {
131	  const job_id = opts.job_id || mintJobId()
132	  await rest('POST', 'run', {
133	    id: job_id,                                // `run` PK (epic #200 rename of job_id)
134	    verb: cmd,
135	    cmd,
136	    site,
137	    account: opts.account ?? accountFromArgv(argv),
138	    status: 'queued',
139	    input: { _raw: true, site, cmd, argv },
140	    run_type: 'job',
141	    workflow_run_id: opts.batch_id ?? null,    // batch FK (epic #200 rename of batch_id)
142	    not_before: opts.not_before ?? null,
143	    priority: opts.priority ?? 0,
144	    created_at: new Date().toISOString(),
145	  }, { headers: { Prefer: 'return=minimal' }, label: 'queue-raw' })
146	  return { job_id, status: 'queued' }
147	}
148	
149	/** claim — atomically take the next runnable queued job for this worker. */
150	export async function claim(workerId: string): Promise<JobRow | null> {
151	  const rows = await rest('POST', 'rpc/claim_job', { p_worker: workerId }, { silent: true })
152	  return Array.isArray(rows) && rows.length ? normalizeRun(rows[0]) : null
153	}
154	
155	/** Fetch one job row by id (for GET /jobs/:id). */
156	export async function getJob(job_id: string): Promise<JobRow | null> {
157	  const rows = await rest('GET', `run?id=eq.${encodeURIComponent(job_id)}&limit=1`, null, { silent: true })
158	  return Array.isArray(rows) && rows.length ? normalizeRun(rows[0]) : null
159	}
160	
161	/** Most-recent jobs (for GET /jobs). */
162	export async function listJobs({ limit = 50 }: { limit?: number } = {}): Promise<JobRow[]> {
163	  const rows = await rest('GET', `run?order=created_at.desc&limit=${Number(limit)}`, null, { silent: true })
164	  return Array.isArray(rows) ? rows.map(normalizeRun) : []
165	}
166	
167	// ── The single status-writer funnel (issue #19 Phase 1) ───────────────────────
168	// updateRunStatus is the ONE place a `run` row's status transition is written in
169	// the queue path: it derives the legacy `ok` boolean FROM the status (so no caller
170	// hand-writes an inconsistent ok/status pair — the drift Phase 1 kills), stamps
171	// ended_at on a terminal status, and scopes the PATCH to non-terminal rows so a
172	// banked terminal is never clobbered.
173	//
174	// EXPAND/CONTRACT: `ok` is still WRITTEN (derived), not dropped — the read-side
175	// migration off `ok` onto `status` is the later contract step. The LIVE runner-
176	// finalize (scripts/_lib/adapter-finalize.ts) + pace-loop writers still write
177	// directly today; routing THEM through this funnel is the supervised-live
178	// remainder of #19 Phase 1 (inv #24 — it needs a real drive, not a headless merge).
179	
180	// Statuses that mean the run SUCCEEDED (ok=true). A benign terminal
181	// (skipped/partial/content_unavailable) is NOT a success — the runner banks it
182	// with ok=false — so okForStatus returns false for it, matching the ledger's data.
183	const LEDGER_SUCCESS = ['ok', 'done', 'completed'] as const
184	
185	/** Derive the legacy `ok` boolean a ledger status implies: true for a success
186	 *  terminal, false for any other terminal (failure or benign), null while active. */
187	export function okForStatus(status: string): boolean | null {
188	  if ((LEDGER_SUCCESS as readonly string[]).includes(status)) return true
189	  if (isLedgerTerminal(status)) return false
190	  return null   // queued / claimed / running — undecided
191	}
192	
193	/** Build the PATCH body for a status transition (pure — no I/O; unit-tested). */
194	export function statusPatch(status: string, reason?: unknown): Record<string, unknown> {
195	  const now = new Date().toISOString()
196	  const body: Record<string, unknown> = { status, ok: okForStatus(status), updated_at: now }
197	  if (isLedgerTerminal(status)) body.ended_at = now
198	  if (reason != null) body.aborted_reason = String(reason).slice(0, 500)
199	  return body
200	}
201	
202	/** THE single writer for a `run` row's status transition (queue path). Scoped to
203	 * non-terminal rows so a banked terminal is NEVER clobbered — the PATCH matches no
204	 * row in that case (a safe no-op). Callers pass a status; `ok` is derived here. */
205	export async function updateRunStatus(job_id: string, status: string, reason?: unknown): Promise<void> {
206	  await rest('PATCH', `run?id=eq.${encodeURIComponent(job_id)}&status=in.(${LEDGER_ACTIVE.join(',')})`,
207	    statusPatch(status, reason), { headers: { Prefer: 'return=minimal' }, silent: true })
208	}
209	
210	/** Mark a claimed/running job failed when the worker itself can't finish it (spawn
211	 * error, child died before writing a terminal row). Routes through the single
212	 * updateRunStatus funnel; the non-terminal scope means a runner that already wrote
213	 * a terminal row is NEVER clobbered. Guards against a job stuck in `claimed` forever
214	 * (which permanently blocks its account via claim_job). */
215	export async function markFailed(job_id: string, reason: unknown): Promise<void> {
216	  await updateRunStatus(job_id, 'failed', reason)
217	}
218	
219	/** Sweep `claimed`/`running` rows whose worker process died WITHOUT ever hitting
220	 * markFailed (the live worker's child.on('exit') path, worker.ts) — e.g. the worker
221	 * itself was killed. Those rows have no other reclaim path: LEDGER_ACTIVE scopes
222	 * them as active forever, and the account-lane unique index (job_one_active_per_account)
223	 * stays held, permanently blocking that account. Time-boxed off `claimed_at` (set by
224	 * claim_job, db/0002_claim_job.sql) — any row older than REAP_STALE_MS is reclaimed to
225	 * `failed` via the same markFailed funnel, so a row already finished by its runner is
226	 * never clobbered (updateRunStatus's non-terminal scope). Called once per worker tick
227	 * (worker.ts) — safe to call repeatedly/concurrently since each markFailed is a scoped
228	 * no-op once a row is terminal. Returns the count of rows reaped (for logging). */
229	export async function reapStale(staleMs: number = REAP_STALE_MS): Promise<number> {
230	  const cutoff = new Date(Date.now() - staleMs).toISOString()
231	  const rows = await rest(
232	    'GET',
233	    `run?status=in.(claimed,running)&claimed_at=lt.${encodeURIComponent(cutoff)}&select=id,claimed_at`,
234	    null,
235	    { silent: true },
236	  )
237	  if (!Array.isArray(rows) || !rows.length) return 0
238	  await Promise.all(rows.map((row: Record<string, unknown>) => {
239	    const job_id = String(row.id ?? row.job_id ?? '')
240	    return job_id ? markFailed(job_id, 'reaped: stale claim').catch(() => {}) : Promise.resolve()
241	  }))
242	  return rows.length
243	}
244
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; ls server/jobs/; echo \"=== worker ===\"; find server -name \"worker*.ts\" -o -name \"worker*.mjs\" | head; echo \"=== dormancy gate in bridge.ts ===\"; grep -n \"BLUEBUT_QUEUE_WORKER\" server/http/routes/bridge.ts server/http/index.mjs", "description": "Locate worker and dormancy gates"}
catalog-integrity.test.mjs
catalog.test.mjs
catalog.ts
enqueue-broadcast.ts
enqueue-validation.test.mjs
job-id.ts
job-stream.test.mjs
job-stream.ts
queue-status.test.mjs
queue.ts
verbs
worker.test.mjs
worker.ts
=== worker ===
server/jobs/worker.test.mjs
server/jobs/worker.ts
=== dormancy gate in bridge.ts ===
server/http/index.mjs:419:  // until BLUEBUT_QUEUE_WORKER=1 is set (after confirming claim_job exists + a
server/http/index.mjs:422:  const queueWorkerOn = process.env.BLUEBUT_QUEUE_WORKER === '1'
server/http/index.mjs:423:    || (process.env.NODE_ENV !== 'production' && process.env.BLUEBUT_QUEUE_WORKER !== '0');
server/http/index.mjs:427:  if (!queueWorkerOn) console.log('[backend] queue worker OFF (set BLUEBUT_QUEUE_WORKER=1 to drain /api/queue jobs)');
server/http/routes/bridge.ts:421:// epic #6 cutover: when the durable queue worker owns dispatch (BLUEBUT_QUEUE_WORKER=1),
server/http/routes/bridge.ts:425:const QUEUE_WORKER_ON = process.env.BLUEBUT_QUEUE_WORKER === "1";
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/worker.ts"}
1	// worker — claims queued jobs and runs them through the EXISTING runner.
2	//
3	// This is the industry-standard replacement for the backend's spawn-per-request +
4	// in-memory ACCOUNT_LOCK Map. The worker owns no job state: it polls claim() (the
5	// atomic RPC), spawns scripts/bluebut-adapter.ts for the claimed verb, and the
6	// runner writes the durable ledger row. A dead worker leaves rows queued/claimed
7	// to be reclaimed by the periodic reaper (reapStale, wired below), no lock Map,
8	// no bridge process required.
9	//
10	// Concurrency is bounded by MAX_CONCURRENT; per-account exclusion is enforced in
11	// SQL (claim_job won't hand out a second job for a busy account), so the worker
12	// never has to reason about it.
13	import { spawn } from 'node:child_process'
14	import { join, dirname } from 'node:path'
15	import { fileURLToPath, pathToFileURL } from 'node:url'
16	import { getVerb } from './catalog.ts'
17	import { claim, markFailed, reapStale, type JobRow } from './queue.ts'
18	import { publishLine, endStream } from './job-stream.ts'
19	import { numEnv } from '../../adapters/_lib/num-env.ts'
20	import { adapterPath } from '../http/runtime/spawn-env.mjs'
21	
22	const HERE = dirname(fileURLToPath(import.meta.url))
23	const REPO = join(HERE, '..', '..')
24	const RUNNER = process.env.BLUEBUT_ADAPTER_BIN || join(REPO, 'scripts', 'bluebut-adapter.ts')
25	const NODE = process.execPath
26	
27	const POLL_MS = numEnv('BLUEBUT_QUEUE_POLL_MS', 1500)
28	const MAX_CONCURRENT = numEnv('BLUEBUT_QUEUE_CONCURRENCY', 3)
29	// The reaper runs on its own slower cadence (not every claim poll) — sweeping stale
30	// claimed/running rows is a cleanup, not a hot path, and a per-poll DB scan across the
31	// fleet would be wasteful. reapStale itself is time-boxed (BLUEBUT_QUEUE_REAP_STALE_MS).
32	const REAP_POLL_MS = numEnv('BLUEBUT_QUEUE_REAP_POLL_MS', 60_000)
33	const DRY_RUN = /^(1|true|yes)$/i.test(process.env.BLUEBUT_QUEUE_DRYRUN || '')
34	// Graceful-drain window for a STANDALONE worker (worker.ts run directly). When the
35	// worker is imported by server/http/index.mjs, that barrel owns SIGTERM/SIGINT and
36	// calls our stop fn — the entry-point guard below only self-registers signals when
37	// this module IS the process entry, so handlers are never double-installed.
38	const SHUTDOWN_GRACE_MS = numEnv('BLUEBUT_SHUTDOWN_GRACE_MS', 8000)
39	
40	let running = 0
41	let stopped = false
42	
43	/**
44	 * PURE: resolve a claimed job to its runner adapter + argv. THROWS on a malformed
45	 * row — an unknown/empty verb (getVerb → ValidationError) or bad input (toArgv).
46	 * runJob MUST call this inside a try: a synchronous throw here would skip tick's
47	 * `.finally(() => running -= 1)` and leak a concurrency slot forever (3 malformed
48	 * jobs wedge the worker), and leave the row `claimed` (account lane blocked until
49	 * the reaper). Exported so that guard is unit-tested without spawning a child.
50	 */
51	export function jobSpawnArgs(job: JobRow): { name: string; adapter: string; argv: string[] } {
52	  const input = (job.input ?? {}) as Record<string, unknown>
53	  // epic #6 cutover: RAW jobs (enqueueRaw — the UI's argv-speaking /exec path). They
54	  // carry `input._raw` with the (site, cmd, argv) verbatim instead of a typed verb, so
55	  // resolve the adapter by PATH and pass argv through EXACTLY like the legacy /exec
56	  // dispatcher — same adapterPath + same --format/json strip. A missing adapter throws,
57	  // which runJob catches → markFailed (the row never wedges the account lane).
58	  if (input._raw) {
59	    const site = String(input.site || '')
60	    const cmd = String(input.cmd || '')
61	    const adapter = adapterPath(site, cmd)
62	    if (!adapter) throw new Error(`no adapter at adapters/${site}/${cmd}.js (raw job ${job.job_id})`)
63	    const rawArgv = Array.isArray(input.argv) ? (input.argv as unknown[]) : []
64	    const argv = rawArgv.filter((a) => a != null && a !== '--format' && a !== 'json').map(String)
65	    return { name: `${site}/${cmd}`, adapter, argv }
66	  }
67	  const verb = getVerb(String(job.verb || job.cmd || ''))
68	  return { name: verb.name, adapter: verb.adapter, argv: verb.toArgv(job.input ?? {}) }
69	}
70	
71	// Run one claimed job to completion. Resolves when the child exits (any code) —
72	// the runner owns the terminal ledger write, so the worker only frees its slot.
73	// NEVER throws synchronously (see jobSpawnArgs) — a malformed job resolves after
74	// being marked failed, so tick's slot-release `.finally` always runs.
75	function runJob(job: JobRow): Promise<void> {
76	  let name: string, adapter: string, argv: string[]
77	  try {
78	    ({ name, adapter, argv } = jobSpawnArgs(job))
79	  } catch (err) {
80	    void markFailed(job.job_id, `worker setup failed: ${(err as Error).message}`).catch(() => {})
81	    return Promise.resolve()
82	  }
83	  const env: NodeJS.ProcessEnv = { ...process.env, BLUEBUT_JOB_ID: job.job_id }
84	  if (job.batch_id) env.BLUEBUT_BATCH_ID = String(job.batch_id)
85	
86	  if (DRY_RUN) {
87	    console.log(`[worker] DRY_RUN ${job.job_id} ${name} ${argv.join(' ')}`)
88	    return Promise.resolve()
89	  }
90	
91	  return new Promise<void>((resolve) => {
92	    // Capture the runner's stderr (its step-log lines) into the in-process tap so a live
93	    // HTTP tailer — the /api/queue/jobs/:id/events SSE, same process — can stream them to
94	    // the dashboard (epic #6: the worker spawns out-of-band, so the request handler has no
95	    // child to tee; the tap bridges it). stdout is discarded — the runner writes the
96	    // durable `run` row itself, so the terminal RESULT is read from the ledger, not here.
97	    const child = spawn(NODE, [RUNNER, adapter, ...argv], { env, cwd: REPO, stdio: ['ignore', 'ignore', 'pipe'] })
98	    let ebuf = ''
99	    child.stderr?.on('data', (d: Buffer) => {
100	      ebuf += d
101	      let i: number
102	      while ((i = ebuf.indexOf('\n')) >= 0) {
103	        const line = ebuf.slice(0, i).replace(/\x1b\[[0-9;]*m/g, '')
104	        ebuf = ebuf.slice(i + 1)
105	        if (line.trim()) publishLine(job.job_id, line)
106	      }
107	    })
108	    child.on('error', (err: Error) => {
109	      void markFailed(job.job_id, `worker spawn failed: ${err.message}`).catch(() => {})
110	      endStream(job.job_id)
111	      resolve()
112	    })
113	    child.on('exit', (code) => {
114	      if (ebuf.trim()) publishLine(job.job_id, ebuf.replace(/\x1b\[[0-9;]*m/g, ''))   // flush trailing partial line
115	      endStream(job.job_id)
116	      if (code !== 0) {
117	        // The runner OWNS the terminal write on a clean run, but a child that dies
118	        // before writing one (crash, OOM, SIGKILL) would leave the row stuck in
119	        // `claimed`/`running` — and claim_job treats that as an active job, so the
120	        // account is blocked forever. markFailed is status-scoped (queued/claimed/
121	        // running only), so it's a no-op when the runner already wrote a terminal row.
122	        console.warn(`[worker] ${job.job_id} exited ${code} — sweeping any non-terminal row to failed`)
123	        void markFailed(job.job_id, `child exited ${code}`).catch(() => {})
124	      }
125	      resolve()
126	    })
127	  })
128	}
129	
130	async function tick(): Promise<void> {
131	  if (stopped) return
132	  while (running < MAX_CONCURRENT) {
133	    let job: JobRow | null
134	    try {
135	      job = await claim(WORKER_ID)
136	    } catch (err) {
137	      console.warn(`[worker] claim error: ${(err as Error).message}`)
138	      break
139	    }
140	    if (!job) break
141	    running += 1
142	    void runJob(job).finally(() => { running -= 1 })
143	  }
144	}
145	
146	export const WORKER_ID = `w_${process.pid}`
147	
148	export function startWorker(): () => void {
149	  console.log(`[worker] ${WORKER_ID} up — poll ${POLL_MS}ms, concurrency ${MAX_CONCURRENT}${DRY_RUN ? ' (DRY_RUN)' : ''}`)
150	  const timer = setInterval(() => { void tick().catch((e) => console.warn(`[worker] tick: ${(e as Error).message}`)) }, POLL_MS)
151	  timer.unref?.()
152	  // Reclaim rows orphaned in claimed/running by a worker that died before its
153	  // child.on('exit') could markFailed them (queue.ts reapStale) — the account lane
154	  // stays blocked until this sweeps. Runs regardless of claim activity.
155	  const reaper = setInterval(() => {
156	    void reapStale()
157	      .then((n) => { if (n) console.warn(`[worker] reaped ${n} stale claimed/running row(s)`) })
158	      .catch((e) => console.warn(`[worker] reap: ${(e as Error).message}`))
159	  }, REAP_POLL_MS)
160	  reaper.unref?.()
161	  return () => { stopped = true; clearInterval(timer); clearInterval(reaper) }
162	}
163	
164	// Standalone entry: when worker.ts is the process (not imported by index.mjs), it
165	// must catch SIGTERM/SIGINT itself — container stop/redeploy sends SIGTERM, and a
166	// bare process.on listener would otherwise OVERRIDE Node's default-exit, wedging the
167	// container. So we only self-register when this module IS the entry point; under
168	// index.mjs the barrel's own handlers drive our stop fn instead (no double-install).
169	const isEntry = !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
170	if (isEntry) {
171	  const stop = startWorker()
172	  let shuttingDown = false
173	  const shutdown = (sig: string): void => {
174	    if (shuttingDown) return
175	    shuttingDown = true
176	    console.log(`[worker] ${sig} — draining ${running} in-flight (grace ${SHUTDOWN_GRACE_MS}ms)`)
177	    stop() // idempotent: flips `stopped`, halts new claims + reaper
178	    const hard = setTimeout(() => { console.warn('[worker] grace elapsed — exiting'); process.exit(0) }, SHUTDOWN_GRACE_MS)
179	    hard.unref?.()
180	    const poll = setInterval(() => {
181	      if (running === 0) { clearTimeout(hard); clearInterval(poll); process.exit(0) }
182	    }, 100)
183	    poll.unref?.()
184	  }
185	  process.once('SIGTERM', () => shutdown('SIGTERM'))
186	  process.once('SIGINT', () => shutdown('SIGINT'))
187	}
188
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== migrations dir ===\"; ls db/ 2>/dev/null | tail -20; echo; echo \"=== files reading/writing run.ok ('ok:' or .ok or ok=) near run/job ===\"; grep -rn \"\\bok\\b\" --include=*.ts --include=*.mjs scripts/_lib/adapter-finalize.ts 2>/dev/null | grep -i ok | head -40", "description": "List migrations and adapter-finalize ok usage"}
=== migrations dir ===
0000_init_baseline.sql
0001_job_queue.sql
0002_claim_job.sql
0003_worried_goblin_queen.sql
0004_milky_mac_gargan.sql
0005_calm_speedball.sql
0006_parched_cammi.sql
0007_natural_colleen_wing.sql
0008_parched_johnny_storm.sql
0009_issue_186_fb_actor_identity.sql
0010_rename_run_workflow_run.sql
0011_issue_187_friendship_broadcast_config.sql
0012_lively_mystique.sql
0013_oval_typhoid_mary.sql
0014_powerful_the_anarchist.sql
meta
migrations
relations.ts
retired
schema.ts

=== files reading/writing run.ok ('ok:' or .ok or ok=) near run/job ===
scripts/_lib/adapter-finalize.ts:56:    const ok = !!(result && typeof result === 'object' && result.ok === true);
scripts/_lib/adapter-finalize.ts:58:    const severity = ok || isBenignStatus(status) ? 'INFO' : 'ERROR';
scripts/_lib/adapter-finalize.ts:59:    try { await shipLog('bluebut-traffic', severity, `◆ job-end ${cmd.name} account=${account} ${ok ? 'ok' : (isBenignStatus(status) ? status : 'FAIL')} (${Date.now() - t0}ms)`, { ...jobBase, phase: 'end', ok: ok ? 1 : 0, status, duration_ms: Date.now() - t0 }, 'bluebut.runner'); } catch { /* non-fatal */ }
scripts/_lib/adapter-finalize.ts:207:      emitStep(`access: ${acc.access || 'ok'}${acc.doc_status ? ` (HTTP ${acc.doc_status})` : ''}${acc.error_kind ? ` · error=${acc.error_kind}${acc.retryable ? ' (retryable)' : ''}` : ''}${acc.errors ? ` · ${acc.errors.length} err` : ''}${said}`);
scripts/_lib/adapter-finalize.ts:355:      const okEnd = !!(result && typeof result === 'object' && result.ok === true);
scripts/_lib/adapter-finalize.ts:371:        severity: okEnd || endBenign ? 'INFO' : 'ERROR', body: `job-end ${cmd.name} ${okEnd ? 'ok' : (endBenign ? endStatus : 'FAIL')}`,
scripts/_lib/adapter-finalize.ts:372:        attrs: { kind: 'job-end', job_id, account, cmd: cmd.name, op, ok: okEnd ? 1 : 0, status: endStatus, duration_ms: Date.now() - t0 },
scripts/_lib/adapter-finalize.ts:384:    const ok = r.ok === true;
scripts/_lib/adapter-finalize.ts:405:    if (!ok && status === 'failed') {
scripts/_lib/adapter-finalize.ts:412:      status, ok,
scripts/_lib/adapter-finalize.ts:433:    if (!ok && batch_id && process.env.BLUEBUT_TG_JOBS !== '0') {
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/scripts/_lib/adapter-finalize.ts", "offset": 378, "limit": 60}
378	
379	// DURABLE JOB LEDGER — terminal update. Prefer the target the adapter
380	// surfaced in its result (richer numeric id + name + url) over the raw arg.
381	async function updateJobLedger({ result, walled, cmd, account, target, ledgerJobId, batch_id, harKey, recKey, t0, timeline }) {
382	  try {
383	    const r = (result && typeof result === 'object') ? result : {};
384	    const ok = r.ok === true;
385	    const status = jobLifecycleStatus(r, walled);
386	    const rid   = r.group_fb_id || r.fb_group_id || r.group_id || (r.group && (r.group.fb_group_id || r.group.id)) || null;
387	    const rurl  = r.group_url  || (r.group && r.group.url)  || null;
388	    const rname = r.group_name || (r.group && r.group.name) || null;
389	    // Bank the full step/action timeline onto the durable row (`timeline` drained
390	    // above). This is what lets the in-memory JOBS Map stop being the history
391	    // store — a finished job now renders its steps from Postgres, not from the
392	    // capped, restart-lossy bridge Map. Compact + bounded (the wire trail is in
393	    // the HAR; this is the human-readable phase log).
394	    const steps = timeline.slice(-600).map((e) => ({
395	      t: e.t - t0,
396	      s: String(e.text || e.action || e.phase || '').slice(0, 300),
397	      k: e.phase === 'action' ? 'action' : 'step',
398	      src: e.source || 'adapter',
399	    }));
400	    // AUTO GITHUB ISSUE — only on a real FAILURE (not `blocked` gate-refusals, which
401	    // are expected ops states: usync-dead/frozen/velocity). Deduped by signature in
402	    // job_error_issue (one issue per distinct cause, not per run). Fail-open. Awaited
403	    // so the number lands in this same summary write; the child exits right after.
404	    let githubIssue = null;
405	    if (!ok && status === 'failed') {
406	      githubIssue = await fileErrorIssue({
407	        cmd: cmd.name, reason: r.aborted_reason ?? `${cmd.name} failed`,
408	        account, target, job_id: ledgerJobId, batch_id, status,
409	      });
410	    }
411	    await dbRest('PATCH', `run?id=eq.${encodeURIComponent(ledgerJobId)}`, {
412	      status, ok,
413	      aborted_reason: r.aborted_reason ?? null,
414	      summary: githubIssue ? { ...jobSummary(r), github_issue: githubIssue } : jobSummary(r),
415	      steps: steps.length ? steps : null,
416	      har_key: harKey,
417	      rec_key: recKey,
418	      ended_at: new Date().toISOString(),
419	      duration_ms: Date.now() - t0,
420	      updated_at: new Date().toISOString(),
421	      target_type: target.type || (rid ? 'group' : null),
422	      target_id: (target.type === 'group' && rid) ? rid : (target.id || rid),
423	      target_url: rurl || target.url,
424	      target_name: rname || target.name,
425	    }, { silent: true });
426	
427	    // EVERY FAILED JOB PINGS TELEGRAM. This is the ONE settle point every job
428	    // crosses (single, broadcast, workflow, scheduled). Non-batch jobs already
429	    // alert via the backend's notifyJobEnd, so gate on batch_id to fire for the
430	    // children that path never sees (broadcast/workflow/scheduled) — one alert
431	    // per failed job, no double. AWAITED (the child exits right after this, so a
432	    // fire-and-forget fetch would be killed mid-flight). BLUEBUT_TG_JOBS=0 mutes.
433	    if (!ok && batch_id && process.env.BLUEBUT_TG_JOBS !== '0') {
434	      const reason = r.aborted_reason ?? (status === 'blocked' ? 'blocked by Facebook' : `${cmd.name} failed`);
435	      await notifyTelegram(
436	        `❌ Job · ${cmd.name}${account ? ` · ${account}` : ''}\n${status}${target.name || target.url ? ` · ${target.name || target.url}` : ''}\n${String(reason).slice(0, 220)}\n(job ${ledgerJobId} · batch ${String(batch_id).slice(0, 14)})`,
437	      ).catch(() => {});

dispatchedFind all run.ok DB reads/writes

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== db/migrations dir ===\"; ls db/migrations/ 2>/dev/null | tail; echo \"=== schema.ts run table ok field ===\"; grep -n \"ok\" db/schema.ts | head -30; echo \"=== recent drop-column migration example ===\"; grep -rln \"DROP COLUMN\" db/migrations db/ 2>/dev/null | tail -3", "description": "Inspect schema and migration conventions"}
=== db/migrations dir ===
cloak
entity
README.md
=== schema.ts run table ok field ===
31:	renter_facebook_user_id: text(),
32:	renter_facebook_profile_url: text(),
82:	index("idx_intake_homii_renter_fb").using("btree", table.renter_facebook_user_id.asc().nullsLast().op("text_ops")).where(sql`(renter_facebook_user_id IS NOT NULL)`),
541:	cookies: jsonb(),
780:	req_cookies: jsonb(),
783:	resp_cookies: jsonb(),
836:	ok: boolean(),
881:	check("run_status_chk", sql`status = ANY (ARRAY['created'::text, 'queued'::text, 'claimed'::text, 'running'::text, 'completed'::text, 'ok'::text, 'done'::text, 'failed'::text, 'blocked'::text, 'skipped'::text, 'partial'::text, 'content_unavailable'::text, 'terminated'::text, 'canceled'::text, 'timed_out'::text])`),
955:	n_ok: integer().default(0).notNull(),
1004:	site: text().default('facebook').notNull(),
1103:	cookies_set: jsonb(),
1138:	// so pfbid lookups seq-scan (issue #44.6). Partial → only the rows carrying a pfbid.
1617:	site: text().default('facebook').notNull(),
1697:	// btree can't serve a value-only lookup → seq-scan the alias tier (issue #44.5).
1866:	check("batch_target_status_chk", sql`status = ANY (ARRAY['pending'::text, 'running'::text, 'ok'::text, 'completed'::text, 'failed'::text, 'blocked'::text, 'skipped'::text, 'partial'::text])`),
=== recent drop-column migration example ===
db/migrations/entity/0017_fix_existing_schema.sql
db/migrations/entity/0019_drop_fb_person_uuid.sql
db/migrations/entity/0039_drop_lead_replied_at.sql
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/db/schema.ts", "offset": 820, "limit": 65}
820	// compat VIEW (`CREATE VIEW job AS SELECT * FROM run`) declared in the hand-authored migration
821	// db/migrations/entity/0047_rename_run_workflow_run.sql — NOT here, so drizzle never tries to
822	// manage two objects sharing one name.
823	export const run = pgTable("run", {
824		job_id: text().primaryKey().notNull(),
825		batch_id: text(),
826		site: text(),
827		cmd: text().notNull(),
828		op: text(),
829		access: text(),
830		account: text(),
831		target_type: text(),
832		target_id: text(),
833		target_url: text(),
834		target_name: text(),
835		status: text().notNull(),
836		ok: boolean(),
837		aborted_reason: text(),
838		summary: jsonb(),
839		started_at: timestamp({ withTimezone: true, mode: 'string' }),
840		ended_at: timestamp({ withTimezone: true, mode: 'string' }),
841		duration_ms: integer(),
842		created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow(),
843		updated_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow(),
844		har_key: text(),
845		run_id: text(),   // soft join → cloak_runs.id (engine store, separate DB → no FK). id-collapse killed bridge_job_id.
846		rec_key: text(),
847		reharvested_at: timestamp({ withTimezone: true, mode: 'string' }),
848		// The COMPLETE run timeline banked on the row (Skyvern-alignment: the durable Job row
849		// is the whole record of a Run). Shape {t,s,k,src}[]. Added out-of-band by migration
850		// entity/0040_job_steps.sql; declared here so db/schema.ts (the SoT, invariant #25) matches.
851		steps: jsonb(),
852		// job-queue columns (server/jobs): the job ledger doubles as the durable work queue.
853		verb: text(),
854		input: jsonb(),
855		worker_id: text(),
856		claimed_at: timestamp({ withTimezone: true, mode: 'string' }),
857		// bluebut#201: the Skyvern-style discriminator for what KIND of run this row records
858		// (e.g. 'job' | 'workflow_run' | 'e2ee-keepalive' | …). Backfilled from cmd/op by 0047.
859		run_type: text(),
860		// Pacing primitives (issue #217): the ledger itself expresses inter-post jitter +
861		// velocity backoff. `not_before` gates a queued row from being claimed until due
862		// (claim_job_v2 adds `AND (not_before IS NULL OR not_before <= now())`); `priority`
863		// lets an urgent job jump the FIFO order (claim_job_v2 orders priority DESC, then
864		// created_at ASC). Both nullable/defaulted so legacy enqueue paths are unaffected.
865		not_before: timestamp({ withTimezone: true, mode: 'string' }),
866		priority: integer().default(0),
867	}, (table) => [
868		index("job_account_idx").using("btree", table.account.asc().nullsLast().op("timestamptz_ops"), table.created_at.desc().nullsFirst().op("text_ops")),
869		index("job_batch_idx").using("btree", table.batch_id.asc().nullsLast().op("text_ops")),
870		index("job_run_idx").using("btree", table.run_id.asc().nullsLast().op("text_ops")),
871		index("job_created_idx").using("btree", table.created_at.desc().nullsFirst().op("timestamptz_ops")),
872		index("job_queued_idx").using("btree", table.created_at.asc().nullsLast().op("timestamptz_ops")).where(sql`(status = 'queued')`),
873		// Serves claim_job_v2's ORDER BY priority DESC, created_at ASC over the queued slice (issue #217).
874		index("job_queue_pace_idx").using("btree", table.priority.desc().nullsLast().op("int4_ops"), table.created_at.asc().nullsLast().op("timestamptz_ops")).where(sql`(status = 'queued')`),
875		uniqueIndex("job_one_active_per_account").using("btree", table.account.asc().nullsLast().op("text_ops")).where(sql`(status = ANY (ARRAY['claimed'::text, 'running'::text])) AND account IS NOT NULL`),
876		index("job_target_idx").using("btree", table.target_type.asc().nullsLast().op("text_ops"), table.target_id.asc().nullsLast().op("text_ops"), table.created_at.desc().nullsFirst().op("text_ops")),
877		// bluebut#263: bind status to the canonical taxonomy.ts LIFECYCLE UNION the live ledger
878		// vocab (server/jobs/queue.ts LEDGER_ACTIVE+LEDGER_TERMINAL / server/contract/job.ts
879		// JobStatus) — see db/migrations/entity/0049_run_status_check.sql for the authoritative
880		// (NOT VALID) constraint; mirrored here per inv #25 so schema.ts matches the live DB.
881		check("run_status_chk", sql`status = ANY (ARRAY['created'::text, 'queued'::text, 'claimed'::text, 'running'::text, 'completed'::text, 'ok'::text, 'done'::text, 'failed'::text, 'blocked'::text, 'skipped'::text, 'partial'::text, 'content_unavailable'::text, 'terminated'::text, 'canceled'::text, 'timed_out'::text])`),
882	]);
883	
884	// A reusable, versioned Workflow definition (Skyvern WorkflowDefinition-as-data). The
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; ls db/migrations/entity/ | sort | tail -15; echo \"=== latest entity migration head ===\"; tail -20 db/migrations/entity/0049_run_status_check.sql 2>/dev/null; echo \"=== how migrations are applied? ===\"; grep -rn \"migrations/entity\" package.json scripts/ 2>/dev/null | head", "description": "Find latest entity migration and runner"}
0040_job_steps.sql
0041_rename_to_skyvern.sql
0042_capture_legacy_rpcs.sql
0043_broadcast_config_closest_km.sql
0044_job_error_issue.sql
0045_homii_resolve_claim.sql
0046_resync_workflow_campaign.sql
0047_rename_run_workflow_run.sql
0048_outreach_geo_source.sql
0049_run_status_check.sql
0050_drop_legacy_run_views.sql
0051_rename_run_columns_expand.sql
0052_drop_run_compat_columns.sql
0053_schedule_compat_and_run_grants.sql
pending
=== latest entity migration head ===
        status = ANY (ARRAY[
          'pending','running',
          'ok','completed',
          'failed','blocked','skipped','partial'
        ])
      ) NOT VALID;
  END IF;
END $$;

-- target_run has NO status column (last_run_at/last_job_id/last_result only) — nothing to
-- constrain there; see db/schema.ts target_run.

-- NOT VALID leaves these unenforced against pre-existing rows but enforced on every INSERT/
-- UPDATE from here on (Postgres always validates NOT VALID checks against new/changed rows).
-- A future migration can `VALIDATE CONSTRAINT <name>` once a one-off audit query confirms zero
-- live rows fall outside the ANY-list above (`SELECT status, count(*) FROM run GROUP BY 1` etc.)
-- — deliberately NOT done in this pass per the #263 scope note (smallest correct fix; don't risk
-- a VALIDATE scan finding a surprise value and failing this deploy).

NOTIFY pgrst, 'reload schema';
=== how migrations are applied? ===
scripts/docker-migrate-boot.sh:12:#   2. hand-SQL imperative pile (db/migrations/entity/*.sql), entity DB — the things
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== 0052 ===\"; cat db/migrations/entity/0052_drop_run_compat_columns.sql; echo \"=== pending dir ===\"; ls -la db/migrations/entity/pending/ 2>/dev/null", "description": "Read 0052 migration and pending dir"}
=== 0052 ===
-- 0052_drop_run_compat_columns — CONTRACT (drop) pass of bluebut#305 (epic #200 Phase B),
-- the follow-on to 0051's EXPAND. 0051 renamed the run-registry COLUMNS to their canonical
-- Skyvern names (job_id→id, run_id→engine_run_id, batch_id→workflow_run_id) and kept the OLD
-- names as compat columns kept writable by a mirror trigger, so a still-deployed pre-rename
-- container survived the deploy window. This deploy migrates EVERY reader/writer onto the
-- canonical names (server/runs, scripts/bluebut-adapter, adapter-finalize, health reaper,
-- the UI queryKeys), so the shims can finally go:
--
--   • drop the run_compat_mirror trigger + its function
--   • drop run.job_id / run.run_id / run.batch_id compat columns
--   • drop the batch_target passthrough compat VIEW (workflow_run_target is the base table)
--   • rename job_schedule → run_schedule
--
-- The job/batch compat VIEWs (0047) were already dropped in 0050; workflow_run's OWN `batch_id`
-- PK is a distinct concept and is NOT touched. Idempotent: every drop is IF EXISTS and the
-- rename is guarded by information_schema, so a re-run — or a run against an already-contracted
-- DB — is a no-op. No explicit BEGIN/COMMIT: the runner (scripts/apply-migrations.ts) wraps
-- each file in one transaction (matches 0047/0049/0050/0051).

-- 0) Repoint the claim_job RPC onto the run registry BEFORE dropping run.job_id. Its original
--    body (db/0002_claim_job.sql) read `FROM job ... WHERE job_id = ...`; the `job` compat view
--    was dropped in 0050 and the run PK is now `id`. queue.ts still calls rpc/claim_job (the
--    durable-queue worker path), so keep it valid. DROP+CREATE because the return rowtype moves
--    from SETOF job → SETOF run. Same atomic FOR UPDATE SKIP LOCKED + per-account active guard.
DROP FUNCTION IF EXISTS public.claim_job(text);
CREATE FUNCTION public.claim_job(p_worker text)
RETURNS SETOF public.run
LANGUAGE plpgsql AS $$
DECLARE
  v_run public.run;
BEGIN
  SELECT j.* INTO v_run
  FROM public.run j
  WHERE j.status = 'queued'
    AND (
      j.account IS NULL
      OR NOT EXISTS (
        SELECT 1 FROM public.run b
        WHERE b.account = j.account
          AND b.status IN ('claimed', 'running')
      )
    )
  ORDER BY j.created_at ASC
  FOR UPDATE OF j SKIP LOCKED
  LIMIT 1;

  IF NOT FOUND THEN
    RETURN;
  END IF;

  BEGIN
    UPDATE public.run
      SET status = 'claimed', worker_id = p_worker, claimed_at = now(), updated_at = now()
      WHERE id = v_run.id;
  EXCEPTION WHEN unique_violation THEN
    RETURN;  -- another worker just claimed this account — leave ours queued
  END;

  v_run.status := 'claimed';
  v_run.worker_id := p_worker;
  RETURN NEXT v_run;
END;
$$;

-- 1) Drop the old⇄new mirror trigger + its function — no writer touches the old names anymore.
DROP TRIGGER IF EXISTS run_compat_mirror_trg ON public.run;
DROP FUNCTION IF EXISTS public.run_compat_mirror();

-- 2) Drop the run-table compat alias columns. The canonical id (PK) / engine_run_id /
--    workflow_run_id remain; no index or view references the alias columns (the indexes were
--    renamed to run_*_idx over the canonical columns in 0051; overview_daily/overview_totals
--    read only run.created_at/status).
ALTER TABLE public.run DROP COLUMN IF EXISTS job_id;
ALTER TABLE public.run DROP COLUMN IF EXISTS run_id;
ALTER TABLE public.run DROP COLUMN IF EXISTS batch_id;

-- 3) Drop the batch_target passthrough compat VIEW — callers read/write workflow_run_target now.
DROP VIEW IF EXISTS public.batch_target;

-- 4) Rename job_schedule → run_schedule (Skyvern run vocabulary). No FK or view depends on the
--    table, and the scheduler runtime (single web replica) is redeployed with this migration, so
--    a clean rename with no compat view is safe. Index names keep their job_schedule_* labels
--    (functionally inert; a later cosmetic pass can rename them).
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables
             WHERE table_schema = 'public' AND table_name = 'job_schedule' AND table_type = 'BASE TABLE')
     AND NOT EXISTS (SELECT 1 FROM information_schema.tables
                     WHERE table_schema = 'public' AND table_name = 'run_schedule' AND table_type = 'BASE TABLE')
  THEN
    EXECUTE 'ALTER TABLE public.job_schedule RENAME TO run_schedule';
  END IF;
END $$;

NOTIFY pgrst, 'reload schema';
=== pending dir ===
total 12
drwxrwxr-x 2 philip philip 4096 Jul 24 17:10 .
drwxrwxr-x 3 philip philip 4096 Jul 24 17:10 ..
-rw-rw-r-- 1 philip philip 1092 Jul 24 17:10 README.md
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== pending README ===\"; cat db/migrations/entity/pending/README.md; echo; echo \"=== bridge.ts 415-440 ===\"; sed -n '415,445p' server/http/routes/bridge.ts", "description": "Read pending README and bridge gate"}
=== pending README ===
# Pending (NOT auto-applied) entity migrations

The migration runner (`scripts/apply-migrations.ts`) does a **non-recursive**
`readdirSync('db/migrations/entity')`, so `.sql` files in THIS subdir are **never**
auto-applied on deploy. They are held here deliberately.

_Currently empty — no held migrations._

## History

- **`0048_drop_legacy_run_views.sql`** — the CONTRACT step of epic #200 / issue #206.
  `0047` renamed `job`→`run` / `batch`→`workflow_run`, keeping the old names alive as
  passthrough compat VIEWS through the deploy window (expand/contract, inv #21). Once `0047`
  was live in prod and every call site repointed at `run`/`workflow_run` (grep = zero legacy
  refs), the drop was promoted out of `pending/` as
  `db/migrations/entity/0050_drop_legacy_run_views.sql` — renumbered off the taken `0048`
  slot (`0048_outreach_geo_source`), and extended to first `CREATE OR REPLACE` the two
  `overview_*` analytics views onto `run` so the `DROP VIEW job` no longer fails on that
  dependency. It ships in a SEPARATE, LATER deploy than `0047` (never the same one).

=== bridge.ts 415-440 ===
  "POST /workflow/plan", "POST /campaign/plan", "POST /workflow/run", "POST /campaign/run",
  "POST /schedule/run", "POST /workflow/stop", "POST /campaign/stop",
  "GET /accounts/health", "GET /accounts/usable",
  "POST /accounts/sync-farm",
]);

// epic #6 cutover: when the durable queue worker owns dispatch (BLUEBUT_QUEUE_WORKER=1),
// this inline /exec spawn is DORMANT — the UI drives jobs through /api/queue instead.
// Leaving it live alongside the worker = double-dispatch (inv #23 runaway). Gated, not
// deleted: unset the env and the inline path drives again. /login/stream is untouched.
const QUEUE_WORKER_ON = process.env.BLUEBUT_QUEUE_WORKER === "1";

// ── FINAL job-spawn catch-all — /login/stream + the site/cmd adapter spawn. RAW
//    (req,res), byte-for-byte identical to the original: reads the body, then
//    dispatches. A request with no site/cmd gets a 400 here (the chain's terminal
//    handler), so this ALWAYS handles the request.
function execCatchAll(req: IncomingMessage, res: ServerResponse) {
  let body = "";
  req.on("data", (c) => (body += c));
  req.on("end", () => {
    let b: Record<string, unknown> = {};
    try { b = JSON.parse(body || "{}"); } catch { /* empty */ }
    const path0 = (req.url || "").split("?")[0];

    if (path0 === "/login/stream") { handleLogin(req, res, b); return; }

    // Header-only auth on the process-spawn path (issue #20): /exec runs an adapter
    // subprocess. A query-string token (accepted by the global gate for reads) is too
    // weak to authorize spawning code — require the Authorization header here.
    if ((path0 === "/exec" || path0 === "/exec/stream") && !authed(req, { allowQuery: false })) {
      res.writeHead(401, { ...CORS, "Content-Type": "application/json" });
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; grep -rn \"tickSchedules\\|SCHED_ENABLED\\|SCHED_TICK_MS\" server/http/runtime/*.mjs server/http/*.mjs | grep -v scheduler.mjs", "description": "Find scheduler boot trigger"}
server/http/runtime/auto-broadcast.mjs:81://   • hosted-only: gated on SCHED_ENABLED, exactly like the scheduler + boot-resume,
server/http/runtime/broadcast.mjs:219:    // cmd='create-group-post' reserves sweep.batch (tickSchedules) but
server/http/runtime.mjs:37:if (SCHED_ENABLED) {
server/http/runtime.mjs:38:  tickSchedules();
server/http/runtime.mjs:39:  setInterval(tickSchedules, SCHED_TICK_MS).unref?.();
server/http/runtime.mjs:40:} else if (SCHED_TICK_MS > 0) {
server/http/runtime.mjs:45:if (SCHED_ENABLED && RECOGNIZE_TICK_MS > 0) {
server/http/runtime.mjs:53:if (SCHED_ENABLED) setTimeout(() => { bcastResumeRunning(); }, 8_000).unref?.();
server/http/runtime.mjs:56:if (SCHED_ENABLED && AUTO_BCAST_TICK_MS > 0) {
server/http/runtime.mjs:61:  // listings) alone — NOT tickSchedules and NOT the funnel sweep — so a local run can
server/http/runtime.mjs:69:// Durable manual broadcast QUEUE drain. Unconditional (NOT SCHED_ENABLED-gated): a
server/http/runtime/run-core.mjs:7://                       _activeSweepBatch`; because BOTH the scheduler (tickSchedules)
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime.mjs", "offset": 30, "limit": 45}
30	import { SCHED_ENABLED, SCHED_TICK_MS, RECOGNIZE_TICK_MS, fireSchedule, fireWorkflowSchedule, tickSchedules, tickRecognize } from "./runtime/scheduler.mjs";
31	import { AUTO_BCAST_TICK_MS, bcastResumeRunning, bcastAutoSweep, bcastAutoSweepAll, bcastQueueDrain } from "./runtime/auto-broadcast.mjs";
32	import { handleLogin } from "./runtime/login.mjs";
33	
34	// ── BOOT SIDE EFFECTS — verbatim order from the pre-split runtime.mjs ──────────
35	
36	// Recurring scheduler (HOSTED-ONLY; the SCHED_ENABLED gate lives in scheduler.mjs).
37	if (SCHED_ENABLED) {
38	  tickSchedules();
39	  setInterval(tickSchedules, SCHED_TICK_MS).unref?.();
40	} else if (SCHED_TICK_MS > 0) {
41	  console.log("[backend] recurring scheduler DISABLED (no API_TOKEN → local/dev bridge). Prod fires schedules; set BLUEBUT_FORCE_SCHEDULER=1 to run them here.");
42	}
43	
44	// Runtime recognizer — recognize cloak_captures → fb_* continuously.
45	if (SCHED_ENABLED && RECOGNIZE_TICK_MS > 0) {
46	  setTimeout(tickRecognize, 10_000).unref?.();   // first sweep shortly after boot
47	  setInterval(tickRecognize, RECOGNIZE_TICK_MS).unref?.();
48	}
49	
50	// Batch boot-resume — re-run any batch left 'running' when this bridge last died.
51	// Only the always-on hosted bridge resumes (same gate as the scheduler — a local
52	// dev bridge must not pick up prod's live batches).
53	if (SCHED_ENABLED) setTimeout(() => { bcastResumeRunning(); }, 8_000).unref?.();
54	
55	// Auto-broadcast — a new bofrid/funnel listing goes live → run its broadcast.
56	if (SCHED_ENABLED && AUTO_BCAST_TICK_MS > 0) {
57	  setTimeout(() => { bcastAutoSweepAll(); }, 20_000).unref?.();   // first sweep shortly after boot
58	  setInterval(bcastAutoSweepAll, AUTO_BCAST_TICK_MS).unref?.();
59	} else if (process.env.BLUEBUT_FORCE_BROADCAST_SWEEP === "1" && AUTO_BCAST_TICK_MS > 0) {
60	  // Broadcast-ONLY on-demand drip (no full scheduler): ticks bcastAutoSweep (bofrid
61	  // listings) alone — NOT tickSchedules and NOT the funnel sweep — so a local run can
62	  // drain the armed auto_broadcast cursor without also firing every due scrape/join.
63	  // Same guardrails (serialized, paced, hourly cap, stop-on-block, repost guard).
64	  console.log("[backend] FORCE broadcast-only sweep ON (BLUEBUT_FORCE_BROADCAST_SWEEP=1) — bofrid listings only");
65	  setTimeout(() => { bcastAutoSweep(); }, 5_000).unref?.();
66	  setInterval(() => { bcastAutoSweep(); }, AUTO_BCAST_TICK_MS).unref?.();
67	}
68	
69	// Durable manual broadcast QUEUE drain. Unconditional (NOT SCHED_ENABLED-gated): a
70	// manual Run isn't gated either, and the drain is cheap — one indexed peek that
71	// returns instantly when nothing is queued.
72	setInterval(bcastQueueDrain, 30_000).unref?.();
73	
74	// (Live tab CDP screencast removed — watching a browser is noVNC-only now:
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 52'; $PROG read done \"root cause: (1) scheduler fires run_schedule inline via fireSchedule spawn, no queue producer; (2) run.ok still written by statusPatch+adapter-finalize\"; $PROG worktree start; pwd; git branch --show-current; $PROG worktree done; $PROG fix start \"scheduler producer + drop run.ok\"", "description": "Progress read/worktree done, fix start"}
/home/philip/Documents/GitHub/bluebut-issue-52
fix/issue-52
Now let me implement the scheduler producer + dormancy gate. First the imports and flag:

Jul 24, 03:14:03 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs", "old_string": "import { newJob, pushLog, withAccountLock } from \"./job-registry.mjs\";\nimport { sweep, usableDrivableFleet } from \"./run-core.mjs\";\nimport { workflowStart, resolveWorkflowPlan } from \"./workflow.mjs\";\nimport { numEnv } from \"../../../adapters/_lib/num-env.ts\";", "new_string": "import { newJob, pushLog, withAccountLock } from \"./job-registry.mjs\";\nimport { sweep, usableDrivableFleet } from \"./run-core.mjs\";\nimport { workflowStart, resolveWorkflowPlan } from \"./workflow.mjs\";\nimport { numEnv } from \"../../../adapters/_lib/num-env.ts\";\nimport { enqueueRaw } from \"../../jobs/queue.ts\";\n\n// epic #6 cutover \u2014 the DEFERRED half (iss
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Now the dispatch branch in the non-workflow lane:

Jul 24, 03:14:15 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs", "old_string": " const won = await casClaimSchedule(s.schedule_id, s.last_run_at ?? null,\n { last_run_at: startIso, next_run_at: next ? next.toISOString() : null, last_run_status: \"running\", last_run_job_id: jobId, last_run_account: acct, updated_at: startIso });\n if (!won) { _schedInflight.delete(s.schedule_id); continue; }\n fireSchedule(s, jobId, acct)\n .then((res) => dbRest(\"PATCH\", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,\n { last_run_status: res.ok ? \"ok\" : \"failed\", updated_at: new Date().toISOString() },\n { silent: true, headers: { Prefer: \"return=minimal\" } }).catch(() => {}))\
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Now update the stale comment above `tickSchedules` that claimed there's no producer:

Jul 24, 03:14:34 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs", "old_string": "// epic #6 cutover: the UI/exec dispatch path moved to the durable queue worker\n// (bridge.ts /exec is gated dormant under BLUEBUT_QUEUE_WORKER=1). The in-process\n// SCHEDULER stays live for now: it fires recurring run_schedule rows via fireSchedule()\n// (spawn), a lane the queue worker does NOT cover \u2014 there is no schedule\u2192queue producer\n// yet, so gating this dormant would silently stop every recurring job. Migrating the\n// scheduler onto the queue is tracked as a follow-up; until then it keeps driving.\nasync function tickSchedules() {", "new_string": "// epic #6 cutover / issue #52: the UI/exec dispatch path moved to the durable queue\n//
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== writes: 'ok' appearing in a run/job PATCH/POST body ===\"; grep -rn \"ok:\" --include=*.ts --include=*.mjs server scripts adapters | grep -iv \"okForStatus\\|book\\|lookok\\|// \" | grep -i \"\\bok:\" | head -40", "description": "Grep ok: writes"}
=== writes: 'ok' appearing in a run/job PATCH/POST body ===
server/app.ts:59:  app.get('/health', (c) => c.json({ ok: true, service: 'bluebut' }))
server/http/listings/geo.mjs:45:    return kvJson(res, 200, { ok: true, kommun: byLevel("kommun"), lan: byLevel("lan") });
server/http/listings/geo.mjs:173:    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return kvJson(res, 400, { ok: false, error: "lat + lng required" });
server/http/listings/geo.mjs:189:      ok: true, lat, lng: lon,
server/http/listings/geo.mjs:206:    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return kvJson(res, 400, { ok: false, error: "lat + lng required" });
server/http/listings/geo.mjs:243:      ok: true, lat, lng: lon,
server/http/listings/geo.mjs:265:      ok: true,
server/http/listings/geo.mjs:289:      return kvJson(res, 400, { ok: false, error: "fb_group_id + geo_level (lan|kommun|area) + geo_ref required" });
server/http/listings/geo.mjs:314:          return kvJson(res, 400, { ok: false, error: `no such ${geo_level}: "${geo_ref}" (geo.bofrid.eu → 404)` });
server/http/listings/geo.mjs:327:    return kvJson(res, 200, { ok: true, fb_group_id, geo_level, geo_ref, polygon_cached, polygon_error });
server/http/listings/geo.mjs:338:      return kvJson(res, 400, { ok: false, error: "fb_group_id + geo_level + geo_ref required" });
server/http/listings/geo.mjs:344:    return kvJson(res, 200, { ok: true, fb_group_id, geo_level, geo_ref, removed: true });
server/store/profiles.ts:126:  async delete(id: string): Promise<{ ok: true; id: string }> {
server/store/profiles.ts:128:    return { ok: true, id }
server/contract/job.ts:41:  ok: z.boolean().nullable().optional(),
scripts/canary-admin.ts:77:        resolve({ ok: false, aborted_reason: 'unparseable adapter output', raw: out.slice(0, 300) });
scripts/canary-admin.ts:80:    child.on('error', (e) => resolve({ ok: false, aborted_reason: `spawn failed: ${e.message}` }));
scripts/canary-admin.ts:110:  try { res = (await fn()) || {}; } catch (e) { res = { ok: false, detail: String(e) }; }
scripts/canary-admin.ts:112:  const rec = { step: name, ok: !!res.ok, detail: res.detail || '', secs, status: res.status, diagnostic: res.diagnostic || null };
scripts/canary-admin.ts:116:    { step: name, ok: String(!!res.ok), status: res.status, group: GROUP, actor: ACTOR, target: TARGET, diag_url: res.diagnostic?.url, diag_heading: res.diagnostic?.heading });
scripts/canary-admin.ts:134:    return { ok: !!(r.logged_in || r.status === 'logged_in'), detail: `status=${r.status}`, status: r.status, stop: true };
scripts/canary-admin.ts:139:    return { ok: !!(r.logged_in || r.status === 'logged_in') && !!targetFbId, detail: `fb_id=${targetFbId}`, status: r.status, stop: true };
scripts/canary-admin.ts:156:    steps.push({ step: 'accept', ok: true, detail: 'skipped — target already admin', secs: '0.0' });
scripts/canary-admin.ts:161:      return { ok: r.ok && r.status === 'accepted', detail: r.ok ? `verified_role=${r.verified_role}` : r.aborted_reason, status: r.status, diagnostic: r.diagnostic, stop: !r.ok };
scripts/canary-admin.ts:174:    return { ok: r.ok && r.status === 'demoted', detail: r.status || r.aborted_reason, status: r.status, diagnostic: r.diagnostic };
scripts/canary-admin.ts:201:  const slim = steps.map(s => ({ step: s.step, ok: s.ok, detail: s.detail, secs: s.secs, status: s.status,
server/http/runtime/login.mjs:41:  if (!account) return j(400, { ok: false, error: "account required" });
server/http/runtime/login.mjs:72:            emit(`[RESULT] ${JSON.stringify({ result: { ok: true, did_login: false, status: "already_logged_in", fb_id: p.fb_id || null } })}`);
server/http/runtime/login.mjs:108:        const result = parseLoose(out) || { ok: rc === 0, logged_in: rc === 0 };
server/http/runtime/login.mjs:112:        emit(`[RESULT] ${JSON.stringify({ result: { did_login: true, ...result, ok: job.ok } })}`);
server/http/lib.mjs:140:  if (r?.ok && fallbackLink && r?.warming_up === false) return { ok: true, link: fallbackLink };
server/http/lib.mjs:141:  if (!seed) return fallbackLink ? { ok: true, link: fallbackLink } : { ok: false, link: null, error: String(r?.error || "focus returned no seed") };
server/http/lib.mjs:147:      if (sess?.browser_up && sess?.alive && sess?.viewer_url) return { ok: true, link: sess.viewer_url };
server/http/lib.mjs:152:  return { ok: false, link: null, error: lastErr || "focus did not become drivable" };
server/http/api-v1-tables.ts:54:  | { ok: true; params: Record<string, string>; limit: number; offset: number }
server/http/api-v1-tables.ts:55:  | { ok: false; error: string; valid_filters: string[] };
server/http/api-v1-tables.ts:68:    if (!cols.has(col)) return { ok: false, error: `unknown order column "${col}"`, valid_filters: [...cols].sort() };
server/http/api-v1-tables.ts:80:    if (!cols.has(key)) return { ok: false, error: `unknown filter "${key}"`, valid_filters: [...cols].sort() };
server/http/api-v1-tables.ts:83:  return { ok: true, params, limit, offset };
server/routes/jobs.ts:73:        await stream.writeSSE({ event: 'status', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok }) })
Let me check the subagent's inventory results before touching the ok column:

Jul 24, 03:14:56 PM

Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/routes/jobs.ts", "offset": 55, "limit": 55}
55	jobs.get('/:id/events', (c) => streamSSE(c, async (stream) => {
56	  const id = c.req.param('id')
57	  // epic #6: tail the worker's live step-log lines (job-stream tap, same process) so the
58	  // dashboard shows per-step progress — the durable queue SSE otherwise carries only
59	  // status/result frames (the worker runs the runner out-of-band, no child to tee). Bare
60	  // `data:` frames (no event name) render as progress lines in ui/src/api/jobs.ts (onLine).
61	  const tap = subscribe(id)
62	  const pending: string[] = [...tap.replay]
63	  tap.onLine((line) => pending.push(line))
64	  const flush = async (): Promise<void> => { while (pending.length && !stream.aborted) await stream.writeSSE({ data: pending.shift()! }) }
65	  let last = null
66	  try {
67	    for (let i = 0; i < 600 && !stream.aborted; i += 1) {
68	      await flush()
69	      const job = await getJob(id)
70	      if (!job) { await stream.writeSSE({ event: 'error', data: JSON.stringify({ error: 'not_found' }) }); return }
71	      if (job.status !== last) {
72	        last = job.status
73	        await stream.writeSSE({ event: 'status', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok }) })
74	      }
75	      if (isLedgerTerminal(job.status)) {
76	        await flush()   // drain any final lines before the terminal frame
77	        // epic #6: emit a terminal `result` frame so the repointed UI job client
78	        // (ui/src/api/jobs.ts, off /exec/stream → this stream) can resolve its
79	        // RunResult. `ok` is the funnel-derived boolean; `result` rides through if
80	        // the ledger row carries one (the UI falls back to deriving ok from status).
81	        await stream.writeSSE({ event: 'result', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok ?? okForStatus(job.status), result: job.result ?? null }) })
82	        return
83	      }
84	      await stream.sleep(1000)
85	    }
86	  } finally { tap.close() }
87	}))
88
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== ui selects mentioning ok column on run/job ===\"; grep -rn \"\\.ok\\b\" ui/src --include=*.ts --include=*.tsx | grep -i \"run\\|job\\|batch\" | grep -iv \"result\\|res\\.ok\\|response\" | head -20; echo \"=== JobRow.ok / job.ok / row.ok reads in server ===\"; grep -rn \"\\.ok\\b\" server --include=*.ts --include=*.mjs | grep -i \"job\\.\\|run\\.\\|row\\.ok\\|\\.ok ??\" | head", "description": "Check UI and server ok reads"}
=== ui selects mentioning ok column on run/job ===
ui/src/api/har-traces.ts:11:  if (!r.ok) throw new Error(r.status === 404 ? "no HAR captured for this job" : `/har/blob HTTP ${r.status}`);
ui/src/api/har-traces.ts:63:  if (!r.ok) throw new Error(r.status === 404 ? "no HAR captured for this job" : `/har/blob HTTP ${r.status}`);
ui/src/components/WorkflowView.tsx:62:    // i.e. wire data — NOT the retiring run.ok column — so it is read as-is.
ui/src/state/jobs.tsx:128:      if (r.ok) {
ui/src/components/AccountJobs.tsx:30:// funnel writes), NOT the retiring `run.ok` boolean (#295: read-side off run.ok).
ui/src/components/AccountJobs.tsx:43:  return j.ok === false ? "failed" : "ok";
ui/src/components/AccountJobs.tsx:123:        phase: live ? phaseOfBridge(live) : phaseFromStatus(j.status, j.ok),
ui/src/pages/JobDebug.tsx:64:  const ok = fromSnap?.ok ?? log?.jobOk ?? null;
ui/src/pages/groups/MultiGroupScrapeForm.tsx:244:                border: `1px solid ${res?.status === "running" ? "var(--color-info-fg)" : !e.ok ? "var(--color-danger-bg)" : "var(--color-surface-2)"}`, borderRadius: 8, padding: 10,
ui/src/api/jobs.ts:109:    if (!enq.ok) {
ui/src/api/jobs.ts:121:    if (!r.ok || !r.body) throw new Error(`/api/queue/jobs/${job_id}/events HTTP ${r.status}`);
ui/src/api/jobs.ts:226:  if (!r.ok || !r.body) {
ui/src/api/jobs.ts:298:  if (!r.ok) throw new Error(`/jobs HTTP ${r.status}`);
ui/src/api/jobs.ts:330:  if (!r.ok) throw new Error(`/jobs/failures HTTP ${r.status}`);
ui/src/api/jobs.ts:372:  if (!r.ok) throw new Error(`/messenger/socket-status HTTP ${r.status}`);
ui/src/api/jobs.ts:395:  if (!r.ok && !("alive" in d)) throw new Error(d.error || `/messenger/health HTTP ${r.status}`);
ui/src/api/jobs.ts:408:  if (!r.ok) throw new Error(d?.error || `/messenger/connect HTTP ${r.status}`);
ui/src/api/jobs.ts:418:  if (!r.ok) throw new Error(`/jobs/cancel HTTP ${r.status}`);
ui/src/api/jobs.ts:431:  if (!r.ok) throw new Error(`/job-log HTTP ${r.status}`);
ui/src/api/workflow-runs.ts:105:    if (r.status === "ok") c.ok++;
=== JobRow.ok / job.ok / row.ok reads in server ===
server/routes/jobs.ts:73:        await stream.writeSSE({ event: 'status', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok }) })
server/routes/jobs.ts:81:        await stream.writeSSE({ event: 'result', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok ?? okForStatus(job.status), result: job.result ?? null }) })
server/http/runtime/recordings.mjs:47:      row.ok = d.result?.success ?? d.result?.done ?? null;
server/http/runtime/login.mjs:55:    job.endedAt = Date.now(); job.status = "done"; job.ok = false;
server/http/runtime/login.mjs:71:            job.ok = true;
server/http/runtime/login.mjs:111:        job.ok = rc === 0 && result.ok !== false && result.logged_in !== false;
server/http/runtime/login.mjs:112:        emit(`[RESULT] ${JSON.stringify({ result: { did_login: true, ...result, ok: job.ok } })}`);
server/http/runtime/login.mjs:115:      child.on("error", (e) => { clearTimeout(killTimer); job.ok = false; emit(`[ERROR] ${e.message}`); res.end(); resolve(); });
server/http/runtime/job-registry.mjs:249:  if (job.ok === true) b.ok++; else if (job.ok === false) b.failed++;
server/http/runtime/job-registry.mjs:266:  const ok = job.ok === true;
The writers are confirmed. Let me remove the `ok` write from the queue funnel and update its docs:

Jul 24, 03:15:26 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue.ts", "old_string": "/** Build the PATCH body for a status transition (pure \u2014 no I/O; unit-tested). */\nexport function statusPatch(status: string, reason?: unknown): Record<string, unknown> {\n const now = new Date().toISOString()\n const body: Record<string, unknown> = { status, ok: okForStatus(status), updated_at: now }\n if (isLedgerTerminal(status)) body.ended_at = now\n if (reason != null) body.aborted_reason = String(reason).slice(0, 500)\n return body\n}", "new_string": "/** Build the PATCH body for a status transition (pure \u2014 no I/O; unit-tested).\n * issue #52: `ok` is NO LONGER written \u2014 the column is dropped (migration\n * entity/0054). Every reader now rol
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue.ts", "old_string": "// updateRunStatus is the ONE place a `run` row's status transition is written in\n// the queue path: it derives the legacy `ok` boolean FROM the status (so no caller\n// hand-writes an inconsistent ok/status pair \u2014 the drift Phase 1 kills), stamps\n// ended_at on a terminal status, and scopes the PATCH to non-terminal rows so a\n// banked terminal is never clobbered.\n//\n// EXPAND/CONTRACT: `ok` is still WRITTEN (derived), not dropped \u2014 the read-side\n// migration off `ok` onto `status` is the later contract step. The LIVE runner-\n// finalize (scripts/_lib/adapter-finalize.ts) + pace-loop writers still write\n// directly today; routing THEM through this fun
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Comprehensive inventory received — more writers/readers than the funnel alone. All must go to drop the column safely. Let me read the relevant sections:

Jul 24, 03:16:02 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== health.mjs 240-250 ===\"; sed -n '240,250p' server/http/runtime/health.mjs; echo \"=== health.mjs 348-358 ===\"; sed -n '348,358p' server/http/runtime/health.mjs; echo \"=== bluebut-adapter 145-155 ===\"; sed -n '145,155p' scripts/bluebut-adapter.ts; echo \"=== bluebut-adapter blocked inserts pattern (grep) ===\"; grep -n \"ok: false\" scripts/bluebut-adapter.ts", "description": "Read health and adapter writers"}
=== health.mjs 240-250 ===
  try {
    // Cover BOTH orphan shapes (issue #43.F1): a row stuck `running` past its
    // started_at, AND a row stuck `claimed` past its claimed_at — a worker that
    // claimed then died before writing its first `running` row leaves started_at
    // NULL, so a status=running-only sweep never matches it and claim_job's
    // per-account guard leaves that lane permanently unclaimable.
    const rows = await dbRest("PATCH",
      `run?or=(and(status.eq.running,started_at.lt.${encodeURIComponent(cutoff)}),and(status.eq.claimed,claimed_at.lt.${encodeURIComponent(cutoff)}))`,
      { status: "failed", ok: false, aborted_reason: "runner_died (no terminal update — reaped)",
        ended_at: new Date().toISOString(), updated_at: new Date().toISOString() },
      { silent: true, headers: { Prefer: "return=representation" } });
=== health.mjs 348-358 ===
async function reapOnBoot() {
  const cutoff = new Date(Date.now() - 60_000).toISOString();
  try {
    // Same running+claimed coverage as the steady reaper (issue #43.F1): a boot
    // SIGKILLed every child this process owned, so a row left `claimed` (never
    // reached the first `running` write) is just as orphaned as a `running` one.
    const rows = await dbRest("PATCH",
      `run?or=(and(status.eq.running,started_at.lt.${encodeURIComponent(cutoff)}),and(status.eq.claimed,claimed_at.lt.${encodeURIComponent(cutoff)}))`,
      { status: "failed", ok: false, aborted_reason: "bridge_restart (orphaned running/claimed row — reaped on boot)",
        ended_at: new Date().toISOString(), updated_at: new Date().toISOString() },
      { silent: true, headers: { Prefer: "return=representation" } });
=== bluebut-adapter 145-155 ===
      const result = { ok: false, status: 'blocked', aborted_reason: reason,
        cdp_unreachable: !health.reachable, cdp_not_drivable: true, cdp_base: CLOAK_API_BASE,
        cdp_native: !!health.native, account: kwargs.account || null, cmd: cmd.name, op };
      try {
        const nowIso = new Date().toISOString();
        await dbRest('POST', 'run', {
          id: job_id, workflow_run_id: process.env.BLUEBUT_BATCH_ID || null, site: cmd.site, cmd: cmd.name,
          op, access, account: kwargs.account || null, status: 'blocked', ok: false,
          aborted_reason: reason, summary: { cdp_unreachable: !health.reachable, not_drivable: true, base: CLOAK_API_BASE, native: !!health.native, reason: health.reason },
          started_at: nowIso, ended_at: nowIso, duration_ms: 0, updated_at: nowIso,
        }, { silent: true, headers: { Prefer: 'resolution=merge-duplicates' } });
=== bluebut-adapter blocked inserts pattern (grep) ===
135:    const health = await platformHealth().catch((e) => ({ ok: false, reachable: false, drivable: false,
145:      const result = { ok: false, status: 'blocked', aborted_reason: reason,
152:          op, access, account: kwargs.account || null, status: 'blocked', ok: false,
181:      const result = { ok: false, status: 'blocked', aborted_reason: reason, frozen: true,
189:          op, access, account: acct, status: 'blocked', ok: false,
223:      const result = { ok: false, status: 'blocked', aborted_reason: reason, account: acct, cmd: cmd.name, op,
229:          op, access, account: acct, status: 'blocked', ok: false,
258:      const result = { ok: false, status: 'blocked', aborted_reason: reason, account: acct, cmd: cmd.name, op,
264:          op, access, account: acct, status: 'blocked', ok: false,
389:            ok: false,
415:        result = { ...(result && typeof result === 'object' ? result : {}), ok: false, status: 'error', aborted_reason: `output failed schema: ${chk.error}`, output_contract_failed: true, raw_result: result };
427:    result = { ...(result && typeof result === 'object' ? result : {}), ok: false, status: 'error', aborted_reason: msg, error: msg };
464:  try { process.stdout.write(JSON.stringify({ ok: false, status, aborted_reason: msg, error: msg }) + '\n'); } catch {}
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== bluebut-adapter 380-395 ===\"; sed -n '380,395p' scripts/bluebut-adapter.ts; echo \"=== accounts-mint 40-55 ===\"; sed -n '40,55p' server/http/routes/accounts-mint.ts; echo \"=== accounts-mint 244-274 ===\"; sed -n '244,274p' server/http/routes/accounts-mint.ts; echo \"=== e2ee 82-90, 140-148 ===\"; sed -n '82,90p' adapters/_lib/e2ee-event-connect.ts; sed -n '140,148p' adapters/_lib/e2ee-event-connect.ts", "description": "Read remaining writers"}
=== bluebut-adapter 380-395 ===
      try {
        const e = await detectFbError(br.page);
        if (e && e.kind) {
          let diagnostic = null;
          try { diagnostic = await captureDiagnostic(br.page, { label: `runner-backstop: ${e.kind}` }); } catch { /* best-effort */ }
          const status = e.kind === 'unavailable' ? 'content_unavailable' : e.kind; // block | checkpoint
          emitStep(`⚠ runner backstop: FB ${e.kind} on final page — overriding ok:true → ok:false`);
          result = {
            ...result,
            ok: false,
            status,
            blocked: !!e.blocking,
            fb_error: e.kind,
            aborted_reason: `FB ${e.kind} on final page (runner backstop): ${e.text || 'interstitial'}`,
            diagnostic: diagnostic || result.diagnostic || null,
            runner_backstop: true,
=== accounts-mint 40-55 ===

const FB_LOGIN_URL = "https://www.facebook.com/login/device-based/regular/login/";
const CLOAK_VNC_URL = "https://cloak-vnc.hostbun.cc";

// A mint is a JOB — one deterministic ledger row per minted seed (mint-<seed>), so
// the onboarding shows up in Jobs/history like every other action. Upsert on
// job_id so start → observe → bank all update the same row. Best-effort.
async function writeMintJob(account: string, seed: string, patch: Row) {
  try {
    await dbRest("POST", "run", [{
      job_id: `mint-${seed}`, site: CLOAK_SERVICE, cmd: "mint", op: "mint", access: "write",
      account, target_type: "account", target_id: account, ...patch, updated_at: new Date().toISOString(),
    }] as any, { headers: { Prefer: "resolution=merge-duplicates,return=minimal" } } as any);
  } catch { /* ledger best-effort */ }
}

=== accounts-mint 244-274 ===
    catch (e: any) { observeErr = String(e?.message || e); }
    const hardWall = observed?.login?.hard_wall === true;
    await writeMintJob(account, seed, {
      status: hardWall ? "blocked" : "ok", ok: !hardWall,
      ended_at: new Date().toISOString(),
      summary: { banked: true, login: observed?.login ?? null, wiretap_session: observed?.session_id ?? null, ...(observeErr ? { observe_failed: observeErr } : {}) },
    });
    return { status: 200, body: { ok: true, account, seed, saved, observed } };
  } catch (e: any) {
    return { status: 502, body: { ok: false, error: `bank session failed (cdp_save): ${e?.message || e}` } };
  }
}

// POST /accounts/mint/observe {account,seed} — run the passive wires over the
// mint's wiretap session WITHOUT banking. This is how a checkpoint that BLOCKS
// login is seen + saved: the human never completes login (so never banks), but
// the wiretap already captured the /checkpoint/<id> + UFAC screen, so harvesting
// it burns the account (login-wire) and returns the verdict. Poll this from the
// dialog to show "logged in ✓" vs "checkpoint: video_selfie → burned".
export async function handleAccountMintObserve(b: Row): Promise<Resp> {
  const account = String(b.account || "").trim();
  const seed = String(b.seed || "").trim();
  if (!/^[a-z0-9][a-z0-9_-]{1,39}$/.test(account) || !seed) {
    return { status: 400, body: { ok: false, error: "account (valid slug) + seed are required" } };
  }
  try {
    const observed = await observeMintSeed(account, seed);
    if (observed?.login?.hard_wall) {
      await writeMintJob(account, seed, { status: "blocked", ok: false, ended_at: new Date().toISOString(), summary: { login: observed.login, wiretap_session: observed.session_id } });
    }
    return { status: 200, body: { ok: true, account, seed, ...observed } };
=== e2ee 82-90, 140-148 ===
}

// Write the event JOB row (running → terminal). Same ledger shape the runner writes.
async function ledger(job) {
  try {
    await rest('POST', 'run', job, { silent: true, headers: { Prefer: 'resolution=merge-duplicates' } });
  } catch { /* ledger best-effort */ }
}

  const enrolled = !!res?.registered;
  const endIso = new Date().toISOString();
  await ledger({
    job_id, site: 'messenger', cmd: 'e2ee-autoconnect', op: 'messenger.e2ee_autoconnect',
    access: 'write', account, target_type: 'account', target_id: account, target_name: account,
    status: ok ? 'ok' : 'failed', ok,
    aborted_reason: ok ? null : (err || 'stored-CAT + fetch-mint both failed — jar likely dead'),
    summary: { trigger: 'cat-harvest', mode, connected: ok, enrolled, wa_device_id: res?.wa_device_id || null },
    started_at: nowIso, ended_at: endIso, updated_at: endIso,
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== job-registry 120-145 ===\"; sed -n '120,145p' server/http/runtime/job-registry.mjs; echo \"=== job-registry 170-182 ===\"; sed -n '170,182p' server/http/runtime/job-registry.mjs; echo \"=== job-registry 240-270 ===\"; sed -n '240,270p' server/http/runtime/job-registry.mjs", "description": "Read job-registry ok usage"}
=== job-registry 120-145 ===
// Map is only the live in-flight supervisor. id = job_id (id-collapse: the ledger PK
// IS the bridge/UI id now — no separate bridge_job_id), so a still-running row
// reconciles with its live Map entry (same id → the UI dedupes active vs recent).
// Status is normalised back to the Map's {running|done} vocab (the ledger's lifecycle
// ok/failed/partial/blocked all render as done+`ok`), and ISO timestamps → epoch ms,
// so the wire contract the UI reads is unchanged.
async function recentFromLedger(limit, liveIds) {
  const cols = "job_id,batch_id,cmd,account,target_type,target_id,target_name,status,ok,started_at,ended_at,created_at";
  const rows = (await dbRest("GET", `run?select=${cols}&order=created_at.desc&limit=${limit}`, null, { silent: true })) || [];
  const ms = (s) => (s ? Date.parse(s) || null : null);
  const out = [];
  for (const r of rows) {
    const id = r.job_id;
    if (!id || (liveIds && liveIds.has(id))) continue;   // already shown live in active/queued
    out.push({
      id, account: r.account || null, kind: "job", cmd: r.cmd || null, label: r.cmd || "job",
      group: r.target_type === "group" ? r.target_id : null, groupName: r.target_name || null,
      traceJobId: r.job_id || null,
      batch: r.batch_id || null, batchLabel: null, batchSize: null, batchIndex: null,
      status: r.status === "running" ? "running" : "done", queuePos: 0,
      queuedAt: ms(r.created_at), startedAt: ms(r.started_at), endedAt: ms(r.ended_at),
      ok: r.ok, lastLine: null, browserSeed: null, logLen: 0,
    });
  }
  return out;
}
=== job-registry 170-182 ===
// threshold, newest first, each with its auto-filed GitHub issue number.
const FAIL_STUCK_MS = numEnv('JOB_REAP_AFTER_MS', 45 * 60_000);
async function jobFailures() {
  const since = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
  const stuckBefore = new Date(Date.now() - FAIL_STUCK_MS).toISOString();
  const cols = "job_id,batch_id,cmd,account,status,ok,aborted_reason,target_name,target_url,started_at,ended_at,summary";
  const q = `run?select=${cols}` +
    `&or=(and(status.in.(failed,blocked),created_at.gte.${encodeURIComponent(since)}),and(status.eq.running,started_at.lt.${encodeURIComponent(stuckBefore)}))` +
    `&order=created_at.desc&limit=200`;
  const rows = await dbRest("GET", q, null, { silent: true });
  const failures = (Array.isArray(rows) ? rows : []).map((r) => ({
    job_id: r.job_id, batch_id: r.batch_id || null, cmd: r.cmd, account: r.account || null,
    status: r.status === "running" ? "stuck" : r.status,
=== job-registry 240-270 ===
  const b = { label: job.batchLabel || id, size: Number(job.batchSize) || 0, ok: 0, failed: 0, done: 0, timer: null };
  BATCHES.set(id, b);
  notifyTelegram(`▶️ Batch · ${b.label}\nstarting${b.size ? ` · ${b.size} target${b.size === 1 ? "" : "s"}` : ""} (${id})`).catch(() => {});
}
function batchJobEnd(job) {
  const id = job.batch;
  const b = BATCHES.get(id);
  if (!b) return;
  b.done++;
  if (job.ok === true) b.ok++; else if (job.ok === false) b.failed++;
  if (b.timer) clearTimeout(b.timer);
  if (b.size && b.done >= b.size) { flushBatch(id); return; }
  b.timer = setTimeout(() => flushBatch(id), BATCH_IDLE_MS);
  if (b.timer.unref) b.timer.unref();
}

function notifyJobStart(job) {
  if (!tgJobsOn() || !job || job.util || (job.scheduled && !TG_SCHEDULED)) return;
  if (job.batch) { batchJobStart(job); return; }   // child → rolled into the batch alert, no per-job spam
  const who = job.account ? ` · ${job.account}` : "";
  notifyTelegram(`▶️ ${job.label || job.cmd || "job"}${who}\nstarted (${job.id})`).catch(() => {});
}
function notifyJobEnd(job) {
  if (!tgJobsOn() || !job || job.util || (job.scheduled && !TG_SCHEDULED)) return;
  if (job.batch) { batchJobEnd(job); return; }   // child → tallied into the batch summary
  const secs = job.startedAt && job.endedAt ? Math.round((job.endedAt - job.startedAt) / 1000) : null;
  const ok = job.ok === true;
  const icon = ok ? "✅" : job.ok === false ? "❌" : "☑️";
  const state = ok ? "ok" : job.ok === false ? "failed" : "done";
  const who = job.account ? ` · ${job.account}` : "";
  const tail = job.aborted_reason ? `\n${String(job.aborted_reason).slice(0, 200)}`
Now I'll make all the edits. Starting with the writers:

Jul 24, 03:16:49 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/scripts/_lib/adapter-finalize.ts", "old_string": " await dbRest('PATCH', `run?id=eq.${encodeURIComponent(ledgerJobId)}`, {\n status, ok,\n aborted_reason: r.aborted_reason ?? null,", "new_string": " await dbRest('PATCH', `run?id=eq.${encodeURIComponent(ledgerJobId)}`, {\n status, // issue #52: `run.ok` dropped \u2014 readers derive ok from status (okForStatus). `ok` (local) still gates the alerts/issue-file below.\n aborted_reason: r.aborted_reason ?? null,"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/scripts/_lib/adapter-finalize.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/health.mjs", "old_string": " { status: \"failed\", ok: false, aborted_reason: \"runner_died (no terminal update \u2014 reaped)\",\n ended_at: new Date().toISOString(), updated_at: new Date().toISOString() },", "new_string": " { status: \"failed\", aborted_reason: \"runner_died (no terminal update \u2014 reaped)\", // issue #52: run.ok dropped (derived from status)\n ended_at: new Date().toISOString(), updated_at: new Date().toISOString() },"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/health.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/health.mjs", "old_string": " { status: \"failed\", ok: false, aborted_reason: \"bridge_restart (orphaned running/claimed row \u2014 reaped on boot)\",\n ended_at: new Date().toISOString(), updated_at: new Date().toISOString() },", "new_string": " { status: \"failed\", aborted_reason: \"bridge_restart (orphaned running/claimed row \u2014 reaped on boot)\", // issue #52: run.ok dropped (derived from status)\n ended_at: new Date().toISOString(), updated_at: new Date().toISOString() },"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/health.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Now the four blocked-insert writers in bluebut-adapter.ts (all identical `status: 'blocked', ok: false,` in POST body):

Jul 24, 03:17:04 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; grep -n \"status: 'blocked', ok: false,\" scripts/bluebut-adapter.ts", "description": "Confirm exact matches"}
152:          op, access, account: kwargs.account || null, status: 'blocked', ok: false,
189:          op, access, account: acct, status: 'blocked', ok: false,
229:          op, access, account: acct, status: 'blocked', ok: false,
264:          op, access, account: acct, status: 'blocked', ok: false,
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/scripts/bluebut-adapter.ts", "old_string": " op, access, account: kwargs.account || null, status: 'blocked', ok: false,", "new_string": " op, access, account: kwargs.account || null, status: 'blocked', // issue #52: run.ok dropped (derived from status)"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/scripts/bluebut-adapter.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": true, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/scripts/bluebut-adapter.ts", "old_string": " op, access, account: acct, status: 'blocked', ok: false,", "new_string": " op, access, account: acct, status: 'blocked', // issue #52: run.ok dropped (derived from status)"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/scripts/bluebut-adapter.ts has been updated. All occurrences were successfully replaced. (file state is current in your context — no need to Read it back)
Now accounts-mint.ts (two writes) and e2ee-event-connect.ts:

Jul 24, 03:17:16 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/routes/accounts-mint.ts", "old_string": " status: hardWall ? \"blocked\" : \"ok\", ok: !hardWall,\n ended_at: new Date().toISOString(),", "new_string": " status: hardWall ? \"blocked\" : \"ok\", // issue #52: run.ok dropped (derived from status)\n ended_at: new Date().toISOString(),"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/routes/accounts-mint.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/routes/accounts-mint.ts", "old_string": " await writeMintJob(account, seed, { status: \"blocked\", ok: false, ended_at: new Date().toISOString(), summary: { login: observed.login, wiretap_session: observed.session_id } });", "new_string": " await writeMintJob(account, seed, { status: \"blocked\", ended_at: new Date().toISOString(), summary: { login: observed.login, wiretap_session: observed.session_id } }); // issue #52: run.ok dropped (derived from status)"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/routes/accounts-mint.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/adapters/_lib/e2ee-event-connect.ts", "old_string": " status: ok ? 'ok' : 'failed', ok,\n aborted_reason: ok ? null : (err || 'stored-CAT + fetch-mint both failed \u2014 jar likely dead'),", "new_string": " status: ok ? 'ok' : 'failed', // issue #52: run.ok dropped (derived from status)\n aborted_reason: ok ? null : (err || 'stored-CAT + fetch-mint both failed \u2014 jar likely dead'),"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/adapters/_lib/e2ee-event-connect.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now the readers. First `routes/jobs.ts` — derive from status:

Jul 24, 03:17:33 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; grep -n \"okForStatus\\|import\" server/routes/jobs.ts | head", "description": "Check okForStatus import in jobs.ts"}
3:import { Hono } from 'hono'
4:import { z } from 'zod'
5:import { streamSSE } from 'hono/streaming'
6:import { zValidator } from '@hono/zod-validator'
7:import { EnqueueRequest } from '../contract/job.ts'
8:import { catalogSummary } from '../jobs/catalog.ts'
9:import { enqueue, enqueueRaw, getJob, listJobs, isLedgerTerminal, okForStatus } from '../jobs/queue.ts'
10:import { subscribe } from '../jobs/job-stream.ts'
81:        await stream.writeSSE({ event: 'result', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok ?? okForStatus(job.status), result: job.result ?? null }) })
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/routes/jobs.ts", "old_string": " await stream.writeSSE({ event: 'status', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok }) })", "new_string": " await stream.writeSSE({ event: 'status', data: JSON.stringify({ job_id: id, status: job.status, ok: okForStatus(job.status) }) })"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/routes/jobs.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/routes/jobs.ts", "old_string": " await stream.writeSSE({ event: 'result', data: JSON.stringify({ job_id: id, status: job.status, ok: job.ok ?? okForStatus(job.status), result: job.result ?? null }) })", "new_string": " await stream.writeSSE({ event: 'result', data: JSON.stringify({ job_id: id, status: job.status, ok: okForStatus(job.status), result: job.result ?? null }) })"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/routes/jobs.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now the `queue.ts` JobRow type field:

Jul 24, 03:17:46 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue.ts", "old_string": " status: string\n input?: unknown\n batch_id?: string | null\n ok?: boolean | null\n [k: string]: unknown", "new_string": " status: string\n input?: unknown\n batch_id?: string | null\n // issue #52: `ok` column dropped \u2014 derive it from `status` via okForStatus, never read it off the row.\n [k: string]: unknown"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now `job-registry.mjs` — remove `ok` from the two select lists and derive in the DTO. Let me add a local helper and update:

Jul 24, 03:17:51 PM

Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs", "offset": 180, "limit": 15}
180	  const failures = (Array.isArray(rows) ? rows : []).map((r) => ({
181	    job_id: r.job_id, batch_id: r.batch_id || null, cmd: r.cmd, account: r.account || null,
182	    status: r.status === "running" ? "stuck" : r.status,
183	    reason: r.aborted_reason || null,
184	    target: r.target_name || r.target_url || null,
185	    started_at: r.started_at || null, ended_at: r.ended_at || null,
186	    github_issue: r.summary && typeof r.summary === "object" ? (r.summary.github_issue ?? null) : null,
187	  }));
188	  return { ok: true, failures };
189	}
190	
191	// Operator stop: kill a job by id. Running → the installed cancel() SIGKILLs the
192	// child (its close handler then writes the terminal [RESULT]/[ERROR] + ends the
193	// SSE). Queued → cancel() drops it from the account queue and closes its stream.
194	// Already finished → no-op. Returns {code, ok, ...} for the HTTP layer.
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; sed -n '1,20p' server/http/runtime/job-registry.mjs | grep -n \"import\"", "description": "Check job-registry imports"}
6:import { notifyTelegram } from "../../../adapters/_lib/notify/tg-notify.ts";
7:import { rest as dbRest } from "../../../adapters/_lib/db/db.ts";
8:import { mintJobId } from "../../jobs/job-id.ts";
9:import { API_TOKEN, rememberCloakCdp } from "../lib.mjs";
10:import { envFile } from "./spawn-env.mjs";
11:import { numEnv } from "../../../adapters/_lib/num-env.ts";
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs", "old_string": "import { mintJobId } from \"../../jobs/job-id.ts\";", "new_string": "import { mintJobId } from \"../../jobs/job-id.ts\";\nimport { okForStatus } from \"../../jobs/queue.ts\"; // issue #52: run.ok dropped \u2014 derive ok from status"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs", "old_string": " const cols = \"job_id,batch_id,cmd,account,target_type,target_id,target_name,status,ok,started_at,ended_at,created_at\";", "new_string": " const cols = \"job_id,batch_id,cmd,account,target_type,target_id,target_name,status,started_at,ended_at,created_at\"; // issue #52: run.ok dropped"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs", "old_string": " ok: r.ok, lastLine: null, browserSeed: null, logLen: 0,", "new_string": " ok: okForStatus(r.status), lastLine: null, browserSeed: null, logLen: 0, // issue #52: derived from status (run.ok dropped)"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs", "old_string": " const cols = \"job_id,batch_id,cmd,account,status,ok,aborted_reason,target_name,target_url,started_at,ended_at,summary\";", "new_string": " const cols = \"job_id,batch_id,cmd,account,status,aborted_reason,target_name,target_url,started_at,ended_at,summary\"; // issue #52: run.ok dropped (unused in the mapped DTO below)"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/job-registry.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Now `analyze-job.ts`:

Jul 24, 03:18:23 PM

Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/scripts/analyze-job.ts", "offset": 140, "limit": 40}
140	const { traceId, via, bridge } = await resolveTraceId(JOB);
141	
142	const ledgerRows = await rest(HB_URL, HB_KEY,
143	  `run?id=eq.${encodeURIComponent(traceId)}&select=*&limit=1`);
144	const ledger = ledgerRows && ledgerRows[0] || null;
145	
146	const harKey = (ledger && ledger.har_key) || `job/${traceId}.har.gz`;
147	
148	const har = await fetchHar(harKey);
149	const harA = har ? analyzeHar(har) : null;
150	
151	// ── verdict: CONFIRMED vs UNCONFIRMED ────────────────────────────────────────
152	const confirmed = [];
153	const unconfirmed = [];
154	const signals = [];
155	
156	if (ledger) {
157	  if (ledger.ok === true) confirmed.push(`ledger: terminal status='${ledger.status}', duration ${ledger.duration_ms}ms`);
158	  else unconfirmed.push(`ledger ok=${ledger.ok} status='${ledger.status}'${ledger.aborted_reason ? ` (${ledger.aborted_reason})` : ''}`);
159	} else {
160	  unconfirmed.push(`NO ledger row for ${traceId} — the runner never wrote it (job died pre-write, or .env/bofrid key missing on the runner).`);
161	}
162	if (harA) {
163	  confirmed.push(`HAR present: ${harA.entries} entries, ${harA.ws_frames} WS frames (${harA.ws_sockets} sockets), ${harA.graphql.length} graphql calls, ${harA.ls_payloads} LightSpeed payloads`);
164	  if (harA.ws_frames === 0) unconfirmed.push('0 WebSocket frames in HAR — if this job touched Messenger/MQTT, the browser-level WS tap may not have attached (or there was genuinely no WS traffic).');
165	  if (harA.e2ee_signals.length) confirmed.push(`E2EE/restore evidence on the wire: ${harA.e2ee_signals.join(', ')}`);
166	  for (const e of harA.errors.slice(0, 10)) signals.push(`HTTP ${e.status} ${e.url}`);
167	} else {
168	  unconfirmed.push(`NO HAR blob at ${harKey} — nothing lossless to confirm against.`);
169	}
170	
171	const report = {
172	  input: JOB, traceId, resolvedVia: via,
173	  bridge: bridge ? { id: bridge.id, cmd: bridge.cmd, account: bridge.account, status: bridge.status, ok: bridge.ok } : null,
174	  ledger: ledger ? { cmd: ledger.cmd, account: ledger.account, status: ledger.status, ok: ledger.ok, op: ledger.op, target: `${ledger.target_type || ''}:${ledger.target_id || ''}`, summary: ledger.summary, har_key: ledger.har_key, duration_ms: ledger.duration_ms } : null,
175	  har: harA, confirmed, unconfirmed, signals,
176	};
177	
178	if (JSON_OUT) { console.log(JSON.stringify(report, null, 2)); process.exit(0); }
179
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/scripts/analyze-job.ts", "old_string": "if (ledger) {\n if (ledger.ok === true) confirmed.push(`ledger: terminal status='${ledger.status}', duration ${ledger.duration_ms}ms`);\n else unconfirmed.push(`ledger ok=${ledger.ok} status='${ledger.status}'${ledger.aborted_reason ? ` (${ledger.aborted_reason})` : ''}`);\n} else {", "new_string": "// issue #52: run.ok column dropped \u2014 derive the boolean from `status` (ok/done/completed\n// = success; any other terminal = false; queued/claimed/running = undecided/null).\nconst okFromStatus = (s) => ['ok', 'done', 'completed'].includes(s) ? true\n : (s && !['created', 'queued', 'claimed', 'running'].includes(s)) ? false : null;\nif (ledger) {\n const ledgerOk
The file /home/philip/Documents/GitHub/bluebut-issue-52/scripts/analyze-job.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/scripts/analyze-job.ts", "old_string": " ledger: ledger ? { cmd: ledger.cmd, account: ledger.account, status: ledger.status, ok: ledger.ok, op: ledger.op,", "new_string": " ledger: ledger ? { cmd: ledger.cmd, account: ledger.account, status: ledger.status, ok: okFromStatus(ledger.status), op: ledger.op,"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/scripts/analyze-job.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now the UI reads. Let me read the relevant lines:

Jul 24, 03:18:45 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== queryKeys 172-182 ===\"; sed -n '172,182p' ui/src/lib/util/queryKeys.ts; echo \"=== GroupPostView 70-78, 190-200 ===\"; sed -n '70,78p' ui/src/pages/broadcast/GroupPostView.tsx; sed -n '190,200p' ui/src/pages/broadcast/GroupPostView.tsx; echo \"=== db-types.gen 1115-1122 ===\"; sed -n '1115,1122p' ui/src/api/db-types.gen.ts", "description": "Read UI ok reads"}
=== queryKeys 172-182 ===
  groupPostsForAccount:      (account: string) => ({ queryKey: ["groupPostsFor", account],    queryFn: () => dbGet<GroupPost>("group_post", { account: `eq.${account}`, select: "id,fb_post_id,permalink_url,message,fb_group_id,group_name,account,status,skip_reason,created_at,posted_as,as_page_id", order: "created_at.desc", limit: "500" }) }),
  // The whole durable `job` ledger (most-recent N), agnostic across every
  // target/account/site — the source for the Jobs page's History & analytics
  // view (the durable counterpart to the backend's wiped-on-restart in-memory
  // list). Capped high enough to chart trends; indexed (created_at DESC).
  allJobs: (limit = 5000) => ({ queryKey: ["allJobs", limit], queryFn: () => dbGet<Job>("run", { select: "job_id,batch_id,site,cmd,op,access,account,target_type,target_id,target_url,target_name,status,ok,aborted_reason,summary,started_at,ended_at,duration_ms,har_key,created_at", order: "created_at.desc", limit: String(limit) }) }),
  // Per-group job rollup (count + last date) for the Groups index columns.
  groupJobStats: () => ({ queryKey: ["groupJobStats"], queryFn: () => dbGet<GroupJobStat>("group_job_stats", { select: "fb_group_id,jobs_count,last_job_at" }) }),
  // Per-group rollup of the posts we made (group_post, status='posted') for the
  // Groups index "Our posts" / "Last post" columns.
  groupPostStats: () => ({ queryKey: ["groupPostStats"], queryFn: () => dbGet<GroupPostStat>("group_post_stats", { select: "fb_group_id,posts_count,last_post_at,listings_count,last_listing_at" }) }),
=== GroupPostView 70-78, 190-200 ===
  // The job that made it — its verdict, not just its id.
  const jobQ = useQuery<Job | null>({
    queryKey: ["job", p?.job_id],
    enabled: !!p?.job_id,
    queryFn: async () => (await dbGet<Job>("run", { job_id: `eq.${p!.job_id}`, select: "*", limit: "1" }))[0] ?? null,
  });
  // The scraped mirror of our own post. Joined on the NUMERIC fb post id, never on the
  // permalink string: FB serves the same story as both /permalink/<id>/ and /posts/<id>/,
  // so a url equality test misses half of them.
          note={neverRan > 0 ? `${neverRan} target${neverRan === 1 ? "" : "s"} never ran` : undefined}
        />
        {Arrow}
        <Rung
          label="JOB"
          value={p.job_id ? (jobQ.data?.status ?? p.job_id.slice(0, 12)) : "—"}
          to={p.job_id ? `/runs/${p.job_id}` : undefined}
          tone={jobQ.data && !jobQ.data.ok ? "var(--color-danger-fg)" : undefined}
        />
        {Arrow}
        <Rung label="MIRROR" value={mirrorId ? "scraped fb_post" : "not scraped"} to={mirrorId ? `/post/${mirrorId}` : undefined} />
=== db-types.gen 1115-1122 ===
  target_id: string | null;
  target_url: string | null;
  target_name: string | null;
  status: string | null;
  ok: boolean | null;
  aborted_reason: string | null;
  summary: unknown | null;
  started_at: string | null;
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; grep -n \"export function\\|export const\" ui/src/lib/taxonomy.ts | grep -i \"ok\\|status\\|fail\\|rollup\\|terminal\\|success\" | head; echo \"=== phaseFromStatus def ===\"; grep -rn \"phaseFromStatus\" ui/src/components/AccountJobs.tsx | head -2", "description": "Find UI status helpers"}
14:export const TERMINAL: ReadonlySet<Lifecycle> = new Set<Lifecycle>([
17:export const isFinal = (s: Lifecycle | string): boolean => TERMINAL.has(s as Lifecycle);
31:export function classify(richStatus: string | null | undefined): Lifecycle {
46:export function rollup(childStatuses: (string | null | undefined)[], opts: { aborted?: boolean } = {}): Lifecycle {
71:export function batchRollup(
93:export const lifecycleTone: Record<Lifecycle, "ok" | "warn" | "bad" | "run" | "muted"> = {
=== phaseFromStatus def ===
ui/src/components/AccountJobs.tsx:33:function phaseFromStatus(status: string | null | undefined, ok: boolean | null | undefined): Phase {
ui/src/components/AccountJobs.tsx:123:        phase: live ? phaseOfBridge(live) : phaseFromStatus(j.status, j.ok),
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/ui/src/components/AccountJobs.tsx", "offset": 25, "limit": 105}
25	type Phase = "running" | "queued" | "ok" | "failed";
26	const COLOR: Record<Phase, string> = { running: "var(--color-success)", queued: "var(--color-warning)", ok: "var(--color-info-fg)", failed: "var(--color-danger-fg)" };
27	const PHASE_LABEL: Record<Phase, string> = { running: "Running", queued: "Queued", ok: "Done", failed: "Failed" };
28	
29	// Durable-ledger phase — derive from `status` (the authoritative field the single
30	// funnel writes), NOT the retiring `run.ok` boolean (#295: read-side off run.ok).
31	// Any terminal status that isn't `ok` is a failure; `ok` is only a fallback for a
32	// row with no status at all (never the case for a durable row).
33	function phaseFromStatus(status: string | null | undefined, ok: boolean | null | undefined): Phase {
34	  if (status === "running") return "running";
35	  if (status === "queued") return "queued";
36	  if (status === "ok") return "ok";
37	  if (status) return "failed";
38	  return ok === false ? "failed" : "ok";
39	}
40	function phaseOfBridge(j: BridgeJob): Phase {
41	  if (j.status === "running") return "running";
42	  if (j.status === "queued") return "queued";
43	  return j.ok === false ? "failed" : "ok";
44	}
45	
46	function ago(ts: number | null): string {
47	  if (!ts) return "";
48	  const s = Math.round((Date.now() - ts) / 1000);
49	  if (s < 60) return `${s}s ago`;
50	  if (s < 3600) return `${Math.round(s / 60)}m ago`;
51	  if (s < 86400) return `${Math.round(s / 3600)}h ago`;
52	  return `${Math.round(s / 86400)}d ago`;
53	}
54	const ms = (iso: string | null | undefined): number => (iso ? Date.parse(iso) || 0 : 0);
55	
56	// One unified row, whether it came from the durable ledger, live bridge, or both.
57	interface Row {
58	  key: string;          // job_id (the stable, durable id) — DataGrid row id
59	  bridgeId?: string;    // bridge job id, when a live/recent run matches → deep-link target
60	  label: string;
61	  cmd: string | null;
62	  op: string | null;
63	  phase: Phase;
64	  at: number;           // ms, sort + "ago"
65	  durationMs: number | null;
66	  live: boolean;
67	  queuePos: number | null;
68	  harKey: string | null; // durable HAR present → offer download
69	}
70	
71	// One row's HAR button — opens the in-app HAR explorer (DevTools-style request
72	// list + Headers/Cookies/Payload/Response/Timing) for this job. The viewer
73	// itself keeps a "Download .har" escape hatch for opening in real Chrome
74	// DevTools. stopPropagation so clicking it doesn't also trigger the row's
75	// navigate-to-Job-detail.
76	function HarCell({ jobId }: { jobId: string }) {
77	  const [open, setOpen] = useState(false);
78	  return (
79	    <>
80	      <Button
81	        variant="secondary"
82	        title="Explore this job's full HAR — every request, header, cookie & body — in the dashboard"
83	        onClick={(e) => { e.stopPropagation(); setOpen(true); }}
84	        style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", fontSize: 11 }}
85	      >
86	        <FileSearch size={12} />
87	        HAR
88	      </Button>
89	      {open && <HarViewer jobId={jobId} open={open} onClose={() => setOpen(false)} />}
90	    </>
91	  );
92	}
93	
94	export function AccountJobs({ account }: { account: string }) {
95	  // Durable history (survives bridge restarts).
96	  const { data: ledger, isError: ledgerErr } = useQuery<Job[]>({ ...Q.jobsForAccount(account), refetchInterval: 5000, retry: false });
97	  // Live overlay (instant status for in-flight runs).
98	  const { data: bridge } = useQuery<JobsSnapshot>({ queryKey: ["bridge-jobs"], queryFn: fetchBridgeJobs, refetchInterval: (q) => bridgeJobsRefetch(q.state.data), retry: false });
99	
100	  const rows = useMemo<Row[]>(() => {
101	    // Index live bridge jobs by the id that threads to the runner (traceJobId =
102	    // the UI/runner job_id = the ledger's job_id), falling back to the backend id.
103	    const liveAll = [...(bridge?.active ?? []), ...(bridge?.queued ?? []), ...(bridge?.recent ?? [])]
104	      .filter((j) => j.account === account);
105	    const liveByJobId = new Map<string, BridgeJob>();
106	    for (const j of liveAll) liveByJobId.set(j.traceJobId || j.id, j);
107	
108	    const byKey = new Map<string, Row>();
109	
110	    // Durable rows first — the authoritative history.
111	    for (const j of ledger ?? []) {
112	      const live = liveByJobId.get(j.job_id);
113	      // job_id is typed non-null, but a run-ledger row can arrive with a null
114	      // job_id (e.g. the half-applied #305 job→run rename); synthesize a stable
115	      // unique key so the DataGrid always has a valid row id (MUI X error #85).
116	      const k = j.job_id || live?.id || `run:${j.op ?? j.cmd ?? "job"}:${ms(j.ended_at) || ms(j.started_at) || ms(j.created_at)}`;
117	      byKey.set(k, {
118	        key: k,
119	        bridgeId: live?.id,
120	        label: live?.label || `${j.site ? `${j.site}/` : ""}${j.cmd}`,
121	        cmd: j.cmd,
122	        op: j.op,
123	        phase: live ? phaseOfBridge(live) : phaseFromStatus(j.status, j.ok),
124	        at: ms(j.ended_at) || ms(j.started_at) || ms(j.created_at),
125	        durationMs: j.duration_ms ?? null,
126	        live: live ? live.status === "running" || live.status === "queued" : j.status === "running",
127	        queuePos: live?.queuePos ?? null,
128	        harKey: j.har_key ?? null,
129	      });
Critical: any PostgREST `select=` that names `ok` will 400 once the column is dropped. Let me find every explicit select of `ok` on run/job:

Jul 24, 03:19:28 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; grep -rn \"status,ok\\|ok,aborted\\|,ok,\" --include=*.ts --include=*.tsx --include=*.mjs ui server scripts adapters | grep -iv \"n_ok\\|book\"; echo \"=== jobsForAccount select ===\"; grep -n \"jobsForAccount\" ui/src/lib/util/queryKeys.ts", "description": "Find all selects naming ok"}
ui/src/pages/MemberRequests.tsx:87:      select: "job_id,account,target_id,target_name,status,ok,summary,created_at,duration_ms",
ui/src/lib/util/queryKeys.ts:177:  allJobs: (limit = 5000) => ({ queryKey: ["allJobs", limit], queryFn: () => dbGet<Job>("run", { select: "job_id,batch_id,site,cmd,op,access,account,target_type,target_id,target_url,target_name,status,ok,aborted_reason,summary,started_at,ended_at,duration_ms,har_key,created_at", order: "created_at.desc", limit: String(limit) }) }),
ui/src/pages/Overview.tsx:61:      select: "job_id,cmd,op,account,target_type,target_id,target_name,status,ok,created_at",
=== jobsForAccount select ===
166:  jobsForAccount: (account: string) => ({ queryKey: ["jobsForAccount", account], queryFn: () => dbGet<Job>("run", { account: `eq.${account}`, order: "created_at.desc", limit: "500" }) }),
Two more the inventory missed. Let me remove `ok` from all three UI selects and check their `.ok` reads:

Jul 24, 03:19:36 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== MemberRequests .ok reads ===\"; grep -n \"\\.ok\\b\" ui/src/pages/MemberRequests.tsx; echo \"=== Overview .ok reads ===\"; grep -n \"\\.ok\\b\" ui/src/pages/Overview.tsx", "description": "Check .ok reads in these pages"}
=== MemberRequests .ok reads ===
=== Overview .ok reads ===
165:        dbCount("run", { cmd: "eq.approve-join-request", status: "eq.ok", created_at: `gte.${day}` }),
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/ui/src/lib/util/queryKeys.ts", "old_string": "select: \"job_id,batch_id,site,cmd,op,access,account,target_type,target_id,target_url,target_name,status,ok,aborted_reason,summary,started_at,ended_at,duration_ms,har_key,created_at\", order: \"created_at.desc\", limit: String(limit) }) }),", "new_string": "select: \"job_id,batch_id,site,cmd,op,access,account,target_type,target_id,target_url,target_name,status,aborted_reason,summary,started_at,ended_at,duration_ms,har_key,created_at\", order: \"created_at.desc\", limit: String(limit) }) }), // issue #52: run.ok dropped \u2014 readers derive ok from status"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/ui/src/lib/util/queryKeys.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/MemberRequests.tsx", "old_string": " select: \"job_id,account,target_id,target_name,status,ok,summary,created_at,duration_ms\",", "new_string": " select: \"job_id,account,target_id,target_name,status,summary,created_at,duration_ms\", // issue #52: run.ok dropped"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/MemberRequests.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/Overview.tsx", "old_string": " select: \"job_id,cmd,op,account,target_type,target_id,target_name,status,ok,created_at\",", "new_string": " select: \"job_id,cmd,op,account,target_type,target_id,target_name,status,created_at\", // issue #52: run.ok dropped"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/Overview.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
Now the GroupPostView tone (derive from status via taxonomy). Let me check its imports and taxonomy:

Jul 24, 03:20:01 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; sed -n '31,45p' ui/src/lib/taxonomy.ts; echo \"=== GroupPostView imports of taxonomy ===\"; grep -n \"taxonomy\\|classify\\|isFinal\" ui/src/pages/broadcast/GroupPostView.tsx | head", "description": "Check taxonomy classify and GroupPostView imports"}
export function classify(richStatus: string | null | undefined): Lifecycle {
  const s = String(richStatus ?? "").toLowerCase();
  if (!s) return "completed";
  if (s === "running" || s === "queued" || s === "created") return s as Lifecycle;
  if (s === "pending") return "running";
  if (TERMINATED_RICH.has(s)) return "terminated";
  if (FAILED_RICH.has(s)) return "failed";
  if (s === "timed_out" || s === "canceled" || s === "terminated") return s as Lifecycle;
  if (s === "warn") return "completed";
  if (s.endsWith("_failed")) return "failed";
  return "completed";
}

// Aggregate a Batch's lifecycle from its jobs (mirror of taxonomy.js rollup()).
// Mixed → "partial"; aborted-with-nothing-good → "terminated".
=== GroupPostView imports of taxonomy ===
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/ui/src/lib/taxonomy.ts", "offset": 93, "limit": 6}
93	export const lifecycleTone: Record<Lifecycle, "ok" | "warn" | "bad" | "run" | "muted"> = {
94	  completed: "ok",
95	  partial: "warn",
96	  terminated: "warn",
97	  failed: "bad",
98	  canceled: "muted",
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; sed -n '1,15p' ui/src/pages/broadcast/GroupPostView.tsx | grep -n \"import\"", "description": "GroupPostView imports"}
(Bash completed with no output)
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; grep -n \"^import\" ui/src/pages/broadcast/GroupPostView.tsx | head -20", "description": "GroupPostView import lines"}
18:import { useMemo } from "react";
19:import { useQuery } from "@tanstack/react-query";
20:import { Alert, AlertTitle, Box, Chip, Divider, Link, Skeleton, Stack, Typography } from "@mui/material";
21:import { PageHeader } from "../../components/PageHeader";
22:import { ReadField } from "../../components/ui/fields";
23:import { PageLink, GroupLink } from "../../components/EntityLinks";
24:import { dbGet, fetchWorkflowRun, type DbWorkflowRun, type GroupPost, type Job } from "../../api";
25:import { Q, posterLabel } from "../../lib/util";
26:import { StatusChip } from "../../components/StatusChip";
27:import { useCloakboxOpener } from "../../components/OpenInCloakbox";
28:import { fmtStamp } from "./listing";
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/broadcast/GroupPostView.tsx", "old_string": "import { fmtStamp } from \"./listing\";", "new_string": "import { fmtStamp } from \"./listing\";\nimport { classify } from \"../../lib/taxonomy\"; // issue #52: run.ok dropped \u2014 derive the failure tone from status"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/broadcast/GroupPostView.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/broadcast/GroupPostView.tsx", "old_string": " tone={jobQ.data && !jobQ.data.ok ? \"var(--color-danger-fg)\" : undefined}", "new_string": " tone={jobQ.data?.status && ![\"completed\", \"running\", \"queued\", \"created\"].includes(classify(jobQ.data.status)) ? \"var(--color-danger-fg)\" : undefined}"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/ui/src/pages/broadcast/GroupPostView.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
Now update the Drizzle schema SoT (must match the dropped column to avoid migration drift) and do a final sweep for any remaining `ok` selects/writes on the run table:

Jul 24, 03:21:11 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/db/schema.ts", "old_string": "\tstatus: text().notNull(),\n\tok: boolean(),\n\taborted_reason: text(),", "new_string": "\tstatus: text().notNull(),\n\t// issue #52: the legacy `ok` boolean was DROPPED (migration entity/0054_drop_run_ok) \u2014 every\n\t// reader rolls it up from `status` (server/jobs/queue.ts okForStatus, ui/src/lib/taxonomy.ts).\n\taborted_reason: text(),"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/db/schema.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== any run/job select still naming ok ===\"; grep -rEn \"select[=:][^\\\"']*[\\\"'][^\\\"']*\\bok\\b\" --include=*.ts --include=*.tsx --include=*.mjs ui server scripts adapters | grep -i \"ok\" | grep -v \"n_ok\\|status.*eq.*ok\\|book\" | grep -iE \"run|job|,ok,|ok,|,ok\\b\" | head; echo \"=== any remaining 'ok:' or 'ok,' in a POST/PATCH run body ===\"; grep -rn \"ok: false\\|ok: true\\|ok: !\\|, ok,\" --include=*.ts --include=*.mjs server scripts adapters | grep -iv \"result\\|res\\.\\|return\\|c.json\\|kvJson\\|j(4\\|j(2\\|emit\\|body: { ok\\|{ ok: true, service\" | head", "description": "Final sweep for lingering ok selects/writes"}
=== any run/job select still naming ok ===
=== any remaining 'ok:' or 'ok,' in a POST/PATCH run body ===
server/http/runtime/health.mjs:166:      ok: true, probed_at: Date.now(), total: probed.length, live,
server/http/runtime/health.mjs:190:      ok: true, ...cfg,
server/http/routes/store.ts:124:    ok: true,
server/http/runtime/scheduler.mjs:114:      child.on("error", (e) => { clearTimeout(killTimer); job.ok = false; resolve({ ok: false, jobId, error: e.message }); done(); });
server/http/routes/cloakbox.ts:68:    op: "read", access: "auth", account: slug, status: "ok", ok: true,
server/http/routes/cloakbox.ts:196:            ok: false,
server/http/routes/cloakbox.ts:231:      ok: true, account, slug, seed, url, reused, navigated,
server/http/routes/api-v1.ts:222:    ok: true,
server/jobs/queue-status.test.mjs:15:  for (const r of [{ ok: true }, { ok: false }, { ok: false, status: 'skipped' },
server/jobs/queue-status.test.mjs:16:    { ok: false, status: 'blocked' }, { ok: true, status: 'partial' },
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/routes/cloakbox.ts", "offset": 58, "limit": 18}
58	// every later velocity gate (the "uncounted" hole). Terminal status so it never
59	// holds the one-active-per-account lane. Reuse-navigations open no new session →
60	// no row (that call site never runs this).
61	export function uiOpenLedgerRow(
62	  slug: string,
63	  url: string,
64	  { jobId, nowIso = new Date().toISOString() }: { jobId?: string; nowIso?: string } = {},
65	) {
66	  return {
67	    job_id: jobId || `uiopen-${slug}-${Date.now()}`, site: "facebook", cmd: "ui-open",
68	    op: "read", access: "auth", account: slug, status: "ok", ok: true,
69	    summary: { ui_open: true, url }, started_at: nowIso, ended_at: nowIso,
70	    duration_ms: 0, updated_at: nowIso,
71	  };
72	}
73	
74	interface CloakboxDeps {
75	  liveInstances: () => Promise<Array<Record<string, unknown>>>;
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/routes/cloakbox.ts", "old_string": " op: \"read\", access: \"auth\", account: slug, status: \"ok\", ok: true,", "new_string": " op: \"read\", access: \"auth\", account: slug, status: \"ok\", // issue #52: run.ok dropped (derived from status)"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/http/routes/cloakbox.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== all POST/PATCH to run (to eyeball for ok) ===\"; grep -rn \"'PATCH', 'run\\|\\\"PATCH\\\", \\\"run\\|'POST', 'run\\|\\\"POST\\\", \\\"run\\|POST\\\", 'run\\|PATCH\\\", 'run\\|'PATCH', \\`run\\|\\\"PATCH\\\", \\`run\\|'POST', \\`run\\|\\\"POST\\\", \\`run\" --include=*.ts --include=*.mjs server scripts adapters | head -40", "description": "List all run writes"}
=== all POST/PATCH to run (to eyeball for ok) ===
server/http/routes/cloakbox.ts:210:        await dbRest("POST", "run", uiOpenLedgerRow(slug, url) as unknown as Parameters<typeof dbRest>[2], { silent: true });
server/http/routes/accounts-mint.ts:49:    await dbRest("POST", "run", [{
server/http/runtime/health.mjs:272:          .then((num) => { if (num) dbRest("PATCH", `run?id=eq.${encodeURIComponent(r.id)}`, { summary: { github_issue: num } }, { silent: true }).catch(() => {}); })
server/jobs/queue.ts:90:  await rest('POST', 'run', {
server/jobs/queue.ts:132:  await rest('POST', 'run', {
server/jobs/queue.ts:209:  await rest('PATCH', `run?id=eq.${encodeURIComponent(job_id)}&status=in.(${LEDGER_ACTIVE.join(',')})`,
scripts/bluebut-adapter.ts:150:        await dbRest('POST', 'run', {
scripts/bluebut-adapter.ts:187:        await dbRest('POST', 'run', {
scripts/bluebut-adapter.ts:227:        await dbRest('POST', 'run', {
scripts/bluebut-adapter.ts:262:        await dbRest('POST', 'run', {
scripts/bluebut-adapter.ts:339:    await dbRest('POST', 'run', { ...ledgerBase, status: 'running', started_at: new Date(t0).toISOString() },
server/http/routes/bridge.ts:344:      dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(id)}`,
server/http/routes/bridge.ts:357:    dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(id)}`,
server/http/routes/bridge.ts:361:      .then((r: { ok: boolean }) => dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(id)}`, { last_run_status: r.ok ? "ok" : "failed", updated_at: new Date().toISOString() } as unknown as Parameters<typeof dbRest>[2], { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {}))
scripts/_lib/adapter-finalize.ts:411:    await dbRest('PATCH', `run?id=eq.${encodeURIComponent(ledgerJobId)}`, {
adapters/_lib/e2ee-event-connect.ts:87:    await rest('POST', 'run', job, { silent: true, headers: { Prefer: 'resolution=merge-duplicates' } });
adapters/_lib/post-login-orchestrator.ts:53:  try { await rest('POST', 'run', job, { silent: true, headers: { Prefer: 'resolution=merge-duplicates' } }); }
server/http/runtime/scheduler.mjs:287:            return dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
server/http/runtime/scheduler.mjs:347:          dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
server/http/runtime/scheduler.mjs:354:          .then(() => dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
server/http/runtime/scheduler.mjs:357:          .catch((e) => { console.error(`[backend] schedule ${s.schedule_id} enqueue error: ${e?.message || e}`); return dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`, { last_run_status: "failed", updated_at: new Date().toISOString() }, { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {}); })
server/http/runtime/scheduler.mjs:364:        .then((res) => dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== post-login-orchestrator job ok? ===\"; grep -n \"ok\" adapters/_lib/post-login-orchestrator.ts | head; echo \"=== ledgerBase def ===\"; grep -n \"ledgerBase\" scripts/bluebut-adapter.ts", "description": "Check post-login and ledgerBase for ok"}
=== post-login-orchestrator job ok? ===
10://     (type-login.ts), synchronous + operator-invoked, and STOPPED at save.
14:// wire) produced an fb_logins row and nothing else — cookies unbanked past the
21://   2. bank cookies (saveProfile) — best-effort, a no-op off a live window,
22://   3. arm e2ee if usync is down — reuses the health-gated on-load hook
107:      job_id: key, site: 'facebook', cmd: 'post-login-ready', op: 'facebook.post_login_ready',
118:    // 2. BANK cookies — saveProfile is idempotent + a no-op off a live window (returns
123:    // 3. ARM e2ee — the SAME health-gated on-load hook: a live usync socket ⇒ silent
135:    // cookie-bank are best-effort environmentals — recorded, but they don't gate ready.
139:      job_id: key, site: 'facebook', cmd: 'post-login-ready', op: 'facebook.post_login_ready',
141:      status: steps.stamped ? (ready ? 'ok' : 'partial') : 'failed', ok: ready,
=== ledgerBase def ===
327:  const ledgerBase = {
339:    await dbRest('POST', 'run', { ...ledgerBase, status: 'running', started_at: new Date(t0).toISOString() },
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; sed -n '139,143p' adapters/_lib/post-login-orchestrator.ts; echo \"--- also line 107 block ---\"; sed -n '105,112p' adapters/_lib/post-login-orchestrator.ts; echo \"--- ledgerBase 327-340 ---\"; sed -n '327,340p' scripts/bluebut-adapter.ts", "description": "View post-login and ledgerBase"}
job_id: key, site: 'facebook', cmd: 'post-login-ready', op: 'facebook.post_login_ready',
      access: 'write', account, target_type: 'account', target_id: account, target_name: account,
      status: steps.stamped ? (ready ? 'ok' : 'partial') : 'failed', ok: ready,
      aborted_reason: ready ? null : (steps.stamp_error || `wiretap ${steps.wiretap?.reason || 'unconfirmed'}`),
      summary: { trigger: 'login-event', fb_id: ev.fb_id || null, ready, ...steps },
--- also line 107 block ---
  try {
    await ledger({
      job_id: key, site: 'facebook', cmd: 'post-login-ready', op: 'facebook.post_login_ready',
      access: 'write', account, target_type: 'account', target_id: account, target_name: account,
      status: 'running', summary: { trigger: 'login-event', fb_id: ev.fb_id || null, login_job: ctx.job_id || null },
      started_at: startIso, updated_at: startIso,
    });

--- ledgerBase 327-340 ---
  const ledgerBase = {
    id: ledgerJobId, workflow_run_id: batch_id, site: cmd.site || null, cmd: cmd.name, op, access,
    account: account === 'na' ? null : account,
    target_type: target.type, target_id: target.id, target_url: target.url, target_name: target.name,
    // Soft join to the engine store (id-collapse). cloak_runs lives in the SEPARATE
    // cloak Postgres (CLOAKBOX_DATABASE_URL) → no cross-DB FK; we carry the run id as
    // a plain column so a job row links to the exact browser launch that drove it
    // (its HAR/captures/egress). Set on browser jobs (setupBrowserJob → br.runId from
    // loadProfile's /api/start), null for browserless/CLI runs.
    engine_run_id: br.runId || null,
  };
  try {
    await dbRest('POST', 'run', { ...ledgerBase, status: 'running', started_at: new Date(t0).toISOString() },
      { headers: { Prefer: 'resolution=merge-duplicates,return=minimal' }, silent: true });
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/adapters/_lib/post-login-orchestrator.ts", "old_string": " status: steps.stamped ? (ready ? 'ok' : 'partial') : 'failed', ok: ready,", "new_string": " status: steps.stamped ? (ready ? 'ok' : 'partial') : 'failed', // issue #52: run.ok dropped (derived from status)"}
The file /home/philip/Documents/GitHub/bluebut-issue-52/adapters/_lib/post-login-orchestrator.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== contract/job.ts 30-50 ===\"; sed -n '30,50p' server/contract/job.ts; echo \"=== queue-status.test statusPatch assertions ===\"; grep -n \"ok\\|statusPatch\" server/jobs/queue-status.test.mjs | head -30", "description": "Check contract schema and test assertions"}
=== contract/job.ts 30-50 ===
])

// The durable job row, as the API returns it (a projection of the `job` ledger table).
export const Job = z.object({
  job_id: z.string(),
  verb: z.string().nullable().optional(),
  cmd: z.string().nullable().optional(),
  account: z.string().nullable().optional(),
  target_type: z.string().nullable().optional(),
  target_id: z.string().nullable().optional(),
  status: JobStatus,
  ok: z.boolean().nullable().optional(),
  aborted_reason: z.string().nullable().optional(),
  summary: z.record(z.string(), z.unknown()).nullable().optional(),
  created_at: z.string().nullable().optional(),
  started_at: z.string().nullable().optional(),
  ended_at: z.string().nullable().optional(),
  duration_ms: z.number().nullable().optional(),
})

// POST /jobs — enqueue a job. `verb` selects a catalog entry; `input` is validated
=== queue-status.test statusPatch assertions ===
5:// SUCCESS='ok' (NOT 'done'), plus the benign terminals skipped/partial/
9:import { isLedgerTerminal, LEDGER_ACTIVE, LEDGER_TERMINAL, okForStatus, statusPatch } from './queue.ts'
15:  for (const r of [{ ok: true }, { ok: false }, { ok: false, status: 'skipped' },
16:    { ok: false, status: 'blocked' }, { ok: true, status: 'partial' },
17:    { ok: false, status: 'content_unavailable' }]) {
25:test("success ('ok') is terminal — the SSE-hang regression", () => {
26:  assert.equal(jobLifecycleStatus({ ok: true }, false), 'ok')
27:  assert.equal(isLedgerTerminal('ok'), true)
35:  for (const s of LEDGER_ACTIVE) assert.ok(!LEDGER_TERMINAL.includes(s), `${s} is in both sets`)
39:test('okForStatus: success terminals → true, other terminals → false, active → null', () => {
40:  for (const s of ['ok', 'done', 'completed']) assert.equal(okForStatus(s), true, s)
42:    assert.equal(okForStatus(s), false, s)
43:  for (const s of LEDGER_ACTIVE) assert.equal(okForStatus(s), null, s)
46:test('okForStatus agrees with the runner on the decided failure/success cases', () => {
47:  // jobLifecycleStatus is the authoritative producer; the runner banks ok=(result.ok===true).
48:  assert.equal(okForStatus(jobLifecycleStatus({ ok: true }, false)), true)   // 'ok'  → ok=true
49:  assert.equal(okForStatus(jobLifecycleStatus({ ok: false }, false)), false) // 'failed' → ok=false
50:  assert.equal(okForStatus(jobLifecycleStatus({}, true)), false)             // walled 'blocked' → ok=false
53:test('statusPatch: a terminal stamps ended_at + derived ok; an active status does not', () => {
54:  const f = statusPatch('failed', 'boom')
55:  assert.equal(f.status, 'failed'); assert.equal(f.ok, false)
56:  assert.equal(f.aborted_reason, 'boom'); assert.ok(f.ended_at); assert.ok(f.updated_at)
57:  const r = statusPatch('running')
58:  assert.equal(r.ok, null); assert.equal(r.ended_at, undefined); assert.ok(r.updated_at)
61:test('statusPatch preserves the exact old markFailed shape (behavior-preserving refactor)', () => {
62:  // Old markFailed wrote { status:'failed', ok:false, aborted_reason, ended_at, updated_at }.
63:  const p = statusPatch('failed', 'x'.repeat(999))
65:  assert.equal(p.ok, false)
67:  assert.ok(p.ended_at && p.updated_at)
The test asserts `statusPatch().ok` — I'll update those assertions to match the intended drop (updating existing tests to reflect the deliberate change, not adding new ones):

Jul 24, 03:22:15 PM

Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue-status.test.mjs", "offset": 53, "limit": 18}
53	test('statusPatch: a terminal stamps ended_at + derived ok; an active status does not', () => {
54	  const f = statusPatch('failed', 'boom')
55	  assert.equal(f.status, 'failed'); assert.equal(f.ok, false)
56	  assert.equal(f.aborted_reason, 'boom'); assert.ok(f.ended_at); assert.ok(f.updated_at)
57	  const r = statusPatch('running')
58	  assert.equal(r.ok, null); assert.equal(r.ended_at, undefined); assert.ok(r.updated_at)
59	})
60	
61	test('statusPatch preserves the exact old markFailed shape (behavior-preserving refactor)', () => {
62	  // Old markFailed wrote { status:'failed', ok:false, aborted_reason, ended_at, updated_at }.
63	  const p = statusPatch('failed', 'x'.repeat(999))
64	  assert.equal(p.status, 'failed')
65	  assert.equal(p.ok, false)
66	  assert.equal(String(p.aborted_reason).length, 500)   // truncated to 500 as before
67	  assert.ok(p.ended_at && p.updated_at)
68	})
69
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue-status.test.mjs", "old_string": "test('statusPatch: a terminal stamps ended_at + derived ok; an active status does not', () => {\n const f = statusPatch('failed', 'boom')\n assert.equal(f.status, 'failed'); assert.equal(f.ok, false)\n assert.equal(f.aborted_reason, 'boom'); assert.ok(f.ended_at); assert.ok(f.updated_at)\n const r = statusPatch('running')\n assert.equal(r.ok, null); assert.equal(r.ended_at, undefined); assert.ok(r.updated_at)\n})\n\ntest('statusPatch preserves the exact old markFailed shape (behavior-preserving refactor)', () => {\n // Old markFailed wrote { status:'failed', ok:false, aborted_reason, ended_at, updated_at }.\n const p = statusPatch('failed', 'x'.repea
The file /home/philip/Documents/GitHub/bluebut-issue-52/server/jobs/queue-status.test.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
Now the migration to drop the column:

Jul 24, 03:22:30 PM

Write{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/db/migrations/entity/0054_drop_run_ok.sql", "content": "-- 0054_drop_run_ok \u2014 CONTRACT: drop the retiring `run.ok` boolean (issue #52, the\n-- deferred half of the epic #6 Skyvern cutover). The read-side already rolls the\n-- success/failure boolean up FROM `status` everywhere \u2014 server/jobs/queue.ts\n-- `okForStatus`, the terminal SSE `result` frame in server/routes/jobs.ts, and\n-- ui/src/lib/taxonomy.ts `classify`/`batchRollup` \u2014 so `ok` is pure derived state.\n--\n-- This deploy removes EVERY writer of the column in one pass (the single status funnel\n-- statusPatch, the runner terminal-finalize scripts/_lib/adapter-finalize, the health\n-- reaper/boot-reap, the adapter blocked-gate + running inserts, the mint/
File created successfully at: /home/philip/Documents/GitHub/bluebut-issue-52/db/migrations/entity/0054_drop_run_ok.sql (file state is current in your context — no need to Read it back)
Let me verify the scheduler changes are coherent:

Jul 24, 03:22:44 PM

Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-52/server/http/runtime/scheduler.mjs", "offset": 316, "limit": 55}
316	          _schedInflight.delete(s.schedule_id);
317	          continue;
318	        }
319	        acct = actor.account;
320	        actorReason = actor.reason;
321	      }
322	      const jobId = mintJobId();
323	      const startIso = new Date().toISOString();
324	      const next = nextRunAt({ ...s, last_run_at: startIso }, now);
325	      console.log(`[backend] schedule fire: ${s.schedule_id} (${s.label}) → ${s.site || "facebook"}/${s.cmd} as ${acct || "(browserless)"} [${actorReason}] job ${jobId}`);
326	      // CAS-claim the interval slot at START (issue #43.F3) — only if no other tick/
327	      // process already advanced last_run_at. Holds the interval gate across a bridge
328	      // restart mid-run AND prevents a redeploy-overlap double-fire; a crash can't
329	      // cause a re-fire storm. Lost the race → skip (a sibling already fired this
330	      // interval). The terminal status/ok is patched when the job finishes.
331	      const won = await casClaimSchedule(s.schedule_id, s.last_run_at ?? null,
332	        { last_run_at: startIso, next_run_at: next ? next.toISOString() : null, last_run_status: "running", last_run_job_id: jobId, last_run_account: acct, updated_at: startIso });
333	      if (!won) { _schedInflight.delete(s.schedule_id); continue; }
334	
335	      // ── PRODUCER (issue #52): when the durable worker owns dispatch, DON'T spawn the
336	      // adapter inline — enqueue a queued `run` row (jobId == run PK, batch_id =
337	      // schedule_id so every run is linkable back to its schedule) and let the worker
338	      // drain it. It resolves the same adapter by (site, cmd) + passes schedArgs verbatim,
339	      // so the runner runs IDENTICALLY to fireSchedule's spawn. last_run_status "ok" here
340	      // means ENQUEUED (launched) — mirroring the workflow lane's launch semantics; the
341	      // run's own success/failure is banked on its `run` ledger row, not on run_schedule.
342	      if (QUEUE_WORKER_ON) {
343	        const site = s.site || "facebook";
344	        const cmd = s.cmd;
345	        if (!adapterPath(site, cmd)) {
346	          console.error(`[backend] schedule ${s.schedule_id}: no adapter for ${site}/${cmd} — not enqueuing`);
347	          dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
348	            { last_run_status: "failed", updated_at: new Date().toISOString() },
349	            { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {});
350	          _schedInflight.delete(s.schedule_id);
351	          continue;
352	        }
353	        enqueueRaw(site, cmd, schedArgs(s, acct), { job_id: jobId, account: acct, batch_id: s.schedule_id })
354	          .then(() => dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
355	            { last_run_status: "ok", updated_at: new Date().toISOString() },
356	            { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {}))
357	          .catch((e) => { console.error(`[backend] schedule ${s.schedule_id} enqueue error: ${e?.message || e}`); return dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`, { last_run_status: "failed", updated_at: new Date().toISOString() }, { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {}); })
358	          .finally(() => _schedInflight.delete(s.schedule_id));
359	        continue;
360	      }
361	
362	      // LEGACY inline lane (worker off): spawn the adapter directly (the original path).
363	      fireSchedule(s, jobId, acct)
364	        .then((res) => dbRest("PATCH", `run_schedule?schedule_id=eq.${encodeURIComponent(s.schedule_id)}`,
365	          { last_run_status: res.ok ? "ok" : "failed", updated_at: new Date().toISOString() },
366	          { silent: true, headers: { Prefer: "return=minimal" } }).catch(() => {}))
367	        .catch((e) => console.error(`[backend] schedule ${s.schedule_id} error: ${e?.message || e}`))
368	        .finally(() => _schedInflight.delete(s.schedule_id));
369	    }
370	  } catch (e) { console.error(`[backend] schedule tick failed: ${e?.message || e}`); }
Let me do a light syntax-only parse check on the edited `.mjs` files (parse-only, no imports resolved, no server):

Jul 24, 03:23:08 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; for f in server/http/runtime/scheduler.mjs server/http/runtime/job-registry.mjs server/http/runtime/health.mjs; do node --check \"$f\" && echo \"OK $f\" || echo \"FAIL $f\"; done", "description": "Syntax parse-check edited mjs files"}
OK server/http/runtime/scheduler.mjs
OK server/http/runtime/job-registry.mjs
OK server/http/runtime/health.mjs
All parse clean. Let me review the full diff summary before committing:

Jul 24, 03:23:13 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; git status --short; echo \"=== stat ===\"; git diff --stat", "description": "Review changed files"}
M adapters/_lib/e2ee-event-connect.ts
 M adapters/_lib/post-login-orchestrator.ts
 M db/schema.ts
 M scripts/_lib/adapter-finalize.ts
 M scripts/analyze-job.ts
 M scripts/bluebut-adapter.ts
 M server/http/routes/accounts-mint.ts
 M server/http/routes/cloakbox.ts
 M server/http/runtime/health.mjs
 M server/http/runtime/job-registry.mjs
 M server/http/runtime/scheduler.mjs
 M server/jobs/queue-status.test.mjs
 M server/jobs/queue.ts
 M server/routes/jobs.ts
 M ui/src/lib/util/queryKeys.ts
 M ui/src/pages/MemberRequests.tsx
 M ui/src/pages/Overview.tsx
 M ui/src/pages/broadcast/GroupPostView.tsx
?? db/migrations/entity/0054_drop_run_ok.sql
=== stat ===
 adapters/_lib/e2ee-event-connect.ts      |  2 +-
 adapters/_lib/post-login-orchestrator.ts |  2 +-
 db/schema.ts                             |  3 +-
 scripts/_lib/adapter-finalize.ts         |  2 +-
 scripts/analyze-job.ts                   | 11 ++++--
 scripts/bluebut-adapter.ts               |  8 ++---
 server/http/routes/accounts-mint.ts      |  4 +--
 server/http/routes/cloakbox.ts           |  2 +-
 server/http/runtime/health.mjs           |  4 +--
 server/http/runtime/job-registry.mjs     |  7 ++--
 server/http/runtime/scheduler.mjs        | 59 ++++++++++++++++++++++++++++----
 server/jobs/queue-status.test.mjs        | 15 ++++----
 server/jobs/queue.ts                     | 27 ++++++++-------
 server/routes/jobs.ts                    |  4 +--
 ui/src/lib/util/queryKeys.ts             |  2 +-
 ui/src/pages/MemberRequests.tsx          |  2 +-
 ui/src/pages/Overview.tsx                |  2 +-
 ui/src/pages/broadcast/GroupPostView.tsx |  3 +-
 18 files changed, 109 insertions(+), 50 deletions(-)
Both parts complete. Committing:

Jul 24, 03:23:19 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 52'; $PROG fix done \"scheduler producer+dormancy gate; dropped run.ok (migration+all writers/readers)\"; git add -A; git commit -q -m \"$(cat <<'EOF'\nfeat(runtime): finish Skyvern cutover \u2014 scheduler\u2192queue producer + drop run.ok (#52)\n\nDeferred half of epic #6 (#52), two parts:\n\n1. Schedule\u2192durable-queue producer + re-introduced dormancy gate.\n Under BLUEBUT_QUEUE_WORKER=1 the scheduler's single-job lane no longer\n spawns adapters inline (fireSchedule) \u2014 it ENQUEUES a queued `run` row via\n enqueueRaw (jobId == run PK, batch_id = schedule_id) that the worker drains,\n so the inline path is dormant WITHOUT
committed
efe6b264 feat(runtime): finish Skyvern cutover — scheduler→queue producer + drop run.ok (#52)
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 52'; $PROG push start \"pushing + MR\"; git fetch origin main -q; git merge --no-edit origin/main 2>&1 | tail -5; git push -u origin fix/issue-52 2>&1 | tail -5", "description": "Merge main and push branch"}
direct push to main is blocked - open a merge request (glab mr create).
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; git branch --show-current; git log --oneline -3; echo \"=== remote branches ===\"; git branch -r 2>/dev/null | head; echo \"=== status vs origin ===\"; git status -sb | head -3", "description": "Diagnose push state"}
fix/issue-52
efe6b264 feat(runtime): finish Skyvern cutover — scheduler→queue producer + drop run.ok (#52)
a50e9337 Merge branch 'fix/issue-53' into 'main'
18fb9f40 feat(accounts): Load is a verify-and-finalize workflow (#53)
=== remote branches ===
  origin/HEAD -> origin/main
  origin/fix/epic-207
  origin/fix/epic-6
  origin/fix/epic-77
  origin/fix/epic-79
  origin/fix/issue-185
  origin/main
  origin/verify/epic-6
=== status vs origin ===
## fix/issue-52...origin/main [ahead 1]
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; echo \"=== push config ===\"; git config --get-all remote.origin.push; git config --get push.default; git config --get branch.fix/issue-52.merge; echo \"=== explicit refspec push ===\"; git push origin fix/issue-52:refs/heads/fix/issue-52 2>&1 | tail -8", "description": "Check push refspec and push explicitly"}
=== push config ===
current
refs/heads/main
=== explicit refspec push ===
remote: 
remote: To create a merge request for fix/issue-52, visit:        
remote:   https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/new?merge_request%5Bsource_branch%5D=fix%2Fissue-52        
remote: 
To https://gitlab.hostbun.cc/devdashco/bluebut.git
 * [new branch]        fix/issue-52 -> fix/issue-52
Branch pushed. Now create the MR and merge:

Jul 24, 03:24:01 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; glab mr create --repo devdashco/bluebut --source-branch fix/issue-52 --target-branch main --title \"fix: finish Skyvern cutover \u2014 scheduler\u2192queue producer + drop run.ok (#52)\" --description \"Closes #52 \u2014 the deferred half of epic #6.\n\n**1. Schedule\u2192durable-queue producer + re-introduced dormancy gate.** Under \\`BLUEBUT_QUEUE_WORKER=1\\` the scheduler's single-job lane ENQUEUES a queued \\`run\\` row (enqueueRaw, jobId==run PK, batch_id=schedule_id) for the worker to drain instead of spawning the adapter inline via fireSchedule. The inline path is now dormant WITHOUT silently stopping recurring jobs (why #6's gate was reverted). Same env starts the worker + dorments /exec, so all three flip together. An
Creating merge request for fix/issue-52 into main in devdashco/bluebut

https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/17
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; glab mr merge fix/issue-52 --repo devdashco/bluebut --yes 2>&1 | tail -12", "description": "Merge the MR into main"}
ERROR  
          
  All attempts fail:                                                                                                  
  #1: PUT https://gitlab.hostbun.cc/api/v4/projects/devdashco%2Fbluebut/merge_requests/17/merge: 400 {message: SHA    
  must be provided when merging}.
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; SHA=$(git rev-parse fix/issue-52); echo \"sha=$SHA\"; glab mr merge fix/issue-52 --repo devdashco/bluebut --sha \"$SHA\" --yes 2>&1 | tail -12", "description": "Merge MR with explicit SHA"}
sha=efe6b264fc60911d1474a402d30d69a70472218c
! No pipeline running on fix/issue-52
✓ Merged!
https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/17
Merged to main. Now verify it landed and close the issue:

Jul 24, 03:24:26 PM

Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 52'; $PROG push done \"MR !17 merged to main\"; git fetch origin main -q; echo \"=== main contains our commit? ===\"; git branch -r --contains efe6b264 2>/dev/null | grep -E \"origin/main\" && echo \"LANDED on main\" || echo \"NOT on main\"; $PROG close start", "description": "Confirm landed on main"}
=== main contains our commit? ===
  origin/HEAD -> origin/main
  origin/main
LANDED on main
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-52; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 52'; glab issue note 52 --repo devdashco/bluebut --message \"fixed on fix/issue-52 (MR !17, merged to main, commit efe6b264):\n\n**1. Scheduler \u2192 durable queue producer + re-introduced dormancy gate** (server/http/runtime/scheduler.mjs). Under BLUEBUT_QUEUE_WORKER=1 the single-job lane now ENQUEUES a queued \\`run\\` row via enqueueRaw (jobId==run PK, batch_id=schedule_id so runs link back to the schedule) that the worker drains, instead of spawning the adapter inline via fireSchedule. This is the schedule\u2192queue producer #6 lacked, so the inline path goes dormant WITHOUT silently stopping recurring jobs. The same env starts the wo
no result shipped
258 messages · 88 tool calls

Find all run.ok DB reads/writes

1 brief from the lead

done
the lead asked
In the repo /home/philip/Documents/GitHub/bluebut-issue-52, I need an EXHAUSTIVE inventory of every place that reads or writes the `ok` COLUMN of the `run` ledger table (formerly the `job` table; epic #200 renamed table job→run, PK job_id→id). I'm about to DROP the `run.ok` column, so I must find every DB read and write of it. Search the whole repo (server/, scripts/, adapters/, ui/, ontology/, db/). For each hit report file:line and a one-line quote. Report SEPARATELY: A) DB WRITES of `ok` to the run/job table

Jul 24, 03:12:30 PM

it reported back
Async agent launched successfully. (This tool result is internal metadata — never quote or paste any part of it, including the agentId below, into a user-facing reply.) agentId: a5c727c2cffdb08f4 (internal ID - do not mention to user. Use SendMessage with to: 'a5c727c2cffdb08f4', summary: '<5-10 word recap>' to continue this agent.) The agent is working in the background. You will be notified automatically when it completes. You know nothing about its results until that notification arrives — do not report, assume,

Jul 24, 03:12:30 PM

last activity Jul 24, 03:12:30 PM

drag to pan · ⌘/ctrl + scroll to zoom · double-click to fit

100%

Goes into the live fixer's pane (or resumes its session if it has none) — the same path a Telegram reply takes.