ouroboros/web/modules/api_client.js
Ouroboros b2bcd659c1 gateway: passive GET /api/widgets, discovery-free module endpoint, drop dead ui_tabs_pending
- New ouroboros/gateway/widgets.py: GET /api/widgets projects live UI tabs
  from the in-memory extension snapshot only (no discover_skills, no
  stale-review reconcile, no schedule sync, no hashing, no writes). Each
  card carries the owning skill's live payload content_hash as `revision`
  -- a revision fact for the page's change signature, not an ETag. Wired
  through router, endpoint_index, api_client (`apiClient.widgets`),
  api_types (WidgetTab, WidgetsResponse) and the parity/smoke suites. The
  Widgets page itself still reads GET /api/extensions; the frontend switch
  is a later phase.
- api_extension_module: authorization is the live loader state alone.
  `extension_loader.live_bundle_facts` resolves the reviewed payload
  directory from the loaded bundle and the live tab registration must still
  declare exactly this entry, so both discover_skills walks leave this hot
  read path. Adds `Access-Control-Allow-Origin: *` (the opaque-origin module
  frame fetches anonymously cross-origin); `Cache-Control: no-store` stays.
  An unloaded skill now answers 409 "not live" before the 404 entry check.
- Remove the dead two-phase fields `ui_host_pending` / `ui_tabs_pending`
  (always True / always []; no frontend, CLI, or colab reader) and their
  test pins; type the `live` block of GET /api/extensions as
  ExtensionLiveSnapshot (homed in gateway/widgets.py to keep contracts.py
  in its size band).
- ARCHITECTURE.md: module tree entry, endpoint rows, Skills and Widgets
  rationale for the passive read and the CORS header.
- tests/test_gateway_widgets.py pins zero discovery/reconcile/sync/hash
  calls on both read paths and the exact WidgetTab shape.

No version bump: contribution branch, release carriers byte-identical to
the base.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-02 17:22:55 +03:00

271 lines
13 KiB
JavaScript

