feat: atomization pipeline with job queue, atomizer client, WS hub, and board UI (phase 7)

- internal/extsvc: circuit breaker (§11.16) + retrying bearer JSON client
- atomizer client: §5.1 contract incl. defensive coefficient normalization
  and extension coefficient range (0,2]
- persisted jobs collection: atomic claim, panic recovery, exponential
  backoff (max 3), stale-running requeue, shutdown-safe bookkeeping
- subdivide (async 202, atomizing status, re-run replaces unpublished
  children after confirm) and extend (sibling task, source budget)
- failure path reverts status and notifies the consultant on final attempt
- PATCH task editing with bounty recompute, budget cascade to descendants
- publish single + bulk; task detail endpoint role-scoped
- coder/websocket hub: multiplexed channels, origin check, 30s heartbeats;
  client ws.js with reconnect + 15s polling fallback
- consultant atomization board: tree by root, coefficient sliders with live
  per-parent sum indicator, bounty preview, modals, shimmer, atomizer-down
  gating via /api/v1/service-health
- fix: tasks external-ref unique index is partial, not sparse (compound
  sparse indexed every task through customerId and broke child inserts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 19:17:09 +02:00
parent 4e01b64bbd
commit f87b954f27
23 changed files with 2628 additions and 7 deletions
+319
View File
@@ -0,0 +1,319 @@
import { api, toast } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
const errorBox = document.getElementById('error');
const board = document.getElementById('board');
const customerSel = document.getElementById('board-customer');
const icons = { epic: '◆', story: '▣', task: '▢' };
let customers = [];
let tasks = [];
let atomizerUp = true;
const selected = new Set();
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
async function checkHealth() {
const badge = document.getElementById('atomizer-health');
try {
const res = await api('GET', '/api/v1/service-health');
const a = res.atomizer;
atomizerUp = a.healthy && a.breaker !== 'open';
badge.textContent = atomizerUp
? `atomizer healthy · ${a.latencyMs} ms`
: `atomizer ${a.healthy ? 'breaker ' + a.breaker : 'DOWN'} — Subdivide/Extend disabled`;
badge.style.color = atomizerUp ? 'var(--ok)' : 'var(--err)';
} catch (e) {
atomizerUp = false;
badge.textContent = 'atomizer status unknown';
}
document.querySelectorAll('[data-needs-atomizer]').forEach((b) => {
b.disabled = !atomizerUp;
b.title = atomizerUp ? '' : 'Atomization Service is unavailable';
});
}
async function load() {
try {
const cid = customerSel.value || '';
const res = await api('GET', `/api/v1/consultant/board?customerId=${encodeURIComponent(cid)}`);
customers = res.customers;
tasks = res.tasks;
if (!customerSel.options.length) {
customerSel.replaceChildren(
new Option('All customers', ''),
...customers.map((c) => new Option(c.name, c.id)),
);
customerSel.value = new URLSearchParams(location.search).get('customerId') || '';
if (customerSel.value) return load();
}
render();
} catch (e) { errorBox.textContent = e.message; }
}
function childrenOf(id) { return tasks.filter((t) => t.parentId === id); }
function render() {
selected.forEach((id) => { if (!tasks.find((t) => t.id === id)) selected.delete(id); });
updateBulkButton();
const roots = tasks.filter((t) => !t.parentId);
board.replaceChildren(...roots.map(renderRoot));
if (!roots.length) {
board.innerHTML = '<p class="muted">No tasks on the atomization board for this selection.</p>';
}
checkHealth();
}
function statusBadge(t) {
const cls = { atomizing: 'warn', published: 'ok' }[t.status] || '';
return `<span class="badge" style="${cls === 'ok' ? 'color:var(--ok)' : cls === 'warn' ? 'color:var(--warn)' : ''}">${esc(t.status)}</span>`;
}
function renderRoot(root) {
const card = document.createElement('div');
card.className = 'card' + (root.status === 'atomizing' ? ' shimmer' : '');
const cust = customers.find((c) => c.id === root.customerId);
const icon = root.external ? (icons[root.external.type] || '▢') : '▢';
const orphan = root.external && root.external.orphaned
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
card.innerHTML = `
<div class="spread">
<h3>${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}</h3>
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
</div>
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
<div class="spread">
<span>Budget <strong>${root.budget}</strong> · bounty preview <strong>${root.bounty}</strong></span>
<span>
${root.external && root.external.url ? `<a class="btn small" href="${esc(root.external.url)}" target="_blank" rel="noopener">source ↗</a>` : ''}
<button class="btn small" data-act="edit">Edit</button>
<button class="btn small primary" data-act="subdivide" data-needs-atomizer ${root.status === 'atomizing' ? 'disabled' : ''}>Subdivide</button>
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
<button class="btn small" data-act="archive">Archive</button>
</span>
</div>
<div class="children"></div>`;
card.querySelector('[data-act=subdivide]').addEventListener('click', () => openSubdivide(root));
card.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(root));
card.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(root, true));
card.querySelector('[data-act=archive]').addEventListener('click', () => archive(root));
const kids = childrenOf(root.id);
if (kids.length) {
card.querySelector('.children').appendChild(renderChildren(root, kids, 1));
}
return card;
}
function renderChildren(parent, kids, depth) {
const wrap = document.createElement('div');
wrap.style.marginLeft = `${depth * 16}px`;
wrap.className = 'stack mt';
const subdivided = kids.filter((k) => k.origin === 'subdivided');
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
const hasExtension = kids.some((k) => k.origin === 'extended');
if (subdivided.length) {
const ind = document.createElement('p');
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
: bad ? ' — should be 1.00' : '');
wrap.appendChild(ind);
}
kids.forEach((k) => {
const row = document.createElement('div');
row.className = 'card' + (k.status === 'atomizing' ? ' shimmer' : '');
row.style.padding = '12px 16px';
const ext = k.origin === 'extended' ? '<span class="badge">extension</span>' : '';
row.innerHTML = `
<div class="spread">
<span>
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
<strong>${esc(k.title)}</strong> ${ext} ${statusBadge(k)}
</span>
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
</div>
<p class="muted" style="margin:4px 0">${esc((k.description || '').slice(0, 160))}</p>
<div class="spread">
<label style="flex:1;font-weight:normal">
<span class="muted">effort <span data-coeff>${k.effortCoefficient.toFixed(2)}</span></span>
<input type="range" min="0.01" max="${k.origin === 'extended' ? 2 : 1}" step="0.01"
value="${k.effortCoefficient}" ${['atomized', 'published'].includes(k.status) ? '' : 'disabled'}
aria-label="Effort coefficient for ${esc(k.title)}">
</label>
<span>
<button class="btn small" data-act="edit">Edit</button>
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
<button class="btn small" data-act="archive">Archive</button>
</span>
</div>`;
const slider = row.querySelector('input[type=range]');
let sliderTimer = null;
slider.addEventListener('input', () => {
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
clearTimeout(sliderTimer);
sliderTimer = setTimeout(async () => {
try {
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
effortCoefficient: parseFloat(slider.value), version: k.version,
});
k.version = res.task.version;
k.effortCoefficient = res.task.effortCoefficient;
k.bounty = res.task.bounty;
render();
} catch (e) {
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
else toast(e.message, 'err');
}
}, 400);
});
const sel = row.querySelector('[data-sel]');
if (sel) {
sel.addEventListener('change', () => {
if (sel.checked) selected.add(k.id); else selected.delete(k.id);
updateBulkButton();
});
}
row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false));
row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k));
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
const pub = row.querySelector('[data-act=publish]');
if (pub) {
pub.addEventListener('click', async () => {
try { await api('POST', `/api/v1/tasks/${k.id}/publish`, {}); toast('Published.', 'ok'); load(); }
catch (e) { toast(e.message, 'err'); }
});
}
wrap.appendChild(row);
const grand = childrenOf(k.id);
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
});
return wrap;
}
async function archive(t) {
if (!confirm(`Archive "${t.title}"?`)) return;
try { await api('POST', `/api/v1/tasks/${t.id}/archive`, {}); load(); }
catch (e) { toast(e.message, 'err'); }
}
function updateBulkButton() {
const btn = document.getElementById('bulk-publish');
btn.textContent = `Publish selected (${selected.size})`;
btn.disabled = selected.size === 0;
}
document.getElementById('select-all').addEventListener('click', () => {
tasks.filter((t) => t.status === 'atomized').forEach((t) => selected.add(t.id));
render();
});
document.getElementById('bulk-publish').addEventListener('click', async () => {
try {
const res = await api('POST', '/api/v1/tasks/publish', { ids: [...selected] });
toast(`Published ${res.published.length} task(s)` +
(Object.keys(res.failed).length ? `, ${Object.keys(res.failed).length} failed` : '.'),
Object.keys(res.failed).length ? 'err' : 'ok');
selected.clear();
load();
} catch (e) { toast(e.message, 'err'); }
});
// ---- dialogs ----
let dialogTask = null;
const sdDlg = document.getElementById('subdivide-dialog');
function openSubdivide(t) {
dialogTask = t;
document.getElementById('subdivide-task-title').textContent = t.title;
document.getElementById('sd-replace-warning').hidden = !childrenOf(t.id).length;
sdDlg.showModal();
}
document.getElementById('sd-cancel').addEventListener('click', () => sdDlg.close());
document.getElementById('subdivide-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', `/api/v1/tasks/${dialogTask.id}/subdivide`, {
note: document.getElementById('sd-note').value.trim(),
constraints: {
minTasks: parseInt(document.getElementById('sd-min').value, 10) || 0,
maxTasks: parseInt(document.getElementById('sd-max').value, 10) || 0,
},
confirmReplace: !document.getElementById('sd-replace-warning').hidden,
});
sdDlg.close();
toast('Subdivision queued — results stream in shortly.', 'ok');
load();
} catch (err) { toast(err.message, 'err'); }
});
const exDlg = document.getElementById('extend-dialog');
function openExtend(t) {
dialogTask = t;
document.getElementById('extend-task-title').textContent = t.title;
exDlg.showModal();
}
document.getElementById('ex-cancel').addEventListener('click', () => exDlg.close());
document.getElementById('extend-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', `/api/v1/tasks/${dialogTask.id}/extend`, {
note: document.getElementById('ex-note').value.trim(),
});
exDlg.close();
document.getElementById('ex-note').value = '';
toast('Extension queued.', 'ok');
} catch (err) { toast(err.message, 'err'); }
});
const edDlg = document.getElementById('edit-dialog');
function openEdit(t, isRoot) {
dialogTask = t;
document.getElementById('ed-title').value = t.title;
document.getElementById('ed-desc').value = t.description || '';
document.getElementById('ed-ac').value = (t.acceptanceCriteria || []).join('\n');
document.getElementById('ed-budget').value = t.budget;
document.getElementById('ed-cascade-wrap').style.display = isRoot ? '' : 'none';
edDlg.showModal();
}
document.getElementById('ed-cancel').addEventListener('click', () => edDlg.close());
document.getElementById('edit-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PATCH', `/api/v1/tasks/${dialogTask.id}`, {
version: dialogTask.version,
title: document.getElementById('ed-title').value.trim(),
description: document.getElementById('ed-desc').value,
acceptanceCriteria: document.getElementById('ed-ac').value.split('\n').map((s) => s.trim()).filter(Boolean),
budget: parseFloat(document.getElementById('ed-budget').value) || 0,
cascadeBudget: document.getElementById('ed-cascade').checked &&
document.getElementById('ed-cascade-wrap').style.display !== 'none',
});
edDlg.close();
toast('Saved.', 'ok');
load();
} catch (err) {
if (err.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
else toast(err.message, 'err');
}
});
// ---- live updates ----
let reloadTimer = null;
function scheduleReload() {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(load, 300);
}
subscribe('board', () => scheduleReload());
onPollFallback(load);
customerSel.addEventListener('change', load);
setInterval(checkHealth, 30000);
load();
+57
View File
@@ -0,0 +1,57 @@
// Live updates: one multiplexed WebSocket with auto-reconnect and a 15 s
// polling fallback signal (§2.2). Subscribers register per channel.
const subs = new Map(); // channel -> Set<fn>
let socket = null;
let attempts = 0;
let fallbackPolling = false;
const pollListeners = new Set();
export function subscribe(channel, fn) {
if (!subs.has(channel)) subs.set(channel, new Set());
subs.get(channel).add(fn);
ensureSocket();
return () => subs.get(channel).delete(fn);
}
// onPollFallback registers a callback invoked every 15 s while the socket is
// down, so pages can refresh their data without WS.
export function onPollFallback(fn) {
pollListeners.add(fn);
}
export function send(channel, event, data) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ channel, event, data }));
}
}
let pollTimer = null;
function startPolling() {
if (fallbackPolling) return;
fallbackPolling = true;
pollTimer = setInterval(() => pollListeners.forEach((fn) => fn()), 15000);
}
function stopPolling() {
fallbackPolling = false;
if (pollTimer) clearInterval(pollTimer);
}
function ensureSocket() {
if (socket && socket.readyState <= WebSocket.OPEN) return;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${proto}//${window.location.host}/ws`);
socket.addEventListener('open', () => { attempts = 0; stopPolling(); });
socket.addEventListener('message', (e) => {
try {
const msg = JSON.parse(e.data);
(subs.get(msg.channel) || []).forEach((fn) => fn(msg.event, msg.data));
} catch (err) { /* ignore malformed frames */ }
});
socket.addEventListener('close', () => {
startPolling();
attempts += 1;
const delay = Math.min(30000, 1000 * 2 ** attempts);
setTimeout(ensureSocket, delay);
});
socket.addEventListener('error', () => socket.close());
}