mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
* Chart-transform plugin capability * PR comments * Cleanup * Fix markdown * CodeRabbit feedback Signed-off-by: Joe <jphinspace@gmail.com> --------- Signed-off-by: Joe <jphinspace@gmail.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
1541 lines
77 KiB
JavaScript
1541 lines
77 KiB
JavaScript
// FeedBack capability registry and dispatcher.
|
|
(function () {
|
|
'use strict';
|
|
|
|
function _fallbackEventTarget() {
|
|
const listeners = new Map();
|
|
return {
|
|
addEventListener(type, handler, options) {
|
|
if (typeof handler !== 'function') return;
|
|
const eventType = String(type || '');
|
|
const list = listeners.get(eventType) || [];
|
|
list.push({ handler, once: !!(options && options.once) });
|
|
listeners.set(eventType, list);
|
|
},
|
|
removeEventListener(type, handler) {
|
|
const eventType = String(type || '');
|
|
const list = listeners.get(eventType) || [];
|
|
listeners.set(eventType, list.filter(entry => entry.handler !== handler));
|
|
},
|
|
dispatchEvent(event) {
|
|
if (!event || !event.type) return true;
|
|
const eventType = String(event.type);
|
|
const list = (listeners.get(eventType) || []).slice();
|
|
for (const entry of list) {
|
|
entry.handler(event);
|
|
if (entry.once) this.removeEventListener(eventType, entry.handler);
|
|
}
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function _ensureFeedBackEventBus() {
|
|
const existing = window.feedBack && typeof window.feedBack === 'object' ? window.feedBack : null;
|
|
const hasEventTarget = existing
|
|
&& typeof existing.addEventListener === 'function'
|
|
&& typeof existing.removeEventListener === 'function'
|
|
&& typeof existing.dispatchEvent === 'function';
|
|
const bus = hasEventTarget
|
|
? existing
|
|
: (typeof EventTarget === 'function' ? new EventTarget() : _fallbackEventTarget());
|
|
if (existing && existing !== bus) {
|
|
for (const key of Object.keys(existing)) {
|
|
if (!(key in bus)) bus[key] = existing[key];
|
|
}
|
|
}
|
|
bus.emit = function (event, detail) {
|
|
this.dispatchEvent(new CustomEvent(event, { detail }));
|
|
};
|
|
bus.on = function (event, fn, options) {
|
|
this.addEventListener(event, fn, options);
|
|
};
|
|
bus.off = function (event, fn, options) {
|
|
this.removeEventListener(event, fn, options);
|
|
};
|
|
window.feedBack = bus;
|
|
return bus;
|
|
}
|
|
|
|
_ensureFeedBackEventBus();
|
|
if (window.feedBack.capabilities && window.feedBack.capabilities.version === 1) return;
|
|
|
|
const VALID_ROLES = new Set([
|
|
'owner', 'coordinator', 'provider', 'executor', 'observer', 'requester', 'transformer', 'handler',
|
|
'validator', 'short-circuiter', 'contributor',
|
|
]);
|
|
const VALID_MODES = new Set(['active', 'optional', 'legacy-shim', 'disabled']);
|
|
const VALID_COMPATIBILITY = new Set(['none', 'shim-allowed', 'degrade-noop', 'required', 'legacy-window-shim']);
|
|
const VALID_OWNERSHIP = new Set(['exclusive-owner', 'multi-provider', 'observer-only', 'requester-only', 'privileged', 'diagnostic-only']);
|
|
const VALID_DOMAIN_KINDS = new Set(['command', 'provider-coordinator', 'event', 'diagnostic', 'privileged']);
|
|
const VALID_SAFETY = new Set(['safe', 'privileged', 'sensitive', 'diagnostic-only']);
|
|
const OUTCOMES = new Set([
|
|
'passed', 'transformed', 'handled', 'denied', 'degraded', 'failed',
|
|
'short-circuited', 'overridden', 'no-owner', 'no-handler',
|
|
'unsupported-command', 'incompatible', 'incompatible-version',
|
|
'unavailable', 'provider-selection-required', 'user-action-required', 'no-target',
|
|
'stale', 'cancelled', 'stopped',
|
|
]);
|
|
const MAX_DECISIONS = 100;
|
|
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
const DEFAULT_HANDLER_TIMEOUT_MS = 250;
|
|
// Per-(capability, command) handler-timeout overrides. A few commands front a
|
|
// user-action / OS-permission prompt (fader reads that await a UI; MIDI/mic
|
|
// device access) that legitimately runs far longer than the default budget,
|
|
// so the dispatch/command surface must not fail them at 250 ms while the
|
|
// operation is still completing through the provider.
|
|
const COMMAND_TIMEOUTS_MS = {
|
|
'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 },
|
|
'midi-input': { 'discover': 15000, 'open-source': 15000 },
|
|
};
|
|
function _commandTimeoutFor(capability, commandName) {
|
|
const byCap = COMMAND_TIMEOUTS_MS[capability];
|
|
return byCap ? byCap[commandName] : undefined;
|
|
}
|
|
const RESERVED_FUTURE_DOMAINS = new Set([
|
|
'ui.navigation',
|
|
'ui.plugin-screens',
|
|
'settings',
|
|
'backend.routes',
|
|
'ui.player-controls',
|
|
'ui.player-panels',
|
|
'ui.player-overlays',
|
|
'plugins',
|
|
'jobs',
|
|
'midi-control',
|
|
'tempo-clock',
|
|
]);
|
|
const RUNTIME_DOMAIN_DEFAULTS = Object.freeze({
|
|
library: Object.freeze({
|
|
roles: ['provider'],
|
|
commands: [],
|
|
operations: ['query-page', 'query-artists', 'query-stats', 'get-art', 'sync-song'],
|
|
events: [],
|
|
description: 'Provides library source operations to the core library domain owner.',
|
|
compatibility: 'none',
|
|
safety: 'safe',
|
|
}),
|
|
});
|
|
const CORE_DOMAIN_REVIEW = Object.freeze({
|
|
diagnostics: Object.freeze({ lifecycle: 'diagnostic', label: 'Snapshot surface', tone: 'info', summary: 'Read-only diagnostics snapshot/export facade for support bundles and the Capability Inspector.' }),
|
|
'audio-input': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Sensitive input-source identity, availability, and redacted diagnostics for monitoring and later note detection.' }),
|
|
'audio-effects': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Provider-selected effect routes, constrained chain-plan resolution, route/stage controls, fallback, and redaction-safe diagnostics.' }),
|
|
'audio-mix': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated song route, fader, participant, and analyser inspection surface.' }),
|
|
'audio-monitoring': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated monitoring lifecycle, availability, consent, and bridge diagnostics.' }),
|
|
library: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Current local and plugin-provided library source selection and sync surface.' }),
|
|
playback: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated song transport, timing, loop, route, requester, bridge, and diagnostics surface.' }),
|
|
pipeline: Object.freeze({ lifecycle: 'diagnostic', label: 'Graph controls', tone: 'info', summary: 'Capability graph operations: resolve, inspect, validate, and enable or disable participants.' }),
|
|
stems: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated stem automation, restore, manual override, and compatibility bridge surface backed by the active Stems provider.' }),
|
|
visualization: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated highway renderer providers: discovery, picker selection, auto-match attribution, failure fallback, and redaction-safe diagnostics.' }),
|
|
'note-detection': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates note-detection providers and requester-owned, context-scoped detection bindings (spec 009); consumers own judgment, hit/miss flow as observability events.' }),
|
|
'chart-transform': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with persisted selection, refresh, and fixed-reason failure attribution (#952).' }),
|
|
});
|
|
const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({});
|
|
|
|
const pipelines = new Map();
|
|
const recentDecisions = [];
|
|
const missingProviders = [];
|
|
const compatibilityShims = [];
|
|
const userOverrides = [];
|
|
const claimLifecycle = [];
|
|
const unsupportedVersions = [];
|
|
const activeClaims = new Map();
|
|
const subscribers = new Map();
|
|
const knownPlugins = new Map();
|
|
let commandSeq = 0;
|
|
let decisionSeq = 0;
|
|
|
|
function _now() { return new Date().toISOString(); }
|
|
|
|
function _asArray(value) {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function _uniqueStrings(value, allowed = null) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const item of _asArray(value)) {
|
|
if (typeof item !== 'string' || !item.trim()) continue;
|
|
const normalized = item.trim();
|
|
if (allowed && !allowed.has(normalized)) continue;
|
|
if (seen.has(normalized)) continue;
|
|
seen.add(normalized);
|
|
out.push(normalized);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function _shortDescription(value) {
|
|
if (typeof value !== 'string') return '';
|
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized;
|
|
}
|
|
|
|
function _order(order) {
|
|
const source = order && typeof order === 'object' ? order : {};
|
|
return {
|
|
fixed: !!source.fixed,
|
|
before: _uniqueStrings(source.before),
|
|
after: _uniqueStrings(source.after),
|
|
};
|
|
}
|
|
|
|
function _ownershipForKind(kind) {
|
|
if (kind === 'provider-coordinator') return 'multi-provider';
|
|
if (kind === 'diagnostic') return 'diagnostic-only';
|
|
if (kind === 'privileged') return 'privileged';
|
|
return 'exclusive-owner';
|
|
}
|
|
|
|
function _manifestUnsupported(source) {
|
|
const standards = _standardList(source);
|
|
if (standards.some(item => /^capability-pipelines\.v(?!1$)/.test(item))) return true;
|
|
const version = Number(source && source.version);
|
|
return Number.isFinite(version) && version !== 1;
|
|
}
|
|
|
|
// Recursive clone of a plain-JSON value into null-prototype objects, so a
|
|
// consumer mutating a returned copy can't leak back into registry state and
|
|
// a manifest-controlled `__proto__` key is an ordinary own property rather
|
|
// than a prototype-pollution vector.
|
|
function _deepCloneJson(value) {
|
|
if (Array.isArray(value)) return value.map(_deepCloneJson);
|
|
if (value && typeof value === 'object') {
|
|
const copy = Object.create(null);
|
|
for (const key of Object.keys(value)) copy[key] = _deepCloneJson(value[key]);
|
|
return copy;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const VALID_SETTING_TYPES = new Set(['toggle', 'range', 'select']);
|
|
|
|
// Normalize per-instance control descriptors (feedBack#849) declared on a
|
|
// capability. Lenient (drops malformed entries rather than failing the whole
|
|
// declaration): the server-side validator in plugins/__init__.py already
|
|
// strict-checks the /api/plugins manifest path; this keeps runtime
|
|
// registerParticipant calls forgiving while preserving the descriptors on
|
|
// the registered participant so generic inspect() — not just the
|
|
// visualization side channel — sees them.
|
|
function _normalizeSettings(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const key = typeof entry.key === 'string' ? entry.key.trim() : '';
|
|
if (!key || seen.has(key) || !VALID_SETTING_TYPES.has(entry.type)) continue;
|
|
const descriptor = { key, type: entry.type };
|
|
if (typeof entry.label === 'string' && entry.label.trim()) descriptor.label = entry.label.trim();
|
|
// Clone `default` (schema-unconstrained, can be a nested object/array)
|
|
// so a caller mutating its own object after registerParticipant()
|
|
// can't reach into registry state.
|
|
if ('default' in entry) descriptor.default = _deepCloneJson(entry.default);
|
|
for (const num of ['min', 'max', 'step']) {
|
|
if (typeof entry[num] === 'number' && Number.isFinite(entry[num])) descriptor[num] = entry[num];
|
|
}
|
|
if (entry.type === 'select' && Array.isArray(entry.options)) {
|
|
const opts = [];
|
|
for (const opt of entry.options) {
|
|
if (!opt || typeof opt !== 'object') continue;
|
|
const id = typeof opt.id === 'string' ? opt.id.trim() : '';
|
|
if (!id) continue;
|
|
const normalized = { id };
|
|
if (typeof opt.label === 'string' && opt.label.trim()) normalized.label = opt.label.trim();
|
|
opts.push(normalized);
|
|
}
|
|
if (opts.length) descriptor.options = opts;
|
|
}
|
|
out.push(descriptor);
|
|
seen.add(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function _normalizeDeclaration(declaration, container = null) {
|
|
const source = declaration && typeof declaration === 'object' ? declaration : {};
|
|
const mode = VALID_MODES.has(source.mode) ? source.mode : 'active';
|
|
const kind = VALID_DOMAIN_KINDS.has(source.kind) ? source.kind : '';
|
|
const compatibility = VALID_COMPATIBILITY.has(source.compatibility)
|
|
? source.compatibility
|
|
: 'degrade-noop';
|
|
const ownership = VALID_OWNERSHIP.has(source.ownership) ? source.ownership : _ownershipForKind(kind);
|
|
const safety = VALID_SAFETY.has(source.safety) ? source.safety : 'safe';
|
|
const incompatible = !!(source.incompatible || source.unsupportedVersion || _manifestUnsupported(source) || _manifestUnsupported(container));
|
|
return {
|
|
roles: _uniqueStrings(source.roles, VALID_ROLES),
|
|
events: _uniqueStrings(source.events),
|
|
commands: _uniqueStrings(source.commands),
|
|
operations: _uniqueStrings(source.operations || source.providerOperations || source.provider_operations),
|
|
requests: _uniqueStrings(source.requests),
|
|
observes: _uniqueStrings(source.observes),
|
|
emits: _uniqueStrings(source.emits),
|
|
description: _shortDescription(source.description) || _shortDescription(source.summary),
|
|
order: _order(source.order),
|
|
kind,
|
|
mode: incompatible ? 'disabled' : mode,
|
|
compatibility,
|
|
ownership,
|
|
safety,
|
|
providerPolicy: source.provider_policy && typeof source.provider_policy === 'object' ? source.provider_policy : {},
|
|
settings: _normalizeSettings(source.settings),
|
|
handlers: source.handlers && typeof source.handlers === 'object' ? source.handlers : {},
|
|
eventHandlers: source.eventHandlers && typeof source.eventHandlers === 'object' ? source.eventHandlers : {},
|
|
runtime: !!source.runtime,
|
|
availability: incompatible ? 'incompatible' : (mode === 'disabled' ? 'disabled' : 'available'),
|
|
incompatible,
|
|
version: Number(source.version) || 1,
|
|
};
|
|
}
|
|
|
|
function _runtimeDomainRoleList(source) {
|
|
const roles = _uniqueStrings(source && source.roles, VALID_ROLES);
|
|
const role = typeof (source && source.role) === 'string' ? source.role.trim() : '';
|
|
if (role && VALID_ROLES.has(role) && !roles.includes(role)) roles.push(role);
|
|
return roles;
|
|
}
|
|
|
|
function _runtimeDomainDeclaration(domainName, declaration) {
|
|
if (!domainName || !declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return null;
|
|
const canonical = _canonicalCapabilityName(domainName);
|
|
const defaults = RUNTIME_DOMAIN_DEFAULTS[canonical] || {};
|
|
const roles = _runtimeDomainRoleList(declaration);
|
|
const commands = _uniqueStrings(declaration.commands || defaults.commands);
|
|
const operations = _uniqueStrings(declaration.operations || declaration.providerOperations || declaration.provider_operations || defaults.operations);
|
|
const requests = _uniqueStrings(declaration.requests || defaults.requests);
|
|
const observes = _uniqueStrings(declaration.observes || defaults.observes);
|
|
const emits = _uniqueStrings(declaration.emits || defaults.emits);
|
|
const events = _uniqueStrings(declaration.events || defaults.events);
|
|
const kind = VALID_DOMAIN_KINDS.has(declaration.kind) ? declaration.kind : (defaults.kind || '');
|
|
const normalized = {
|
|
roles: roles.length ? roles : _uniqueStrings(defaults.roles, VALID_ROLES),
|
|
commands,
|
|
operations,
|
|
requests,
|
|
observes,
|
|
emits,
|
|
events,
|
|
kind,
|
|
mode: VALID_MODES.has(declaration.mode) ? declaration.mode : 'active',
|
|
compatibility: VALID_COMPATIBILITY.has(declaration.compatibility) ? declaration.compatibility : (defaults.compatibility || 'degrade-noop'),
|
|
ownership: VALID_OWNERSHIP.has(declaration.ownership) ? declaration.ownership : (defaults.ownership || _ownershipForKind(kind)),
|
|
safety: VALID_SAFETY.has(declaration.safety) ? declaration.safety : (defaults.safety || 'safe'),
|
|
};
|
|
const description = _shortDescription(declaration.description) || _shortDescription(declaration.summary) || _shortDescription(defaults.description);
|
|
if (description) normalized.description = description;
|
|
if (declaration.order && typeof declaration.order === 'object') normalized.order = declaration.order;
|
|
if (declaration.provider_policy && typeof declaration.provider_policy === 'object') normalized.provider_policy = declaration.provider_policy;
|
|
if (!normalized.roles.length || (!normalized.commands.length && !normalized.operations.length && !normalized.requests.length && !normalized.events.length && !normalized.observes.length && !normalized.emits.length)) return null;
|
|
return normalized;
|
|
}
|
|
|
|
function _runtimeDomainCapabilityMap(declaration) {
|
|
if (!declaration || typeof declaration !== 'object') return {};
|
|
const domains = {
|
|
...(declaration.runtime_domains && typeof declaration.runtime_domains === 'object' ? declaration.runtime_domains : {}),
|
|
...(declaration.domains && typeof declaration.domains === 'object' ? declaration.domains : {}),
|
|
};
|
|
const capabilities = {};
|
|
for (const [domainName, domainDeclaration] of Object.entries(domains)) {
|
|
const normalized = _runtimeDomainDeclaration(domainName, domainDeclaration);
|
|
if (normalized) capabilities[_canonicalCapabilityName(domainName)] = normalized;
|
|
}
|
|
return capabilities;
|
|
}
|
|
|
|
function _capabilityMap(declaration) {
|
|
if (!declaration || typeof declaration !== 'object') return {};
|
|
const runtimeCapabilities = _runtimeDomainCapabilityMap(declaration);
|
|
if (declaration.capabilities && typeof declaration.capabilities === 'object') {
|
|
return { ...runtimeCapabilities, ...declaration.capabilities };
|
|
}
|
|
if (Object.keys(runtimeCapabilities).length) return runtimeCapabilities;
|
|
if ('id' in declaration || 'name' in declaration || 'pluginId' in declaration || 'ui_contributions' in declaration || 'ui' in declaration) return {};
|
|
return declaration;
|
|
}
|
|
|
|
function _standardList(source) {
|
|
if (!source || typeof source !== 'object' || !Array.isArray(source.standards)) return [];
|
|
const result = [];
|
|
const seen = new Set();
|
|
for (const entry of source.standards) {
|
|
if (typeof entry !== 'string' || !entry.trim()) continue;
|
|
const standard = entry.trim();
|
|
if (seen.has(standard)) continue;
|
|
seen.add(standard);
|
|
result.push(standard);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function _canonicalCapabilityName(name) {
|
|
const value = String(name || '').trim();
|
|
return value === 'library.providers' ? 'library' : value;
|
|
}
|
|
|
|
function _rememberPluginManifest(pluginId, declaration) {
|
|
if (!pluginId || typeof pluginId !== 'string') return;
|
|
const existing = knownPlugins.get(pluginId) || {
|
|
pluginId,
|
|
capabilities: new Set(),
|
|
standards: new Set(),
|
|
firstSeenAt: _now(),
|
|
updatedAt: null,
|
|
};
|
|
for (const standard of _standardList(declaration)) {
|
|
existing.standards.add(standard);
|
|
}
|
|
const caps = _capabilityMap(declaration && declaration.declaration ? declaration.declaration : declaration);
|
|
for (const [capabilityName, rawDeclaration] of Object.entries(caps)) {
|
|
const canonicalCapabilityName = _canonicalCapabilityName(capabilityName);
|
|
if (!canonicalCapabilityName || !rawDeclaration || typeof rawDeclaration !== 'object') continue;
|
|
if (RESERVED_FUTURE_DOMAINS.has(canonicalCapabilityName)) continue;
|
|
existing.capabilities.add(canonicalCapabilityName);
|
|
}
|
|
if (_manifestUnsupported(declaration)) {
|
|
_remember(unsupportedVersions, {
|
|
pluginId,
|
|
standards: _standardList(declaration),
|
|
reason: 'Unsupported capability-pipelines version',
|
|
});
|
|
}
|
|
existing.updatedAt = _now();
|
|
knownPlugins.set(pluginId, existing);
|
|
}
|
|
|
|
function _pluginEntryId(entry) {
|
|
if (!entry || typeof entry !== 'object') return '';
|
|
return typeof entry.pluginId === 'string' ? entry.pluginId : (typeof entry.id === 'string' ? entry.id : '');
|
|
}
|
|
|
|
function _pluginEntryDeclaration(entry) {
|
|
if (!entry || typeof entry !== 'object') return {};
|
|
if (entry.declaration && typeof entry.declaration === 'object') return entry.declaration;
|
|
return _capabilityMap(entry);
|
|
}
|
|
|
|
function _pipeline(name) {
|
|
const capabilityName = _canonicalCapabilityName(name);
|
|
if (!pipelines.has(capabilityName)) {
|
|
pipelines.set(capabilityName, {
|
|
name: capabilityName,
|
|
participants: new Map(),
|
|
order: [],
|
|
conflicts: [],
|
|
resolvedAt: null,
|
|
});
|
|
}
|
|
return pipelines.get(capabilityName);
|
|
}
|
|
|
|
function _mergeParticipant(existing, incoming) {
|
|
if (!existing) return incoming;
|
|
const runtimeOverride = existing.runtimeOverride;
|
|
const mergedDeclarationMode = incoming.declarationMode || existing.declarationMode;
|
|
const mergedMode = runtimeOverride
|
|
? (runtimeOverride.enabled ? (mergedDeclarationMode !== 'disabled' ? mergedDeclarationMode : 'active') : 'disabled')
|
|
: (incoming.mode || existing.mode);
|
|
return {
|
|
...existing,
|
|
roles: _uniqueStrings([...(existing.roles || []), ...(incoming.roles || [])], VALID_ROLES),
|
|
events: _uniqueStrings([...(existing.events || []), ...(incoming.events || [])]),
|
|
commands: _uniqueStrings([...(existing.commands || []), ...(incoming.commands || [])]),
|
|
operations: _uniqueStrings([...(existing.operations || []), ...(incoming.operations || [])]),
|
|
requests: _uniqueStrings([...(existing.requests || []), ...(incoming.requests || [])]),
|
|
observes: _uniqueStrings([...(existing.observes || []), ...(incoming.observes || [])]),
|
|
emits: _uniqueStrings([...(existing.emits || []), ...(incoming.emits || [])]),
|
|
order: {
|
|
fixed: !!(existing.order && existing.order.fixed) || !!(incoming.order && incoming.order.fixed),
|
|
before: _uniqueStrings([...(existing.order?.before || []), ...(incoming.order?.before || [])]),
|
|
after: _uniqueStrings([...(existing.order?.after || []), ...(incoming.order?.after || [])]),
|
|
},
|
|
mode: mergedMode,
|
|
kind: incoming.kind || existing.kind || '',
|
|
compatibility: incoming.compatibility || existing.compatibility,
|
|
ownership: incoming.ownership || existing.ownership || 'exclusive-owner',
|
|
safety: incoming.safety || existing.safety || 'safe',
|
|
description: incoming.description || existing.description || '',
|
|
providerPolicy: { ...(existing.providerPolicy || {}), ...(incoming.providerPolicy || {}) },
|
|
// Prefer an incoming settings list (a deliberate re-declaration) but
|
|
// fall back to the existing one — a runtime owner re-registering a
|
|
// manifest participant (e.g. the viz host adding provider_policy)
|
|
// doesn't carry settings and must not wipe the manifest's.
|
|
settings: (incoming.settings && incoming.settings.length) ? incoming.settings : (existing.settings || []),
|
|
handlers: { ...(existing.handlers || {}), ...(incoming.handlers || {}) },
|
|
eventHandlers: { ...(existing.eventHandlers || {}), ...(incoming.eventHandlers || {}) },
|
|
runtime: !!existing.runtime || !!incoming.runtime,
|
|
availability: incoming.availability || existing.availability || 'available',
|
|
incompatible: !!existing.incompatible || !!incoming.incompatible,
|
|
version: incoming.version || existing.version || 1,
|
|
declarationMode: mergedDeclarationMode,
|
|
runtimeOverride,
|
|
};
|
|
}
|
|
|
|
function _missingOrderPeerConflict(capabilityName, participant, peer, relation) {
|
|
const inactivePeer = _pipeline(capabilityName).participants.get(peer);
|
|
if (inactivePeer && inactivePeer.mode === 'disabled') {
|
|
return {
|
|
type: 'disabled-order-peer',
|
|
participant: participant.pluginId,
|
|
peer,
|
|
reason: `${relation} peer is registered but disabled`,
|
|
};
|
|
}
|
|
const knownPeer = knownPlugins.get(peer);
|
|
if (knownPeer && !(knownPeer.capabilities || new Set()).has(capabilityName)) {
|
|
return {
|
|
type: 'missing-capability-peer',
|
|
participant: participant.pluginId,
|
|
peer,
|
|
reason: `${relation} peer is registered but does not declare capability ${capabilityName}`,
|
|
};
|
|
}
|
|
return {
|
|
type: 'missing-order-peer',
|
|
participant: participant.pluginId,
|
|
peer,
|
|
reason: `${relation} peer is not registered`,
|
|
};
|
|
}
|
|
|
|
function _participantPriority(participant) {
|
|
if (participant.roles.includes('owner')) return 0;
|
|
if (participant.roles.includes('coordinator')) return 1;
|
|
if (participant.roles.includes('provider')) return 2;
|
|
if (participant.roles.includes('validator')) return 3;
|
|
if (participant.roles.includes('transformer')) return 4;
|
|
if (participant.roles.includes('handler')) return 5;
|
|
if (participant.roles.includes('short-circuiter')) return 6;
|
|
return 7;
|
|
}
|
|
|
|
function _resolvePipeline(name) {
|
|
const pipeline = _pipeline(name);
|
|
const participants = Array.from(pipeline.participants.values())
|
|
.filter(p => p.mode !== 'disabled')
|
|
.sort((a, b) => {
|
|
const fixedDelta = Number(!!b.order.fixed) - Number(!!a.order.fixed);
|
|
if (fixedDelta) return fixedDelta;
|
|
const priorityDelta = _participantPriority(a) - _participantPriority(b);
|
|
if (priorityDelta) return priorityDelta;
|
|
return a.pluginId.localeCompare(b.pluginId);
|
|
});
|
|
|
|
const byId = new Map(participants.map(p => [p.pluginId, p]));
|
|
const ids = participants.map(p => p.pluginId);
|
|
const edges = new Map(ids.map(id => [id, new Set()]));
|
|
const conflicts = [];
|
|
|
|
for (const participant of participants) {
|
|
for (const before of participant.order.before || []) {
|
|
if (!byId.has(before)) {
|
|
conflicts.push(_missingOrderPeerConflict(name, participant, before, 'before'));
|
|
continue;
|
|
}
|
|
edges.get(participant.pluginId).add(before);
|
|
}
|
|
for (const after of participant.order.after || []) {
|
|
if (!byId.has(after)) {
|
|
conflicts.push(_missingOrderPeerConflict(name, participant, after, 'after'));
|
|
continue;
|
|
}
|
|
edges.get(after).add(participant.pluginId);
|
|
}
|
|
}
|
|
|
|
const owners = participants.filter(p => p.roles.includes('owner') && p.ownership !== 'multi-provider');
|
|
if (owners.length > 1) {
|
|
conflicts.push({
|
|
type: 'duplicate-owner',
|
|
participants: owners.map(p => p.pluginId),
|
|
reason: `Capability ${name} has multiple owners`,
|
|
});
|
|
}
|
|
|
|
const indegree = new Map(ids.map(id => [id, 0]));
|
|
for (const [, next] of edges) {
|
|
for (const id of next) indegree.set(id, (indegree.get(id) || 0) + 1);
|
|
}
|
|
const baseOrder = new Map(ids.map((id, idx) => [id, idx]));
|
|
const queue = ids.filter(id => indegree.get(id) === 0)
|
|
.sort((a, b) => baseOrder.get(a) - baseOrder.get(b));
|
|
const insertByBaseOrder = (id) => {
|
|
const rank = baseOrder.get(id);
|
|
let low = 0;
|
|
let high = queue.length;
|
|
while (low < high) {
|
|
const mid = (low + high) >>> 1;
|
|
if (baseOrder.get(queue[mid]) < rank) low = mid + 1;
|
|
else high = mid;
|
|
}
|
|
queue.splice(low, 0, id);
|
|
};
|
|
const resolved = [];
|
|
while (queue.length) {
|
|
const id = queue.shift();
|
|
resolved.push(id);
|
|
for (const next of edges.get(id) || []) {
|
|
indegree.set(next, indegree.get(next) - 1);
|
|
if (indegree.get(next) === 0) {
|
|
insertByBaseOrder(next);
|
|
}
|
|
}
|
|
}
|
|
if (resolved.length !== ids.length) {
|
|
conflicts.push({ type: 'order-cycle', reason: `Capability ${name} has incompatible ordering constraints` });
|
|
pipeline.order = ids;
|
|
} else {
|
|
pipeline.order = resolved;
|
|
}
|
|
pipeline.conflicts = conflicts;
|
|
pipeline.resolvedAt = _now();
|
|
_emitEvent('pipeline', 'resolved', { capability: name, order: pipeline.order, conflicts });
|
|
return pipeline;
|
|
}
|
|
|
|
function _participantSummary(participant) {
|
|
return {
|
|
pluginId: participant.pluginId,
|
|
capability: participant.capability,
|
|
roles: participant.roles.slice(),
|
|
events: participant.events.slice(),
|
|
commands: participant.commands.slice(),
|
|
operations: (participant.operations || []).slice(),
|
|
requests: (participant.requests || []).slice(),
|
|
observes: (participant.observes || []).slice(),
|
|
emits: (participant.emits || []).slice(),
|
|
description: participant.description || '',
|
|
order: {
|
|
fixed: !!participant.order.fixed,
|
|
before: participant.order.before.slice(),
|
|
after: participant.order.after.slice(),
|
|
},
|
|
mode: participant.mode,
|
|
declarationMode: participant.declarationMode || participant.mode,
|
|
enabled: participant.mode !== 'disabled',
|
|
kind: participant.kind || '',
|
|
compatibility: participant.compatibility,
|
|
ownership: participant.ownership || 'exclusive-owner',
|
|
safety: participant.safety || 'safe',
|
|
providerPolicy: participant.providerPolicy || {},
|
|
// Deep-clone (not shallow): `default` is schema-unconstrained, so a
|
|
// shallow copy would leak nested-value references and let a consumer
|
|
// mutate registry state through inspect()/diagnostics.
|
|
settings: _deepCloneJson(participant.settings || []),
|
|
availability: participant.availability || (participant.mode === 'disabled' ? 'disabled' : 'available'),
|
|
incompatible: !!participant.incompatible,
|
|
version: participant.version || 1,
|
|
runtime: !!participant.runtime,
|
|
runtimeOverride: participant.runtimeOverride || null,
|
|
};
|
|
}
|
|
|
|
function _pipelineParticipants(pipeline) {
|
|
const ordered = [];
|
|
const seen = new Set();
|
|
for (const id of pipeline.order) {
|
|
const participant = pipeline.participants.get(id);
|
|
if (!participant) continue;
|
|
ordered.push(participant);
|
|
seen.add(id);
|
|
}
|
|
const remaining = Array.from(pipeline.participants.values())
|
|
.filter(participant => !seen.has(participant.pluginId))
|
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
return [...ordered, ...remaining];
|
|
}
|
|
|
|
function _pipelineSummary(pipeline) {
|
|
return {
|
|
name: pipeline.name,
|
|
review: _domainReviewSummary(pipeline.name),
|
|
order: pipeline.order.slice(),
|
|
resolvedAt: pipeline.resolvedAt,
|
|
conflicts: pipeline.conflicts.slice(),
|
|
participants: _pipelineParticipants(pipeline).map(_participantSummary),
|
|
};
|
|
}
|
|
|
|
function _domainReviewSummary(domainName) {
|
|
return CORE_DOMAIN_REVIEW[domainName] || {
|
|
lifecycle: 'plugin-defined',
|
|
label: 'Plugin-defined',
|
|
tone: 'info',
|
|
summary: 'Declared by a plugin or test fixture rather than registered as a core FeedBack domain.',
|
|
};
|
|
}
|
|
|
|
function _knownPluginSummary(plugin) {
|
|
return {
|
|
pluginId: plugin.pluginId,
|
|
standards: Array.from(plugin.standards || []).sort(),
|
|
capabilities: Array.from(plugin.capabilities || []).sort(),
|
|
firstSeenAt: plugin.firstSeenAt,
|
|
updatedAt: plugin.updatedAt,
|
|
};
|
|
}
|
|
|
|
function _expectedCompatibilityShimSummary() {
|
|
return Object.entries(EXPECTED_COMPATIBILITY_SHIMS).flatMap(([capability, shims]) => (
|
|
shims.map(shim => ({ capability, status: 'expected', ...shim }))
|
|
));
|
|
}
|
|
|
|
function _redactString(value) {
|
|
return String(value)
|
|
.replace(/\/Users\/[^\s/]+(?:\/[^\s]*)?/g, '[path]')
|
|
.replace(/[A-Za-z]:\\[^\s]+/g, '[path]')
|
|
.replace(/\b(token|secret|password|api[_-]?key)=([^\s&]+)/gi, '$1=[redacted]');
|
|
}
|
|
|
|
function _safeValue(value, depth = 0) {
|
|
if (typeof value === 'string') return _redactString(value);
|
|
if (value == null || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
if (depth > 8) return '[truncated]';
|
|
if (Array.isArray(value)) return value.slice(0, 50).map(item => _safeValue(item, depth + 1));
|
|
if (typeof value === 'object') {
|
|
const out = {};
|
|
for (const [key, item] of Object.entries(value).slice(0, 50)) {
|
|
out[_redactString(key)] = _safeValue(item, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function _recordDecision(decision) {
|
|
recentDecisions.push({ decisionId: `decision-${++decisionSeq}`, timestamp: _now(), ...decision });
|
|
while (recentDecisions.length > MAX_DECISIONS) recentDecisions.shift();
|
|
_contributeDiagnostics();
|
|
}
|
|
|
|
function _remember(list, entry, max = 50) {
|
|
list.push({ timestamp: _now(), ...entry });
|
|
while (list.length > max) list.shift();
|
|
_contributeDiagnostics();
|
|
}
|
|
|
|
function _claimKey(capability, claimId) {
|
|
return `${capability || 'unknown'}:${claimId || 'unknown'}`;
|
|
}
|
|
|
|
function _targetSelector(source = {}) {
|
|
const payload = source.payload && typeof source.payload === 'object' ? source.payload : source.args || source;
|
|
const target = payload.target && typeof payload.target === 'object' ? payload.target : source.target || {};
|
|
const value = payload.selector || target.selector || target.id || target.kind || source.selector || '*';
|
|
return String(value || '*').toLowerCase();
|
|
}
|
|
|
|
function _claimFromContext(ctx) {
|
|
const payload = ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
|
|
const claim = ctx.claim && typeof ctx.claim === 'object' ? ctx.claim : (payload.claim && typeof payload.claim === 'object' ? payload.claim : {});
|
|
const claimId = claim.claimId || payload.claimId;
|
|
if (!claimId) return null;
|
|
const capability = claim.capability || ctx.capability;
|
|
return activeClaims.get(_claimKey(capability, claimId)) || {
|
|
claimId,
|
|
capability,
|
|
owner: claim.owner || ctx.requester || 'unknown',
|
|
createdAt: ctx.timestamp || _now(),
|
|
synthetic: true,
|
|
};
|
|
}
|
|
|
|
function _claimOwner(capability, source = {}) {
|
|
const explicitOwner = String(source.owner || '').trim();
|
|
if (explicitOwner) return explicitOwner;
|
|
const pipeline = _resolvePipeline(capability);
|
|
const owners = (pipeline.order || [])
|
|
.map(pluginId => pipeline.participants.get(pluginId))
|
|
.filter(participant => participant && participant.mode !== 'disabled' && participant.roles.includes('owner'));
|
|
if (owners.length === 1) return owners[0].pluginId;
|
|
const exclusiveOwners = owners.filter(participant => participant.ownership !== 'multi-provider');
|
|
if (exclusiveOwners.length === 1) return exclusiveOwners[0].pluginId;
|
|
return 'unknown';
|
|
}
|
|
|
|
function _releaseClaimEntry(key, entry, reason = 'Claim released') {
|
|
activeClaims.delete(key);
|
|
const released = {
|
|
...entry,
|
|
state: 'released',
|
|
updatedAt: _now(),
|
|
restoreSnapshotRef: null,
|
|
reason,
|
|
};
|
|
_remember(claimLifecycle, released);
|
|
_emitEvent(entry.capability, 'claim:released', released);
|
|
return released;
|
|
}
|
|
|
|
function _orphanClaimEntry(entry, reason) {
|
|
entry.state = 'orphaned';
|
|
entry.updatedAt = _now();
|
|
entry.orphanReason = reason;
|
|
entry.nonDispatchable = true;
|
|
entry.restoreSnapshotRef = null;
|
|
_remember(claimLifecycle, { ...entry });
|
|
_emitEvent(entry.capability, 'claim:orphaned', entry);
|
|
}
|
|
|
|
function _cleanupClaimsForParticipant(pluginId, capabilityName = null) {
|
|
for (const [key, entry] of Array.from(activeClaims.entries())) {
|
|
if (capabilityName && entry.capability !== capabilityName) continue;
|
|
if (entry.requester === pluginId) {
|
|
const released = _releaseClaimEntry(key, entry, `Requester ${pluginId} disappeared`);
|
|
_recordDecision({ commandId: `release-${released.claimId}`, capability: released.capability, command: 'release', requester: pluginId, participant: 'core', outcome: 'handled', reason: released.reason });
|
|
continue;
|
|
}
|
|
if (entry.owner === pluginId && entry.state !== 'orphaned') {
|
|
_orphanClaimEntry(entry, `Owner ${pluginId} disappeared`);
|
|
_recordDecision({ commandId: `orphan-${entry.claimId}`, capability: entry.capability, command: 'orphan', requester: entry.requester || 'unknown', participant: pluginId, outcome: 'no-owner', reason: entry.orphanReason });
|
|
}
|
|
}
|
|
}
|
|
|
|
function _overrideMatchesClaim(ctx, claim) {
|
|
const selector = _targetSelector(ctx);
|
|
const createdAt = Date.parse(claim.createdAt || '') || 0;
|
|
return userOverrides.some(entry => {
|
|
if (entry.type !== 'manual') return false;
|
|
if (entry.capability !== claim.capability) return false;
|
|
if ((Date.parse(entry.timestamp || '') || 0) < createdAt) return false;
|
|
const entrySelector = _targetSelector(entry);
|
|
return selector === '*' || entrySelector === '*' || selector === entrySelector;
|
|
});
|
|
}
|
|
|
|
function _notifySubscribers(event, detail) {
|
|
for (const key of [event, '*']) {
|
|
const handlers = subscribers.get(key) || [];
|
|
for (const handler of handlers.slice()) {
|
|
try { handler(detail); }
|
|
catch (err) { console.warn('[capabilities] subscriber failed:', err); }
|
|
}
|
|
}
|
|
}
|
|
|
|
function _withTimeout(promise, timeoutMs, participant) {
|
|
// Capture + clear the timer once the race settles — otherwise a handler
|
|
// that resolves first leaves a live setTimeout (up to timeoutMs) that
|
|
// keeps the event loop alive and, for long overrides (MIDI permission
|
|
// commands at 15s), accumulates delayed callbacks across repeated calls.
|
|
let timer;
|
|
const timeout = new Promise(resolve => {
|
|
timer = setTimeout(() => resolve({
|
|
outcome: 'failed',
|
|
reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`,
|
|
}), timeoutMs);
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
function _normalizeDecision(participant, result) {
|
|
const source = result && typeof result === 'object' ? result : { outcome: result ? 'handled' : 'passed' };
|
|
const outcome = OUTCOMES.has(source.outcome) ? source.outcome : 'handled';
|
|
const decision = {
|
|
participant: participant.pluginId,
|
|
outcome,
|
|
status: typeof source.status === 'string' ? source.status : undefined,
|
|
reason: typeof source.reason === 'string' ? source.reason : undefined,
|
|
payload: source.payload,
|
|
};
|
|
if (['denied', 'degraded', 'failed', 'short-circuited', 'overridden'].includes(decision.outcome) && !decision.reason) {
|
|
decision.reason = `${participant.pluginId} returned ${decision.outcome}`;
|
|
}
|
|
return decision;
|
|
}
|
|
|
|
function _finalOutcome(decisions) {
|
|
if (!decisions.length) return 'degraded';
|
|
const terminal = decisions.find(d => ['denied', 'failed', 'short-circuited', 'handled', 'degraded', 'overridden', 'no-owner', 'no-handler', 'no-target', 'unsupported-command', 'incompatible', 'incompatible-version', 'unavailable', 'provider-selection-required', 'user-action-required', 'stale', 'cancelled', 'stopped'].includes(d.outcome));
|
|
return terminal ? terminal.outcome : decisions[decisions.length - 1].outcome;
|
|
}
|
|
|
|
function _blockingConflict(conflicts) {
|
|
return conflicts.find(conflict => conflict.type === 'duplicate-owner' || conflict.type === 'order-cycle') || null;
|
|
}
|
|
|
|
function _emitEvent(capability, event, payload) {
|
|
const capabilityName = _canonicalCapabilityName(capability);
|
|
if (!capabilityName || RESERVED_FUTURE_DOMAINS.has(capabilityName)) return;
|
|
const detail = { capability: capabilityName, event, payload: payload || {}, timestamp: _now() };
|
|
_notifySubscribers(event, detail);
|
|
_notifySubscribers(`${capabilityName}:${event}`, detail);
|
|
try {
|
|
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
|
window.feedBack.emit(`${capabilityName}:${event}`, detail);
|
|
window.feedBack.emit('capability:event', detail);
|
|
} else {
|
|
window.dispatchEvent(new CustomEvent(`${capabilityName}:${event}`, { detail }));
|
|
window.dispatchEvent(new CustomEvent('feedBack:capability:event', { detail }));
|
|
}
|
|
} catch (err) {
|
|
console.warn('[capabilities] event dispatch failed:', err);
|
|
}
|
|
const pipeline = pipelines.get(capabilityName);
|
|
if (!pipeline) return;
|
|
for (const participant of pipeline.participants.values()) {
|
|
const handler = participant.eventHandlers && participant.eventHandlers[event];
|
|
if (typeof handler !== 'function') continue;
|
|
try { handler(detail); }
|
|
catch (err) {
|
|
_recordDecision({
|
|
commandId: `event-${capability}-${event}`,
|
|
capability: capabilityName,
|
|
command: event,
|
|
requester: detail.payload.source || 'event',
|
|
participant: participant.pluginId,
|
|
outcome: 'failed',
|
|
reason: err && err.message ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function command(capabilityName, commandName, context = {}) {
|
|
capabilityName = _canonicalCapabilityName(capabilityName);
|
|
const commandId = `command-${++commandSeq}`;
|
|
if (!capabilityName || RESERVED_FUTURE_DOMAINS.has(capabilityName)) {
|
|
return {
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: context.requester || 'unknown',
|
|
outcome: 'degraded',
|
|
reason: `Capability ${capabilityName || 'unknown'} is reserved for a future domain PR`,
|
|
decisions: [],
|
|
};
|
|
}
|
|
const pipeline = _resolvePipeline(capabilityName);
|
|
const commandContext = {
|
|
...context,
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: context.requester || 'unknown',
|
|
origin: context.origin || 'system',
|
|
reason: context.reason || 'No reason provided',
|
|
};
|
|
const decisions = [];
|
|
const blockingConflict = _blockingConflict(pipeline.conflicts || []);
|
|
if (blockingConflict) {
|
|
const reason = `Capability ${capabilityName}.${commandName} degraded because ${blockingConflict.reason || blockingConflict.type}`;
|
|
const decision = { participant: 'core', outcome: 'degraded', reason };
|
|
decisions.push(decision);
|
|
_recordDecision({
|
|
commandId,
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: commandContext.requester,
|
|
origin: commandContext.origin,
|
|
target: commandContext.target,
|
|
participant: 'core',
|
|
outcome: 'degraded',
|
|
reason,
|
|
});
|
|
return {
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: commandContext.requester,
|
|
outcome: 'degraded',
|
|
reason,
|
|
decisions,
|
|
};
|
|
}
|
|
const claim = _claimFromContext(commandContext);
|
|
if (claim && _overrideMatchesClaim(commandContext, claim)) {
|
|
const reason = `Capability ${capabilityName}.${commandName} skipped because a user override beat claim ${claim.claimId}`;
|
|
const decision = { participant: 'core', outcome: 'overridden', reason, payload: { claimId: claim.claimId } };
|
|
decisions.push(decision);
|
|
_recordDecision({
|
|
commandId,
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: commandContext.requester,
|
|
origin: commandContext.origin,
|
|
target: commandContext.target,
|
|
participant: 'core',
|
|
outcome: 'overridden',
|
|
reason,
|
|
claimId: claim.claimId,
|
|
});
|
|
_emitEvent(capabilityName, 'override', { command: commandName, requester: commandContext.requester, claimId: claim.claimId, target: commandContext.target || commandContext.payload?.target || null });
|
|
return {
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: commandContext.requester,
|
|
outcome: 'overridden',
|
|
reason,
|
|
payload: decision.payload,
|
|
decisions,
|
|
};
|
|
}
|
|
for (const participantId of pipeline.order) {
|
|
const participant = pipeline.participants.get(participantId);
|
|
if (!participant || participant.mode === 'disabled') continue;
|
|
const handler = participant.handlers && participant.handlers[commandName];
|
|
if (typeof handler !== 'function') continue;
|
|
let decision;
|
|
try {
|
|
const timeoutMs = Number(commandContext.timeoutMs || _commandTimeoutFor(capabilityName, commandName) || DEFAULT_HANDLER_TIMEOUT_MS);
|
|
const result = await _withTimeout(Promise.resolve(handler(commandContext)), timeoutMs, participant);
|
|
decision = _normalizeDecision(participant, result);
|
|
} catch (err) {
|
|
decision = {
|
|
participant: participant.pluginId,
|
|
outcome: 'failed',
|
|
reason: err && err.message ? err.message : String(err),
|
|
};
|
|
}
|
|
decisions.push(decision);
|
|
_recordDecision({
|
|
commandId,
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: commandContext.requester,
|
|
origin: commandContext.origin,
|
|
target: commandContext.target,
|
|
participant: decision.participant,
|
|
outcome: decision.outcome,
|
|
reason: decision.reason,
|
|
});
|
|
if (decision.outcome === 'transformed' && decision.payload && typeof decision.payload === 'object') {
|
|
commandContext.payload = decision.payload;
|
|
continue;
|
|
}
|
|
if (['denied', 'failed', 'short-circuited', 'handled', 'degraded', 'overridden', 'no-owner', 'no-handler', 'no-target', 'unsupported-command', 'incompatible', 'incompatible-version', 'unavailable', 'provider-selection-required', 'user-action-required', 'stale', 'cancelled', 'stopped'].includes(decision.outcome)) break;
|
|
}
|
|
if (!decisions.length) {
|
|
const reason = `No provider handled ${capabilityName}.${commandName}`;
|
|
_remember(missingProviders, { capability: capabilityName, command: commandName, requester: commandContext.requester, reason });
|
|
const decision = { participant: 'core', outcome: 'no-handler', reason };
|
|
decisions.push(decision);
|
|
_recordDecision({
|
|
commandId,
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: commandContext.requester,
|
|
origin: commandContext.origin,
|
|
participant: 'core',
|
|
outcome: 'no-handler',
|
|
reason,
|
|
});
|
|
}
|
|
const outcome = _finalOutcome(decisions);
|
|
const terminalDecision = decisions.find(d => ['denied', 'failed', 'short-circuited', 'handled', 'degraded', 'overridden', 'no-owner', 'no-handler', 'no-target', 'unsupported-command', 'incompatible', 'incompatible-version', 'unavailable', 'provider-selection-required', 'user-action-required', 'stale', 'cancelled', 'stopped'].includes(d.outcome))
|
|
|| decisions[decisions.length - 1];
|
|
return {
|
|
capability: capabilityName,
|
|
command: commandName,
|
|
requester: commandContext.requester,
|
|
outcome,
|
|
status: terminalDecision && terminalDecision.status,
|
|
reason: decisions.find(d => d.reason)?.reason,
|
|
payload: terminalDecision && terminalDecision.payload,
|
|
decisions,
|
|
};
|
|
}
|
|
|
|
function _registerParticipant(pluginId, declaration, options = {}) {
|
|
if (!pluginId || typeof pluginId !== 'string') return;
|
|
_rememberPluginManifest(pluginId, declaration);
|
|
const caps = _capabilityMap(declaration);
|
|
const touched = new Set();
|
|
for (const [capabilityName, rawDeclaration] of Object.entries(caps)) {
|
|
const canonicalCapabilityName = _canonicalCapabilityName(capabilityName);
|
|
if (!canonicalCapabilityName || !rawDeclaration || typeof rawDeclaration !== 'object') continue;
|
|
if (RESERVED_FUTURE_DOMAINS.has(canonicalCapabilityName)) continue;
|
|
const normalized = _normalizeDeclaration(rawDeclaration, declaration);
|
|
const pipeline = _pipeline(canonicalCapabilityName);
|
|
const participant = {
|
|
pluginId,
|
|
capability: canonicalCapabilityName,
|
|
...normalized,
|
|
declarationMode: normalized.mode,
|
|
};
|
|
if (normalized.incompatible) {
|
|
participant.handlers = {};
|
|
participant.eventHandlers = {};
|
|
}
|
|
pipeline.participants.set(pluginId, _mergeParticipant(pipeline.participants.get(pluginId), participant));
|
|
touched.add(canonicalCapabilityName);
|
|
if (!options.deferResolve) _resolvePipeline(canonicalCapabilityName);
|
|
_notifySubscribers('registered', { capability: canonicalCapabilityName, pluginId, timestamp: _now() });
|
|
}
|
|
return touched;
|
|
}
|
|
|
|
function registerParticipant(pluginId, declaration) {
|
|
_registerParticipant(pluginId, declaration);
|
|
_contributeDiagnostics();
|
|
}
|
|
|
|
function registerOwner(domainName, spec = {}) {
|
|
const capabilityName = _canonicalCapabilityName(domainName);
|
|
if (!capabilityName || RESERVED_FUTURE_DOMAINS.has(capabilityName)) return null;
|
|
const source = spec && typeof spec === 'object' ? spec : {};
|
|
const pluginId = typeof source.pluginId === 'string' && source.pluginId ? source.pluginId : `core.${capabilityName}`;
|
|
const kind = VALID_DOMAIN_KINDS.has(source.kind) ? source.kind : 'command';
|
|
const declaration = {
|
|
roles: ['owner'],
|
|
commands: _uniqueStrings(source.commands),
|
|
operations: _uniqueStrings(source.operations || source.providerOperations || source.provider_operations),
|
|
events: _uniqueStrings(source.events),
|
|
emits: _uniqueStrings(source.emits),
|
|
kind,
|
|
mode: VALID_MODES.has(source.mode) ? source.mode : 'active',
|
|
compatibility: VALID_COMPATIBILITY.has(source.compatibility) ? source.compatibility : 'none',
|
|
ownership: VALID_OWNERSHIP.has(source.ownership) ? source.ownership : _ownershipForKind(kind),
|
|
safety: VALID_SAFETY.has(source.safety) ? source.safety : (kind === 'diagnostic' ? 'diagnostic-only' : 'safe'),
|
|
description: _shortDescription(source.description) || _shortDescription(source.summary),
|
|
provider_policy: source.provider_policy && typeof source.provider_policy === 'object' ? source.provider_policy : {},
|
|
handlers: source.handlers && typeof source.handlers === 'object' ? source.handlers : {},
|
|
eventHandlers: source.eventHandlers && typeof source.eventHandlers === 'object' ? source.eventHandlers : {},
|
|
runtime: true,
|
|
version: 1,
|
|
};
|
|
registerParticipant(pluginId, { [capabilityName]: declaration });
|
|
return { pluginId, capability: capabilityName, kind };
|
|
}
|
|
|
|
function setParticipantEnabled(pluginId, capabilityName, enabled, options = {}) {
|
|
capabilityName = _canonicalCapabilityName(capabilityName);
|
|
if (!pluginId || typeof pluginId !== 'string' || !capabilityName || typeof capabilityName !== 'string') {
|
|
return { ok: false, reason: 'pluginId and capabilityName are required' };
|
|
}
|
|
if (pluginId === 'core') {
|
|
return { ok: false, reason: 'Core capability participants cannot be disabled at runtime' };
|
|
}
|
|
const pipeline = pipelines.get(capabilityName);
|
|
const participant = pipeline && pipeline.participants.get(pluginId);
|
|
if (!participant) {
|
|
return { ok: false, reason: `${pluginId} is not registered for ${capabilityName}` };
|
|
}
|
|
const nextEnabled = !!enabled;
|
|
const previousMode = participant.mode;
|
|
const restoredMode = participant.declarationMode && participant.declarationMode !== 'disabled'
|
|
? participant.declarationMode
|
|
: 'active';
|
|
participant.mode = nextEnabled ? restoredMode : 'disabled';
|
|
participant.runtimeOverride = {
|
|
enabled: nextEnabled,
|
|
requester: options.requester || 'runtime',
|
|
reason: options.reason || (nextEnabled ? 'Runtime capability enabled' : 'Runtime capability disabled'),
|
|
timestamp: _now(),
|
|
};
|
|
const resolved = _resolvePipeline(capabilityName);
|
|
_emitEvent('pipeline', 'participant.state-changed', {
|
|
capability: capabilityName,
|
|
pluginId,
|
|
enabled: nextEnabled,
|
|
previousMode,
|
|
mode: participant.mode,
|
|
conflicts: resolved.conflicts.slice(),
|
|
});
|
|
_contributeDiagnostics();
|
|
return {
|
|
ok: true,
|
|
capability: capabilityName,
|
|
pluginId,
|
|
enabled: nextEnabled,
|
|
previousMode,
|
|
mode: participant.mode,
|
|
conflicts: resolved.conflicts.slice(),
|
|
};
|
|
}
|
|
|
|
function registerParticipants(entries) {
|
|
const touched = new Set();
|
|
const list = Array.isArray(entries) ? entries : [];
|
|
for (const entry of list) {
|
|
const pluginId = _pluginEntryId(entry);
|
|
if (!pluginId) continue;
|
|
_rememberPluginManifest(pluginId, entry);
|
|
}
|
|
for (const entry of list) {
|
|
const pluginId = _pluginEntryId(entry);
|
|
if (!pluginId) continue;
|
|
const participantCaps = _registerParticipant(pluginId, _pluginEntryDeclaration(entry), { deferResolve: true });
|
|
for (const capabilityName of participantCaps || []) touched.add(capabilityName);
|
|
}
|
|
for (const capabilityName of Array.from(touched).sort()) _resolvePipeline(capabilityName);
|
|
_contributeDiagnostics();
|
|
return Array.from(touched).sort();
|
|
}
|
|
|
|
function unregisterParticipant(pluginId, capabilityName = null) {
|
|
capabilityName = capabilityName == null ? null : _canonicalCapabilityName(capabilityName);
|
|
_cleanupClaimsForParticipant(pluginId, capabilityName);
|
|
for (const [name, pipeline] of pipelines.entries()) {
|
|
if (capabilityName && name !== capabilityName) continue;
|
|
if (pipeline.participants.delete(pluginId)) {
|
|
_resolvePipeline(name);
|
|
_notifySubscribers('unregistered', { capability: name, pluginId, timestamp: _now() });
|
|
}
|
|
}
|
|
_contributeDiagnostics();
|
|
}
|
|
|
|
function inspect(capabilityName = null) {
|
|
if (capabilityName) {
|
|
const pipeline = pipelines.get(_canonicalCapabilityName(capabilityName));
|
|
return pipeline ? _pipelineSummary(pipeline) : null;
|
|
}
|
|
return Array.from(pipelines.values()).map(_pipelineSummary);
|
|
}
|
|
|
|
function validateRuntime(options = {}) {
|
|
const phase = options && typeof options.phase === 'string' ? options.phase : 'runtime';
|
|
for (const capabilityName of Array.from(pipelines.keys()).sort()) _resolvePipeline(capabilityName);
|
|
const snapshot = snapshotDiagnostics();
|
|
_emitEvent('pipeline', 'runtime.validated', {
|
|
phase,
|
|
conflicts: snapshot.conflicts || [],
|
|
pipelineCount: (snapshot.pipelines || []).length,
|
|
participantCount: (snapshot.participants || []).length,
|
|
});
|
|
_contributeDiagnostics();
|
|
return snapshot;
|
|
}
|
|
|
|
function registerCompatibilityShim(shim) {
|
|
const source = shim && typeof shim === 'object' ? shim : {};
|
|
const capability = _canonicalCapabilityName(source.capability || 'unknown') || 'unknown';
|
|
if (RESERVED_FUTURE_DOMAINS.has(capability)) return null;
|
|
const shimId = source.shimId || `${capability}:${source.legacySurface || 'legacy'}`;
|
|
const existing = compatibilityShims.find(entry => entry.shimId === shimId);
|
|
const hit = source.status === 'used' || source.used === true || source.hit === true;
|
|
const explicitHitCount = Number(source.hitCount);
|
|
const hasExplicitHitCount = Number.isFinite(explicitHitCount) && explicitHitCount >= 0;
|
|
const previousHitCount = existing && Number.isFinite(Number(existing.hitCount)) ? Number(existing.hitCount) : 0;
|
|
const hitCount = Math.max(previousHitCount, hasExplicitHitCount ? explicitHitCount : 0) + (hit && !hasExplicitHitCount ? 1 : 0);
|
|
const status = hit || hitCount > 0 ? 'used' : (source.status || (existing && existing.status) || 'active');
|
|
const entry = {
|
|
...(existing || {}),
|
|
shimId,
|
|
source: source.source || 'unknown',
|
|
capability,
|
|
legacySurface: source.legacySurface || 'unknown',
|
|
status,
|
|
reason: source.reason,
|
|
hitCount,
|
|
lastHitAt: source.lastHitAt || (hit ? _now() : (existing && existing.lastHitAt) || null),
|
|
};
|
|
const providerId = source.providerId || source.provider_id || (existing && existing.providerId);
|
|
if (providerId) entry.providerId = String(providerId);
|
|
const ownerPluginId = source.ownerPluginId || source.owner_plugin_id || (existing && existing.ownerPluginId);
|
|
if (ownerPluginId) entry.ownerPluginId = String(ownerPluginId);
|
|
if (existing) {
|
|
const index = compatibilityShims.indexOf(existing);
|
|
compatibilityShims.splice(index, 1, { timestamp: existing.timestamp || _now(), ...entry });
|
|
_contributeDiagnostics();
|
|
} else {
|
|
_remember(compatibilityShims, entry);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function _legacyHitSource(source) {
|
|
if (!source || typeof source !== 'object') return 'legacy-runtime';
|
|
const candidate = source.source || source.pluginId || source.owner || source.requester || source.providerId || source.id;
|
|
if (candidate) return String(candidate);
|
|
const activePluginId = window.feedBack && (window.feedBack._loadingPluginId || window.feedBack._activePluginId);
|
|
return activePluginId ? String(activePluginId) : 'legacy-runtime';
|
|
}
|
|
|
|
function _legacyHitIdPart(value) {
|
|
return String(value || 'unknown').replace(/[^a-zA-Z0-9_.:-]+/g, '-');
|
|
}
|
|
|
|
function recordLegacyHit(hit) {
|
|
const source = hit && typeof hit === 'object' ? hit : {};
|
|
if (!source.capability || !source.legacySurface) return null;
|
|
const actor = _legacyHitSource(source);
|
|
const capability = _canonicalCapabilityName(source.capability);
|
|
return registerCompatibilityShim({
|
|
shimId: source.shimId || `runtime:${_legacyHitIdPart(capability)}:${_legacyHitIdPart(source.legacySurface)}:${_legacyHitIdPart(actor)}`,
|
|
source: actor,
|
|
capability,
|
|
legacySurface: source.legacySurface,
|
|
status: 'used',
|
|
reason: source.reason || 'Legacy runtime surface used',
|
|
hit: true,
|
|
lastHitAt: source.lastHitAt,
|
|
providerId: source.providerId || source.provider_id,
|
|
ownerPluginId: source.ownerPluginId || source.owner_plugin_id,
|
|
});
|
|
}
|
|
|
|
function recordUserOverride(override) {
|
|
const source = override && typeof override === 'object' ? override : {};
|
|
_remember(userOverrides, {
|
|
type: 'manual',
|
|
capability: _canonicalCapabilityName(source.capability || 'unknown') || 'unknown',
|
|
command: source.command,
|
|
source: source.source || 'unknown',
|
|
target: source.target,
|
|
selector: source.selector || _targetSelector(source),
|
|
reason: source.reason || 'User override recorded',
|
|
});
|
|
}
|
|
|
|
function claim(request = {}) {
|
|
const source = request && typeof request === 'object' ? request : {};
|
|
const capability = _canonicalCapabilityName(source.capability || 'unknown') || 'unknown';
|
|
const claimId = source.claimId || source.id;
|
|
if (!claimId) return () => {};
|
|
const key = _claimKey(capability, claimId);
|
|
if (!activeClaims.has(key)) {
|
|
const owner = _claimOwner(capability, source);
|
|
const entry = {
|
|
claimId,
|
|
capability,
|
|
owner,
|
|
requester: source.requester || source.source || source.owner || 'unknown',
|
|
targetSelector: _targetSelector(source),
|
|
state: 'active',
|
|
createdAt: _now(),
|
|
updatedAt: _now(),
|
|
restoreSnapshotRef: source.restoreSnapshotRef || null,
|
|
reason: source.reason,
|
|
};
|
|
activeClaims.set(key, entry);
|
|
_recordDecision({ commandId: `claim-${claimId}`, capability, command: 'claim', requester: entry.requester, participant: 'core', outcome: 'handled', reason: entry.reason });
|
|
_emitEvent(capability, 'claim:created', entry);
|
|
}
|
|
return () => release({ capability, claimId });
|
|
}
|
|
|
|
function release(request = {}) {
|
|
const source = typeof request === 'string' ? { claimId: request } : (request && typeof request === 'object' ? request : {});
|
|
if (source.capability) source.capability = _canonicalCapabilityName(source.capability);
|
|
const claimId = source.claimId || source.id;
|
|
if (!claimId) return { ok: true, released: false };
|
|
let released = null;
|
|
for (const [key, entry] of Array.from(activeClaims.entries())) {
|
|
if (entry.claimId !== claimId) continue;
|
|
if (source.capability && entry.capability !== source.capability) continue;
|
|
released = _releaseClaimEntry(key, entry, source.reason || 'Claim released');
|
|
}
|
|
if (!released) {
|
|
_recordDecision({ commandId: `release-${claimId}`, capability: source.capability || 'unknown', command: 'release', requester: source.requester || source.source || source.owner || 'unknown', participant: 'core', outcome: 'degraded', reason: 'Release requested for unknown claim' });
|
|
return { ok: true, released: false };
|
|
}
|
|
_recordDecision({ commandId: `release-${claimId}`, capability: released.capability, command: 'release', requester: source.requester || source.source || released.requester || released.owner || source.owner, participant: 'core', outcome: 'handled' });
|
|
return { ok: true, released: true, claim: released };
|
|
}
|
|
|
|
function _dispatchStatus(result) {
|
|
if (!result) return 'error';
|
|
if (result.status) return result.status;
|
|
if (result.outcome === 'handled' || result.outcome === 'passed') return 'applied';
|
|
if (result.outcome === 'overridden') return 'overridden';
|
|
if (result.outcome === 'no-owner') return 'no-owner';
|
|
if (result.outcome === 'no-handler') return 'no-handler';
|
|
if (result.outcome === 'no-target') return 'no-target';
|
|
if (result.outcome === 'unsupported-command') return 'unsupported-command';
|
|
if (result.outcome === 'incompatible') return 'incompatible';
|
|
if (result.outcome === 'incompatible-version') return 'incompatible-version';
|
|
if (result.outcome === 'unavailable') return 'unavailable';
|
|
if (result.outcome === 'provider-selection-required') return 'provider-selection-required';
|
|
if (result.outcome === 'user-action-required') return 'user-action-required';
|
|
if (result.outcome === 'stale') return 'stale';
|
|
if (result.outcome === 'cancelled') return 'cancelled';
|
|
if (result.outcome === 'stopped') return 'stopped';
|
|
if (result.outcome === 'denied' || result.outcome === 'short-circuited') return 'blocked';
|
|
if (result.outcome === 'failed') return 'error';
|
|
if (result.outcome === 'degraded') return 'no-handler';
|
|
return result.outcome || 'error';
|
|
}
|
|
|
|
async function dispatch(request = {}) {
|
|
const source = request && typeof request === 'object' ? request : {};
|
|
const capability = _canonicalCapabilityName(source.capability);
|
|
const commandName = source.command;
|
|
if (!capability || !commandName) return { status: 'error', outcome: 'failed', reason: 'capability and command are required' };
|
|
if (RESERVED_FUTURE_DOMAINS.has(capability)) {
|
|
return { status: 'no-handler', outcome: 'degraded', capability, command: commandName, reason: `Capability ${capability} is reserved for a future domain PR` };
|
|
}
|
|
const pipeline = _resolvePipeline(capability);
|
|
const incompatibleParticipants = _pipelineParticipants(pipeline).filter(participant => participant.incompatible);
|
|
if (incompatibleParticipants.length && incompatibleParticipants.length === pipeline.participants.size) {
|
|
const reason = `Capability ${capability} is unavailable because all participants are incompatible`;
|
|
_recordDecision({ commandId: `dispatch-${++commandSeq}`, capability, command: commandName, requester: source.source || source.requester || 'dispatch', participant: 'core', outcome: 'incompatible-version', reason });
|
|
return { status: 'incompatible-version', outcome: 'incompatible-version', capability, command: commandName, reason };
|
|
}
|
|
const participants = _pipelineParticipants(pipeline).filter(participant => participant.mode !== 'disabled' && !participant.incompatible);
|
|
const owners = participants.filter(participant => participant.roles.includes('owner'));
|
|
const coordinators = participants.filter(participant => participant.roles.includes('coordinator'));
|
|
if (!owners.length && !coordinators.length) {
|
|
const reason = `No owner or coordinator registered for ${capability}`;
|
|
_remember(missingProviders, { capability, command: commandName, requester: source.source || source.requester || 'dispatch', reason });
|
|
_recordDecision({ commandId: `dispatch-${++commandSeq}`, capability, command: commandName, requester: source.source || source.requester || 'dispatch', participant: 'core', outcome: 'no-owner', reason });
|
|
_emitEvent(capability, 'conflict:missing-provider', { command: commandName, reason });
|
|
return { status: 'no-owner', outcome: 'no-owner', capability, command: commandName, reason };
|
|
}
|
|
const commandDeclared = participants.some(participant => (participant.commands || []).includes(commandName));
|
|
if (!commandDeclared) {
|
|
const reason = `Unsupported command ${capability}.${commandName}`;
|
|
_recordDecision({ commandId: `dispatch-${++commandSeq}`, capability, command: commandName, requester: source.source || source.requester || 'dispatch', participant: 'core', outcome: 'unsupported-command', reason });
|
|
return { status: 'unsupported-command', outcome: 'unsupported-command', capability, command: commandName, reason };
|
|
}
|
|
const hasHandler = participants.some(participant => participant.handlers && typeof participant.handlers[commandName] === 'function');
|
|
if (!hasHandler) {
|
|
const reason = `No handler registered for ${capability}.${commandName}`;
|
|
_remember(missingProviders, { capability, command: commandName, requester: source.source || source.requester || 'dispatch', reason });
|
|
_recordDecision({ commandId: `dispatch-${++commandSeq}`, capability, command: commandName, requester: source.source || source.requester || 'dispatch', participant: 'core', outcome: 'no-handler', reason });
|
|
return { status: 'no-handler', outcome: 'no-handler', capability, command: commandName, reason };
|
|
}
|
|
const result = await command(capability, commandName, {
|
|
requester: source.source || source.requester || 'dispatch',
|
|
origin: source.origin || 'dispatch',
|
|
reason: source.reason || 'Capability dispatch',
|
|
target: source.target || source.args?.target || null,
|
|
payload: source.args || source.payload || {},
|
|
claim: source.claim,
|
|
timeoutMs: source.timeoutMs || _commandTimeoutFor(capability, commandName),
|
|
});
|
|
const status = _dispatchStatus(result);
|
|
_emitEvent(capability, 'dispatched', { command: commandName, status, result, source: source.source || source.requester || 'dispatch' });
|
|
return { ...result, status };
|
|
}
|
|
|
|
function subscribe(event, fn) {
|
|
if (typeof event !== 'string' || typeof fn !== 'function') return () => {};
|
|
const handlers = subscribers.get(event) || [];
|
|
handlers.push(fn);
|
|
subscribers.set(event, handlers);
|
|
return () => {
|
|
const current = subscribers.get(event) || [];
|
|
subscribers.set(event, current.filter(handler => handler !== fn));
|
|
};
|
|
}
|
|
|
|
function snapshotDiagnostics() {
|
|
const pipelineSummaries = inspect();
|
|
const snapshot = {
|
|
schema: 'feedBack.capabilities.diagnostics.v1',
|
|
pipelines: pipelineSummaries,
|
|
participants: pipelineSummaries.flatMap(pipeline => Array.isArray(pipeline.participants) ? pipeline.participants : []),
|
|
recentDecisions: recentDecisions.slice(),
|
|
conflicts: Array.from(pipelines.values()).flatMap(p => p.conflicts.map(c => ({ capability: p.name, ...c }))),
|
|
missingProviders: missingProviders.slice(),
|
|
compatibilityShims: compatibilityShims.slice(),
|
|
userOverrides: userOverrides.slice(),
|
|
activeClaims: Array.from(activeClaims.values()),
|
|
claimLifecycle: claimLifecycle.slice(),
|
|
unsupportedVersions: unsupportedVersions.slice(),
|
|
knownPlugins: Array.from(knownPlugins.values()).map(_knownPluginSummary),
|
|
expectedCompatibilityShims: _expectedCompatibilityShimSummary(),
|
|
};
|
|
let currentSize = JSON.stringify(snapshot).length;
|
|
while (snapshot.recentDecisions.length && currentSize > MAX_SNAPSHOT_BYTES) {
|
|
const removedDecision = snapshot.recentDecisions.shift();
|
|
currentSize -= JSON.stringify(removedDecision).length;
|
|
}
|
|
snapshot.snapshotBytes = JSON.stringify(snapshot).length;
|
|
return _safeValue(snapshot);
|
|
}
|
|
|
|
let contributing = false;
|
|
let capabilitiesChangeScheduled = false;
|
|
|
|
function _scheduleCapabilitiesChanged() {
|
|
if (capabilitiesChangeScheduled) return;
|
|
capabilitiesChangeScheduled = true;
|
|
const schedule = window.queueMicrotask ? window.queueMicrotask.bind(window) : callback => setTimeout(callback, 0);
|
|
schedule(() => {
|
|
capabilitiesChangeScheduled = false;
|
|
const detail = { timestamp: _now() };
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('feedBack:capabilities:changed', { detail }));
|
|
} catch (_err) { /* capability diagnostics must not break runtime behavior */ }
|
|
});
|
|
}
|
|
|
|
function _contributeDiagnostics() {
|
|
if (contributing) return;
|
|
const diagnostics = window.feedBack && window.feedBack.diagnostics;
|
|
if (diagnostics && typeof diagnostics.contribute === 'function') {
|
|
contributing = true;
|
|
try { diagnostics.contribute('capabilities', snapshotDiagnostics()); }
|
|
catch (_err) { /* diagnostics must never break plugin behavior */ }
|
|
finally { contributing = false; }
|
|
}
|
|
_scheduleCapabilitiesChanged();
|
|
}
|
|
|
|
function _coreHandled(payload = {}) {
|
|
return { outcome: 'handled', payload };
|
|
}
|
|
|
|
function _coreDegraded(reason, payload = {}) {
|
|
return { outcome: 'degraded', reason, payload };
|
|
}
|
|
|
|
const api = {
|
|
version: 1,
|
|
registerOwner,
|
|
registerParticipant,
|
|
registerParticipants,
|
|
unregisterParticipant,
|
|
setParticipantEnabled,
|
|
command,
|
|
dispatch,
|
|
claim,
|
|
release,
|
|
subscribe,
|
|
emitEvent: _emitEvent,
|
|
inspect,
|
|
validateRuntime,
|
|
snapshotDiagnostics,
|
|
getDiagnostics: snapshotDiagnostics,
|
|
registerCompatibilityShim,
|
|
recordLegacyHit,
|
|
recordUserOverride,
|
|
};
|
|
|
|
window.feedBack.capabilities = api;
|
|
|
|
registerParticipant('core', {
|
|
diagnostics: {
|
|
roles: ['owner', 'provider'],
|
|
commands: ['snapshot'],
|
|
events: [],
|
|
kind: 'diagnostic',
|
|
description: 'Provides read-only capability snapshots for support bundles and the Capability Inspector.',
|
|
compatibility: 'none',
|
|
ownership: 'diagnostic-only',
|
|
safety: 'diagnostic-only',
|
|
handlers: {
|
|
snapshot: () => _coreHandled(snapshotDiagnostics()),
|
|
},
|
|
},
|
|
pipeline: {
|
|
roles: ['owner', 'provider'],
|
|
commands: ['resolve', 'inspect', 'validate', 'participant.set-enabled'],
|
|
events: ['resolved', 'runtime.validated', 'participant.state-changed'],
|
|
kind: 'diagnostic',
|
|
description: 'Owns capability graph inspection, validation, resolution, and participant enablement commands.',
|
|
compatibility: 'none',
|
|
ownership: 'diagnostic-only',
|
|
safety: 'diagnostic-only',
|
|
handlers: {
|
|
resolve: (ctx) => _coreHandled(inspect(ctx.target && ctx.target.capability)),
|
|
inspect: (ctx) => _coreHandled(inspect(ctx.target && ctx.target.capability)),
|
|
validate: () => _coreHandled(validateRuntime({ phase: 'pipeline-command' })),
|
|
'participant.set-enabled': (ctx) => _coreHandled(setParticipantEnabled(
|
|
ctx.target && ctx.target.pluginId,
|
|
ctx.target && ctx.target.capability,
|
|
!!(ctx.target && ctx.target.enabled),
|
|
{ requester: ctx.requester, reason: ctx.reason }
|
|
)),
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
|
|
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
|
|
} catch (_) {}
|
|
})();
|