mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
- F1: the historical model-send seal audit leaves the readiness path and runs
as one session-custodied child per generation (ouroboros.startup_historical_audit),
in its own process group; readiness, gateway requests, Panic and the forced
emergency cleanup never wait on it.
- F3: the in-process extension execution barrier becomes a non-reentrant
shared-reader/exclusive-writer lease: no-deps handlers overlap, a deps
injection scope still excludes every reader across its await and cleanup.
- F4: execution hashes only the selected payload; peers are an immutable
non-executable projection (skill_peer_inventory.py) sharing one conflict
verdict (skill_conflicts.py); freshness on the next request is unchanged.
- F5: the Widgets list/preferences reads share the existing widget request
timeout under one AbortController; a timeout keeps the last good cards and
offers the existing Retry (widget_list.js owns the request seam); a cancelled
body read stays a rejection and a malformed list is refused, so navigating
away can never stop a kept-running frame.
- Regression: a custody-record failure inside spawn_supervised kills only the
child's dedicated group, never the spawner's.
Version-neutral contributor candidate over official 3bdbfef5; release metadata
is allocated at integration. Investigation evidence and the separate
token-usage skill candidate stay on fix/issue-1195-responsiveness.
Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
132 lines
5.3 KiB
JavaScript
132 lines
5.3 KiB
JavaScript
/* Widgets list seam (no DOM): the per-card and whole-list change signatures the
|
|
page compares after every `GET /api/widgets`, the keyed patch plan it applies
|
|
to the existing <article> nodes when the list changed, and the list READ
|
|
itself — its shared deadline, its sibling-abort policy and the controller
|
|
lifecycle navigation and disposal cancel through.
|
|
Card order is deliberately NOT part of the list signature — `widget_order`
|
|
is a separate, cheap fact the page applies through the masonry key order,
|
|
never by moving or rebuilding nodes. */
|
|
|
|
import { WIDGET_REQUEST_TIMEOUT_MS, withWidgetRequestTimeout } from './widget_job.js';
|
|
|
|
export function widgetKey(tab) {
|
|
return tab.key || `${tab.skill}:${tab.tab_id}`;
|
|
}
|
|
|
|
// JSON with sorted object keys, so two snapshots of one declaration compare
|
|
// equal regardless of the serializer's key order.
|
|
function stableStringify(value) {
|
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
if (value && typeof value === 'object') {
|
|
const body = Object.keys(value).sort()
|
|
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
.join(',');
|
|
return `{${body}}`;
|
|
}
|
|
return JSON.stringify(value) ?? 'null';
|
|
}
|
|
|
|
/** Everything a card's mount consumes, plus the owning skill's `revision`. */
|
|
export function widgetCardSignature(tab) {
|
|
return stableStringify({
|
|
key: widgetKey(tab),
|
|
title: tab.title ?? '',
|
|
icon: tab.icon ?? '',
|
|
span: Number(tab.span || tab.grid_span || 1),
|
|
ws_prefix: tab.ws_prefix ?? '',
|
|
render: tab.render ?? null,
|
|
revision: tab.revision ?? '',
|
|
});
|
|
}
|
|
|
|
/** Order-independent signature of the whole card list. */
|
|
export function widgetTabsSignature(tabs) {
|
|
return (Array.isArray(tabs) ? tabs : []).map(widgetCardSignature).sort().join('\n');
|
|
}
|
|
|
|
/** Keyed diff: cards to add, cards to remove, cards whose own entry changed. */
|
|
export function planWidgetListPatch(previousTabs, nextTabs) {
|
|
const before = new Map((previousTabs || []).map((tab) => [widgetKey(tab), widgetCardSignature(tab)]));
|
|
const after = new Set((nextTabs || []).map(widgetKey));
|
|
const added = [];
|
|
const changed = [];
|
|
for (const tab of nextTabs || []) {
|
|
const key = widgetKey(tab);
|
|
if (!before.has(key)) added.push(key);
|
|
else if (before.get(key) !== widgetCardSignature(tab)) changed.push(key);
|
|
}
|
|
return { added, changed, removed: [...before.keys()].filter((key) => !after.has(key)) };
|
|
}
|
|
|
|
/**
|
|
* One Widgets-list read: the cards and the owner's card preferences under ONE
|
|
* abort controller and ONE deadline that spans headers AND body of both.
|
|
*
|
|
* Failure asymmetry is deliberate and pre-existing: the cards ARE the page, so
|
|
* a list failure aborts its preferences sibling and surfaces; a preferences
|
|
* failure degrades to `null` and the last known order is kept. The deadline
|
|
* still covers a preferences-only stall, because `Promise.all` cannot settle
|
|
* until both sides do.
|
|
*
|
|
* Exported so the deadline is testable against a fake clock as the product
|
|
* composes it, rather than against a copy of it.
|
|
*
|
|
* @returns {Promise<[import('./api_types.js').WidgetsResponse, any]>}
|
|
*/
|
|
/**
|
|
* The cards list is authoritative for what may be STOPPED (a kept-running frame
|
|
* whose skill left the list), so a reply without an `ui_tabs` array is refused
|
|
* rather than read as "no cards": a truncated or foreign body must never
|
|
* dispose retained work.
|
|
*/
|
|
export function assertWidgetsList(data) {
|
|
if (!data || typeof data !== 'object' || !Array.isArray(data.ui_tabs)) {
|
|
const error = new Error('malformed widgets list response: ui_tabs is not an array');
|
|
error.code = 'WIDGET_LIST_MALFORMED';
|
|
throw error;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export function requestWidgetListPayload(client, controller, timeoutMs = WIDGET_REQUEST_TIMEOUT_MS) {
|
|
return withWidgetRequestTimeout((signal) => Promise.all([
|
|
client.widgets({ signal }).then(assertWidgetsList).catch((error) => {
|
|
controller.abort();
|
|
throw error;
|
|
}),
|
|
client.uiPreferences({ signal }).catch(() => null),
|
|
]), controller, timeoutMs);
|
|
}
|
|
|
|
/** The cards alone, under the same deadline — the kept-running reconcile. */
|
|
export function requestWidgetCards(client, controller, timeoutMs = WIDGET_REQUEST_TIMEOUT_MS) {
|
|
return withWidgetRequestTimeout((signal) => client.widgets({ signal }).then(assertWidgetsList), controller, timeoutMs);
|
|
}
|
|
|
|
/**
|
|
* The Widgets page's list-request lifecycle: one controller per read, tracked
|
|
* so navigation, disposal or page hide can cancel whatever is in flight.
|
|
*
|
|
* Owning it here keeps the page module from repeating the same
|
|
* create/track/finally/abort-all shape at four call sites, and makes the
|
|
* cancellation contract testable on its own.
|
|
*/
|
|
export function widgetListRequests() {
|
|
const controllers = new Set();
|
|
return {
|
|
async run(task) {
|
|
const controller = new AbortController();
|
|
controllers.add(controller);
|
|
try {
|
|
return await task(controller);
|
|
} finally {
|
|
controllers.delete(controller);
|
|
}
|
|
},
|
|
abortAll() {
|
|
controllers.forEach((controller) => controller.abort());
|
|
controllers.clear();
|
|
},
|
|
get size() { return controllers.size; },
|
|
};
|
|
}
|