import './api_types.js';
/**
* Single browser-side gateway client. Keep backend calls here so UI modules
* depend on named boundary helpers rather than raw transport details.
*/
export async function apiFetch(url, init = {}) {
return fetch(url, init);
}
/**
* Read an owner-visible sentence out of a gateway error body. Several routes
* answer with a STRUCTURED error (`{error: {code, message, ...}}`), and
* interpolating that object straight into an Error message produced the
* literal `[object Object]` in a toast — the owner saw an alarm carrying no
* fact at all. Prefer the object's own sentence, then its code, and serialize
* only as a last resort so nothing is silently swallowed.
*/
function errorText(data) {
for (const value of [data?.error, data?.message]) {
if (typeof value === 'string' && value.trim()) return value;
if (value && typeof value === 'object') {
const inner = value.message || value.detail || value.code;
if (typeof inner === 'string' && inner.trim()) return inner;
try { return JSON.stringify(value); } catch { /* fall through */ }
}
}
return '';
}
export async function fetchJson(url, init = {}, options = {}) {
const response = await apiFetch(url, init);
let data = null;
try {
data = await response.json();
} catch {
data = { error: `non-json response (HTTP ${response.status})` };
}
if (!response.ok || (options.rejectOkFalse && data && data.ok === false)) {
const message = errorText(data) || `HTTP ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.body = data;
error.payload = data;
throw error;
}
return data;
}
export function jsonPost(url, payload = {}, options = {}) {
return fetchJson(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}, options);
}
/**
* Run the read-only publication preflight for one selected skill. Domain
* states, including repairable findings, remain successful JSON responses;
* only transport/admission refusals reject through fetchJson.
* @param {string} skill
* @returns {Promise<import('./api_types.js').SkillPublishPreflightResponse>}
*/
export function skillPublishPreflight(skill) {
return jsonPost(`/api/skills/${encodeURIComponent(skill)}/publish-preflight`, {});
}
/**
* Create one ordinary managed task through the shared task gateway.
* @param {import('./api_types.js').TaskCreateRequest} payload
* @returns {Promise<import('./api_types.js').TaskCreateResponse>}
*/
export function createTask(payload) {
return jsonPost('/api/tasks', payload);
}
/**
* Cancel a task. With {cascade:true} the server also cancels the task's live
* subtree and answers only once that teardown has finished; without it the
* request stays the synchronous single-task cancel (no body — headless compat).
* S3 (Q1/Q2): stopPolicy "finalize_then_cancel" requests the soft
* finalize-then-stop episode (202 acknowledgement, cancel_state "pending");
* "immediate" (or absent) keeps today's hard cancel byte-identical.
* Shared by the Chat live-card stop control and the Activity tab.
* @param {string} taskId
* @param {{cascade?: boolean, stopPolicy?: string}} [options]
* @returns {Promise<import('./api_types.js').TaskCancelResponse>}
*/
export function cancelTask(taskId, { cascade = false, stopPolicy = '' } = {}) {
const url = `/api/tasks/${encodeURIComponent(taskId)}/cancel`;
const policy = String(stopPolicy || '');
const body = {
...(cascade ? { cascade: true } : {}),
...(policy && policy !== 'immediate' ? { stop_policy: policy } : {}),
};
return Object.keys(body).length ? jsonPost(url, body) : fetchJson(url, { method: 'POST' });
}
export async function resumeTask(taskId) {
return fetchJson(`/api/tasks/${encodeURIComponent(taskId)}/resume`, { method: 'POST' });
}
/**
* Owner hurry (S3, HQ1): the text-free typed task-local acceleration control.
* The body carries ONLY the client-generated stable request_id (reuse the same
* id on retry — the acknowledgement is idempotent). This path never creates a
* chat message anywhere; the durable facts are the typed owner-mailbox control
* and the owner_hurry task-result projection.
* @param {string} taskId
* @param {string} requestId
* @returns {Promise<import('./api_types.js').TaskHurryResponse>}
*/
export function hurryTask(taskId, requestId) {
return jsonPost(
`/api/tasks/${encodeURIComponent(taskId)}/hurry`,
{ request_id: String(requestId || '') },
{ rejectOkFalse: true },
);
}
/**
* Fetch one task's durable detail record, or null when unreachable — the
* shared reconcile read used by the cancel/stop card flows.
* @param {string} taskId
* @returns {Promise<import('./api_types.js').TaskDetailResponse|null>}
*/
export async function fetchTaskDetail(taskId) {
const resp = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
return (resp && typeof resp.json === 'function' && resp.ok !== false) ? resp.json() : null;
}
/**
* Strict task-detail read for consumers that must tell a genuinely absent
* record (404 → null) apart from a failed read (rejects). The lenient
* fetchTaskDetail above keeps its every-failure→null contract for reconcile
* flows that treat all misses alike.
* @param {string} taskId
* @returns {Promise<import('./api_types.js').TaskDetailResponse|null>}
*/
export async function fetchTaskDetailStrict(taskId) {
const resp = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
if (resp && typeof resp.json === 'function') {
if (resp.ok !== false) return resp.json();
if (resp.status === 404) return null;
}
throw new Error(`task detail read failed (HTTP ${resp?.status ?? 'no response'})`);
}
export function cleanExtensionRoute(value) {
const route = String(value || '').trim().replace(/^\/+/, '');
const parts = route.split('/').filter(Boolean);
if (!route || route.includes('\\') || parts.some((part) => part === '.' || part === '..')) {
return '';
}
return parts.map(encodeURIComponent).join('/');
}
export function extensionRoutePrefix(skill) {
return `/api/extensions/${encodeURIComponent(skill)}/`;
}
export function extensionRoutePath(skill, route, params = null) {
const cleanRoute = cleanExtensionRoute(route);
if (!cleanRoute) return '';
const query = params instanceof URLSearchParams && String(params) ? `?${params}` : '';
return `${extensionRoutePrefix(skill)}${cleanRoute}${query}`;
}
export function updateStrategyForPlan(plan = {}) {
if (!plan.available) return '';
const kind = String(plan.kind || '');
if (!['clean', 'conflicting'].includes(kind)) return '';
return kind === 'clean' ? 'auto_merge' : 'assisted';
}
export const apiClient = {
/** @returns {Promise<import('./api_types.js').HealthResponse>} */
health: () => fetchJson('/api/health', { cache: 'no-store' }),
/** @returns {Promise<import('./api_types.js').StateResponse>} */
state: () => fetchJson('/api/state', { cache: 'no-store' }),
settings: () => fetchJson('/api/settings', { cache: 'no-store' }),
/** @returns {Promise<import('./api_types.js').UiPreferencesResponse>} */
uiPreferences: () => fetchJson('/api/ui/preferences', { cache: 'no-store' }),
saveUiPreferences: (payload) => jsonPost('/api/ui/preferences', payload),
saveSettings: (payload) => fetchJson('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
/**
* Finish first-run onboarding in ONE atomic owner-scoped save (D-8):
* settings + runtime mode + safety default + the completion fact land
* together, or nothing does. The install-time agent preset and its marker
* ride the same write only when the response says `preset.applied` —
* `not_requested`, `skipped_by_owner` and `not_install_time` are ordinary
* successes that persist no preset.
* @param {import('./api_types.js').OnboardingCompleteRequest} payload
* @returns {Promise<import('./api_types.js').OnboardingCompleteResponse>}
*/
completeOnboarding: (payload) => jsonPost('/api/onboarding/complete', payload),
/**
* @param {import('./api_types.js').OnboardingSubagentsPreviewRequest} payload
* @returns {Promise<import('./api_types.js').OnboardingSubagentsPreviewResponse>}
*/
previewOnboardingSubagents: (payload) => jsonPost('/api/onboarding/subagents/preview', payload),
ownerRuntimeMode: (mode) => jsonPost('/api/owner/runtime-mode', { mode }),
ownerAutoGrant: (enabled) => jsonPost('/api/owner/auto-grant', { enabled: Boolean(enabled) }),
ownerContextMode: (mode) => jsonPost('/api/owner/context-mode', { mode }),
/** @returns {Promise<import('./api_types.js').OwnerScopeReviewFloorResponse>} */
// DEPRECATED (v6.80.0): the value is stored but nothing consults it — BIBLE P3
// scope-review applicability follows the owner context mode. Kept as a frozen
// contract surface; the response carries an explicit deprecation notice.
ownerScopeReviewFloor: (floor) => jsonPost('/api/owner/scope-review-floor', { floor }),
/** @returns {Promise<import('./api_types.js').OwnerSafetyModeResponse>} */
ownerSafetyMode: (mode) => jsonPost('/api/owner/safety-mode', { mode }),
logsTail: (name, limit = 2000) => fetchJson(`/api/logs/${encodeURIComponent(name)}?limit=${encodeURIComponent(limit)}`, { cache: 'no-store' }),
ownerCapabilityAck: (payload) => jsonPost('/api/owner/capability-ack', payload),
/** @returns {Promise<import('./api_types.js').OpenAICompatibleModelsResponse>} */
openAICompatibleModels: (payload) => jsonPost('/api/openai-compatible/models', payload),
/**
* @param {import('./api_types.js').ProviderTestRequest} payload
* @returns {Promise<import('./api_types.js').ProviderTestResponse>}
*/
providerTest: (payload) => jsonPost('/api/providers/test', payload),
extensions: () => fetchJson('/api/extensions', { cache: 'no-store' }),
/**
* Widgets page cards: live extension UI tabs projected from the loader
* snapshot (no skill discovery), each stamped with the owning skill's
* payload `revision`.
* @returns {Promise<import('./api_types.js').WidgetsResponse>}
*/
widgets: () => fetchJson('/api/widgets', { cache: 'no-store' }),
skillPublishPreflight,
createTask,
skillLifecycleQueue: () => fetchJson('/api/skills/lifecycle-queue', { cache: 'no-store' }),
/** @returns {Promise<import('./api_types.js').SkillDeleteResponse>} */
deleteSkill: (skill, payloadRoot) => jsonPost(`/api/skills/${encodeURIComponent(skill)}/delete`, {
payload_root: payloadRoot,
}),
skillGrants: (skill, items) => jsonPost(`/api/skills/${encodeURIComponent(skill)}/grants`, { items }),
/**
* @param {string} skill
* @param {import('./api_types.js').OwnerSkillPresenceRuntimeRequest} payload
* @returns {Promise<import('./api_types.js').OwnerSkillPresenceRuntimeResponse>}
*/
savePresenceRuntime: (skill, payload) => jsonPost(
`/api/owner/skills/${encodeURIComponent(skill)}/presence-runtime`,
payload,
),
chatHistory: (limit = 1000) => fetchJson(`/api/chat/history?limit=${encodeURIComponent(limit)}`, { cache: 'no-store' }),
projectFromTask: (taskId, id, name, objectiveHint = '') => jsonPost('/api/projects/from-task', { task_id: taskId, id, name, objective_hint: objectiveHint }),
/** @param {import('./api_types.js').ProjectCreateRequest} payload */
projectCreate: (payload) => jsonPost('/api/projects', payload),
projectUpdate: (projectId, name) => jsonPost(`/api/projects/${encodeURIComponent(projectId)}/update`, { name }),
/** @returns {Promise<import('./api_types.js').ProjectDeleteResponse>} */
projectDelete: (projectId) => jsonPost(`/api/projects/${encodeURIComponent(projectId)}/delete`, {}),
/** @returns {Promise<import('./api_types.js').FsDirsResponse>} */
fsDirs: (path = '') => fetchJson(`/api/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ''}`, { cache: 'no-store' }),
updateStatus: () => fetchJson('/api/update/status', { cache: 'no-store' }),
updateCheck: () => jsonPost('/api/update/check', {}),
/** @returns {Promise<import('./api_types.js').UpdatePreflightResponse>} */
updatePreflight: () => jsonPost('/api/update/preflight', {}),
/** @returns {Promise<import('./api_types.js').UpdateApplySuccessResponse>} */
updateApply: (strategy, plan = {}, { confirmRecovery = false } = {}) => jsonPost('/api/update/apply', {
strategy,
expected_base_sha: String(plan.base_sha || ''),
expected_target_sha: String(plan.target_sha || ''),
...(confirmRecovery ? { confirm_recovery: true } : {}),
}),
};