Implement

This commit is contained in:
barlind
2026-06-18 00:40:22 -07:00
committed by Bret Mogilefsky
parent 41e62af5e9
commit 853db94887
18 changed files with 1853 additions and 93 deletions
+1
View File
@@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Audio-input control plane** — makes `audio-input` the redaction-safe source of truth for instrument input discovery and lifecycle. Core now exposes `list-sources`, `select-source`, `open-source`, and `close-source` through the capability runtime, persists selected logical sources, keeps inspect/list/select prompt-free, routes provider `source.open`/`source.close` operations with bounded outcomes, shares compatible open sessions across requesters, suppresses compatibility-backed duplicate sources when a native provider owns the same logical key, and expands audio-session diagnostics/Capability Inspector rendering for selected input, open sessions, bridge hits, storage status, and permission/device failures without exposing raw device labels or live audio handles.
- **Audio-monitoring control plane** — makes `audio-monitoring` the shared live-monitoring coordinator. Core now exposes provider registration/list/selection, explicit user-action `start`, requester-counted `stop`, prompt-free `inspect`/`monitoring.status`, and `set-direct-monitor` through the capability runtime. Monitoring starts integrate with selected `audio-input` readiness, background requesters can only attach to active compatible sessions, active sessions survive song/playback stops without auto-resuming after reload, native providers suppress compatibility-backed legacy monitor paths, and diagnostics/Capability Inspector now show providers, sessions, requesters, direct-monitor state, bridge hits, and distinct safe outcomes (`provider-selection-required`, `user-action-required`, `incompatible`, `unavailable`, `stopped`, etc.) without exposing raw audio/device data.
- **Playback control plane** — promotes `playback` to an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposes `inspect`, user-authorized `start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, and `clear-loop` through the capability runtime while `static/app.js` keeps raw `<audio>`/JUCE handles private behind a redaction-safe adapter. Playback diagnostics use pseudonymous targets in exported bundles, local display labels only in the Capability Inspector, bounded recent outcomes/events, and bridge accounting for `window.playSong`, legacy `song:*` events, `window.slopsmith` transport helpers, loop helpers, and browser/native route handoff.
- **Jobs control plane** — promotes `jobs` to an active privileged capability domain for long-running plugin work. Core now exposes provider registration, selected-provider persistence, approval-gated enqueue/retry, capacity-aware scheduling, prompt-free list/inspect, cancel/pause/resume/retry coordination, reload recovery for provider-declared references, compatibility bridge hits, and `slopsmith.jobs.diagnostics.v1` snapshots. Providers keep actual writes/downloads/subprocesses private; diagnostics redact paths, filenames, URLs, tokens, command lines, media/artifacts, recordings, live handles, and provider-private payloads.
- **3D highway — Tone HUD, fret dividers, chord-diagram toggle, FPS counter.** The bundled `plugins/highway_3d` gains an amber **Tone-change HUD** (shows the active tone and the next scheduled tone change; position / size / visibility configurable in settings), a **fret-dividers** toggle (vertical dividers on the highway, on by default, via `h3dBgSetFretDividersVisible`), a **chord-diagram visibility** toggle (`h3dBgSetChordDiagramVisible`), and an **FPS counter** setting migrated to `BG_DEFAULTS.fpsVisible` (drops the legacy `h3d_showFps` localStorage key). Chord-diagram position is restricted to `tl`/`tr`; legacy `bl`/`br` values are coerced on load. Perf: accent-halo shell descriptors are pre-built per string in `initScene()` and the chord-verdict cache key is encoded as a number, eliminating per-frame allocations in the `drawNote()` and chord hot paths.
- **Sloppak assembly preserves a short preview clip.** When a source chart carries a separate short browser-preview audio clip alongside the full song, the sloppak assembler now decodes it to `preview.ogg` at the sloppak root and records it under a new top-level `preview:` manifest key (POSIX relpath, same shape as `lyrics`/`cover`). A failed preview decode is logged at debug and skipped without aborting the overall build. Sources with no separate preview are unaffected. Older sloppak readers ignore the unknown `preview` key, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented in `docs/sloppak-spec.md` §2 alongside the other optional top-level keys. Enables [`slopsmith-plugin-song-preview`](https://github.com/got-feedback/feedback-plugin-song-preview) to render hover-to-listen previews for sloppaks without seeking into the full audio.
- **Generic plugin asset route** — `GET /api/plugins/{plugin_id}/assets/{path}` serves arbitrary static files a plugin bundles under its own `assets/` directory (AudioWorklet modules, WASM, images, etc.), so plugins can self-host browser-fetchable assets without a CDN (Principle II). Containment is enforced by `lib/safepath.safe_join` against `<plugin>/assets/`, so `..` traversal, absolute paths, and NUL bytes cannot escape `assets/` to reach a plugin's Python modules. `.js` is served as `application/javascript`. First consumer: the stems plugin's pitch-preserving time-stretch worklet.
+20 -3
View File
@@ -153,6 +153,18 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
Diagnostics live under `slopsmith.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
## Jobs Control Plane
The jobs slice promotes `jobs` as a privileged core provider-coordinator implemented by [static/capabilities/jobs.js](../static/capabilities/jobs.js). It coordinates long-running plugin work such as conversion, import, cache-building, update, preview, and studio-style background tasks while providers keep the actual file writes, subprocesses, downloads, and private payloads inside their own code.
The public command surface is `register-provider`, `unregister-provider`, `list-providers`, `enqueue`, `list`, `inspect`, `cancel`, `pause`, `resume`, `retry`, and `record-bridge-hit`. Provider operations are `job.enqueue`, `job.status`, `job.cancel`, `job.pause`, `job.resume`, `job.retry`, and `job.recover`. `list` and `inspect` are prompt-free and side-effect-free: they never trigger provider work, writes, downloads, subprocesses, or external calls. Fresh privileged `enqueue` and `retry` requests require an explicit `authorization: "user-action"` or a matching approved-continuation scope before provider callbacks run.
Jobs are scheduled by provider capacity and priority (`user-approved-interactive` before `background-maintenance`). State transitions are explicit (`queued`, `running`, `paused`, `cancellation-requested`, terminal cancelled/completed/failed/provider-unavailable/orphaned), and outcomes use the shared canonical vocabulary: `handled`, `queued`, `denied`, `user-action-required`, `unavailable`, `no-owner`, `no-handler`, `no-target`, `unsupported-command`, `unsupported-operation`, `incompatible`, `incompatible-version`, `provider-selection-required`, `validation-failed`, `stale`, `cancelled`, `completed`, `failed`, `timeout`, and `retry-started`.
Diagnostics live under `slopsmith.jobs.diagnostics.v1`. Snapshots include provider summaries, selected/default providers, active/queued/paused/recent-terminal jobs, bounded per-job history, recent outcomes, bridge hits, limits, and notes. Browser persistence is limited to selected provider choices and provider-declared recoverable job references; it does not store raw payloads, non-recoverable active job state, file paths, command lines, URLs, tokens, media buffers, recordings, live handles, or provider-private data. On reload, only provider-declared recoverable jobs are restored; the rest become orphaned or provider-unavailable with a safe reason.
Compatibility bridge hits such as `jobs.legacy-plugin-queue`, `jobs.legacy-status-screen`, `jobs.legacy-backend-route`, `jobs.legacy-progress-poll`, and `jobs.legacy-update-flow` are diagnostics-only attribution. They never grant approval or execute queued work by themselves.
## Capability Roles
Use capability declarations for provider/requester/observer relationships:
@@ -180,7 +192,9 @@ Core domains include review metadata in diagnostics:
- `active`: wired to current Slopsmith behavior and expected to work as an integration point.
- `diagnostic`: support/inspection-only runtime surfaces.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane, and the jobs slice promotes `jobs` as a privileged provider-coordinator for long-running work. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The jobs slice promotes `jobs` as a privileged provider-coordinator for long-running work. Backend routes, app UI, settings, visualization, note-detection, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
Capability metadata is versioned by the `capability-pipelines.v1` standard. Invalid roles, commands, operations, requests, observes, emits, events, owner kinds, compatibility modes, ownership policies, safety classes, or version fields are excluded from the capability graph and surfaced through `capability_validation_warnings`; legacy plugin fields continue to load through their existing app paths. Plugins that declare a future `capability-pipelines` version are reported through `capability_unsupported_versions` and their runtime handlers are marked incompatible.
@@ -228,11 +242,13 @@ The compatibility ownership vocabulary remains:
- `privileged`: command execution needs an explicit enforcement plan before shipping.
- `diagnostic-only`: read-only support and inspector surfaces.
Dispatch results use explicit outcomes: `handled`, `transformed`, `denied`, `failed`, `degraded`, `short-circuited`, `overridden`, `no-owner`, `no-handler`, `no-target`, `unsupported-command`, `incompatible`, `incompatible-version`, `unavailable`, `provider-selection-required`, `user-action-required`, `stale`, `cancelled`, and `stopped`. No-owner, no-handler, no-target, unsupported-command, incompatible, incompatible-version, provider-selection-required, user-action-required, stale, and cancelled decisions are recorded in diagnostics so support bundles explain why nothing happened.
Dispatch results use explicit outcomes: `handled`, `transformed`, `denied`, `failed`, `degraded`, `short-circuited`, `overridden`, `no-owner`, `no-handler`, `no-target`, `unsupported-command`, `unsupported-operation`, `incompatible`, `incompatible-version`, `unavailable`, `provider-selection-required`, `user-action-required`, `stale`, `queued`, `validation-failed`, `cancelled`, `completed`, `timeout`, `retry-started`, and `stopped`. No-owner, no-handler, no-target, unsupported-command, unsupported-operation, incompatible, incompatible-version, provider-selection-required, user-action-required, stale, queued, validation-failed, cancelled, completed, timeout, and retry-started decisions are recorded in diagnostics so support bundles explain what happened.
## Deferred Core Adapters
UI placement and settings contributions are real Slopsmith surfaces, but they are not PR1 capability contracts (visualization is active as of the cap:6 slice; note-detection as of the spec-009 slice). Audio mixer/session domains are active as of the audio graph/session slice, playback is active as of the playback control-plane slice, and audio-effects is active as a provider/route/chain-plan coordinator; plugins should keep using current documented APIs for remaining areas until the corresponding domain PR ships the host workflow, command/event contract, compatibility shims, diagnostics fields, and tests.
UI placement and settings contributions are real Slopsmith surfaces, but they are not PR1 capability contracts (visualization is active as of the cap:6 slice, note-detection as of the spec-009 slice, and jobs as of the jobs control-plane slice). Audio mixer/session domains are active as of the audio graph/session slice, playback is active as of the playback control-plane slice, and audio-effects is active as a provider/route/chain-plan coordinator; plugins should keep using current documented APIs for remaining areas until the corresponding domain PR ships the host workflow, command/event contract, compatibility shims, diagnostics fields, and tests.
UI placement, settings contributions, visualization, and note-detection are real Slopsmith surfaces, but they are not PR1 capability contracts. Audio mixer/session domains are active as of the audio graph/session slice, playback is active as of the playback control-plane slice, audio-effects is active as a provider/route/chain-plan coordinator, and jobs are active as of the jobs control-plane slice; plugins should keep using current documented APIs for remaining areas until the corresponding domain PR ships the host workflow, command/event contract, compatibility shims, diagnostics fields, and tests.
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.slopsmith` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
@@ -283,6 +299,7 @@ From the `slopsmith/` directory:
```bash
node --check static/app.js
node --check static/capabilities.js
node --check static/capabilities/jobs.js
node --check static/diagnostics.js
node --check plugins/capability_inspector/screen.js
node --test tests/js/*.test.js
+65
View File
@@ -122,6 +122,71 @@ A route-only wrapper that uses the library capability without registering a brow
}
```
## Jobs Provider And Requester
Plugins that run long-lived work should register as `jobs` providers and keep actual work private. The jobs host only stores safe summaries, provider ids, selected-provider choices, progress, terminal outcomes, and provider-declared recovery references.
```json
{
"id": "sloppak_converter",
"name": "Sloppak Converter",
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"capabilities": {
"jobs": {
"roles": ["provider", "observer"],
"operations": ["job.enqueue", "job.status", "job.cancel", "job.retry", "job.recover"],
"events": ["queued", "started", "progress", "completed", "failed", "cancelled"],
"mode": "active",
"compatibility": "shim-allowed",
"ownership": "multi-provider",
"safety": "privileged",
"version": 1
}
}
}
```
Runtime registration supplies the redaction-safe provider summary and operation callbacks:
```js
window.slopsmith.jobs.registerProvider({
providerId: 'sloppak_converter.jobs',
pluginId: 'sloppak_converter',
label: 'Sloppak Converter',
jobTypes: ['psarc-to-sloppak'],
actions: ['enqueue', 'inspect', 'cancel', 'retry', 'recover'],
capacity: { maxRunning: 1, maxQueued: 20 },
recoverySupport: { queued: true, running: false, paused: false },
operationHandlers: {
'job.enqueue': ({ job }) => startConversion(job),
'job.cancel': ({ job }) => cancelConversion(job.jobId),
'job.retry': ({ job }) => startConversion(job),
},
});
```
Requesters enqueue privileged work only from a user action or matching approved continuation. Use safe labels and fingerprints rather than paths, filenames, URLs, command lines, or raw payloads.
```js
const result = await window.slopsmith.capabilities.dispatch({
capability: 'jobs',
command: 'enqueue',
source: 'sloppak_converter',
args: {
jobType: 'psarc-to-sloppak',
requester: 'sloppak_converter',
authorization: 'user-action',
target: { targetRef: 'song-target-abc123' },
inputs: { safeFingerprint: 'input-fingerprint-abc123' },
safeLabel: 'Convert selected song',
},
});
if (result.outcome === 'user-action-required') {
// Show an explicit button; do not start provider work in the background.
}
```
## Audio Mix Fader Provider
Existing plugins can keep using `window.slopsmith.audio.registerFader(spec)` while migrating. The compatibility bridge records the fader as an `audio-mix` participant. New bundled code should prefer a native participant declaration plus the audio-session helper once available in its integration point.
+12 -1
View File
@@ -60,6 +60,14 @@ The progression slice (spec 010) promotes `progression` as an active exclusive-o
Deferred follow-up slices: a `contributor` role so plugins ship their own challenge/quest content (drums challenges from a drums-scoring plugin, quest-pool entries from minigame plugins), and drums scoring wiring so `song_completed {instrument: "drums"}` goals become satisfiable.
## Jobs Control Plane Slice
The jobs slice promotes `jobs` from a deferred domain to an active privileged provider-coordinator. It owns provider registration, selected-provider persistence, capacity-aware scheduling, explicit user-approved enqueue/retry gates, prompt-free list/inspect, cancellation/pause/resume/retry coordination, reload recovery for provider-declared recoverable references, compatibility bridge hit accounting, and redaction-safe diagnostics under `slopsmith.jobs.diagnostics.v1`.
Providers keep actual privileged work private. Core stores only safe job summaries, provider metadata, selected/default provider choices, bounded lifecycle history, terminal outcomes, and recovery references. It does not persist raw payloads, active non-recoverable work, DB schemas, paths, filenames, URLs, tokens, command lines, media/artifacts, recordings, live handles, or provider-private values.
Jobs bridge removal gates are: bundled and first-party long-running workflows use native `jobs` provider registration/dispatch; normal conversion/import/update/cache smoke runs show no unexpected legacy bridge hits; diagnostics distinguish queued, denied, user-action-required, provider-selection-required, stale, cancelled, completed, failed, timeout, retry-started, orphaned, and provider-unavailable cases; repeated plugin hydration does not duplicate providers or jobs; and reload recovery restores only provider-declared safe references.
## Recommended Next Slices
The plugin inventory suggests this migration order after the audio graph/session and playback slices:
@@ -69,6 +77,10 @@ The plugin inventory suggests this migration order after the audio graph/session
3. UI contribution host: migrate navigation, plugin screens, player controls, player panels, overlays, shortcuts, and guided tours under placement/lifecycle policy.
4. Backend and privileged capability cleanup: migrate routes, plugin lifecycle, media import/export, recording, external services, and subprocess-backed workflows with explicit user confirmation and diagnostics redaction.
1. `note-detection`: formalize note-state providers, calibration diagnostics, audio-input coupling, and hit/miss event flow. Requesters should use `audio-input` for source selection/opening rather than owning device prompts directly.
2. UI contribution host: migrate navigation, plugin screens, player controls, player panels, overlays, shortcuts, and guided tours under placement/lifecycle policy.
3. Backend and privileged capability cleanup: migrate routes, plugin lifecycle, media import/export, recording, external services, and subprocess-backed workflows with explicit user confirmation and diagnostics redaction.
## UI/UX Migration Path
This is the recommended order for UI/UX capability work only. It excludes audio semantics, backend route execution, media jobs, and plugin install/update behavior except where those systems need a visible contribution point.
@@ -107,7 +119,6 @@ These domains are planned but should stay out of the runtime graph until a host
| `ui.player-panels` | exclusive-owner | safe | Player panel contributions, mount/unmount, visibility, ordering. | A panel host with layout and focus rules. |
| `ui.player-overlays` | exclusive-owner | safe | Overlay contributions layered over player or highway surfaces. | Overlay placement and z-order rules that coexist with legacy overlays. |
| `plugins` | exclusive-owner | privileged | Plugin enable/disable/install/update workflows. | Visible user confirmation, rollback, and disabled-handler enforcement. |
| `jobs` | multi-provider | privileged | Long-running jobs, cancellation, status, failures. | Scheduling limits, cancellation semantics, and user-visible failures. |
| `midi-control` | multi-provider | sensitive | MIDI device providers and control mappings. | Device consent and redacted diagnostics. |
| `audio-input` | multi-provider | sensitive | Audio input device providers, source selection, open/close lifecycle, shared sessions, and redacted failure diagnostics. | Promoted by the audio graph/session slice and implemented by the audio-input control-plane slice. |
| `tempo-clock` | multi-provider | safe | Tempo/clock provider registration and consumers. | A concrete tempo source and consumer workflow. |
+2 -1
View File
@@ -15,6 +15,8 @@ Core domains also have a review scope. **Active contract** domains are wired to
| stems | coordinator plus plugin provider | safe | inspect, mute, restore | stem.get-state, stem.apply-automation, stem.restore-automation | Core coordinates claims/overrides; the active Stems provider owns actual stem state/playback. |
| playback | exclusive-owner | safe | inspect, start, pause, resume, stop, seek, set-loop, clear-loop, register-requester, register-observer | none | Core owns the transport control plane while `app.js` keeps raw media handles private. Fresh audible starts require explicit user action. Diagnostics expose pseudonymous targets, sanitized route/timing/loop state, requester/observer summaries, bridge hits, bounded recent outcomes, and no audio elements, native handles, decoded buffers, samples, waveforms, or recordings. |
| progression | exclusive-owner | safe | inspect, record-event, list-shop, buy-item, equip-item | none | Core owns mastery rank, the challenge/quest engine, the Decibels wallet, and the cosmetics shop (spec 010). `record-event` accepts whitelisted types only (`minigame_run`); `song_completed` is server-derived in `/api/stats` and denied here. `buy-item`/`equip-item` require explicit user action. Decibels are play-earned only — no real-money path exists or may be added. Diagnostics (`slopsmith.progression.diag.v1`) carry content warnings, rank/level/quest counts, and wallet totals; no song filenames or display names. |
| jobs | provider-coordinator | privileged | register-provider, unregister-provider, list-providers, enqueue, list, inspect, cancel, pause, resume, retry, record-bridge-hit | job.enqueue, job.status, job.cancel, job.pause, job.resume, job.retry, job.recover | Core coordinates provider selection, capacity scheduling, lifecycle state, recovery references, bridge hits, and diagnostics. Fresh privileged enqueue/retry requires explicit user approval or a matching approved-continuation scope. List/inspect are prompt-free and side-effect-free. Diagnostics redact paths, filenames, URLs, tokens, command lines, media/artifacts, recordings, handles, and provider-private payloads. |
| audio-effects | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-chain, resolve-plan, inspect-route, bypass, restore, fallback, activate-segment, set-stage-bypass, set-stage-parameter, record-bridge-hit | chain.resolve, chain.inspect, segment.activate, stage.set-bypass, stage.set-parameter, route.bypass, route.restore | Core owns provider selection, route state, chain-plan schema validation, fallback accounting, and diagnostics. Providers propose opaque NAM/IR/VST/utility chain plans; trusted desktop/native code validates and loads processors. Chain selection and route bypass/restore require explicit user action or restored selection. Diagnostics omit raw paths, filenames, URLs, model/IR names, native preset JSON, VST state blobs, handles, callbacks, DOM nodes, audio buffers, samples, and waveforms. |
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.slopsmithViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.slopsmithViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
@@ -37,7 +39,6 @@ These domains are expected future capability contracts, not current runtime grap
| ui.player-panels | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs a first-party panel host and layout policy. |
| ui.player-overlays | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs overlay placement rules that coexist with legacy highway overlays. |
| plugins | exclusive-owner | privileged | enable, disable, install-missing, update, inspect | Needs explicit user confirmation for writes/install/update. |
| jobs | multi-provider | privileged | register, inspect, cancel | Needs scheduling limits, cancellation semantics, and user-visible failures. |
| midi-control | multi-provider | sensitive | register, inspect | Needs device consent and redacted diagnostics. |
| tempo-clock | multi-provider | safe | register, inspect | Needs a concrete provider and consumer workflow. |
+52 -4
View File
@@ -11,7 +11,6 @@
'ui.player-panels',
'ui.player-overlays',
'plugins',
'jobs',
'midi-control',
'tempo-clock',
]);
@@ -19,8 +18,8 @@
Object.freeze({
id: 'app-library',
label: 'Application and Library',
summary: 'Navigation, plugin screens, settings, library sources, and tunings.',
domains: Object.freeze(['ui.navigation', 'ui.plugin-screens', 'settings', 'library', 'tuning']),
summary: 'Navigation, plugin screens, settings, library/tuning sources, and long-running jobs.',
domains: Object.freeze(['ui.navigation', 'ui.plugin-screens', 'settings', 'library', 'tuning', 'jobs']),
}),
Object.freeze({
id: 'player-audio',
@@ -1474,6 +1473,54 @@
catch (_) { return null; }
}
function jobsSnapshot() {
const api = window.slopsmith && window.slopsmith.jobs;
if (!api || typeof api.snapshot !== 'function') return null;
try { return api.snapshot({ exportMode: 'local-inspector' }); }
catch (_) { return null; }
}
function jobsSupportPanel(jobsData) {
if (!jobsData || !jobsData.jobs) return '';
const providers = Array.isArray(jobsData.providers) ? jobsData.providers : [];
const selectedProviders = Array.isArray(jobsData.selectedProviders) ? jobsData.selectedProviders : [];
const active = Array.isArray(jobsData.jobs.active) ? jobsData.jobs.active : [];
const queued = Array.isArray(jobsData.jobs.queued) ? jobsData.jobs.queued : [];
const paused = Array.isArray(jobsData.jobs.paused) ? jobsData.jobs.paused : [];
const recentTerminal = Array.isArray(jobsData.jobs.recentTerminal) ? jobsData.jobs.recentTerminal : [];
const outcomes = Array.isArray(jobsData.outcomes) ? jobsData.outcomes.slice(-6) : [];
const bridgeHits = Array.isArray(jobsData.bridgeHits) ? jobsData.bridgeHits : [];
const unavailableProviders = providers.filter(provider => provider.availability && provider.availability !== 'available').length;
const terminalFailures = recentTerminal.filter(job => job && job.terminalOutcome && ['failed', 'timeout', 'provider-unavailable'].includes(job.terminalOutcome.status)).length;
return `<section class="mb-4 rounded-lg border border-gray-800 bg-dark-900/40 p-4" data-jobs-support>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-white">Jobs</h3>
<p class="mt-1 text-xs text-gray-500">Provider selection, active work, scheduling, terminal outcomes, and bridge hits from the jobs host.</p>
</div>
<div class="flex flex-wrap gap-2">
${pill(`${providers.length} provider${providers.length === 1 ? '' : 's'}`, providers.length ? 'info' : 'muted')}
${pill(`${active.length} active`, active.length ? 'used' : 'muted')}
${pill(`${queued.length} queued`, queued.length ? 'warning' : 'muted')}
${pill(`${paused.length} paused`, paused.length ? 'warning' : 'muted')}
${pill(`${recentTerminal.length} recent terminal`, recentTerminal.length ? (terminalFailures ? 'conflict' : 'clean') : 'muted')}
${pill(`${bridgeHits.length} bridge hit${bridgeHits.length === 1 ? '' : 's'}`, bridgeHits.length ? 'used' : 'muted')}
${pill(`${unavailableProviders} unavailable provider${unavailableProviders === 1 ? '' : 's'}`, unavailableProviders ? 'warning' : 'muted')}
</div>
</div>
<div class="mt-3 grid gap-2 text-xs text-gray-400 md:grid-cols-2">
<div data-jobs-providers>Providers: ${providers.map(provider => `${text(provider.label || provider.providerId)}:${text(provider.availability || 'unknown')}:${text((provider.jobTypes || []).join('+') || 'none')}`).join(', ') || 'none'}</div>
<div data-jobs-selected>Selected: ${selectedProviders.map(selection => `${text(selection.jobType)}:${text(selection.providerId)}:${text(selection.source || 'stored')}`).join(', ') || 'none'}</div>
<div data-jobs-active>Active: ${active.map(job => `${text(job.safeLabel || job.jobId)}:${text(job.providerId)}:${text(job.progress && job.progress.percent != null ? `${job.progress.percent}%` : job.progress && job.progress.step || job.state)}`).join(', ') || 'none'}</div>
<div data-jobs-queued>Queued: ${queued.map(job => `${text(job.safeLabel || job.jobId)}:${text(job.providerId)}:${text(job.priority || 'normal')}`).join(', ') || 'none'}</div>
<div data-jobs-paused>Paused: ${paused.map(job => `${text(job.safeLabel || job.jobId)}:${text(job.providerId)}`).join(', ') || 'none'}</div>
<div data-jobs-terminal>Terminal: ${recentTerminal.map(job => `${text(job.safeLabel || job.jobId)}:${text(job.terminalOutcome && job.terminalOutcome.status || job.state)}${job.terminalOutcome && job.terminalOutcome.category ? `:${text(job.terminalOutcome.category)}` : ''}`).join(', ') || 'none'}</div>
<div data-jobs-bridges>Bridges: ${bridgeHits.map(hit => `${text(hit.bridgeId || hit.legacySurface)}:${text(hit.operation || 'unknown')}${hit.diagnosticsOnly ? ':diagnostics-only' : ''}`).join(', ') || 'none'}</div>
<div data-jobs-outcomes>Outcomes: ${outcomes.map(outcome => `${text(outcome.operation)}:${text(outcome.status || outcome.outcome)}`).join(', ') || 'none'}</div>
</div>
</section>`;
}
function playbackSupportPanel(playbackData) {
if (!playbackData || !playbackData.state) return '';
const state = playbackData.state || {};
@@ -1642,9 +1689,10 @@
const compatibilityShims = Array.isArray(data.compatibilityShims) ? data.compatibilityShims : [];
if (summary) summary.innerHTML = summaryDashboard(data, pipelines, compatibilityShims);
destroyActiveGraphs();
const jobsPanel = jobsSupportPanel(jobsSnapshot());
const playbackPanel = playbackSupportPanel(playbackSnapshot());
const audioPanel = audioDomainSupportPanel(audioSessionSnapshot());
content.innerHTML = playbackPanel + audioPanel + (selected
content.innerHTML = jobsPanel + playbackPanel + audioPanel + (selected
? visible.map(pipeline => domainGraphView(pipeline, shimsByCapability.get(pipeline.name) || [], expectedShimsByCapability.get(pipeline.name) || [])).join('')
: groupedPipelines(visible).map(entry => pipelineGroupSection(entry, shimsByCapability, expectedShimsByCapability, { defaultExpanded: false })).join(''))
|| '<div class="text-gray-500 text-sm">No capability domains registered.</div>';
+72 -72
View File
@@ -17,11 +17,11 @@
**Purpose**: Create the jobs capability host entry points and test harness scaffolding used by all stories.
- [ ] T001 Create jobs capability host shell with IIFE, event-bus access, diagnostics contribution placeholder, and public namespace in static/capabilities/jobs.js
- [ ] T002 Load the jobs capability host after the capability runtime in static/index.html
- [ ] T003 [P] Create jobs Node VM test harness with helpers for loading capabilities, dispatching jobs commands, capturing events, and reading diagnostics in tests/js/jobs_test_harness.js
- [ ] T004 [P] Add jobs test file placeholders importing the harness in tests/js/jobs_domain.test.js, tests/js/jobs_scheduling.test.js, tests/js/jobs_diagnostics.test.js, and tests/js/jobs_compat.test.js
- [ ] T005 [P] Add jobs inspector render fixture placeholders in tests/js/capability_inspector_render.test.js
- [X] T001 Create jobs capability host shell with IIFE, event-bus access, diagnostics contribution placeholder, and public namespace in static/capabilities/jobs.js
- [X] T002 Load the jobs capability host after the capability runtime in static/index.html
- [X] T003 [P] Create jobs Node VM test harness with helpers for loading capabilities, dispatching jobs commands, capturing events, and reading diagnostics in tests/js/jobs_test_harness.js
- [X] T004 [P] Add jobs test file placeholders importing the harness in tests/js/jobs_domain.test.js, tests/js/jobs_scheduling.test.js, tests/js/jobs_diagnostics.test.js, and tests/js/jobs_compat.test.js
- [X] T005 [P] Add jobs inspector render fixture placeholders in tests/js/capability_inspector_render.test.js
---
@@ -31,15 +31,15 @@
**CRITICAL**: No user story work can begin until this phase is complete.
- [ ] T006 Promote `jobs` from reserved future domain to active privileged provider-coordinator review metadata in static/capabilities.js
- [ ] T007 Add or verify the canonical jobs outcome inventory from FR-005 in capability outcome normalization in static/capabilities.js
- [ ] T008 Implement constants for job states, outcomes, actions, priorities, limits, bridge ids, and diagnostics schema in static/capabilities/jobs.js
- [ ] T009 Implement redaction and safe-value helpers for labels, reasons, target refs, logs, command-like text, URLs, local paths, and provider payloads in static/capabilities/jobs.js
- [ ] T010 Implement provider, selected-provider, job, attempt, progress, outcome, history, and bridge in-memory stores with resettable test hooks in static/capabilities/jobs.js
- [ ] T011 Implement diagnostics snapshot builder with active/queued/paused/recent terminal grouping and 64 KB budget trimming in static/capabilities/jobs.js
- [ ] T012 Implement jobs capability owner registration with command/event declarations and diagnostics contribution in static/capabilities/jobs.js
- [ ] T013 Add foundational harness assertions for active jobs owner registration, diagnostics schema, reset hooks, and no raw-provider-payload exposure in tests/js/jobs_domain.test.js
- [ ] T014 [P] Add static idempotence expectations for active jobs domain and new outcomes in tests/test_plugin_runtime_idempotence.py
- [X] T006 Promote `jobs` from reserved future domain to active privileged provider-coordinator review metadata in static/capabilities.js
- [X] T007 Add or verify the canonical jobs outcome inventory from FR-005 in capability outcome normalization in static/capabilities.js
- [X] T008 Implement constants for job states, outcomes, actions, priorities, limits, bridge ids, and diagnostics schema in static/capabilities/jobs.js
- [X] T009 Implement redaction and safe-value helpers for labels, reasons, target refs, logs, command-like text, URLs, local paths, and provider payloads in static/capabilities/jobs.js
- [X] T010 Implement provider, selected-provider, job, attempt, progress, outcome, history, and bridge in-memory stores with resettable test hooks in static/capabilities/jobs.js
- [X] T011 Implement diagnostics snapshot builder with active/queued/paused/recent terminal grouping and 64 KB budget trimming in static/capabilities/jobs.js
- [X] T012 Implement jobs capability owner registration with command/event declarations and diagnostics contribution in static/capabilities/jobs.js
- [X] T013 Add foundational harness assertions for active jobs owner registration, diagnostics schema, reset hooks, and no raw-provider-payload exposure in tests/js/jobs_domain.test.js
- [X] T014 [P] Add static idempotence expectations for active jobs domain and new outcomes in tests/test_plugin_runtime_idempotence.py
**Checkpoint**: Jobs domain loads, registers as active privileged provider-coordinator, and can produce an empty redaction-safe diagnostics snapshot.
@@ -53,21 +53,21 @@
### Tests for User Story 1
- [ ] T015 [US1] Add provider registration, re-registration idempotence, unavailable/degraded provider, and incompatible provider tests in tests/js/jobs_domain.test.js
- [ ] T016 [US1] Add user-approved enqueue, privileged enqueue without approval returning denied/user-action-required before provider work starts, side-effect-free list/inspect, queued/running state, no-owner, unavailable, and validation-failed tests in tests/js/jobs_domain.test.js
- [ ] T017 [US1] Add determinate progress, indeterminate progress, stale progress after terminal state, completion, and safe result summary tests in tests/js/jobs_domain.test.js
- [ ] T018 [P] [US1] Add Capability Inspector jobs provider/progress/current-job rendering tests in tests/js/capability_inspector_render.test.js
- [X] T015 [US1] Add provider registration, re-registration idempotence, unavailable/degraded provider, and incompatible provider tests in tests/js/jobs_domain.test.js
- [X] T016 [US1] Add user-approved enqueue, privileged enqueue without approval returning denied/user-action-required before provider work starts, side-effect-free list/inspect, queued/running state, no-owner, unavailable, and validation-failed tests in tests/js/jobs_domain.test.js
- [X] T017 [US1] Add determinate progress, indeterminate progress, stale progress after terminal state, completion, and safe result summary tests in tests/js/jobs_domain.test.js
- [X] T018 [P] [US1] Add Capability Inspector jobs provider/progress/current-job rendering tests in tests/js/capability_inspector_render.test.js
### Implementation for User Story 1
- [ ] T019 [US1] Implement `register-provider`, `unregister-provider`, `list-providers`, provider validation, and idempotent provider updates in static/capabilities/jobs.js
- [ ] T020 [US1] Implement `enqueue` validation for one compatible provider, explicit user-action or approved-continuation authorization before provider dispatch, provider availability, provider operation dispatch, job creation, queued/running state, and denied/user-action-required/no-owner/no-handler/unavailable outcomes in static/capabilities/jobs.js
- [ ] T021 [US1] Implement side-effect-free `list` and `inspect` command summaries for providers, jobs, progress, attempts, actions, timestamps, and safe reasons without invoking provider work callbacks in static/capabilities/jobs.js
- [ ] T022 [US1] Implement provider progress and log update ingestion with determinate, indeterminate, step-only, decreasing-progress, and stale-after-terminal handling in static/capabilities/jobs.js
- [ ] T023 [US1] Implement provider completion and failure result normalization with terminal outcomes, retryability, result summaries, and active-state cleanup in static/capabilities/jobs.js
- [ ] T024 [US1] Emit `jobs:provider-registered`, `jobs:queued`, `jobs:started`, `jobs:progress`, `jobs:log`, `jobs:completed`, and `jobs:failed` lifecycle events in static/capabilities/jobs.js
- [ ] T025 [US1] Render jobs provider cards, active/queued jobs, progress, action availability, and recent outcomes in plugins/capability_inspector/screen.js
- [ ] T026 [US1] Load and smoke-check jobs host script ordering with index integration in static/index.html
- [X] T019 [US1] Implement `register-provider`, `unregister-provider`, `list-providers`, provider validation, and idempotent provider updates in static/capabilities/jobs.js
- [X] T020 [US1] Implement `enqueue` validation for one compatible provider, explicit user-action or approved-continuation authorization before provider dispatch, provider availability, provider operation dispatch, job creation, queued/running state, and denied/user-action-required/no-owner/no-handler/unavailable outcomes in static/capabilities/jobs.js
- [X] T021 [US1] Implement side-effect-free `list` and `inspect` command summaries for providers, jobs, progress, attempts, actions, timestamps, and safe reasons without invoking provider work callbacks in static/capabilities/jobs.js
- [X] T022 [US1] Implement provider progress and log update ingestion with determinate, indeterminate, step-only, decreasing-progress, and stale-after-terminal handling in static/capabilities/jobs.js
- [X] T023 [US1] Implement provider completion and failure result normalization with terminal outcomes, retryability, result summaries, and active-state cleanup in static/capabilities/jobs.js
- [X] T024 [US1] Emit `jobs:provider-registered`, `jobs:queued`, `jobs:started`, `jobs:progress`, `jobs:log`, `jobs:completed`, and `jobs:failed` lifecycle events in static/capabilities/jobs.js
- [X] T025 [US1] Render jobs provider cards, active/queued jobs, progress, action availability, and recent outcomes in plugins/capability_inspector/screen.js
- [X] T026 [US1] Load and smoke-check jobs host script ordering with index integration in static/index.html
**Checkpoint**: User Story 1 is independently functional; a single provider can enqueue, progress, complete/fail, and appear in diagnostics and inspector.
@@ -81,20 +81,20 @@
### Tests for User Story 2
- [ ] T027 [US2] Add queued cancel, running cancellation-requested, provider terminal after cancellation, late cancel stale, and unsupported cancel tests in tests/js/jobs_domain.test.js
- [ ] T028 [US2] Add pause/resume supported, unsupported pause/resume, terminal pause/resume stale, and same-job-identity tests in tests/js/jobs_domain.test.js
- [ ] T029 [US2] Add retryable failed/cancelled job, linked attempt, approved-continuation scope, widened-scope denial, and concurrent retry tests in tests/js/jobs_domain.test.js
- [ ] T030 [P] [US2] Add inspector tests for cancellation-requested, paused, retryable terminal jobs, and linked attempts in tests/js/capability_inspector_render.test.js
- [X] T027 [US2] Add queued cancel, running cancellation-requested, provider terminal after cancellation, late cancel stale, and unsupported cancel tests in tests/js/jobs_domain.test.js
- [X] T028 [US2] Add pause/resume supported, unsupported pause/resume, terminal pause/resume stale, and same-job-identity tests in tests/js/jobs_domain.test.js
- [X] T029 [US2] Add retryable failed/cancelled job, linked attempt, approved-continuation scope, widened-scope denial, and concurrent retry tests in tests/js/jobs_domain.test.js
- [X] T030 [P] [US2] Add inspector tests for cancellation-requested, paused, retryable terminal jobs, and linked attempts in tests/js/capability_inspector_render.test.js
### Implementation for User Story 2
- [ ] T031 [US2] Implement `cancel` command for queued immediate cancellation, running cancellation-requested, unsupported-operation, stale terminal, and provider failure outcomes in static/capabilities/jobs.js
- [ ] T032 [US2] Implement `pause` command with provider action checks, paused state, unsupported-operation, stale terminal, and safe reason outcomes in static/capabilities/jobs.js
- [ ] T033 [US2] Implement `resume` command returning paused jobs to queued/running state with the same job identity in static/capabilities/jobs.js
- [ ] T034 [US2] Implement `retry` command with terminal-state checks, retryability, one active retry attempt, linked attempts, approved-continuation scope, and retry-started outcomes in static/capabilities/jobs.js
- [ ] T035 [US2] Implement approval scope matching for provider, job type, target, requester, and inputs in static/capabilities/jobs.js
- [ ] T036 [US2] Emit `jobs:cancellation-requested`, `jobs:cancelled`, `jobs:paused`, `jobs:resumed`, and `jobs:retried` events in static/capabilities/jobs.js
- [ ] T037 [US2] Update Capability Inspector jobs rendering for cancellation state, pause/resume actions, retry action, and attempt history in plugins/capability_inspector/screen.js
- [X] T031 [US2] Implement `cancel` command for queued immediate cancellation, running cancellation-requested, unsupported-operation, stale terminal, and provider failure outcomes in static/capabilities/jobs.js
- [X] T032 [US2] Implement `pause` command with provider action checks, paused state, unsupported-operation, stale terminal, and safe reason outcomes in static/capabilities/jobs.js
- [X] T033 [US2] Implement `resume` command returning paused jobs to queued/running state with the same job identity in static/capabilities/jobs.js
- [X] T034 [US2] Implement `retry` command with terminal-state checks, retryability, one active retry attempt, linked attempts, approved-continuation scope, and retry-started outcomes in static/capabilities/jobs.js
- [X] T035 [US2] Implement approval scope matching for provider, job type, target, requester, and inputs in static/capabilities/jobs.js
- [X] T036 [US2] Emit `jobs:cancellation-requested`, `jobs:cancelled`, `jobs:paused`, `jobs:resumed`, and `jobs:retried` events in static/capabilities/jobs.js
- [X] T037 [US2] Update Capability Inspector jobs rendering for cancellation state, pause/resume actions, retry action, and attempt history in plugins/capability_inspector/screen.js
**Checkpoint**: User Story 2 is independently functional; all job control actions produce explicit outcomes and keep job state truthful.
@@ -108,22 +108,22 @@
### Tests for User Story 3
- [ ] T038 [US3] Add exactly-one-provider auto-select, multiple-provider provider-selection-required, selected/default provider, unavailable selection, and explicit provider tests in tests/js/jobs_scheduling.test.js
- [ ] T039 [US3] Add provider capacity, queued blocked reason, user-approved-before-background priority, FIFO within priority, and no overrun tests in tests/js/jobs_scheduling.test.js
- [ ] T040 [US3] Add provider disappearance, provider-unavailable/orphaned state, provider rehydration no duplicates, and incompatible version tests in tests/js/jobs_scheduling.test.js
- [ ] T041 [US3] Add reload recovery tests for persisted recoverable queued/running/paused job references, non-recoverable orphan/provider-unavailable jobs, stale persisted reference cleanup, and terminal preservation in tests/js/jobs_scheduling.test.js
- [ ] T042 [P] [US3] Add selected/default provider persistence fallback tests in tests/js/jobs_scheduling.test.js
- [X] T038 [US3] Add exactly-one-provider auto-select, multiple-provider provider-selection-required, selected/default provider, unavailable selection, and explicit provider tests in tests/js/jobs_scheduling.test.js
- [X] T039 [US3] Add provider capacity, queued blocked reason, user-approved-before-background priority, FIFO within priority, and no overrun tests in tests/js/jobs_scheduling.test.js
- [X] T040 [US3] Add provider disappearance, provider-unavailable/orphaned state, provider rehydration no duplicates, and incompatible version tests in tests/js/jobs_scheduling.test.js
- [X] T041 [US3] Add reload recovery tests for persisted recoverable queued/running/paused job references, non-recoverable orphan/provider-unavailable jobs, stale persisted reference cleanup, and terminal preservation in tests/js/jobs_scheduling.test.js
- [X] T042 [P] [US3] Add selected/default provider persistence fallback tests in tests/js/jobs_scheduling.test.js
### Implementation for User Story 3
- [ ] T043 [US3] Implement selected/default provider state, localStorage persistence with in-memory fallback, and provider-selection-required logic in static/capabilities/jobs.js
- [ ] T044 [US3] Implement scheduler capacity checks, queued blocked reasons, user-approved-interactive priority before background-maintenance, and FIFO ordering within priority in static/capabilities/jobs.js
- [ ] T045 [US3] Implement start-next scheduling after enqueue, completion, cancellation, provider availability change, and resume in static/capabilities/jobs.js
- [ ] T046 [US3] Implement provider disappearance and incompatible-provider handling that marks affected jobs provider-unavailable or orphaned with safe reasons in static/capabilities/jobs.js
- [ ] T047 [US3] Implement provider rehydration and recoverable job reconciliation without duplicating providers, active jobs, terminal jobs, or attempts in static/capabilities/jobs.js
- [ ] T048 [US3] Implement reload recovery hooks that persist only redaction-safe provider-declared recoverable queued/running/paused job references, restore them on reload, clean stale references, and mark non-recoverable jobs orphan/provider-unavailable in static/capabilities/jobs.js
- [ ] T049 [US3] Emit `jobs:provider-unavailable` and `jobs:orphaned` events and scheduling-related queued/started events in static/capabilities/jobs.js
- [ ] T050 [US3] Update Capability Inspector jobs rendering for provider selection, capacity/current load, queued blocked reason, priority, and recovery status in plugins/capability_inspector/screen.js
- [X] T043 [US3] Implement selected/default provider state, localStorage persistence with in-memory fallback, and provider-selection-required logic in static/capabilities/jobs.js
- [X] T044 [US3] Implement scheduler capacity checks, queued blocked reasons, user-approved-interactive priority before background-maintenance, and FIFO ordering within priority in static/capabilities/jobs.js
- [X] T045 [US3] Implement start-next scheduling after enqueue, completion, cancellation, provider availability change, and resume in static/capabilities/jobs.js
- [X] T046 [US3] Implement provider disappearance and incompatible-provider handling that marks affected jobs provider-unavailable or orphaned with safe reasons in static/capabilities/jobs.js
- [X] T047 [US3] Implement provider rehydration and recoverable job reconciliation without duplicating providers, active jobs, terminal jobs, or attempts in static/capabilities/jobs.js
- [X] T048 [US3] Implement reload recovery hooks that persist only redaction-safe provider-declared recoverable queued/running/paused job references, restore them on reload, clean stale references, and mark non-recoverable jobs orphan/provider-unavailable in static/capabilities/jobs.js
- [X] T049 [US3] Emit `jobs:provider-unavailable` and `jobs:orphaned` events and scheduling-related queued/started events in static/capabilities/jobs.js
- [X] T050 [US3] Update Capability Inspector jobs rendering for provider selection, capacity/current load, queued blocked reason, priority, and recovery status in plugins/capability_inspector/screen.js
**Checkpoint**: User Story 3 is independently functional; providers can be selected, scheduled, recovered, and diagnosed without duplicate queues or over-capacity starts.
@@ -137,21 +137,21 @@
### Tests for User Story 4
- [ ] T051 [US4] Add diagnostics schema, active/queued/paused/recent terminal grouping, all-active retention, five-terminal retention, and 50-entry history cap tests in tests/js/jobs_diagnostics.test.js
- [ ] T052 [US4] Add redaction tests for paths, raw filenames, URLs, tokens, command lines, environment values, raw artifacts, media buffers, recordings, native handles, subprocess handles, and provider-private payloads in tests/js/jobs_diagnostics.test.js
- [ ] T053 [US4] Add failure category tests for invalid-input, permission-denied, provider-unavailable, unsupported-operation, timeout, cancellation, external-dependency, storage, provider-failure, and unknown in tests/js/jobs_diagnostics.test.js
- [ ] T054 [US4] Add compatibility bridge hit, legacy duplicate suppression, legacy queue/status/backend-route operations, and diagnostics-only bridge tests in tests/js/jobs_compat.test.js
- [ ] T055 [P] [US4] Add inspector diagnostics tests for terminal jobs, safe failure category, bridge hits, retention limits, and redacted log messages in tests/js/capability_inspector_render.test.js
- [X] T051 [US4] Add diagnostics schema, active/queued/paused/recent terminal grouping, all-active retention, five-terminal retention, and 50-entry history cap tests in tests/js/jobs_diagnostics.test.js
- [X] T052 [US4] Add redaction tests for paths, raw filenames, URLs, tokens, command lines, environment values, raw artifacts, media buffers, recordings, native handles, subprocess handles, and provider-private payloads in tests/js/jobs_diagnostics.test.js
- [X] T053 [US4] Add failure category tests for invalid-input, permission-denied, provider-unavailable, unsupported-operation, timeout, cancellation, external-dependency, storage, provider-failure, and unknown in tests/js/jobs_diagnostics.test.js
- [X] T054 [US4] Add compatibility bridge hit, legacy duplicate suppression, legacy queue/status/backend-route operations, and diagnostics-only bridge tests in tests/js/jobs_compat.test.js
- [X] T055 [P] [US4] Add inspector diagnostics tests for terminal jobs, safe failure category, bridge hits, retention limits, and redacted log messages in tests/js/capability_inspector_render.test.js
### Implementation for User Story 4
- [ ] T056 [US4] Implement diagnostics payload `slopsmith.jobs.diagnostics.v1` with providers, selectedProviders, jobs, outcomes, bridgeHits, limits, and notes in static/capabilities/jobs.js
- [ ] T057 [US4] Implement retention policy preserving all active jobs, at least five recent terminal jobs, and at most 50 progress/log entries per job within snapshot budget in static/capabilities/jobs.js
- [ ] T058 [US4] Implement failure category normalization and safe retryability summaries for invalid input, permission denied, provider unavailable, unsupported operation, timeout, cancellation, external dependency, storage, provider failure, and unknown in static/capabilities/jobs.js
- [ ] T059 [US4] Implement redaction/pseudonymization enforcement for exported diagnostics and local inspector snapshots in static/capabilities/jobs.js
- [ ] T060 [US4] Implement `record-bridge-hit` command and bridge hit normalization for legacy plugin queue, status screen, backend route, progress poll, and update flow in static/capabilities/jobs.js
- [ ] T061 [US4] Implement native-over-compatibility duplicate suppression for logical jobs shared by native providers and compatibility-backed legacy surfaces in static/capabilities/jobs.js
- [ ] T062 [US4] Update Capability Inspector jobs rendering for recent terminal jobs, failure categories, retention notes, bridge hits, and redacted history in plugins/capability_inspector/screen.js
- [X] T056 [US4] Implement diagnostics payload `slopsmith.jobs.diagnostics.v1` with providers, selectedProviders, jobs, outcomes, bridgeHits, limits, and notes in static/capabilities/jobs.js
- [X] T057 [US4] Implement retention policy preserving all active jobs, at least five recent terminal jobs, and at most 50 progress/log entries per job within snapshot budget in static/capabilities/jobs.js
- [X] T058 [US4] Implement failure category normalization and safe retryability summaries for invalid input, permission denied, provider unavailable, unsupported operation, timeout, cancellation, external dependency, storage, provider failure, and unknown in static/capabilities/jobs.js
- [X] T059 [US4] Implement redaction/pseudonymization enforcement for exported diagnostics and local inspector snapshots in static/capabilities/jobs.js
- [X] T060 [US4] Implement `record-bridge-hit` command and bridge hit normalization for legacy plugin queue, status screen, backend route, progress poll, and update flow in static/capabilities/jobs.js
- [X] T061 [US4] Implement native-over-compatibility duplicate suppression for logical jobs shared by native providers and compatibility-backed legacy surfaces in static/capabilities/jobs.js
- [X] T062 [US4] Update Capability Inspector jobs rendering for recent terminal jobs, failure categories, retention notes, bridge hits, and redacted history in plugins/capability_inspector/screen.js
**Checkpoint**: User Story 4 is independently functional; support surfaces explain failures and migration bridges without leaking privileged data.
@@ -161,16 +161,16 @@
**Purpose**: Documentation, migration guidance, validation, and cleanup across all stories.
- [ ] T063 [P] Update jobs active-domain command/event/diagnostics guidance in docs/capability-domains.md
- [ ] T064 [P] Update jobs migration status and removal gates in docs/capability-roadmap.md
- [ ] T065 [P] Update jobs privileged provider-coordinator row and outcomes in docs/capability-safety-matrix.md
- [ ] T066 [P] Add jobs provider/requester manifest and dispatch recipe in docs/capability-recipes.md
- [ ] T067 [P] Add changelog entry for the jobs control plane in CHANGELOG.md
- [ ] T068 Run syntax checks for static/capabilities.js, static/capabilities/jobs.js, and plugins/capability_inspector/screen.js
- [ ] T069 Run focused JS validation with npm run test:js and confirm jobs tests pass in tests/js/jobs_domain.test.js, tests/js/jobs_scheduling.test.js, tests/js/jobs_diagnostics.test.js, tests/js/jobs_compat.test.js, and tests/js/capability_inspector_render.test.js
- [ ] T070 Run focused pytest regression if diagnostics/plugin/redaction Python surfaces changed in tests/test_diagnostics_bundle.py, tests/test_diagnostics_redact.py, tests/test_plugins.py, and tests/test_plugin_runtime_idempotence.py
- [ ] T071 Run browser console smoke after UI wiring with tests/browser/check-errors.spec.ts
- [ ] T072 Verify quickstart scenarios and update any discovered validation notes in specs/009-jobs/quickstart.md
- [X] T063 [P] Update jobs active-domain command/event/diagnostics guidance in docs/capability-domains.md
- [X] T064 [P] Update jobs migration status and removal gates in docs/capability-roadmap.md
- [X] T065 [P] Update jobs privileged provider-coordinator row and outcomes in docs/capability-safety-matrix.md
- [X] T066 [P] Add jobs provider/requester manifest and dispatch recipe in docs/capability-recipes.md
- [X] T067 [P] Add changelog entry for the jobs control plane in CHANGELOG.md
- [X] T068 Run syntax checks for static/capabilities.js, static/capabilities/jobs.js, and plugins/capability_inspector/screen.js
- [X] T069 Run focused JS validation with npm run test:js and confirm jobs tests pass in tests/js/jobs_domain.test.js, tests/js/jobs_scheduling.test.js, tests/js/jobs_diagnostics.test.js, tests/js/jobs_compat.test.js, and tests/js/capability_inspector_render.test.js
- [X] T070 Run focused pytest regression if diagnostics/plugin/redaction Python surfaces changed in tests/test_diagnostics_bundle.py, tests/test_diagnostics_redact.py, tests/test_plugins.py, and tests/test_plugin_runtime_idempotence.py
- [X] T071 Run browser console smoke after UI wiring with tests/browser/check-errors.spec.ts
- [X] T072 Verify quickstart scenarios and update any discovered validation notes in specs/009-jobs/quickstart.md
---
+12 -6
View File
@@ -72,9 +72,9 @@
const OUTCOMES = new Set([
'passed', 'transformed', 'handled', 'denied', 'degraded', 'failed',
'short-circuited', 'overridden', 'no-owner', 'no-handler',
'unsupported-command', 'incompatible', 'incompatible-version',
'unsupported-command', 'unsupported-operation', 'incompatible', 'incompatible-version',
'unavailable', 'provider-selection-required', 'user-action-required', 'no-target',
'stale', 'cancelled', 'stopped',
'stale', 'queued', 'validation-failed', 'cancelled', 'completed', 'timeout', 'retry-started', 'stopped',
]);
const MAX_DECISIONS = 100;
const MAX_SNAPSHOT_BYTES = 64 * 1024;
@@ -88,7 +88,6 @@
'ui.player-panels',
'ui.player-overlays',
'plugins',
'jobs',
'midi-control',
'tempo-clock',
]);
@@ -110,6 +109,7 @@
'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.' }),
jobs: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Privileged provider-coordinator control plane for long-running work, scheduling, recovery, bridges, and redaction-safe diagnostics.' }),
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.' }),
@@ -832,7 +832,7 @@
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));
const terminal = decisions.find(d => ['denied', 'failed', 'short-circuited', 'handled', 'degraded', 'overridden', 'no-owner', 'no-handler', 'no-target', 'unsupported-command', 'unsupported-operation', 'incompatible', 'incompatible-version', 'unavailable', 'provider-selection-required', 'user-action-required', 'stale', 'queued', 'validation-failed', 'cancelled', 'completed', 'timeout', 'retry-started', 'stopped'].includes(d.outcome));
return terminal ? terminal.outcome : decisions[decisions.length - 1].outcome;
}
@@ -986,7 +986,7 @@
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 (['denied', 'failed', 'short-circuited', 'handled', 'degraded', 'overridden', 'no-owner', 'no-handler', 'no-target', 'unsupported-command', 'unsupported-operation', 'incompatible', 'incompatible-version', 'unavailable', 'provider-selection-required', 'user-action-required', 'stale', 'queued', 'validation-failed', 'cancelled', 'completed', 'timeout', 'retry-started', 'stopped'].includes(decision.outcome)) break;
}
if (!decisions.length) {
const reason = `No provider handled ${capabilityName}.${commandName}`;
@@ -1005,7 +1005,7 @@
});
}
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))
const terminalDecision = decisions.find(d => ['denied', 'failed', 'short-circuited', 'handled', 'degraded', 'overridden', 'no-owner', 'no-handler', 'no-target', 'unsupported-command', 'unsupported-operation', 'incompatible', 'incompatible-version', 'unavailable', 'provider-selection-required', 'user-action-required', 'stale', 'queued', 'validation-failed', 'cancelled', 'completed', 'timeout', 'retry-started', 'stopped'].includes(d.outcome))
|| decisions[decisions.length - 1];
return {
capability: capabilityName,
@@ -1317,13 +1317,19 @@
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 === 'unsupported-operation') return 'unsupported-operation';
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 === 'queued') return 'queued';
if (result.outcome === 'validation-failed') return 'validation-failed';
if (result.outcome === 'cancelled') return 'cancelled';
if (result.outcome === 'completed') return 'completed';
if (result.outcome === 'timeout') return 'timeout';
if (result.outcome === 'retry-started') return 'retry-started';
if (result.outcome === 'stopped') return 'stopped';
if (result.outcome === 'denied' || result.outcome === 'short-circuited') return 'blocked';
if (result.outcome === 'failed') return 'error';
File diff suppressed because it is too large Load Diff
+1
View File
@@ -24,6 +24,7 @@
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/jobs.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
+5 -5
View File
@@ -50,7 +50,7 @@ test('diagnostics export expected compatibility shim surfaces', () => {
assert.equal(expected.some(entry => entry.capability === 'jobs'), false);
});
test('diagnostics include active playback but exclude deferred and documentation-only future core domains', () => {
test('diagnostics include active playback and jobs but exclude deferred future core domains', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('playback_probe', {
@@ -74,14 +74,14 @@ test('diagnostics include active playback but exclude deferred and documentation
const playback = pipelines.find(entry => entry.name === 'playback');
assert.ok(playback, 'playback should be part of the active runtime graph when participants register');
assert.equal(playback.review.lifecycle, 'active');
const jobs = pipelines.find(entry => entry.name === 'jobs');
assert.ok(jobs, 'jobs should be part of the active runtime graph when participants register');
assert.equal(jobs.review.lifecycle, 'active');
// `visualization` (cap:6 slice) and `note-detection` (spec 009 slice)
// left this list when their domains were promoted.
const futureDomains = [
'ui.navigation', 'ui.plugin-screens', 'settings',
'backend.routes', 'ui.player-controls',
'ui.player-panels', 'ui.player-overlays', 'plugins', 'jobs', 'midi-control',
'tempo-clock',
'ui.navigation', 'ui.plugin-screens', 'settings', 'backend.routes', 'ui.player-controls', 'ui.player-panels', 'ui.player-overlays', 'plugins', 'midi-control', 'tempo-clock',
];
for (const domain of futureDomains) {
@@ -65,6 +65,9 @@ function loadInspector(snapshot, options = {}) {
playback: options.playbackSnapshot ? {
snapshot: () => options.playbackSnapshot,
} : undefined,
jobs: options.jobsSnapshot ? {
snapshot: () => options.jobsSnapshot,
} : undefined,
},
navigator: { clipboard: { writeText: async () => {} } },
document: {
@@ -114,6 +117,41 @@ test('capability inspector renders playback session route loop bridges and outco
assert.match(content, /seek:completed/);
});
test('capability inspector renders jobs support state and active domain graph', () => {
const snapshot = {
pipelines: [{ name: 'jobs', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Jobs control plane.' }, participants: [{ pluginId: 'core.jobs', kind: 'provider-coordinator', roles: ['owner'], commands: ['enqueue', 'list', 'inspect'], operations: ['job.enqueue'], events: ['queued', 'completed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'privileged' }], conflicts: [] }],
participants: [{ pluginId: 'core.jobs' }],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const jobsSnapshot = {
schema: 'slopsmith.jobs.diagnostics.v1',
providers: [{ providerId: 'provider.cache', label: 'Cache Builder', availability: 'available', jobTypes: ['cache-build'] }],
selectedProviders: [{ jobType: 'cache-build', providerId: 'provider.cache', source: 'user-selected' }],
jobs: {
active: [{ jobId: 'job-1', safeLabel: 'Build cache', providerId: 'provider.cache', state: 'running', progress: { percent: 42 } }],
queued: [{ jobId: 'job-2', safeLabel: 'Later cache', providerId: 'provider.cache', priority: 'background-maintenance' }],
paused: [],
recentTerminal: [{ jobId: 'job-0', safeLabel: 'Old cache', providerId: 'provider.cache', state: 'completed', terminalOutcome: { status: 'completed' } }],
},
outcomes: [{ operation: 'enqueue', status: 'queued' }, { operation: 'complete', status: 'completed' }],
bridgeHits: [{ bridgeId: 'jobs.legacy-plugin-queue', operation: 'enqueue', diagnosticsOnly: true }],
};
const { elements } = loadInspector(snapshot, { jobsSnapshot });
const content = elements.get('capability-inspector-content').innerHTML;
const filter = elements.get('capability-inspector-filter').innerHTML;
assert.match(content, /data-jobs-support/);
assert.match(content, /Providers: Cache Builder:available:cache-build/);
assert.match(content, /Selected: cache-build:provider.cache:user-selected/);
assert.match(content, /Active: Build cache:provider.cache:42%/);
assert.match(content, /Queued: Later cache:provider.cache:background-maintenance/);
assert.match(content, /Bridges: jobs.legacy-plugin-queue:enqueue:diagnostics-only/);
assert.match(content, /Outcomes: enqueue:queued, complete:completed/);
assert.match(content, /data-domain-graph="jobs"/);
assert.match(filter, /jobs/);
});
test('capability inspector renders shims inside their capability domain', () => {
const snapshot = {
pipelines: [
+58
View File
@@ -0,0 +1,58 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadJobs, dispatch, makeProvider, enqueuePayload, diagnosticsSnapshot, captureEvents } = require('./jobs_test_harness');
test('legacy bridge hits are recorded as diagnostics-only compatibility shims', async () => {
const window = loadJobs();
const events = captureEvents(window);
const { provider } = makeProvider({ providerId: 'provider.bridge' });
await dispatch(window, 'register-provider', { provider });
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ providerId: 'provider.bridge', logicalJobKey: 'legacy-key' }));
const result = await dispatch(window, 'record-bridge-hit', {
bridgeId: 'jobs.legacy-plugin-queue',
legacySurface: 'legacy-plugin-queue',
pluginId: 'legacy_plugin',
operation: 'enqueue',
logicalJobKey: 'legacy-key',
safeReason: 'legacy queue adapter used',
});
const runtimeSnapshot = window.slopsmith.capabilities.snapshotDiagnostics();
const diagnostics = diagnosticsSnapshot(window);
assert.equal(result.status, 'applied');
assert.equal(result.payload.bridge.jobId, enqueued.payload.job.jobId);
assert.equal(result.payload.bridge.diagnosticsOnly, true);
assert.ok(runtimeSnapshot.compatibilityShims.some(shim => shim.capability === 'jobs' && shim.status === 'used'));
assert.equal(diagnostics.bridgeHits.length, 1);
assert.ok(events.some(event => event.event === 'bridge-hit'));
});
test('duplicate logical jobs are suppressed and remain tied to the active job', async () => {
const window = loadJobs();
const { provider } = makeProvider({ providerId: 'provider.duplicate' });
await dispatch(window, 'register-provider', { provider });
const first = await dispatch(window, 'enqueue', enqueuePayload({ providerId: 'provider.duplicate', logicalJobKey: 'same-job' }));
const second = await dispatch(window, 'enqueue', enqueuePayload({ providerId: 'provider.duplicate', logicalJobKey: 'same-job' }));
assert.equal(second.status, 'applied');
assert.equal(second.payload.duplicate, true);
assert.equal(second.payload.job.jobId, first.payload.job.jobId);
assert.equal(diagnosticsSnapshot(window).jobs.active.length, 1);
});
test('unsupported provider actions return unsupported-operation instead of invoking callbacks', async () => {
const window = loadJobs();
const { calls, provider } = makeProvider({ providerId: 'provider.limited', actions: ['enqueue', 'inspect'] });
await dispatch(window, 'register-provider', { provider });
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ providerId: 'provider.limited' }));
calls.length = 0;
const pause = await dispatch(window, 'pause', { jobId: enqueued.payload.job.jobId });
const retry = await dispatch(window, 'retry', { jobId: enqueued.payload.job.jobId, authorization: 'user-action' });
assert.equal(pause.status, 'unsupported-operation');
assert.equal(retry.status, 'stale');
assert.equal(calls.length, 0);
});
+73
View File
@@ -0,0 +1,73 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadJobs, dispatch, makeProvider, enqueuePayload, diagnosticsSnapshot, storageEntries } = require('./jobs_test_harness');
test('diagnostics schema redacts raw payloads, paths, command lines, and provider-private fields', async () => {
const window = loadJobs();
const { provider } = makeProvider();
await dispatch(window, 'register-provider', { provider });
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({
safeLabel: 'Generate cache',
target: { path: '/Users/example/Music/Secret Artist - Secret Song_p.psarc', filename: 'Secret Song_p.psarc' },
inputs: { token: 'abc123', rawPayload: 'never export', commandLine: 'ffmpeg -i secret.wav out.ogg', safeFingerprint: 'fingerprint-public' },
}));
window.slopsmith.jobs.log(provider.providerId, enqueued.payload.job.jobId, 'ran ffmpeg -i /Users/example/secret.wav with token=abc123');
window.slopsmith.jobs.fail(provider.providerId, enqueued.payload.job.jobId, { safeReason: 'failed near /Users/example/private/path', retryable: true });
const json = JSON.stringify(diagnosticsSnapshot(window));
assert.match(json, /Generate cache/);
assert.match(json, /fingerprint-public/);
assert.doesNotMatch(json, /Secret Artist|Secret Song|secret\.wav|abc123|rawPayload|commandLine|never export|ffmpeg -i/);
});
test('recoverable job references are the only active state persisted across reloads', async () => {
const window = loadJobs();
const { provider } = makeProvider({ providerId: 'provider.recover', recoverySupport: { queued: true, running: true, paused: false } });
await dispatch(window, 'register-provider', { provider });
await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'recoverable-running', safeLabel: 'Recoverable' }));
const entries = storageEntries(window);
assert.ok(entries['slopsmith.jobs.recoverableRefs.v1']);
assert.doesNotMatch(entries['slopsmith.jobs.recoverableRefs.v1'], /operationHandlers|rawPayload|token/);
const sameWindow = window;
sameWindow.slopsmith.jobs.resetForTests({ clearStorage: false });
assert.equal(sameWindow.slopsmith.jobs._test.pendingRecoverableRefs.size, 1);
await dispatch(sameWindow, 'register-provider', { provider });
const snapshot = diagnosticsSnapshot(sameWindow);
assert.equal(snapshot.jobs.active.length + snapshot.jobs.queued.length, 1);
assert.equal(snapshot.jobs.active[0]?.safeLabel || snapshot.jobs.queued[0]?.safeLabel, 'Recoverable');
});
test('reload marks non-recoverable jobs orphaned or provider-unavailable without restoring raw payloads', async () => {
const window = loadJobs();
const { provider } = makeProvider({ providerId: 'provider.no-recover', recoverySupport: { queued: false, running: false, paused: false } });
await dispatch(window, 'register-provider', { provider });
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ providerId: 'provider.no-recover', logicalJobKey: 'no-recover' }));
window.slopsmith.jobs.simulateReload();
const inspected = await dispatch(window, 'inspect', { jobId: enqueued.payload.job.jobId });
assert.equal(inspected.payload.job.state, 'orphaned');
assert.equal(inspected.payload.job.terminalOutcome.retryable, false);
assert.doesNotMatch(JSON.stringify(inspected.payload.job), /operationHandlers|rawPayload/);
});
test('diagnostics enforce per-job history and bounded snapshot size with terminal minimum retained', async () => {
const window = loadJobs();
const { provider } = makeProvider({ capacity: { maxRunning: 1, maxQueued: 100 } });
await dispatch(window, 'register-provider', { provider });
let lastJobId = null;
for (let index = 0; index < 8; index += 1) {
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: `terminal-${index}`, safeLabel: `Terminal ${index}` }));
lastJobId = enqueued.payload.job.jobId;
for (let line = 0; line < 80; line += 1) window.slopsmith.jobs.log(provider.providerId, lastJobId, `line ${line} /Users/example/private/file-${line}.psarc`);
window.slopsmith.jobs.complete(provider.providerId, lastJobId, { resultSummary: 'done' });
}
const snapshot = diagnosticsSnapshot(window);
assert.ok(snapshot.snapshotBytes <= snapshot.limits.snapshotBudgetBytes + 1024);
assert.ok(snapshot.jobs.recentTerminal.length >= 5);
assert.ok(snapshot.jobs.recentTerminal.every(job => job.history.length <= 50));
});
+78
View File
@@ -0,0 +1,78 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadJobs, dispatch, makeProvider, enqueuePayload, diagnosticsSnapshot, diagnosticsContributions } = require('./jobs_test_harness');
test('jobs domain is active and registers the core provider coordinator owner', async () => {
const window = loadJobs();
const snapshot = window.slopsmith.capabilities.snapshotDiagnostics();
const jobs = snapshot.pipelines.find(pipeline => pipeline.name === 'jobs');
assert.ok(jobs);
assert.equal(jobs.review.lifecycle, 'active');
assert.equal(jobs.review.tone, 'clean');
assert.ok(jobs.participants.some(participant => participant.pluginId === 'core.jobs' && participant.kind === 'provider-coordinator' && participant.safety === 'privileged'));
const result = await dispatch(window, 'list-providers');
assert.equal(result.status, 'applied');
assert.equal(result.payload.providers.length, 0);
});
test('provider registration exposes safe metadata and rejects incompatible versions', async () => {
const window = loadJobs();
const { provider } = makeProvider({ providerId: 'provider.cache', label: 'Cache Builder', jobTypes: ['cache-build'] });
const registered = await dispatch(window, 'register-provider', { provider });
assert.equal(registered.status, 'applied');
assert.equal(registered.payload.provider.providerId, 'provider.cache');
assert.deepEqual(Array.from(registered.payload.provider.jobTypes), ['cache-build']);
const incompatible = await dispatch(window, 'register-provider', { provider: { ...provider, providerId: 'provider.old', version: 2, safeReason: 'requires runtime v2' } });
assert.equal(incompatible.status, 'incompatible-version');
assert.equal(incompatible.payload.provider.availability, 'incompatible');
});
test('privileged enqueue requires approval before provider work starts', async () => {
const window = loadJobs();
const { calls, provider } = makeProvider();
await dispatch(window, 'register-provider', { provider });
const result = await dispatch(window, 'enqueue', enqueuePayload({ authorization: 'background' }));
assert.equal(result.status, 'user-action-required');
assert.equal(calls.length, 0);
assert.equal(diagnosticsSnapshot(window).jobs.active.length, 0);
assert.equal(diagnosticsContributions(window).jobs.schema, 'slopsmith.jobs.diagnostics.v1');
});
test('approved enqueue queues and starts with redaction-safe public job fields', async () => {
const window = loadJobs();
const { calls, provider } = makeProvider({ capacity: { maxRunning: 1, maxQueued: 10 } });
await dispatch(window, 'register-provider', { provider });
const result = await dispatch(window, 'enqueue', enqueuePayload({ target: { path: '/Users/example/DLC/Secret.psarc' }, inputs: { token: 'secret', safeFingerprint: 'fingerprint-1' } }));
const job = result.payload.job;
assert.equal(result.status, 'applied');
assert.equal(job.state, 'running');
assert.equal(job.targetRef.startsWith('target-'), true);
assert.equal(job.inputFingerprint, 'fingerprint-1');
assert.equal(calls.length, 1);
assert.doesNotMatch(JSON.stringify(diagnosticsSnapshot(window)), /Secret\.psarc|token|secret/);
});
test('list and inspect are prompt-free and do not invoke provider callbacks', async () => {
const window = loadJobs();
const { calls, provider } = makeProvider();
await dispatch(window, 'register-provider', { provider });
const enqueued = await dispatch(window, 'enqueue', enqueuePayload());
calls.length = 0;
const listed = await dispatch(window, 'list');
const inspected = await dispatch(window, 'inspect', { jobId: enqueued.payload.job.jobId });
assert.equal(listed.status, 'applied');
assert.equal(inspected.status, 'applied');
assert.equal(listed.payload.jobs.length, 1);
assert.equal(inspected.payload.job.jobId, enqueued.payload.job.jobId);
assert.equal(calls.length, 0);
});
+71
View File
@@ -0,0 +1,71 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadJobs, dispatch, makeProvider, enqueuePayload, captureEvents, diagnosticsSnapshot } = require('./jobs_test_harness');
test('multiple providers require explicit selection before scheduling', async () => {
const window = loadJobs();
await dispatch(window, 'register-provider', { provider: makeProvider({ providerId: 'provider.a' }).provider });
await dispatch(window, 'register-provider', { provider: makeProvider({ providerId: 'provider.b' }).provider });
const blocked = await dispatch(window, 'enqueue', enqueuePayload());
assert.equal(blocked.status, 'provider-selection-required');
window.slopsmith.jobs.setSelectedProvider('transcode', 'provider.b');
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'selected-job' }));
assert.equal(enqueued.status, 'applied');
assert.equal(enqueued.payload.job.providerId, 'provider.b');
});
test('provider capacity starts one job and keeps the rest queued in priority order', async () => {
const window = loadJobs();
const { provider } = makeProvider({ capacity: { maxRunning: 1, maxQueued: 10 } });
await dispatch(window, 'register-provider', { provider });
const first = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'first', priority: 'background-maintenance' }));
const second = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'second', priority: 'background-maintenance', safeLabel: 'Second' }));
const third = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'third', priority: 'user-approved-interactive', safeLabel: 'Third' }));
assert.equal(first.payload.job.state, 'running');
assert.equal(second.payload.job.state, 'queued');
assert.equal(third.payload.job.state, 'queued');
window.slopsmith.jobs.complete(provider.providerId, first.payload.job.jobId, { resultSummary: 'done' });
const snapshot = diagnosticsSnapshot(window);
assert.equal(snapshot.jobs.active.length, 1);
assert.equal(snapshot.jobs.active[0].safeLabel, 'Third');
});
test('progress, completion, and terminal retention are reflected in events and diagnostics', async () => {
const window = loadJobs();
const events = captureEvents(window);
const { provider } = makeProvider();
await dispatch(window, 'register-provider', { provider });
const enqueued = await dispatch(window, 'enqueue', enqueuePayload());
window.slopsmith.jobs.updateProgress(provider.providerId, enqueued.payload.job.jobId, { percent: 33, step: 'convert', message: 'Converting safely' });
window.slopsmith.jobs.complete(provider.providerId, enqueued.payload.job.jobId, { resultSummary: 'Cache ready' });
const snapshot = diagnosticsSnapshot(window);
assert.equal(snapshot.jobs.active.length, 0);
assert.equal(snapshot.jobs.recentTerminal.length, 1);
assert.equal(snapshot.jobs.recentTerminal[0].terminalOutcome.status, 'completed');
assert.ok(snapshot.jobs.recentTerminal[0].history.some(entry => entry.kind === 'progress'));
assert.ok(events.some(event => event.event === 'progress'));
assert.ok(events.some(event => event.event === 'completed'));
});
test('cancel, pause, resume, retry, and stale transitions use canonical outcomes', async () => {
const window = loadJobs();
const { provider } = makeProvider({ capacity: { maxRunning: 1, maxQueued: 10 } });
await dispatch(window, 'register-provider', { provider });
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'control-job' }));
const jobId = enqueued.payload.job.jobId;
assert.equal((await dispatch(window, 'pause', { jobId })).status, 'applied');
assert.equal((await dispatch(window, 'resume', { jobId })).status, 'applied');
assert.equal((await dispatch(window, 'pause', { jobId })).status, 'applied');
assert.equal((await dispatch(window, 'cancel', { jobId })).status, 'applied');
window.slopsmith.jobs.fail(provider.providerId, jobId, { category: 'provider-failure', safeReason: 'retryable failure', retryable: true });
assert.equal((await dispatch(window, 'retry', { jobId, authorization: 'user-action' })).status, 'retry-started');
assert.equal((await dispatch(window, 'retry', { jobId, authorization: 'user-action' })).status, 'stale');
});
+159
View File
@@ -0,0 +1,159 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const JOBS_JS = path.join(ROOT, 'static', 'capabilities', 'jobs.js');
const INSPECTOR_JS = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.js');
function loadJobs(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(JOBS_JS, 'utf8'), context, { filename: JOBS_JS });
window.__vmContext = context;
return window;
}
function loadInspector(window) {
vm.runInContext(fs.readFileSync(INSPECTOR_JS, 'utf8'), window.__vmContext, { filename: INSPECTOR_JS });
return window;
}
function captureEvents(window, capability = 'jobs') {
const events = [];
window.slopsmith.on('capability:event', event => {
if (event.detail && event.detail.capability === capability) events.push(event.detail);
});
return events;
}
function diagnosticsSnapshot(window) {
return window.slopsmith.jobs.snapshot();
}
function diagnosticsContributions(window) {
return Object.fromEntries(window.__diagnosticsContributions || new Map());
}
function storageEntries(window) {
return Object.fromEntries(window.__storage || new Map());
}
async function dispatch(window, command, payload = {}, requester = 'test') {
return window.slopsmith.capabilities.dispatch({ capability: 'jobs', command, args: payload, requester });
}
function makeProvider(overrides = {}) {
const calls = [];
const providerId = overrides.providerId || 'provider.jobs';
const handlers = overrides.operationHandlers || {};
const provider = {
providerId,
pluginId: overrides.pluginId || providerId,
label: overrides.label || 'Jobs Provider',
jobTypes: overrides.jobTypes || ['transcode'],
actions: overrides.actions || ['enqueue', 'inspect', 'cancel', 'pause', 'resume', 'retry', 'recover'],
availability: overrides.availability || 'available',
capacity: overrides.capacity || { maxRunning: 1, maxQueued: 10 },
recoverySupport: overrides.recoverySupport || { queued: true, running: true, paused: true },
version: overrides.version || 1,
safeReason: overrides.safeReason,
operationHandlers: {
'job.enqueue': request => {
calls.push(['job.enqueue', request]);
return handlers['job.enqueue'] ? handlers['job.enqueue'](request) : { outcome: 'handled' };
},
'job.cancel': request => {
calls.push(['job.cancel', request]);
return handlers['job.cancel'] ? handlers['job.cancel'](request) : { outcome: 'handled' };
},
'job.pause': request => {
calls.push(['job.pause', request]);
return handlers['job.pause'] ? handlers['job.pause'](request) : { outcome: 'handled' };
},
'job.resume': request => {
calls.push(['job.resume', request]);
return handlers['job.resume'] ? handlers['job.resume'](request) : { outcome: 'handled' };
},
'job.retry': request => {
calls.push(['job.retry', request]);
return handlers['job.retry'] ? handlers['job.retry'](request) : { outcome: 'handled' };
},
'job.recover': request => {
calls.push(['job.recover', request]);
return handlers['job.recover'] ? handlers['job.recover'](request) : { outcome: 'handled', state: overrides.recoveredState || 'queued' };
},
...handlers,
},
};
return { calls, provider };
}
function enqueuePayload(overrides = {}) {
return {
jobType: overrides.jobType || 'transcode',
requester: overrides.requester || 'plugin.requester',
authorization: overrides.authorization || 'user-action',
priority: overrides.priority || 'user-approved-interactive',
target: overrides.target || { targetRef: 'song-1' },
inputs: overrides.inputs || { safeFingerprint: 'input-1' },
safeLabel: overrides.safeLabel || 'Build playable cache',
logicalJobKey: overrides.logicalJobKey,
providerId: overrides.providerId,
privileged: overrides.privileged,
approvalScopeKey: overrides.approvalScopeKey,
};
}
function installInspectorDom(window) {
const elements = new Map();
function element(id) {
const item = {
id,
value: '',
textContent: '',
innerHTML: '',
className: '',
dataset: {},
style: {},
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
addEventListener() {},
removeEventListener() {},
querySelectorAll() { return []; },
querySelector() { return null; },
closest() { return null; },
getBoundingClientRect() { return { width: 1000, height: 400, left: 0, top: 0 }; },
appendChild(child) { return child; },
};
elements.set(id, item);
return item;
}
element('capability-inspector-filter');
element('capability-inspector-content');
element('capability-inspector-empty');
element('capability-inspector-summary');
element('capability-inspector-refresh');
window.document.readyState = 'complete';
window.document.getElementById = id => elements.get(id) || null;
window.document.querySelectorAll = () => [];
window.document.addEventListener = () => {};
window.document.createElement = () => element(`created-${elements.size}`);
window.requestAnimationFrame = callback => callback();
return elements;
}
module.exports = {
ROOT,
loadJobs,
loadInspector,
captureEvents,
diagnosticsSnapshot,
diagnosticsContributions,
storageEntries,
dispatch,
makeProvider,
enqueuePayload,
installInspectorDom,
};
+7 -1
View File
@@ -63,8 +63,11 @@ def test_app_shell_loads_capability_registry_before_app_runtime():
assert '<script src="/static/capabilities.js"></script>' in source
assert '<script src="/static/capabilities/library.js"></script>' in source
assert '<script src="/static/capabilities/jobs.js"></script>' in source
assert source.index('/static/diagnostics.js') < source.index('/static/capabilities.js')
assert source.index('/static/capabilities.js') < source.index('/static/capabilities/library.js')
assert source.index('/static/capabilities/library.js') < source.index('/static/capabilities/jobs.js')
assert source.index('/static/capabilities/jobs.js') < source.index('/static/app.js')
assert source.index('/static/capabilities/library.js') < source.index('/static/app.js')
@@ -83,7 +86,8 @@ def test_capability_runtime_overrides_do_not_mask_claims():
set_enabled = source[source.index("function setParticipantEnabled("):source.index("function registerParticipants(")]
reserved = source[source.index("const RESERVED_FUTURE_DOMAINS"):source.index("const RUNTIME_DOMAIN_DEFAULTS")]
assert "['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)" in source
for token in ["'unsupported-operation'", "'queued'", "'validation-failed'", "'completed'", "'timeout'", "'retry-started'"]:
assert token in source
assert "if (entry.type !== 'manual') return false;" in source
assert "type: 'manual'" in source
assert "_remember(userOverrides" not in set_enabled
@@ -91,7 +95,9 @@ def test_capability_runtime_overrides_do_not_mask_claims():
assert "'audio-mix'" not in reserved
assert "'audio-input'" not in reserved
assert "'playback'" not in reserved
assert "'jobs'" not in reserved
assert "playback:" in source
assert "jobs:" in source
assert "'backend.routes'" in reserved
assert "'backend.routes':" not in source