ouroboros/web/modules/project_handoff.js

218 lines
12 KiB
JavaScript

/** A compact Main transfer receipt; existing census/task details own its phase. */
import { projectReference } from './project_reference.js';
import { activeModelWaits } from './model_wait.js';
import { isTerminalTaskDetail, taskTerminalPhase, taskPresentation } from './log_events.js';
// The gateway's typed receipt word (ouroboros/project_handoff.py RECEIPT_STATES).
// The binding is committed under every word; only these two prove that the Main
// history row is owed or already delivered.
export const DURABLE_RECEIPTS = new Set(['durable', 'already_delivered']);
/** Owner wording for a non-durable receipt; '' when nothing needs saying. */
export function receiptNotice(status) {
if (DURABLE_RECEIPTS.has(status)) return '';
return {
unregistered: 'Project binding saved; the Main history receipt was sent but is not protected against a restart.',
unavailable: 'Project binding saved; the Main history receipt could not be sent.',
origin_unproven: 'Project binding saved; this conversation has no recorded Main origin, so no history receipt was written.',
}[status] || 'Project binding saved; the Main history receipt is unconfirmed.';
}
export function handoffPhase(activity, detail, connected = true) {
if (isTerminalTaskDetail(detail)) {
const view = taskPresentation(taskTerminalPhase(detail));
return { text: view.headline, className: view.phase };
}
if (!connected || !activity) return { text: 'Activity unconfirmed', className: 'neutral' };
if (activity.required_question || activeModelWaits(activity.model_waits || {}, false, activity.task_attempt || 0).length) {
return { text: 'Waiting', className: 'warn' };
}
const phases = { thinking: 'Thinking', queued: 'Queued', budget_paused: 'Paused', budget_pausing: 'Pausing…', finalizing: 'Finalizing…', working: 'Working' };
const text = phases[activity.phase];
return text ? { text, className: (activity.phase === 'budget_paused' || activity.phase === 'budget_pausing') ? 'warn' : 'working' }
: { text: 'Activity unconfirmed', className: 'neutral' };
}
// Anchors per handoff identity. Two node kinds carry one: a converted live card
// (`card`, at the request's own position; several roots of ONE owner message may
// each convert, and every one of them stays a visible card — a card is never a
// shadow) and the durable receipt row (`receipt`, at most one visible per
// identity). A receipt is folded under the first visible card of its identity, or
// under the earlier receipt, and every folded node stays in `shadows` so an
// evicted anchor hands over instead of dropping the transfer from the feed.
export function createProjectHandoffs({ feed, fetchDetail, mutate }) {
const rows = new Map(); // key: `card:<taskId>` or the receipt's handoff id
let connected = true, destroyed = false, complete = false;
let activities = new Map();
const inFeed = node => feed.contains(node);
const current = row => !destroyed && inFeed(row.node) && rows.get(row.key) === row;
const visibleAnchor = id => [...rows.values()].find(row => row.id === id && inFeed(row.node));
const matching = (taskId, projectId) => [...rows.values()].find(row =>
row.projectId === projectId && row.subjects.has(taskId) && inFeed(row.node));
function paint(row) {
const phase = handoffPhase(activities.get(row.taskId), row.detail, connected);
row.status.textContent = phase.text;
row.status.className = `chat-live-phase ${phase.className}`;
}
function reconcileStarted(node) {
node.hidden = Boolean(matching(node.dataset.taskId, node.dataset.projectId));
}
function reconcileAnnotation(note) {
const [projectId, , taskId] = (note.dataset.destinationKey || '').split('|');
const represented = Boolean(matching(taskId, projectId)
&& ['scheduled', 'delivered'].includes(note.dataset.annotationStatus));
note.hidden = represented;
const actions = note.parentElement?.querySelector('.msg-routing-actions');
if (actions) actions.hidden = represented;
}
// A durable receipt row folded under a card proves the transfer is in Main
// history: the card's "not saved" mark, if any, was about exactly that gap.
function fold(shadow, under) {
shadow.node.hidden = true;
under.shadows.push(shadow, ...shadow.shadows.splice(0));
for (const taskId of shadow.subjects) under.subjects.add(taskId);
if (shadow.kind === 'receipt' && under.node.dataset.receipt) {
delete under.node.dataset.receipt;
under.node.classList.remove('project-handoff--unsaved');
}
}
// An evicted anchor hands its shadows to a surviving anchor of the same
// identity, else the first still-mounted shadow takes over — keeping the
// CURRENT liveness subject (a followed retry), never the shadow's older one.
function promote(row) {
rows.delete(row.key);
const survivor = visibleAnchor(row.id);
if (survivor) for (const taskId of row.subjects) survivor.subjects.add(taskId);
for (const next of row.shadows) {
if (survivor) { fold(next, survivor); continue; }
if (!inFeed(next.node)) continue;
next.node.hidden = false;
rows.set(next.key, { ...next, subjects: new Set([...row.subjects, ...next.subjects]),
shadows: row.shadows.filter(other => other !== next), detail: row.detail, taskId: row.taskId,
epoch: row.epoch + 1, pending: false, checked: false });
return true;
}
return true;
}
function sweep() {
let changed = false;
for (const row of [...rows.values()]) if (!inFeed(row.node)) changed = promote(row) || changed;
return changed;
}
/** With a node: only that node's projection. Without: every dependent node. */
function reconcile(node) {
if (destroyed) return;
if (node) {
if (node.dataset?.systemType === 'project_started') reconcileStarted(node);
const note = node.querySelector?.('.msg-routing-annotation');
if (note) reconcileAnnotation(note);
if (node.dataset?.systemType !== 'project_handoff') return;
}
sweep();
for (const started of feed.querySelectorAll('[data-system-type="project_started"]')) reconcileStarted(started);
for (const note of feed.querySelectorAll('.msg-routing-annotation')) reconcileAnnotation(note);
}
function resolve(row) {
if (!current(row) || !complete || !connected || activities.has(row.taskId)
|| row.pending || row.checked || isTerminalTaskDetail(row.detail)) return;
const taskId = row.taskId, epoch = row.epoch;
row.pending = true;
row.checked = true;
Promise.resolve().then(() => fetchDetail(taskId)).then(detail => {
if (!current(row) || epoch !== row.epoch || taskId !== row.taskId) return;
// The retained task result, never project activity, names a retry.
const effectiveRetry = detail?.task_id !== taskId
&& (detail?.original_task_id === taskId || detail?.retry_lineage?.some(item => item.task_id === taskId));
const successor = String((effectiveRetry ? detail.task_id : '') || detail?.superseded_by || detail?.retry_task_id || '');
if (successor && successor !== taskId) {
if (row.followed.has(successor)) return; // malformed cyclic lineage stays unknown
row.followed.add(successor);
row.subjects.add(successor);
row.taskId = successor;
row.checked = false;
row.detail = null;
} else if (isTerminalTaskDetail(detail)) row.detail = detail;
mutate(() => paint(row));
}).catch(() => {
// A failed read remains unknown until a real re-entry/reconnect,
// not another costly detail request on each census tick.
}).finally(() => {
row.pending = false;
if (current(row) && row.taskId !== taskId) resolve(row);
});
}
function mount(node, { taskId, projectId, projectName, title, handoffId, kind = 'receipt', receipt = '' }) {
if (!taskId || !projectId || destroyed) return node;
const id = handoffId || `legacy:${JSON.stringify([taskId, projectId])}`;
sweep();
const anchor = visibleAnchor(id);
if (anchor && anchor.node === node) { anchor.subjects.add(taskId); return node; }
node.dataset.projectId = projectId;
node.dataset.handoffId = id;
node.dataset.systemType = 'project_handoff';
node.classList.add('project-handoff');
const body = node.querySelector('.message') || node;
const line = document.createElement('div');
line.className = 'project-handoff-heading';
const status = document.createElement('span');
status.setAttribute('role', 'status');
const name = document.createElement('span');
name.className = 'project-handoff-title';
name.textContent = title || projectName || 'Project';
line.append(status, name);
// A converted card whose receipt is not durable is an honest live chip,
// never a claim that Main history holds this transfer (it will not
// survive a reload as an anchor; the binding and the pointer do).
if (kind === 'card' && receipt && !DURABLE_RECEIPTS.has(receipt)) {
node.dataset.receipt = receipt;
node.classList.add('project-handoff--unsaved');
}
body.replaceChildren(line, projectReference({ id: projectId, name: projectName }, { layout: 'inline', taskId }));
const row = { key: kind === 'card' ? `card:${taskId}` : id, id, node, status, kind, taskId, projectId,
subjects: new Set([taskId]), followed: new Set(), shadows: [], detail: null, pending: false, checked: false, epoch: 0 };
if (kind === 'receipt' && anchor) {
// Duplicate delivery is not evidence that a differently named
// execution supersedes its subject: one receipt, the rest shadowed.
fold(row, anchor);
return anchor.node;
}
rows.set(row.key, row);
if (kind === 'card' && anchor?.kind === 'receipt') {
rows.delete(anchor.key);
fold(anchor, row);
row.detail = anchor.detail;
}
paint(row);
// A card is already in the feed when it converts: the rows it now
// represents (Started, routing) fold at once. addMessage appends a
// receipt after mounting; its own reconcile follows the insert.
if (kind === 'card' && inFeed(node)) reconcile();
return node;
}
function snapshot(data) {
if (destroyed) return;
complete = data?.active_chat_activities_complete === true && data.supervisor_ready === true;
const incoming = new Map((data?.active_chat_activities || []).map(a => [String(a.activity_id || ''), a]));
for (const row of rows.values()) {
if (incoming.has(row.taskId) && !activities.has(row.taskId)) {
row.epoch++;
row.checked = false;
}
}
activities = incoming;
// An evicted anchor is the only reason the feed-wide projections move on a
// census tick; a steady feed repaints its rows and nothing else.
if (rows.size) mutate(() => { if (sweep()) reconcile(); for (const row of rows.values()) paint(row); });
for (const row of rows.values()) resolve(row);
}
return { mount, reconcile, snapshot,
setConnected(value) {
if (connected !== value) {
connected = value;
for (const row of rows.values()) { row.epoch++; row.checked = false; }
}
if (rows.size) mutate(() => { for (const row of rows.values()) paint(row); });
},
destroy() { destroyed = true; rows.clear(); activities.clear(); },
};
}