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:
@@ -155,6 +155,23 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
|
||||
/* atomizing progress shimmer */
|
||||
.shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.shimmer::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 12%, transparent), transparent);
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
input[type=range] { width: 100%; accent-color: var(--accent); }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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();
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user