mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 07:04:31 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d676351e3 | ||
|
|
6c6934c29c | ||
|
|
73dd472f52 | ||
|
|
ed4e9fdd67 | ||
|
|
1cc5c951ce | ||
|
|
76a07dacf0 | ||
|
|
eedfdc8bf1 | ||
|
|
2c5974e79d | ||
|
|
853db94887 | ||
|
|
41e62af5e9 |
@@ -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.
|
||||
|
||||
@@ -153,6 +153,20 @@ 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), with a backend visibility companion exposed through `context["jobs"]`, `/api/jobs`, `/api/jobs/providers`, `/api/jobs/{id}`, and `/ws/jobs`. 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. Backend plugin queues can register/adopt/progress/settle redaction-safe job summaries without requiring their browser screen to be loaded.
|
||||
|
||||
The browser command surface is `register-provider`, `unregister-provider`, `list-providers`, `enqueue`, `adopt`, `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`, `job.recover`, and `job.adopt`. `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. Backend provider code can call `register_provider`, `adopt`, `update_progress`, `complete`, `fail`, `cancelled`, `mark_provider_unavailable`, and `record_bridge_hit`; providers may also register private action callbacks for advertised operations such as `job.cancel` and `job.retry`. `/api/jobs/{id}/cancel` and `/api/jobs/{id}/retry` inspect the redaction-safe job owner, enforce the user-action gate for retry, and dispatch to the provider callback without exposing raw payloads to core state, diagnostics, or API responses.
|
||||
|
||||
This slice supports dispatch before execution ownership. Core routes authorized backend job actions to provider callbacks (`cancel` for queued jobs, `retry` for failed jobs, and later provider-level actions where the contract is explicit) while providers keep their private queue stores and worker code. Moving scheduling or execution itself into core is a later, separate upgrade for shared worker patterns across multiple providers; it must not erase domain-specific semantics such as queue-level pause/resume, non-interruptible running jobs, remote service retries, or plugin-owned artifact indexing.
|
||||
|
||||
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`. Browser snapshots include provider summaries, selected/default providers, active/queued/paused/recent-terminal jobs, bounded per-job history, recent outcomes, bridge hits, limits, and notes. Backend snapshots are exported as `system/jobs.json` in diagnostics bundles and include server-side provider/job summaries even when no browser jobs runtime has hydrated. Browser persistence is limited to selected provider choices and provider-declared recoverable job references; backend-integrated queues should restore from their own authoritative server state and re-adopt safe summaries at plugin setup. Neither path stores raw payloads, non-recoverable private state, file paths, command lines, URLs, tokens, media buffers, recordings, live handles, or provider-private data in support surfaces.
|
||||
|
||||
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 +194,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 +244,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 +301,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
|
||||
|
||||
@@ -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: ['sloppak-convert'],
|
||||
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: 'sloppak-convert',
|
||||
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.
|
||||
|
||||
@@ -60,6 +60,16 @@ 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 browser 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`. A backend companion lets plugin route code register/adopt/progress/settle backend-owned jobs into `/api/jobs`, `/ws/jobs`, and diagnostics `system/jobs.json` without exposing provider-private execution payloads.
|
||||
|
||||
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.
|
||||
|
||||
Backend action dispatch is the middle ground before core-owned execution: provider route code declares supported actions such as queued-job cancel or failed-job retry; core checks the caller's authorization and job ownership; then it dispatches to provider-owned callbacks while the provider keeps raw payloads, queue semantics, and domain-specific execution. Future jobs upgrades should only promote shared backend scheduling/execution into core if multiple first-party plugins need the same worker behavior for provider-declared recoverable jobs. That larger step must define queue-vs-job actions, restart recovery, cancellation truthfulness, retry attempts, payload privacy, provider failure handling, diagnostics, and migration from plugin-owned queue stores before replacing plugin execution routes.
|
||||
|
||||
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 +79,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 +121,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. |
|
||||
|
||||
@@ -14,13 +14,9 @@ Core domains also have a review scope. **Active contract** domains are wired to
|
||||
| audio-monitoring | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-provider, start, stop, set-direct-monitor | monitoring.start, monitoring.stop, monitoring.status, monitoring.set-direct-monitor | Inspect/list/select/status are prompt-free. Fresh monitoring start requires explicit user action; background requesters may only attach to an active compatible session. Outcomes distinguish handled, stopped, denied, unavailable, degraded, failed, no-owner, no-handler, unsupported-command, incompatible, incompatible-version, provider-selection-required, and user-action-required. Diagnostics redact raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, waveforms, and recordings. |
|
||||
| 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. |
|
||||
|
||||
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
|
||||
|
||||
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
|
||||
|
||||
## Expected Future Domains
|
||||
@@ -32,12 +28,13 @@ These domains are expected future capability contracts, not current runtime grap
|
||||
| ui.navigation | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, navigate, inspect | Needs a UI host PR with contribution placement and route/screen semantics. |
|
||||
| ui.plugin-screens | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs a screen host PR with mount/unmount and visibility policy. |
|
||||
| settings | exclusive-owner | sensitive | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs redaction rules and a migration story for settings metadata. |
|
||||
| visualization | multi-provider | safe | register-provider, get-current, set-renderer | Needs provider ordering/selection rules and legacy highway shim attribution. |
|
||||
| note-detection | multi-provider | sensitive | register, inspect | Needs performance-data redaction and provider lifecycle tests. |
|
||||
| backend.routes | multi-provider | privileged | register, inspect | Needs a concrete backend route/provider workflow, privilege review, and route diagnostics. |
|
||||
| ui.player-controls | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs a first-party player-control host. |
|
||||
| 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. |
|
||||
|
||||
|
||||
@@ -1019,6 +1019,7 @@ def _assemble_files_and_notes(
|
||||
client_contributions: dict | None,
|
||||
log: logging.Logger,
|
||||
plugins_root: "Path | list[Path] | None" = None,
|
||||
backend_jobs_snapshot: dict | None = None,
|
||||
) -> tuple[dict[str, bytes], list[str], str, "Redactor | None"]:
|
||||
"""Assemble all diagnostic file bytes without packaging into a zip.
|
||||
|
||||
@@ -1044,6 +1045,11 @@ def _assemble_files_and_notes(
|
||||
files["system/env.json"] = env_payload
|
||||
plugins_data = _system_plugins(loaded_plugins, plugins_root=plugins_root, redactor=redactor)
|
||||
files["system/plugins.json"] = _safe_json_dumps(plugins_data).encode("utf-8")
|
||||
if backend_jobs_snapshot is not None:
|
||||
payload = backend_jobs_snapshot
|
||||
if redactor is not None:
|
||||
payload = _redact_value(payload, redactor)
|
||||
files["system/jobs.json"] = _safe_json_dumps(payload).encode("utf-8")
|
||||
# Plugin loading is async and takes a few seconds on cold
|
||||
# boot. If the bundle was captured during that window, every
|
||||
# plugin appears as an "orphan" — flag the likely race so the
|
||||
@@ -1195,6 +1201,7 @@ def build_bundle(
|
||||
client_contributions: dict | None = None,
|
||||
log: logging.Logger,
|
||||
plugins_root: "Path | list[Path] | None" = None,
|
||||
backend_jobs_snapshot: dict | None = None,
|
||||
) -> tuple[bytes, str, dict]:
|
||||
"""Returns (zip_bytes, filename, manifest_dict)."""
|
||||
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
||||
@@ -1212,6 +1219,7 @@ def build_bundle(
|
||||
client_contributions=client_contributions,
|
||||
log=log,
|
||||
plugins_root=plugins_root,
|
||||
backend_jobs_snapshot=backend_jobs_snapshot,
|
||||
)
|
||||
|
||||
manifest = _make_manifest(
|
||||
@@ -1274,6 +1282,7 @@ def preview_bundle(
|
||||
redact: bool,
|
||||
log: logging.Logger,
|
||||
plugins_root: "Path | list[Path] | None" = None,
|
||||
backend_jobs_snapshot: dict | None = None,
|
||||
) -> dict:
|
||||
"""Lightweight preview: returns manifest-shaped output without
|
||||
building the zip or executing plugin diagnostics callables.
|
||||
@@ -1317,6 +1326,7 @@ def preview_bundle(
|
||||
client_contributions=None,
|
||||
log=log,
|
||||
plugins_root=plugins_root,
|
||||
backend_jobs_snapshot=backend_jobs_snapshot,
|
||||
)
|
||||
|
||||
# ── Callable-output placeholders ──────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
"""Backend jobs registry for plugin-owned long-running work.
|
||||
|
||||
This is a server-side companion to the browser jobs capability host. It keeps
|
||||
only redaction-safe public job state; plugin backends remain responsible for
|
||||
private execution payloads such as local filenames, subprocess handles, and
|
||||
artifact paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import inspect as _inspect
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import Any, Callable
|
||||
|
||||
_SCHEMA = "slopsmith.jobs.diagnostics.v1"
|
||||
_STATES = {
|
||||
"queued",
|
||||
"running",
|
||||
"paused",
|
||||
"cancellation-requested",
|
||||
"cancelled",
|
||||
"completed",
|
||||
"failed",
|
||||
"provider-unavailable",
|
||||
"orphaned",
|
||||
}
|
||||
_TERMINAL_STATES = {"cancelled", "completed", "failed", "provider-unavailable", "orphaned"}
|
||||
_ACTIVE_STATES = {"queued", "running", "paused", "cancellation-requested"}
|
||||
_FAILURE_CATEGORIES = {
|
||||
"invalid-input",
|
||||
"permission-denied",
|
||||
"provider-unavailable",
|
||||
"unsupported-operation",
|
||||
"timeout",
|
||||
"cancellation",
|
||||
"external-dependency",
|
||||
"storage",
|
||||
"provider-failure",
|
||||
"unknown",
|
||||
}
|
||||
_PRIORITY_ORDER = {
|
||||
"user-approved-interactive": 0,
|
||||
"background-maintenance": 1,
|
||||
}
|
||||
_SAFE_ID_RE = re.compile(r"[^A-Za-z0-9_.:-]+")
|
||||
_MAX_LABEL = 120
|
||||
_MAX_REASON = 240
|
||||
_MAX_STEP = 80
|
||||
_MAX_HISTORY_PER_JOB = 50
|
||||
_MAX_TERMINAL_JOBS = 50
|
||||
_MAX_OUTCOMES = 100
|
||||
_MAX_BRIDGE_HITS = 100
|
||||
_ACTION_ALIASES = {
|
||||
"cancel": "job.cancel",
|
||||
"retry": "job.retry",
|
||||
"pause": "job.pause",
|
||||
"resume": "job.resume",
|
||||
"recover": "job.recover",
|
||||
"status": "job.status",
|
||||
}
|
||||
_SAFE_ACTION_PAYLOAD_KEYS = {
|
||||
"action",
|
||||
"backendJobId",
|
||||
"bulkJobId",
|
||||
"jobId",
|
||||
"logicalJobKey",
|
||||
"newJobId",
|
||||
"providerId",
|
||||
"safeRef",
|
||||
"sourceJobId",
|
||||
"state",
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _safe_id(value: Any, default: str = "") -> str:
|
||||
if value is None:
|
||||
return default
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return default
|
||||
text = _SAFE_ID_RE.sub("-", text)[:160].strip("-_.:")
|
||||
return text or default
|
||||
|
||||
|
||||
def _safe_text(value: Any, default: str = "", limit: int = _MAX_REASON) -> str:
|
||||
if value is None:
|
||||
return default
|
||||
text = str(value).replace("\x00", " ").strip()
|
||||
if not text:
|
||||
return default
|
||||
# Conservative path/URL/secret scrubbing. Callers should pass safe text
|
||||
# already; this protects support surfaces from accidents.
|
||||
text = re.sub(r"https?://\S+", "<url>", text)
|
||||
text = re.sub(r"(?i)(token|secret|api[_-]?key|password|pwd)=([^\s&]+)", r"\1=<redacted>", text)
|
||||
text = re.sub(r"/Users/[^\s]+", "<path>", text)
|
||||
text = re.sub(r"[A-Za-z]:\\[^\s]+", "<path>", text)
|
||||
if len(text) > limit:
|
||||
text = text[: max(0, limit - 3)].rstrip() + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _number(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _progress(source: Any = None) -> dict:
|
||||
data = source if isinstance(source, dict) else {}
|
||||
raw_mode = data.get("mode")
|
||||
mode = raw_mode if raw_mode in {"determinate", "indeterminate", "step-only"} else ("determinate" if data.get("percent") is not None else "indeterminate")
|
||||
percent = None
|
||||
if mode == "determinate":
|
||||
percent = max(0.0, min(100.0, _number(data.get("percent"), 0.0)))
|
||||
return {
|
||||
"mode": mode,
|
||||
"percent": percent,
|
||||
"step": _safe_text(data.get("step") or data.get("currentStep"), "", _MAX_STEP),
|
||||
"message": _safe_text(data.get("message") or data.get("safeMessage"), "", _MAX_REASON),
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
|
||||
|
||||
def _result(outcome: str, payload: dict | None = None, reason: str = "") -> dict:
|
||||
out = {"outcome": outcome, "status": "applied" if outcome in {"handled", "queued", "completed", "cancelled", "failed", "timeout", "retry-started", "provider-unavailable", "orphaned"} else "rejected"}
|
||||
if payload:
|
||||
out["payload"] = payload
|
||||
if reason:
|
||||
out["reason"] = _safe_text(reason)
|
||||
return out
|
||||
|
||||
|
||||
def _canonical_action(action: Any) -> str:
|
||||
raw = _safe_id(action, "")
|
||||
return _ACTION_ALIASES.get(raw, raw)
|
||||
|
||||
|
||||
def _action_names(action: Any) -> tuple[str, ...]:
|
||||
canonical = _canonical_action(action)
|
||||
names = [canonical]
|
||||
for alias, target in _ACTION_ALIASES.items():
|
||||
if target == canonical:
|
||||
names.append(alias)
|
||||
return tuple(dict.fromkeys(n for n in names if n))
|
||||
|
||||
|
||||
def _safe_action_payload(payload: Any) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
out = {}
|
||||
for key, value in payload.items():
|
||||
safe_key = _safe_id(key, "")
|
||||
if safe_key not in _SAFE_ACTION_PAYLOAD_KEYS:
|
||||
continue
|
||||
out[safe_key] = _safe_id(value, "")
|
||||
return {k: v for k, v in out.items() if v}
|
||||
|
||||
|
||||
class BackendJobs:
|
||||
"""Thread-safe, redaction-safe backend jobs state."""
|
||||
|
||||
def __init__(self, *, log=None) -> None:
|
||||
self._log = log
|
||||
self._lock = threading.RLock()
|
||||
self._providers: dict[str, dict] = {}
|
||||
self._jobs: dict[str, dict] = {}
|
||||
self._terminal_ids: deque[str] = deque(maxlen=_MAX_TERMINAL_JOBS)
|
||||
self._outcomes: deque[dict] = deque(maxlen=_MAX_OUTCOMES)
|
||||
self._bridge_hits: deque[dict] = deque(maxlen=_MAX_BRIDGE_HITS)
|
||||
self._subscribers: set[Callable[[dict], None]] = set()
|
||||
|
||||
def subscribe(self, callback: Callable[[dict], None]) -> Callable[[], None]:
|
||||
with self._lock:
|
||||
self._subscribers.add(callback)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
with self._lock:
|
||||
self._subscribers.discard(callback)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
def _emit(self, event: str, payload: dict | None = None) -> None:
|
||||
message = {"type": event, **(payload or {})}
|
||||
with self._lock:
|
||||
subscribers = list(self._subscribers)
|
||||
for callback in subscribers:
|
||||
try:
|
||||
callback(message)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if self._log:
|
||||
self._log.warning("backend jobs subscriber failed: %s", exc)
|
||||
|
||||
def _remember_outcome(self, operation: str, outcome: str, extra: dict | None = None) -> None:
|
||||
entry = {"at": _now(), "operation": _safe_id(operation, "operation"), "outcome": _safe_id(outcome, "handled")}
|
||||
if extra:
|
||||
for key in ("jobId", "providerId", "requesterId", "category", "safeReason"):
|
||||
if extra.get(key) is not None:
|
||||
entry[key] = _safe_text(extra[key]) if key == "safeReason" else _safe_id(extra[key], "")
|
||||
self._outcomes.append(entry)
|
||||
|
||||
def _history(self, job: dict, kind: str, message: str, extra: dict | None = None) -> None:
|
||||
entry = {"at": _now(), "kind": _safe_id(kind, "event"), "message": _safe_text(message)}
|
||||
if extra:
|
||||
entry.update(extra)
|
||||
job.setdefault("history", []).append(entry)
|
||||
if len(job["history"]) > _MAX_HISTORY_PER_JOB:
|
||||
del job["history"][: len(job["history"]) - _MAX_HISTORY_PER_JOB]
|
||||
|
||||
def _provider_summary(self, provider: dict) -> dict:
|
||||
return {
|
||||
"providerId": provider["providerId"],
|
||||
"pluginId": provider.get("pluginId"),
|
||||
"label": provider.get("label"),
|
||||
"jobTypes": list(provider.get("jobTypes") or []),
|
||||
"actions": list(provider.get("actions") or []),
|
||||
"capacity": dict(provider.get("capacity") or {}),
|
||||
"available": bool(provider.get("available", True)),
|
||||
"recoverySupport": dict(provider.get("recoverySupport") or {}),
|
||||
"updatedAt": provider.get("updatedAt"),
|
||||
}
|
||||
|
||||
def _job_summary(self, job: dict, *, include_history: bool = True) -> dict:
|
||||
summary = {
|
||||
"jobId": job["jobId"],
|
||||
"providerId": job["providerId"],
|
||||
"requesterId": job.get("requesterId"),
|
||||
"jobType": job.get("jobType"),
|
||||
"state": job.get("state"),
|
||||
"priority": job.get("priority"),
|
||||
"safeLabel": job.get("safeLabel"),
|
||||
"target": dict(job.get("target") or {}),
|
||||
"inputs": dict(job.get("inputs") or {}),
|
||||
"progress": dict(job.get("progress") or {}),
|
||||
"createdAt": job.get("createdAt"),
|
||||
"queuedAt": job.get("queuedAt"),
|
||||
"startedAt": job.get("startedAt"),
|
||||
"updatedAt": job.get("updatedAt"),
|
||||
"terminalAt": job.get("terminalAt"),
|
||||
"terminalOutcome": dict(job.get("terminalOutcome") or {}) if job.get("terminalOutcome") else None,
|
||||
"retryable": bool(job.get("retryable")),
|
||||
"externallyManaged": bool(job.get("externallyManaged")),
|
||||
"attempt": int(job.get("attempt", 1)),
|
||||
}
|
||||
if include_history:
|
||||
summary["history"] = list(job.get("history") or [])
|
||||
return summary
|
||||
|
||||
def register_provider(self, provider: dict) -> dict:
|
||||
provider_id = _safe_id(provider.get("providerId") or provider.get("id"), "")
|
||||
if not provider_id:
|
||||
return _result("validation-failed", reason="providerId is required")
|
||||
job_types = [_safe_id(j, "") for j in provider.get("jobTypes", []) if _safe_id(j, "")]
|
||||
if not job_types:
|
||||
return _result("validation-failed", reason="jobTypes are required")
|
||||
actions = [_canonical_action(a) for a in provider.get("actions", []) if _canonical_action(a)]
|
||||
callbacks = provider.get("callbacks") or provider.get("actionHandlers") or provider.get("operationHandlers")
|
||||
action_handlers = {}
|
||||
if isinstance(callbacks, dict):
|
||||
for action, callback in callbacks.items():
|
||||
canonical = _canonical_action(action)
|
||||
if canonical and callable(callback):
|
||||
action_handlers[canonical] = callback
|
||||
if canonical not in actions:
|
||||
actions.append(canonical)
|
||||
now = _now()
|
||||
normalized = {
|
||||
"providerId": provider_id,
|
||||
"pluginId": _safe_id(provider.get("pluginId") or provider.get("plugin_id"), provider_id),
|
||||
"label": _safe_text(provider.get("label") or provider_id, provider_id, _MAX_LABEL),
|
||||
"jobTypes": job_types,
|
||||
"actions": actions,
|
||||
"actionHandlers": action_handlers,
|
||||
"capacity": provider.get("capacity") if isinstance(provider.get("capacity"), dict) else {},
|
||||
"available": bool(provider.get("available", True)),
|
||||
"recoverySupport": provider.get("recoverySupport") if isinstance(provider.get("recoverySupport"), dict) else {},
|
||||
"updatedAt": now,
|
||||
}
|
||||
with self._lock:
|
||||
self._providers[provider_id] = normalized
|
||||
self._remember_outcome("register-provider", "handled", {"providerId": provider_id})
|
||||
self._emit("provider-registered", {"provider": self._provider_summary(normalized)})
|
||||
return _result("handled", {"provider": self._provider_summary(normalized)})
|
||||
|
||||
async def dispatch_action(self, job_id: str, action: str, request: dict | None = None) -> dict:
|
||||
canonical = _canonical_action(action)
|
||||
if not canonical:
|
||||
return _result("validation-failed", reason="action is required")
|
||||
request = request if isinstance(request, dict) else {}
|
||||
authorization = _safe_id(request.get("authorization"), "")
|
||||
requester_id = _safe_id(request.get("requesterId") or request.get("requester_id"), "api.jobs")
|
||||
with self._lock:
|
||||
job = self._jobs.get(_safe_id(job_id, ""))
|
||||
if not job:
|
||||
self._remember_outcome(canonical, "no-target", {"requesterId": requester_id})
|
||||
return _result("no-target", reason="job not found")
|
||||
provider = self._providers.get(job.get("providerId"))
|
||||
if not provider:
|
||||
self._remember_outcome(canonical, "no-owner", {"jobId": job.get("jobId"), "providerId": job.get("providerId"), "requesterId": requester_id})
|
||||
return _result("no-owner", {"job": self._job_summary(job)}, "provider not found")
|
||||
if not provider.get("available", True):
|
||||
self._remember_outcome(canonical, "unavailable", {"jobId": job.get("jobId"), "providerId": provider.get("providerId"), "requesterId": requester_id})
|
||||
return _result("unavailable", {"job": self._job_summary(job), "provider": self._provider_summary(provider)}, "provider unavailable")
|
||||
provider_actions = set(provider.get("actions") or [])
|
||||
if canonical not in provider_actions:
|
||||
self._remember_outcome(canonical, "unsupported-operation", {"jobId": job.get("jobId"), "providerId": provider.get("providerId"), "requesterId": requester_id})
|
||||
return _result("unsupported-operation", {"job": self._job_summary(job), "provider": self._provider_summary(provider)}, "provider does not advertise this action")
|
||||
handlers = provider.get("actionHandlers") or {}
|
||||
handler = None
|
||||
for name in _action_names(canonical):
|
||||
if callable(handlers.get(name)):
|
||||
handler = handlers[name]
|
||||
break
|
||||
if handler is None:
|
||||
self._remember_outcome(canonical, "no-handler", {"jobId": job.get("jobId"), "providerId": provider.get("providerId"), "requesterId": requester_id})
|
||||
return _result("no-handler", {"job": self._job_summary(job), "provider": self._provider_summary(provider)}, "provider action handler not registered")
|
||||
state = job.get("state")
|
||||
if state in _TERMINAL_STATES and canonical != "job.retry":
|
||||
self._remember_outcome(canonical, "stale", {"jobId": job.get("jobId"), "providerId": provider.get("providerId"), "requesterId": requester_id, "safeReason": "job is terminal"})
|
||||
return _result("stale", {"job": self._job_summary(job)}, "job is terminal")
|
||||
if canonical == "job.retry" and (state != "failed" or not job.get("retryable")):
|
||||
self._remember_outcome(canonical, "stale", {"jobId": job.get("jobId"), "providerId": provider.get("providerId"), "requesterId": requester_id, "safeReason": "job is not retryable"})
|
||||
return _result("stale", {"job": self._job_summary(job)}, "job is not retryable")
|
||||
if canonical in {"job.enqueue", "job.retry"} and authorization != "user-action":
|
||||
self._remember_outcome(canonical, "user-action-required", {"jobId": job.get("jobId"), "providerId": provider.get("providerId"), "requesterId": requester_id})
|
||||
return _result("user-action-required", {"job": self._job_summary(job)}, "user action authorization required")
|
||||
call_payload = {
|
||||
"action": canonical,
|
||||
"authorization": authorization,
|
||||
"requesterId": requester_id,
|
||||
"job": self._job_summary(job),
|
||||
"provider": self._provider_summary(provider),
|
||||
}
|
||||
try:
|
||||
provider_result = handler(call_payload)
|
||||
if _inspect.isawaitable(provider_result):
|
||||
provider_result = await provider_result
|
||||
except Exception as exc: # noqa: BLE001
|
||||
safe_reason = _safe_text(str(exc), "provider action failed")
|
||||
with self._lock:
|
||||
self._remember_outcome(canonical, "failed", {"jobId": job_id, "providerId": provider.get("providerId"), "requesterId": requester_id, "safeReason": safe_reason})
|
||||
return _result("failed", reason=safe_reason)
|
||||
|
||||
if not isinstance(provider_result, dict):
|
||||
provider_result = {"outcome": "handled"}
|
||||
outcome = _safe_id(provider_result.get("outcome"), "handled")
|
||||
status = provider_result.get("status")
|
||||
reason = _safe_text(provider_result.get("reason"), "")
|
||||
payload = _safe_action_payload(provider_result.get("payload"))
|
||||
with self._lock:
|
||||
self._remember_outcome(canonical, outcome, {"jobId": job_id, "providerId": provider.get("providerId"), "requesterId": requester_id, "safeReason": reason})
|
||||
result = _result(outcome, payload or None, reason)
|
||||
if status in {"applied", "rejected"}:
|
||||
result["status"] = status
|
||||
return result
|
||||
|
||||
def unregister_provider(self, provider_id: str) -> dict:
|
||||
provider_id = _safe_id(provider_id, "")
|
||||
orphaned: list[dict] = []
|
||||
with self._lock:
|
||||
provider = self._providers.pop(provider_id, None)
|
||||
if not provider:
|
||||
return _result("no-owner", reason="provider not found")
|
||||
for job in self._jobs.values():
|
||||
if job.get("providerId") == provider_id and job.get("state") in _ACTIVE_STATES:
|
||||
self._settle_locked(
|
||||
job,
|
||||
"orphaned",
|
||||
category="provider-failure",
|
||||
safe_reason="Provider unregistered",
|
||||
result_summary="Provider unregistered",
|
||||
)
|
||||
orphaned.append(self._job_summary(job, include_history=False))
|
||||
self._remember_outcome("unregister-provider", "handled", {"providerId": provider_id})
|
||||
if orphaned:
|
||||
self._remember_outcome(
|
||||
"orphaned",
|
||||
"orphaned",
|
||||
{"providerId": provider_id, "safeReason": "Provider unregistered"},
|
||||
)
|
||||
for job in orphaned:
|
||||
self._emit("orphaned", {"job": job})
|
||||
self._emit("provider-unregistered", {"providerId": provider_id})
|
||||
payload = {"orphanedJobs": orphaned} if orphaned else None
|
||||
return _result("handled", payload)
|
||||
|
||||
def adopt(self, *, provider_id: str, job_type: str, job_id: str | None = None, state: str = "running", requester_id: str | None = None, safe_label: str | None = None, target: dict | None = None, inputs: dict | None = None, priority: str = "user-approved-interactive", progress: dict | None = None, category: str | None = None, safe_reason: str | None = None, result_summary: str | None = None, retryable: bool = False, externally_managed: bool = True) -> dict:
|
||||
provider_id = _safe_id(provider_id, "")
|
||||
job_type = _safe_id(job_type, "")
|
||||
if not provider_id or not job_type:
|
||||
return _result("validation-failed", reason="provider_id and job_type are required")
|
||||
job_id = _safe_id(job_id, "") or f"job-{uuid.uuid4().hex[:12]}"
|
||||
desired_state = _safe_id(state, "running")
|
||||
if desired_state not in _STATES and desired_state not in {"done", "error"}:
|
||||
desired_state = "running"
|
||||
if desired_state == "done":
|
||||
desired_state = "completed"
|
||||
if desired_state == "error":
|
||||
desired_state = "failed"
|
||||
now = _now()
|
||||
with self._lock:
|
||||
provider = self._providers.get(provider_id)
|
||||
if provider and job_type not in provider.get("jobTypes", []):
|
||||
return _result("no-handler", reason="provider does not handle job type")
|
||||
job = self._jobs.get(job_id)
|
||||
if job and job.get("state") in _TERMINAL_STATES:
|
||||
return _result("stale", {"job": self._job_summary(job)}, "job is terminal")
|
||||
if not job:
|
||||
job = {
|
||||
"jobId": job_id,
|
||||
"providerId": provider_id,
|
||||
"requesterId": _safe_id(requester_id, provider_id),
|
||||
"jobType": job_type,
|
||||
"state": desired_state,
|
||||
"priority": _safe_id(priority, "user-approved-interactive"),
|
||||
"safeLabel": _safe_text(safe_label or job_type, job_type, _MAX_LABEL),
|
||||
"target": self._safe_ref_dict(target, prefix="target"),
|
||||
"inputs": self._safe_ref_dict(inputs, prefix="input"),
|
||||
"progress": _progress(progress),
|
||||
"createdAt": now,
|
||||
"queuedAt": now if desired_state == "queued" else None,
|
||||
"startedAt": now if desired_state == "running" else None,
|
||||
"updatedAt": now,
|
||||
"terminalAt": None,
|
||||
"terminalOutcome": None,
|
||||
"retryable": False,
|
||||
"externallyManaged": bool(externally_managed),
|
||||
"attempt": 1,
|
||||
"history": [],
|
||||
}
|
||||
self._jobs[job_id] = job
|
||||
self._history(job, "event", "Job adopted from backend provider")
|
||||
else:
|
||||
job.update({
|
||||
"safeLabel": _safe_text(safe_label or job.get("safeLabel"), job.get("safeLabel", job_type), _MAX_LABEL),
|
||||
"target": self._safe_ref_dict(target, prefix="target") or job.get("target") or {},
|
||||
"inputs": self._safe_ref_dict(inputs, prefix="input") or job.get("inputs") or {},
|
||||
"updatedAt": now,
|
||||
"externallyManaged": bool(externally_managed),
|
||||
})
|
||||
if progress is not None:
|
||||
job["progress"] = _progress(progress)
|
||||
if desired_state in _TERMINAL_STATES:
|
||||
self._settle_locked(job, desired_state, category=category, safe_reason=safe_reason, result_summary=result_summary, retryable=retryable)
|
||||
event = desired_state
|
||||
outcome = desired_state
|
||||
else:
|
||||
job["state"] = desired_state
|
||||
job["updatedAt"] = _now()
|
||||
if desired_state == "running":
|
||||
job["startedAt"] = job.get("startedAt") or job["updatedAt"]
|
||||
if desired_state == "queued":
|
||||
job["queuedAt"] = job.get("queuedAt") or job["updatedAt"]
|
||||
self._history(job, "event", f"Job adopted as {desired_state}")
|
||||
self._remember_outcome("adopt", "handled", {"jobId": job_id, "providerId": provider_id, "requesterId": job.get("requesterId")})
|
||||
event = "started" if desired_state == "running" else desired_state
|
||||
outcome = "handled"
|
||||
summary = self._job_summary(job)
|
||||
self._emit(event, {"job": self._job_summary(job, include_history=False)})
|
||||
return _result(outcome, {"job": summary})
|
||||
|
||||
def _safe_ref_dict(self, data: dict | None, *, prefix: str) -> dict:
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for key in ("safeRef", "safeFingerprint", "logicalJobKey", "backendJobId", "bulkJobId"):
|
||||
if key in data:
|
||||
out[key] = _safe_id(data.get(key), "")
|
||||
if not out:
|
||||
for key in ("id", "ref", "fingerprint"):
|
||||
if data.get(key):
|
||||
out[f"{prefix}Ref"] = _safe_id(data.get(key), "")
|
||||
break
|
||||
return {k: v for k, v in out.items() if v}
|
||||
|
||||
def update_progress(self, provider_id: str, job_id: str, progress: dict | None = None) -> dict:
|
||||
provider_id = _safe_id(provider_id, "")
|
||||
job_id = _safe_id(job_id, "")
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job or job.get("providerId") != provider_id:
|
||||
return _result("no-target", reason="job not found")
|
||||
if job.get("state") in _TERMINAL_STATES:
|
||||
self._remember_outcome("progress", "stale", {"jobId": job_id, "providerId": provider_id, "requesterId": job.get("requesterId"), "safeReason": "progress after terminal state"})
|
||||
return _result("stale", {"job": self._job_summary(job)}, "job is terminal")
|
||||
job["progress"] = _progress(progress)
|
||||
job["updatedAt"] = job["progress"]["updatedAt"]
|
||||
self._history(job, "progress", job["progress"].get("message") or job["progress"].get("step") or job["progress"].get("mode"), {"progress": dict(job["progress"])})
|
||||
self._remember_outcome("progress", "handled", {"jobId": job_id, "providerId": provider_id, "requesterId": job.get("requesterId")})
|
||||
summary = self._job_summary(job)
|
||||
self._emit("progress", {"job": self._job_summary(job, include_history=False)})
|
||||
return _result("handled", {"job": summary})
|
||||
|
||||
def complete(self, provider_id: str, job_id: str, result: dict | None = None) -> dict:
|
||||
return self._terminal(provider_id, job_id, "completed", result or {})
|
||||
|
||||
def fail(self, provider_id: str, job_id: str, result: dict | None = None) -> dict:
|
||||
result = dict(result or {})
|
||||
status = "provider-unavailable" if result.get("status") == "provider-unavailable" else "failed"
|
||||
return self._terminal(provider_id, job_id, status, result)
|
||||
|
||||
def cancelled(self, provider_id: str, job_id: str, result: dict | None = None) -> dict:
|
||||
return self._terminal(provider_id, job_id, "cancelled", result or {})
|
||||
|
||||
def mark_provider_unavailable(self, provider_id: str, safe_reason: str = "Provider unavailable") -> dict:
|
||||
provider_id = _safe_id(provider_id, "")
|
||||
changed: list[dict] = []
|
||||
with self._lock:
|
||||
provider = self._providers.get(provider_id)
|
||||
if provider:
|
||||
provider["available"] = False
|
||||
provider["updatedAt"] = _now()
|
||||
for job in self._jobs.values():
|
||||
if job.get("providerId") == provider_id and job.get("state") in _ACTIVE_STATES:
|
||||
self._settle_locked(job, "provider-unavailable", category="provider-unavailable", safe_reason=safe_reason, result_summary="Provider unavailable")
|
||||
changed.append(self._job_summary(job, include_history=False))
|
||||
self._remember_outcome("provider-unavailable", "provider-unavailable", {"providerId": provider_id, "safeReason": safe_reason})
|
||||
for job in changed:
|
||||
self._emit("provider-unavailable", {"job": job})
|
||||
return _result("provider-unavailable", {"jobs": changed})
|
||||
|
||||
def _terminal(self, provider_id: str, job_id: str, state: str, result: dict) -> dict:
|
||||
provider_id = _safe_id(provider_id, "")
|
||||
job_id = _safe_id(job_id, "")
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job or job.get("providerId") != provider_id:
|
||||
return _result("no-target", reason="job not found")
|
||||
if job.get("state") in _TERMINAL_STATES:
|
||||
return _result("stale", {"job": self._job_summary(job)}, "job is terminal")
|
||||
self._settle_locked(
|
||||
job,
|
||||
state,
|
||||
category=result.get("category"),
|
||||
safe_reason=result.get("safeReason") or result.get("safe_reason") or result.get("reason"),
|
||||
result_summary=result.get("resultSummary") or result.get("summary"),
|
||||
retryable=bool(result.get("retryable")),
|
||||
)
|
||||
summary = self._job_summary(job)
|
||||
event = "completed" if state == "completed" else ("cancelled" if state == "cancelled" else ("provider-unavailable" if state == "provider-unavailable" else "failed"))
|
||||
self._emit(event, {"job": self._job_summary(job, include_history=False)})
|
||||
return _result("completed" if state == "completed" else ("cancelled" if state == "cancelled" else "failed"), {"job": summary})
|
||||
|
||||
def _settle_locked(self, job: dict, state: str, *, category: str | None = None, safe_reason: str | None = None, result_summary: str | None = None, retryable: bool = False) -> None:
|
||||
if state not in _TERMINAL_STATES:
|
||||
state = "failed"
|
||||
terminal_at = _now()
|
||||
category = _safe_id(category, "")
|
||||
if state == "completed":
|
||||
category = ""
|
||||
elif category not in _FAILURE_CATEGORIES:
|
||||
category = "unknown"
|
||||
job["state"] = state
|
||||
job["retryable"] = bool(retryable)
|
||||
job["terminalAt"] = terminal_at
|
||||
job["updatedAt"] = terminal_at
|
||||
job["terminalOutcome"] = {
|
||||
"status": state,
|
||||
"category": category or None,
|
||||
"retryable": bool(retryable),
|
||||
"safeReason": _safe_text(safe_reason or ("Completed" if state == "completed" else state.replace("-", " "))),
|
||||
"resultSummary": _safe_text(result_summary or ("Completed" if state == "completed" else state.replace("-", " "))),
|
||||
}
|
||||
if job["jobId"] not in self._terminal_ids:
|
||||
self._terminal_ids.append(job["jobId"])
|
||||
self._history(job, "event", f"Job {state}")
|
||||
self._remember_outcome(state, state, {"jobId": job["jobId"], "providerId": job["providerId"], "requesterId": job.get("requesterId"), "category": category, "safeReason": job["terminalOutcome"].get("safeReason")})
|
||||
|
||||
def record_bridge_hit(self, bridge_id: str, detail: dict | None = None) -> dict:
|
||||
bridge_id = _safe_id(bridge_id, "")
|
||||
if not bridge_id:
|
||||
return _result("validation-failed", reason="bridge id required")
|
||||
data = detail if isinstance(detail, dict) else {}
|
||||
hit = {
|
||||
"at": _now(),
|
||||
"bridgeId": bridge_id,
|
||||
"providerId": _safe_id(data.get("providerId"), ""),
|
||||
"jobType": _safe_id(data.get("jobType"), ""),
|
||||
"operation": _safe_id(data.get("operation"), ""),
|
||||
"safeReason": _safe_text(data.get("safeReason") or data.get("reason"), ""),
|
||||
}
|
||||
hit = {k: v for k, v in hit.items() if v}
|
||||
with self._lock:
|
||||
self._bridge_hits.append(hit)
|
||||
self._remember_outcome("record-bridge-hit", "handled", {"providerId": hit.get("providerId"), "safeReason": hit.get("safeReason")})
|
||||
self._emit("bridge-hit", {"bridgeHit": hit})
|
||||
return _result("handled", {"bridgeHit": hit})
|
||||
|
||||
def list_jobs(self, filters: dict | None = None) -> dict:
|
||||
filters = filters if isinstance(filters, dict) else {}
|
||||
include_terminal = filters.get("includeTerminal", True) is not False
|
||||
with self._lock:
|
||||
jobs = []
|
||||
for job in self._jobs.values():
|
||||
if filters.get("providerId") and job.get("providerId") != filters.get("providerId"):
|
||||
continue
|
||||
if filters.get("jobType") and job.get("jobType") != filters.get("jobType"):
|
||||
continue
|
||||
if filters.get("state") and job.get("state") != filters.get("state"):
|
||||
continue
|
||||
if not include_terminal and job.get("state") in _TERMINAL_STATES:
|
||||
continue
|
||||
jobs.append(self._job_summary(job, include_history=False))
|
||||
jobs.sort(key=lambda j: (_PRIORITY_ORDER.get(j.get("priority"), 99), j.get("createdAt") or ""))
|
||||
providers = [self._provider_summary(p) for p in self._providers.values()]
|
||||
return {"schema": _SCHEMA, "jobs": jobs, "providers": providers}
|
||||
|
||||
def inspect(self, job_id: str) -> dict:
|
||||
job_id = _safe_id(job_id, "")
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return _result("no-target", reason="job not found")
|
||||
provider = self._providers.get(job.get("providerId"))
|
||||
return _result("handled", {"job": self._job_summary(job), "provider": self._provider_summary(provider) if provider else None})
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
providers = [self._provider_summary(p) for p in self._providers.values()]
|
||||
active = []
|
||||
queued = []
|
||||
paused = []
|
||||
recent_terminal = []
|
||||
for job in self._jobs.values():
|
||||
summary = self._job_summary(job)
|
||||
state = job.get("state")
|
||||
if state == "queued":
|
||||
queued.append(summary)
|
||||
elif state == "paused":
|
||||
paused.append(summary)
|
||||
elif state in _TERMINAL_STATES:
|
||||
if job["jobId"] in self._terminal_ids:
|
||||
recent_terminal.append(summary)
|
||||
else:
|
||||
active.append(summary)
|
||||
recent_terminal.sort(key=lambda j: j.get("terminalAt") or "")
|
||||
return {
|
||||
"schema": _SCHEMA,
|
||||
"backendIntegrated": True,
|
||||
"providers": providers,
|
||||
"jobs": {
|
||||
"active": active,
|
||||
"queued": queued,
|
||||
"paused": paused,
|
||||
"recentTerminal": recent_terminal[-_MAX_TERMINAL_JOBS:],
|
||||
},
|
||||
"bridgeHits": list(self._bridge_hits),
|
||||
"recentOutcomes": list(self._outcomes),
|
||||
"limits": {
|
||||
"maxHistoryPerJob": _MAX_HISTORY_PER_JOB,
|
||||
"maxTerminalJobs": _MAX_TERMINAL_JOBS,
|
||||
"maxOutcomes": _MAX_OUTCOMES,
|
||||
"maxBridgeHits": _MAX_BRIDGE_HITS,
|
||||
},
|
||||
"notes": ["Backend jobs contain redaction-safe summaries only; plugin-private execution payloads remain with provider backends."],
|
||||
}
|
||||
|
||||
def reset_for_tests(self) -> None:
|
||||
with self._lock:
|
||||
self._providers.clear()
|
||||
self._jobs.clear()
|
||||
self._terminal_ids.clear()
|
||||
self._outcomes.clear()
|
||||
self._bridge_hits.clear()
|
||||
|
||||
|
||||
backend_jobs = BackendJobs()
|
||||
@@ -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>';
|
||||
|
||||
@@ -45,6 +45,7 @@ import loosefolder as loosefolder_mod
|
||||
# scan workers can import + unpickle _scan_one without re-running this
|
||||
# module's import-time side effects (see lib/scan_worker.py).
|
||||
from scan_worker import _extract_meta_for_file, _relpath, _scan_one
|
||||
from jobs_backend import backend_jobs
|
||||
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
@@ -198,6 +199,9 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("POST", re.compile(r"^/api/diagnostics/export$")),
|
||||
("GET", re.compile(r"^/api/diagnostics/preview$")),
|
||||
("GET", re.compile(r"^/api/diagnostics/hardware$")),
|
||||
("GET", re.compile(r"^/api/jobs(?:/[^/]+)?$")),
|
||||
("GET", re.compile(r"^/api/jobs/providers$")),
|
||||
("POST", re.compile(r"^/api/jobs/[^/]+/(cancel|retry)$")),
|
||||
# Bundled core plugin — video background upload/delete
|
||||
("POST", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||||
("DELETE", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||||
@@ -2444,7 +2448,7 @@ class TuningProviderRegistry:
|
||||
for name, freqs in names.items():
|
||||
result[instrument][name] = [round(f * scale, 4) for f in freqs]
|
||||
except Exception:
|
||||
logger.exception("tuning provider %r raised during get_merged()", provider_id)
|
||||
log.exception("tuning provider %r raised during get_merged()", provider_id)
|
||||
return result
|
||||
|
||||
|
||||
@@ -3357,6 +3361,7 @@ async def startup_events():
|
||||
"unregister_tuning_provider": unregister_tuning_provider,
|
||||
"get_sloppak_cache_dir": lambda: SLOPPAK_CACHE_DIR,
|
||||
"register_demo_janitor_hook": register_demo_janitor_hook,
|
||||
"jobs": backend_jobs,
|
||||
# Unified XP service (fee[dB]ack v0.3.0). Plugins that award XP
|
||||
# (minigames, tutorials, …) should feed the single core store via these
|
||||
# instead of keeping a private XP curve. `award_xp` returns the new
|
||||
@@ -4578,7 +4583,8 @@ def api_award_xp(data: dict):
|
||||
# bind (→ 500) and no real run awards anywhere near this.
|
||||
if not (0 <= amount <= 10_000_000):
|
||||
return JSONResponse({"error": "amount must be between 0 and 10,000,000"}, status_code=400)
|
||||
meta_db.award_xp(amount)
|
||||
source = _clean_str(data.get("source")) or None
|
||||
meta_db.award_xp(amount, source)
|
||||
return meta_db.get_progress()
|
||||
|
||||
|
||||
@@ -6242,6 +6248,7 @@ def export_diagnostics(payload: dict = Body(default_factory=dict)):
|
||||
client_contributions=client_contributions,
|
||||
log=log,
|
||||
plugins_root=_diag_plugins_roots(),
|
||||
backend_jobs_snapshot=backend_jobs.snapshot(),
|
||||
)
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
@@ -6283,6 +6290,7 @@ def preview_diagnostics(
|
||||
redact=redact,
|
||||
log=log,
|
||||
plugins_root=_diag_plugins_roots(),
|
||||
backend_jobs_snapshot=backend_jobs.snapshot(),
|
||||
)
|
||||
|
||||
|
||||
@@ -6293,6 +6301,104 @@ def diagnostics_hardware():
|
||||
return _diag_hardware()
|
||||
|
||||
|
||||
@app.get("/api/jobs/providers")
|
||||
def api_jobs_providers():
|
||||
"""Return backend-registered job providers with redaction-safe metadata."""
|
||||
return {"schema": "slopsmith.jobs.providers.v1", "providers": backend_jobs.list_jobs().get("providers", [])}
|
||||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def api_jobs_list(providerId: str | None = None, jobType: str | None = None,
|
||||
state: str | None = None, includeTerminal: bool = True):
|
||||
"""Return backend job summaries without triggering provider work."""
|
||||
filters = {
|
||||
"providerId": providerId,
|
||||
"jobType": jobType,
|
||||
"state": state,
|
||||
"includeTerminal": includeTerminal,
|
||||
}
|
||||
return backend_jobs.list_jobs(filters)
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def api_jobs_inspect(job_id: str):
|
||||
result = backend_jobs.inspect(job_id)
|
||||
if result.get("outcome") == "no-target":
|
||||
raise HTTPException(status_code=404, detail=result.get("reason") or "job not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/cancel")
|
||||
async def api_jobs_cancel(job_id: str, data: dict | None = Body(default=None)):
|
||||
result = backend_jobs.inspect(job_id)
|
||||
if result.get("outcome") == "no-target":
|
||||
raise HTTPException(status_code=404, detail=result.get("reason") or "job not found")
|
||||
request = dict(data) if isinstance(data, dict) else {}
|
||||
request["authorization"] = "user-action"
|
||||
request["requesterId"] = "api.jobs"
|
||||
return await backend_jobs.dispatch_action(job_id, "job.cancel", request)
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/retry")
|
||||
async def api_jobs_retry(job_id: str, data: dict | None = Body(default=None)):
|
||||
result = backend_jobs.inspect(job_id)
|
||||
if result.get("outcome") == "no-target":
|
||||
raise HTTPException(status_code=404, detail=result.get("reason") or "job not found")
|
||||
request = dict(data) if isinstance(data, dict) else {}
|
||||
request["authorization"] = "user-action"
|
||||
request["requesterId"] = "api.jobs"
|
||||
return await backend_jobs.dispatch_action(job_id, "job.retry", request)
|
||||
|
||||
|
||||
@app.websocket("/ws/jobs")
|
||||
async def jobs_ws(websocket: WebSocket):
|
||||
"""Stream backend job snapshots and lifecycle updates."""
|
||||
if os.environ.get("SLOPSMITH_DEMO_MODE") == "1":
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"error": "demo mode: read-only"})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=200)
|
||||
|
||||
def _on_event(event: dict) -> None:
|
||||
def _put() -> None:
|
||||
if queue.full():
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
queue.put_nowait(event)
|
||||
loop.call_soon_threadsafe(_put)
|
||||
|
||||
unsubscribe = backend_jobs.subscribe(_on_event)
|
||||
try:
|
||||
await websocket.send_json({"type": "snapshot", "snapshot": backend_jobs.snapshot()})
|
||||
while True:
|
||||
event_task = asyncio.create_task(queue.get())
|
||||
recv_task = asyncio.create_task(websocket.receive())
|
||||
done, pending = await asyncio.wait(
|
||||
{event_task, recv_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
if recv_task in done:
|
||||
message = recv_task.result()
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
continue
|
||||
event = event_task.result()
|
||||
await websocket.send_json(event)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
|
||||
|
||||
# ── Plugin-provided routes are registered at startup via plugins/__init__.py ─
|
||||
# (CustomsForge, Ultimate Guitar, etc. are loaded from plugins/ directory)
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Jobs Control Plane
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-31
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [X] No implementation details (languages, frameworks, APIs)
|
||||
- [X] Focused on user value and business needs
|
||||
- [X] Written for non-technical stakeholders
|
||||
- [X] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||
- [X] Requirements are testable and unambiguous
|
||||
- [X] Success criteria are measurable
|
||||
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||
- [X] All acceptance scenarios are defined
|
||||
- [X] Edge cases are identified
|
||||
- [X] Scope is clearly bounded
|
||||
- [X] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [X] All functional requirements have clear acceptance criteria
|
||||
- [X] User scenarios cover primary flows
|
||||
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [X] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation iteration 1 passed. No clarification markers remain.
|
||||
- The specification bounds jobs to coordination, state, progress, cancellation, retry, scheduling, diagnostics, and migration behavior; actual media import/export, plugin update policy, external-service trust, recording, and processing algorithms remain outside this slice.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Contract: Jobs Diagnostics Schema
|
||||
|
||||
## Schema
|
||||
|
||||
Jobs diagnostics are contributed under:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "slopsmith.jobs.diagnostics.v1"
|
||||
}
|
||||
```
|
||||
|
||||
The payload is included in browser diagnostics and the Capability Inspector. Export mode is redaction-safe by default. Local inspector mode may show user-visible labels that are already present in the UI, but must still avoid raw paths, tokens, command lines, handles, and artifacts.
|
||||
|
||||
## Top-Level Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "slopsmith.jobs.diagnostics.v1",
|
||||
"generatedAt": "2026-05-31T00:00:00.000Z",
|
||||
"providers": [],
|
||||
"selectedProviders": [],
|
||||
"jobs": {
|
||||
"active": [],
|
||||
"queued": [],
|
||||
"paused": [],
|
||||
"recentTerminal": []
|
||||
},
|
||||
"outcomes": [],
|
||||
"bridgeHits": [],
|
||||
"limits": {
|
||||
"terminalJobsRetained": 5,
|
||||
"perJobHistoryLimit": 50,
|
||||
"snapshotBudgetBytes": 65536
|
||||
},
|
||||
"notes": []
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Summary
|
||||
|
||||
```json
|
||||
{
|
||||
"providerId": "provider-1",
|
||||
"pluginId": "sloppak_converter",
|
||||
"label": "Sloppak Converter",
|
||||
"jobTypes": ["conversion.sloppak"],
|
||||
"actions": ["enqueue", "cancel", "retry"],
|
||||
"availability": "available",
|
||||
"capacity": { "maxRunning": 1, "maxQueued": 10 },
|
||||
"currentLoad": { "running": 0, "queued": 1 },
|
||||
"recoverySupport": { "queued": true, "running": false, "paused": false },
|
||||
"safeReason": null,
|
||||
"lastSeenAt": "2026-05-31T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Job Summary
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "job-1",
|
||||
"jobType": "conversion.sloppak",
|
||||
"providerId": "provider-1",
|
||||
"requesterId": "core.user",
|
||||
"targetRef": "target-1",
|
||||
"state": "running",
|
||||
"priority": "user-approved-interactive",
|
||||
"safeLabel": "Conversion job",
|
||||
"progress": {
|
||||
"mode": "determinate",
|
||||
"percent": 42,
|
||||
"step": "convert",
|
||||
"message": "Converting arrangement data",
|
||||
"updatedAt": "2026-05-31T00:00:00.000Z"
|
||||
},
|
||||
"actionsAvailable": ["cancel"],
|
||||
"retryable": false,
|
||||
"attempts": [{ "attemptId": "attempt-1", "attemptNumber": 1, "state": "running" }],
|
||||
"safeReason": null,
|
||||
"timestamps": {
|
||||
"createdAt": "2026-05-31T00:00:00.000Z",
|
||||
"queuedAt": "2026-05-31T00:00:00.000Z",
|
||||
"startedAt": "2026-05-31T00:00:01.000Z",
|
||||
"updatedAt": "2026-05-31T00:00:02.000Z",
|
||||
"terminalAt": null
|
||||
},
|
||||
"history": []
|
||||
}
|
||||
```
|
||||
|
||||
## Outcome Summary
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 1,
|
||||
"operation": "enqueue",
|
||||
"jobId": "job-1",
|
||||
"providerId": "provider-1",
|
||||
"requesterId": "core.user",
|
||||
"status": "queued",
|
||||
"category": null,
|
||||
"safeReason": null,
|
||||
"timestamp": "2026-05-31T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Bridge Hit
|
||||
|
||||
```json
|
||||
{
|
||||
"bridgeId": "jobs.legacy-sloppak-queue",
|
||||
"legacySurface": "plugin-queue",
|
||||
"pluginId": "sloppak_converter",
|
||||
"operation": "enqueue",
|
||||
"jobId": "job-1",
|
||||
"providerId": "provider-1",
|
||||
"timestamp": "2026-05-31T00:00:00.000Z",
|
||||
"safeReason": "legacy queue observed"
|
||||
}
|
||||
```
|
||||
|
||||
## Retention Rules
|
||||
|
||||
- Preserve all active, queued, paused, and cancellation-requested jobs.
|
||||
- Preserve at least five most recent terminal jobs when available.
|
||||
- Cap per-job progress/log history at 50 entries unless the support snapshot budget requires stricter trimming.
|
||||
- Trim oldest terminal jobs and oldest per-job history first.
|
||||
- Never trim active job identity, current state, current progress, current action availability, or latest terminal outcome.
|
||||
|
||||
## Redaction Rules
|
||||
|
||||
Diagnostics must not include:
|
||||
|
||||
- unredacted local paths or raw filenames when sensitive
|
||||
- secret-bearing URLs, tokens, cookies, API keys, or environment values
|
||||
- raw command lines or subprocess invocation details
|
||||
- raw media files, converted artifacts, downloaded payloads, recordings, audio buffers, waveform/sample data
|
||||
- subprocess handles, native handles, browser handles, or plugin-private objects
|
||||
- provider-private request payloads or unreviewed artifacts
|
||||
|
||||
Diagnostics may include:
|
||||
|
||||
- provider id and plugin id
|
||||
- safe job type
|
||||
- pseudonymous target ids
|
||||
- bounded safe labels and reasons
|
||||
- failure categories
|
||||
- retryability and action availability
|
||||
- bridge ids and legacy surface categories
|
||||
@@ -0,0 +1,232 @@
|
||||
# Contract: Jobs Control Plane
|
||||
|
||||
## Domain
|
||||
|
||||
`jobs` is an active privileged provider-coordinator capability domain. Core owns command normalization, provider registration, scheduling, diagnostics, and compatibility bridge accounting. Providers own the actual long-running work.
|
||||
|
||||
## Owner Registration
|
||||
|
||||
The core jobs host registers an owner for `jobs` with:
|
||||
|
||||
- `kind`: `provider-coordinator`
|
||||
- `ownership`: `multi-provider`
|
||||
- `safety`: `privileged`
|
||||
- `compatibility`: `shim-allowed`
|
||||
- `commands`: `register-provider`, `unregister-provider`, `list-providers`, `enqueue`, `list`, `inspect`, `cancel`, `pause`, `resume`, `retry`, `record-bridge-hit`
|
||||
- `events`: `provider-registered`, `provider-unregistered`, `provider-unavailable`, `queued`, `started`, `progress`, `log`, `paused`, `resumed`, `cancellation-requested`, `cancelled`, `completed`, `failed`, `retried`, `orphaned`, `bridge-hit`
|
||||
|
||||
Canonical jobs outcomes: `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`.
|
||||
|
||||
## Provider Declaration
|
||||
|
||||
A provider participant describes safe metadata only:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerId": "sloppak_converter.jobs",
|
||||
"pluginId": "sloppak_converter",
|
||||
"label": "Sloppak Converter",
|
||||
"jobTypes": ["conversion.sloppak"],
|
||||
"actions": ["enqueue", "inspect", "cancel", "retry", "recover"],
|
||||
"availability": "available",
|
||||
"capacity": { "maxRunning": 1, "maxQueued": 10 },
|
||||
"recoverySupport": { "queued": true, "running": false, "paused": false }
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `providerId` is stable and unique.
|
||||
- Re-registering the same provider updates metadata.
|
||||
- Incompatible providers remain visible in diagnostics but cannot accept new jobs.
|
||||
- Provider metadata must not include raw local paths, command lines, tokens, native handles, or provider-private payloads.
|
||||
|
||||
## Commands
|
||||
|
||||
### `register-provider`
|
||||
|
||||
Registers or updates a provider.
|
||||
|
||||
Required args:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"providerId": "string",
|
||||
"pluginId": "string",
|
||||
"label": "string",
|
||||
"jobTypes": ["string"],
|
||||
"actions": ["enqueue"],
|
||||
"capacity": { "maxRunning": 1 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Outcomes: `handled`, `validation-failed`, `incompatible-version`, `denied`.
|
||||
|
||||
### `unregister-provider`
|
||||
|
||||
Unregisters a provider and settles provider-owned active jobs to terminal state.
|
||||
|
||||
Required args:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Command is privileged and host-owned.
|
||||
- Missing/unknown provider returns `no-owner`.
|
||||
- Registered provider emits `provider-unregistered`.
|
||||
- Any active jobs owned by the provider transition to terminal unavailable/orphaned state with a safe reason.
|
||||
|
||||
Outcomes: `handled`, `no-owner`, `failed`.
|
||||
|
||||
### `list-providers`
|
||||
|
||||
Returns redaction-safe provider summaries. Prompt-free and side-effect-free.
|
||||
|
||||
Outcomes: `handled`.
|
||||
|
||||
### `enqueue`
|
||||
|
||||
Requests long-running work.
|
||||
|
||||
Required args:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobType": "conversion.sloppak",
|
||||
"target": { "kind": "song", "id": "target-1" },
|
||||
"requester": "core.user",
|
||||
"authorization": "user-action",
|
||||
"providerId": "optional-provider",
|
||||
"priority": "user-approved-interactive",
|
||||
"inputs": { "safeFingerprint": "input-fp" }
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Privileged work requires `authorization: "user-action"` or an approved continuation matching provider, job type, target, requester, and inputs.
|
||||
- Missing or mismatched approval returns `denied` or `user-action-required` before provider operation callbacks run.
|
||||
- If exactly one compatible provider exists, it MUST be selected automatically.
|
||||
- If multiple compatible providers exist, `providerId` or selected/default provider is required; otherwise return `provider-selection-required`.
|
||||
- Validation happens before provider work starts.
|
||||
- Provider capacity controls whether the job enters `queued` or `running`.
|
||||
|
||||
Outcomes: `queued`, `handled`, `denied`, `user-action-required`, `provider-selection-required`, `validation-failed`, `unavailable`, `no-owner`, `no-handler`, `unsupported-operation`, `incompatible`, `incompatible-version`, `failed`.
|
||||
|
||||
### `list`
|
||||
|
||||
Returns summaries for active, queued, paused, and recent terminal jobs. Prompt-free and side-effect-free; it must not invoke provider work callbacks, file writes, downloads, subprocesses, or external-service calls.
|
||||
|
||||
Filters may include `providerId`, `jobType`, `state`, `requesterId`, and `includeTerminal`.
|
||||
|
||||
Outcomes: `handled`.
|
||||
|
||||
### `inspect`
|
||||
|
||||
Returns one job, provider, progress, attempt, action availability, and safe diagnostic context. Prompt-free and side-effect-free; it must not invoke provider work callbacks, file writes, downloads, subprocesses, or external-service calls.
|
||||
|
||||
Required args: `{ "jobId": "string" }`
|
||||
|
||||
Outcomes: `handled`, `unavailable`, `no-target`.
|
||||
|
||||
### `cancel`
|
||||
|
||||
Cancels queued jobs immediately or requests cancellation for running/paused jobs.
|
||||
|
||||
Required args: `{ "jobId": "string", "requester": "string" }`
|
||||
|
||||
Rules:
|
||||
|
||||
- Queued jobs transition to `cancelled` and must not start later.
|
||||
- Running jobs transition to `cancellation-requested` until provider terminal report.
|
||||
- Unsupported cancellation returns `unsupported-operation` without changing state.
|
||||
- Late cancellation after terminal state returns `stale` or the current terminal status.
|
||||
|
||||
Outcomes: `handled`, `cancelled`, `stale`, `unsupported-operation`, `unavailable`, `no-target`, `failed`.
|
||||
|
||||
### `pause`
|
||||
|
||||
Requests pause for a running job when provider supports it.
|
||||
|
||||
Outcomes: `handled`, `unsupported-operation`, `stale`, `unavailable`, `no-target`, `failed`.
|
||||
|
||||
### `resume`
|
||||
|
||||
Returns a paused job to queued or running state with the same job identity.
|
||||
|
||||
Outcomes: `handled`, `queued`, `unsupported-operation`, `stale`, `unavailable`, `no-target`, `failed`.
|
||||
|
||||
### `retry`
|
||||
|
||||
Creates a retry attempt for a terminal retryable job.
|
||||
|
||||
Required args:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "string",
|
||||
"requester": "string",
|
||||
"authorization": "user-action | approved-continuation"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Retry is allowed only for terminal jobs marked retryable or safely re-enqueueable with the same approved provider, job type, target, requester, and inputs.
|
||||
- A retry creates a new attempt linked to the original job.
|
||||
- Only one active retry attempt may exist at a time.
|
||||
|
||||
Outcomes: `retry-started`, `queued`, `denied`, `user-action-required`, `validation-failed`, `stale`, `unsupported-operation`, `unavailable`, `no-target`, `failed`.
|
||||
|
||||
### `record-bridge-hit`
|
||||
|
||||
Records legacy queue/status/backend-route usage for diagnostics.
|
||||
|
||||
Outcomes: `handled`.
|
||||
|
||||
## Events
|
||||
|
||||
Events are emitted as `jobs:<event>` through `window.slopsmith.emit` and mirrored into capability diagnostics.
|
||||
|
||||
Event payloads use redaction-safe ids and summaries only. They must not contain raw artifacts, command lines, local paths, tokens, native handles, subprocess handles, downloaded payloads, or plugin-private objects.
|
||||
|
||||
Required events:
|
||||
|
||||
- `jobs:provider-registered`
|
||||
- `jobs:provider-unavailable`
|
||||
- `jobs:queued`
|
||||
- `jobs:started`
|
||||
- `jobs:progress`
|
||||
- `jobs:log`
|
||||
- `jobs:paused`
|
||||
- `jobs:resumed`
|
||||
- `jobs:cancellation-requested`
|
||||
- `jobs:cancelled`
|
||||
- `jobs:completed`
|
||||
- `jobs:failed`
|
||||
- `jobs:retried`
|
||||
- `jobs:orphaned`
|
||||
- `jobs:bridge-hit`
|
||||
|
||||
## Provider Operations
|
||||
|
||||
Provider operation handlers are implementation-owned callbacks registered by providers. Public commands call these operations only after validation, scheduling, and approval checks.
|
||||
|
||||
Expected operations:
|
||||
|
||||
- `job.enqueue`
|
||||
- `job.status`
|
||||
- `job.cancel`
|
||||
- `job.pause`
|
||||
- `job.resume`
|
||||
- `job.retry`
|
||||
- `job.recover`
|
||||
|
||||
Provider operation results must use normalized states/outcomes and safe reasons. Malformed provider responses produce `failed` or `validation-failed` outcomes and do not expose provider-private data.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Contract: Jobs Migration Notes
|
||||
|
||||
## Migration Goal
|
||||
|
||||
Move plugin-specific long-running work into the shared `jobs` domain without breaking existing plugin queues, status screens, or backend routes during the transition.
|
||||
|
||||
## Native Provider Pattern
|
||||
|
||||
A migrated plugin should:
|
||||
|
||||
1. Declare the `jobs` domain in its manifest as a provider/requester as appropriate.
|
||||
2. Register a native job provider after hydration.
|
||||
3. Report supported job types, actions, availability, capacity, and recovery support.
|
||||
4. Accept public jobs commands only through the jobs control plane.
|
||||
5. Emit or report progress, log, completion, cancellation, failure, retryability, and recovery state through normalized provider operations.
|
||||
6. Keep actual artifacts, file paths, command lines, downloads, subprocess handles, and plugin-private state inside the provider.
|
||||
|
||||
## Compatibility Bridge Pattern
|
||||
|
||||
Existing plugin queues and status screens remain allowed during migration. Core or the plugin should record a bridge hit when a legacy queue, status surface, or job-like route is used.
|
||||
|
||||
Recommended bridge ids:
|
||||
|
||||
- `jobs.legacy-plugin-queue`
|
||||
- `jobs.legacy-status-screen`
|
||||
- `jobs.legacy-backend-route`
|
||||
- `jobs.legacy-progress-poll`
|
||||
- `jobs.legacy-update-flow`
|
||||
|
||||
A compatibility bridge may create or update a diagnostic job summary only when it can identify a logical job without exposing private payloads. If a native provider describes the same logical job, the native provider owns the user-visible job and the compatibility record remains diagnostics-only.
|
||||
|
||||
## Provider Adoption Examples
|
||||
|
||||
### Sloppak Converter
|
||||
|
||||
- Job types: conversion and optional stem-prep coordination.
|
||||
- Expected actions: enqueue, inspect, cancel when supported, retry when inputs are unchanged.
|
||||
- Bridge sources: existing converter queue UI and backend conversion routes.
|
||||
|
||||
### Tab/Profile Import
|
||||
|
||||
- Job types: import and validation.
|
||||
- Expected actions: enqueue, inspect, cancel before start, retry failed validation/import when safe.
|
||||
- Bridge sources: plugin-specific import routes and status screens.
|
||||
|
||||
### Update Manager / Plugin Manager
|
||||
|
||||
- Job types: update check, download, install, rollback coordination.
|
||||
- Expected actions: enqueue, inspect, cancel during safe phases, retry failed downloads.
|
||||
- Extra care: plugin install/update policy and external-service trust remain outside this slice; jobs records only safe state and outcome summaries.
|
||||
|
||||
### Studio / Preview Work
|
||||
|
||||
- Job types: preview generation, render, analysis, studio processing.
|
||||
- Expected actions: enqueue, inspect, cancel, pause/resume only when provider can safely preserve state.
|
||||
- Extra care: recording and audio processing semantics remain outside this slice.
|
||||
|
||||
## Removal Gates
|
||||
|
||||
Legacy job bridges can be removed only when:
|
||||
|
||||
1. Bundled providers use native `jobs` registration for normal queue/progress/cancel/retry flows.
|
||||
2. Normal conversion, import, update, preview, and studio smoke runs show no unexpected bridge hits.
|
||||
3. Provider rehydration does not duplicate providers, active jobs, terminal jobs, or status listeners.
|
||||
4. Privileged enqueue without explicit user approval returns denied or user-action-required before work begins.
|
||||
5. Provider-selection-required is observable when multiple compatible providers lack selected/default choice.
|
||||
6. Scheduling tests show user-approved interactive jobs before background/maintenance jobs and FIFO within priority.
|
||||
7. Reload tests show only provider-recoverable jobs restored; other non-terminal jobs become orphaned or provider-unavailable with safe reasons.
|
||||
8. Diagnostics contain no unredacted paths, tokens, command lines, raw artifacts, media data, native handles, subprocess handles, or provider-private payloads.
|
||||
|
||||
## Out Of Scope During Migration
|
||||
|
||||
The jobs domain does not define:
|
||||
|
||||
- media import/export file semantics
|
||||
- plugin installation trust or rollback policy
|
||||
- external service endpoint trust rules
|
||||
- recording capture or take storage semantics
|
||||
- audio-effects processing, model loading, or IR inventory
|
||||
- backend route privilege review
|
||||
|
||||
Those domains may use jobs state later, but they should not hide their own trust and data contracts inside this slice.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Contract: Jobs Testing
|
||||
|
||||
## Static And Syntax Checks
|
||||
|
||||
- Validate new source-served JavaScript with `node --check`.
|
||||
- Keep new capability host code runnable in Node test harnesses without a browser-only dependency.
|
||||
- Validate docs and contracts have no unresolved `NEEDS CLARIFICATION` markers.
|
||||
|
||||
## Unit / Node Scenarios
|
||||
|
||||
### Provider Registration
|
||||
|
||||
- Register one provider and inspect provider metadata.
|
||||
- Re-register the same provider five times and confirm one provider record remains.
|
||||
- Register incompatible provider version and confirm it is visible but cannot accept jobs.
|
||||
- Register unavailable/degraded provider and inspect safe reason.
|
||||
|
||||
### Enqueue And State
|
||||
|
||||
- Enqueue user-approved job with one compatible provider and confirm `queued` or `running` outcome.
|
||||
- Enqueue without provider when no provider exists and confirm `no-owner` or `unavailable`.
|
||||
- Enqueue invalid parameters and confirm `validation-failed` before provider work starts.
|
||||
- Enqueue privileged background job without approval and confirm denied/user-action-required before provider work starts.
|
||||
- Enqueue with multiple compatible providers and no selected/default provider and confirm `provider-selection-required`.
|
||||
|
||||
### Scheduling
|
||||
|
||||
- Provider capacity prevents overrun.
|
||||
- User-approved interactive jobs start before background/maintenance jobs for same provider capacity.
|
||||
- FIFO order is preserved within each priority class.
|
||||
- Queued cancelled jobs never start later.
|
||||
|
||||
### Progress And Terminal States
|
||||
|
||||
- Determinate progress updates current percentage and step within 1 second in test harness.
|
||||
- Indeterminate progress remains active without invented percent.
|
||||
- Decreasing progress is flagged or normalized by attempt/step.
|
||||
- Progress after terminal state is stale unless tied to a newer retry attempt.
|
||||
- Completion records terminal state, safe result summary, and no active indicator.
|
||||
- Failure records category, safe reason, and retryability.
|
||||
|
||||
### Cancel / Pause / Resume / Retry
|
||||
|
||||
- Queued cancel transitions directly to cancelled.
|
||||
- Running cancel transitions to cancellation-requested until provider terminal result.
|
||||
- Unsupported cancel/pause/resume/retry returns unsupported-operation without inaccurate state changes.
|
||||
- Pause preserves job identity and resume returns to queued/running.
|
||||
- Retry creates linked attempt for retryable terminal job.
|
||||
- Retry without matching approval scope returns denied/user-action-required.
|
||||
- Concurrent retry attempts for same job are rejected or marked stale.
|
||||
|
||||
### Reload / Recovery
|
||||
|
||||
- Provider-recoverable queued/running/paused jobs restore according to provider metadata.
|
||||
- Non-recoverable non-terminal jobs become orphaned or provider-unavailable with safe reason.
|
||||
- Terminal jobs remain terminal across reload/recovery simulation.
|
||||
|
||||
### Diagnostics / Redaction
|
||||
|
||||
- Exported diagnostics preserve all active jobs and at least five recent terminal jobs.
|
||||
- Per-job progress/log history is capped at 50 entries or stricter budget trimming.
|
||||
- Diagnostics redact local paths, raw filenames when sensitive, secret URLs, tokens, command lines, raw artifacts, media buffers, recordings, subprocess handles, native handles, and plugin-private payloads.
|
||||
- Bridge hits are recorded without creating duplicate user-visible jobs when native provider owns the same logical job.
|
||||
|
||||
## Browser / Inspector Scenarios
|
||||
|
||||
- Capability Inspector renders jobs provider summary, active jobs, queued jobs, recent terminal jobs, progress, action availability, bridge hits, and recent outcomes.
|
||||
- Inspector can distinguish queued, running, paused, cancellation-requested, cancelled, completed, failed, provider-unavailable, and orphaned.
|
||||
- Inspector does not show raw paths, command lines, tokens, handles, or artifacts.
|
||||
|
||||
## Python / Backend Regression Scenarios
|
||||
|
||||
Run focused pytest only if implementation touches diagnostics bundle export/import, plugin loading, backend route attribution, or redaction helpers.
|
||||
|
||||
Recommended focused files if touched:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_diagnostics_bundle.py tests/test_diagnostics_redact.py tests/test_plugins.py tests/test_plugin_runtime_idempotence.py -q
|
||||
```
|
||||
|
||||
## Acceptance Validation
|
||||
|
||||
A complete implementation should be able to demonstrate:
|
||||
|
||||
1. One provider can enqueue, progress, and complete a job.
|
||||
2. Cancel, pause, resume, and retry produce distinct safe outcomes.
|
||||
3. Multiple providers without selection return provider-selection-required.
|
||||
4. User-approved jobs outrank background jobs while respecting provider capacity.
|
||||
5. Reload recovery follows provider-declared support.
|
||||
6. Diagnostics preserve useful active/recent context and redact privileged data.
|
||||
7. Legacy bridge hits are counted for plugin-specific queues/status surfaces during migration.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Data Model: Jobs Control Plane
|
||||
|
||||
## Job Provider
|
||||
|
||||
Represents a participant that can accept one or more long-running job types.
|
||||
|
||||
**Fields**:
|
||||
- `providerId`: stable participant id, unique within the jobs domain.
|
||||
- `pluginId`: owning plugin id or `core`.
|
||||
- `label`: redaction-safe display label.
|
||||
- `supportedJobTypes`: list of job type ids the provider can handle.
|
||||
- `supportedActions`: subset of `enqueue`, `inspect`, `cancel`, `pause`, `resume`, `retry`, `recover`.
|
||||
- `availability`: `available`, `unavailable`, `degraded`, `disabled`, or `incompatible`.
|
||||
- `capacity`: provider-declared running and queued limits.
|
||||
- `currentLoad`: running/queued counts visible to scheduling.
|
||||
- `selectionEligible`: whether the provider can be user-selected/default for a job type.
|
||||
- `recoverySupport`: provider-level summary of reload recovery support.
|
||||
- `safeReason`: bounded redaction-safe reason for unavailable/degraded/incompatible state.
|
||||
- `lastSeenAt`: timestamp of latest registration/status update.
|
||||
|
||||
**Validation rules**:
|
||||
- `providerId` must be stable and unique.
|
||||
- Unsupported or incompatible provider versions must not accept new jobs.
|
||||
- Repeated registration for the same provider updates the record instead of creating duplicates.
|
||||
- Capacity values must be non-negative and must not be exceeded by scheduler decisions.
|
||||
|
||||
## Job Requester
|
||||
|
||||
Represents a user action, plugin, or app workflow that asks for job work.
|
||||
|
||||
**Fields**:
|
||||
- `requesterId`: stable requester id.
|
||||
- `pluginId`: plugin id, `core`, or `user`.
|
||||
- `kind`: `user`, `plugin`, `core-workflow`, `background`, or `compatibility`.
|
||||
- `displayLabel`: redaction-safe requester label.
|
||||
- `authorization`: `user-action`, `approved-continuation`, `background`, or `none`.
|
||||
|
||||
**Validation rules**:
|
||||
- Privileged enqueue requires `user-action` or an approved continuation matching the approval scope.
|
||||
- Background requesters may list/inspect but may not start privileged work without approval.
|
||||
|
||||
## Job Approval Scope
|
||||
|
||||
Represents what one explicit user approval covers.
|
||||
|
||||
**Fields**:
|
||||
- `approvalId`: ephemeral approval id.
|
||||
- `providerId`: provider covered by approval.
|
||||
- `jobType`: job type covered by approval.
|
||||
- `targetRef`: redaction-safe target identity.
|
||||
- `requesterId`: requester covered by approval.
|
||||
- `inputFingerprint`: redaction-safe fingerprint for approved inputs.
|
||||
- `allowsRetry`: whether provider-declared retry attempts can reuse approval.
|
||||
- `allowsContinuation`: whether provider-declared continuation attempts can reuse approval.
|
||||
- `createdAt`: approval timestamp.
|
||||
|
||||
**Validation rules**:
|
||||
- Approval cannot widen across provider, job type, target, requester, or inputs.
|
||||
- Retry/continuation use is allowed only when the provider declares it and the scope still matches.
|
||||
|
||||
## Selected Job Provider
|
||||
|
||||
Represents the user-selected/default provider for a job type when multiple providers are compatible.
|
||||
|
||||
**Fields**:
|
||||
- `jobType`: job type id.
|
||||
- `providerId`: selected/default provider id.
|
||||
- `source`: `user-selected`, `default`, or `request-explicit`.
|
||||
- `updatedAt`: timestamp.
|
||||
|
||||
**Validation rules**:
|
||||
- If exactly one compatible provider exists, explicit selection is not required.
|
||||
- If multiple compatible providers exist, enqueue requires a selected/default/explicit provider or returns `provider-selection-required`.
|
||||
- Selection must not point to unavailable or incompatible providers for new work.
|
||||
|
||||
## Job
|
||||
|
||||
Represents a user-visible unit of long-running work.
|
||||
|
||||
**Fields**:
|
||||
- `jobId`: stable job id.
|
||||
- `jobType`: conversion, import, update, preview, studio, compatibility-backed, or future typed work.
|
||||
- `providerId`: provider handling the job.
|
||||
- `requesterId`: requester that enqueued the job.
|
||||
- `targetRef`: redaction-safe target identity.
|
||||
- `state`: current lifecycle state.
|
||||
- `priority`: `user-approved-interactive` or `background-maintenance`.
|
||||
- `safeLabel`: redaction-safe display label.
|
||||
- `progress`: latest progress snapshot.
|
||||
- `attempts`: ordered job attempt ids.
|
||||
- `retryable`: whether retry is currently allowed.
|
||||
- `actionsAvailable`: currently valid user/requester actions.
|
||||
- `createdAt`, `queuedAt`, `startedAt`, `updatedAt`, `terminalAt`: timestamps.
|
||||
- `terminalOutcome`: terminal outcome when state is terminal.
|
||||
- `safeReason`: bounded reason for current or terminal state.
|
||||
- `bridgeSource`: compatibility bridge source when applicable.
|
||||
|
||||
**Validation rules**:
|
||||
- `jobId` must remain stable across progress updates and supported recovery.
|
||||
- State updates after terminal state are stale unless tied to a newer retry attempt.
|
||||
- Queued jobs can cancel immediately and must not start later.
|
||||
- Running cancellation enters `cancellation-requested` until provider terminal report.
|
||||
|
||||
## Job Attempt
|
||||
|
||||
Represents one run of a job, including retries.
|
||||
|
||||
**Fields**:
|
||||
- `attemptId`: stable attempt id.
|
||||
- `jobId`: parent job id.
|
||||
- `attemptNumber`: 1-based attempt number.
|
||||
- `providerId`: provider running the attempt.
|
||||
- `approvalScopeId`: approval scope used for this attempt.
|
||||
- `state`: attempt state.
|
||||
- `startedAt`, `updatedAt`, `terminalAt`: timestamps.
|
||||
- `terminalOutcome`: terminal attempt outcome.
|
||||
|
||||
**Validation rules**:
|
||||
- Retry attempts must link to the original job.
|
||||
- Retry requires terminal parent state and matching approval scope or new user approval.
|
||||
- Only one active retry attempt may exist for a job at a time.
|
||||
|
||||
## Scheduling Policy
|
||||
|
||||
Represents provider capacity and ordering behavior.
|
||||
|
||||
**Fields**:
|
||||
- `providerId`: provider whose queue is governed.
|
||||
- `maxRunning`: maximum simultaneous running jobs.
|
||||
- `maxQueued`: optional maximum queued jobs.
|
||||
- `priorityOrder`: user-approved interactive before background/maintenance.
|
||||
- `withinPriorityOrder`: FIFO.
|
||||
- `blockedReason`: safe reason when queued work cannot start.
|
||||
|
||||
**Validation rules**:
|
||||
- Running jobs must not exceed `maxRunning`.
|
||||
- Background jobs cannot start ahead of queued user-approved interactive jobs for the same provider capacity.
|
||||
- FIFO order applies inside each priority class unless jobs are cancelled or become invalid.
|
||||
|
||||
## Progress Snapshot
|
||||
|
||||
Represents latest progress for a job.
|
||||
|
||||
**Fields**:
|
||||
- `mode`: `determinate`, `indeterminate`, or `step-only`.
|
||||
- `percent`: number from 0 to 100 when determinate.
|
||||
- `step`: redaction-safe current step id or label.
|
||||
- `message`: bounded redaction-safe message.
|
||||
- `updatedAt`: timestamp.
|
||||
|
||||
**Validation rules**:
|
||||
- Determinate progress must stay in range 0..100.
|
||||
- Decreasing progress is flagged unless provider marks a new step/attempt.
|
||||
- Progress after terminal state is stale unless tied to a newer attempt.
|
||||
|
||||
## Terminal Outcome
|
||||
|
||||
Represents final state and reason.
|
||||
|
||||
**Fields**:
|
||||
- `status`: `completed`, `cancelled`, `failed`, `timeout`, `provider-unavailable`, or `orphaned`.
|
||||
- `category`: invalid-input, permission-denied, provider-unavailable, unsupported-operation, timeout, cancellation, external-dependency, storage, provider-failure, or unknown.
|
||||
- `retryable`: boolean.
|
||||
- `safeReason`: bounded redaction-safe reason.
|
||||
- `resultSummary`: redaction-safe completion summary.
|
||||
|
||||
**Validation rules**:
|
||||
- Terminal outcome must not expose raw artifacts, paths, command lines, tokens, native handles, or provider-private payloads.
|
||||
- Terminal state is final for an attempt; retries create a new attempt.
|
||||
|
||||
## Job Diagnostic Log Entry
|
||||
|
||||
Represents bounded log/progress history.
|
||||
|
||||
**Fields**:
|
||||
- `entryId`: local sequence id.
|
||||
- `jobId`: associated job.
|
||||
- `attemptId`: associated attempt when known.
|
||||
- `kind`: `progress`, `log`, `warning`, `error`, or `event`.
|
||||
- `message`: redacted bounded text.
|
||||
- `timestamp`: timestamp.
|
||||
|
||||
**Validation rules**:
|
||||
- Per-job progress/log history is capped at 50 entries or stricter support snapshot trimming.
|
||||
- Entries must be redacted before export.
|
||||
|
||||
## Compatibility Bridge Hit
|
||||
|
||||
Represents legacy job-like behavior observed during migration.
|
||||
|
||||
**Fields**:
|
||||
- `bridgeId`: stable bridge id.
|
||||
- `providerId`: associated provider when known.
|
||||
- `jobId`: associated job when known.
|
||||
- `legacySurface`: plugin queue, status view, backend route, or compatibility status source.
|
||||
- `operation`: observed operation.
|
||||
- `timestamp`: timestamp.
|
||||
- `safeReason`: bounded reason.
|
||||
|
||||
**Validation rules**:
|
||||
- Bridge hits are diagnostics; they must not become a second user-visible job when a native provider describes the same logical job.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```text
|
||||
queued -> running
|
||||
queued -> cancelled
|
||||
queued -> provider-unavailable
|
||||
queued -> orphaned
|
||||
running -> progress-updated -> running
|
||||
running -> cancellation-requested
|
||||
running -> paused
|
||||
running -> completed
|
||||
running -> failed
|
||||
running -> provider-unavailable
|
||||
running -> orphaned
|
||||
cancellation-requested -> cancelled
|
||||
cancellation-requested -> completed
|
||||
cancellation-requested -> failed
|
||||
paused -> queued
|
||||
paused -> running
|
||||
paused -> cancelled
|
||||
paused -> provider-unavailable
|
||||
paused -> orphaned
|
||||
failed -> retry-started -> queued
|
||||
cancelled -> retry-started -> queued
|
||||
completed -> terminal
|
||||
provider-unavailable -> retry-started -> queued when retryable
|
||||
orphaned -> retry-started -> queued when retryable
|
||||
```
|
||||
|
||||
Terminal attempt states are `completed`, `cancelled`, `failed`, `timeout`, `provider-unavailable`, and `orphaned`. A retry creates a new attempt linked to the original job rather than mutating the prior attempt.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Implementation Plan: Jobs Control Plane
|
||||
|
||||
**Branch**: `009-jobs` | **Date**: 2026-05-31 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/009-jobs/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Promote long-running conversion, import, update, preview, and studio work into a first-class `jobs` capability domain. The implementation adds a privileged provider-coordinator host that owns provider registration, enqueue/list/inspect actions, cancellation, pause/resume, retry, scheduling, reload recovery state, compatibility bridge accounting, and redaction-safe diagnostics. Providers keep ownership of the actual work and private payloads; the jobs domain exposes safe state, outcomes, progress, and failure categories through the existing capability runtime and Capability Inspector.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Vanilla JavaScript in the source-served frontend; Python 3.12/FastAPI only if existing diagnostics, plugin loading, or backend route attribution surfaces are touched
|
||||
**Primary Dependencies**: Existing `window.slopsmith` event bus, `static/capabilities.js` (`capability-pipelines.v1`), browser diagnostics contribution pattern, Capability Inspector, plugin manifest/runtime capability registration, `localStorage` for selected/default provider preference when available
|
||||
**Storage**: In-memory jobs provider registry, selected/default provider preference, active/queued/paused/recent terminal job state, bounded per-job progress/log history, bridge hits, and recent outcomes; browser persistence is limited to user-selected/default provider choices and redaction-safe provider-declared recoverable job references so reload can restore only explicitly recoverable queued/running/paused jobs; no raw provider payloads, non-recoverable active job state, or new database schema in this slice
|
||||
**Testing**: `node --check`; focused Node JS tests under `tests/js/` for jobs domain, scheduling, compatibility bridges, diagnostics redaction/retention, reload recovery, cancellation/retry behavior, and inspector rendering; focused pytest diagnostics/plugin tests only if backend diagnostics, redaction, or plugin loading changes; focused Playwright/browser smoke for console errors after inspector/runtime wiring
|
||||
**Target Platform**: Self-hosted single-user Slopsmith browser app served by Docker or local dev server, with optional desktop/native/plugin providers reporting jobs through the same safe control plane
|
||||
**Project Type**: Vanilla web app with FastAPI backend and plugin runtime
|
||||
**Performance Goals**: `list`/`inspect` and command outcomes return within 1 second in focused validation; determinate progress and state changes appear in diagnostics/inspector within 1 second; scheduler never starts more jobs than provider-declared capacity; diagnostics remain within the existing capability snapshot budget
|
||||
**Constraints**: No frontend framework/build step; no new auth, tenant model, mandatory env var, host path, database, or backend service; privileged enqueue requires explicit user action or approved continuation scope; exported diagnostics must not expose raw local paths, command lines, tokens, raw artifacts, media buffers, recordings, subprocess/native/browser handles, or provider-private payloads
|
||||
**Scale/Scope**: Single local user, multiple providers/plugins in one browser session, one shared jobs domain, provider-declared capacity limits, all active jobs retained, at least five recent terminal jobs retained, and no more than 50 progress/log entries per job unless snapshot budget trims harder
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Result | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Self-Hosted, Single-User, Docker-First | PASS | No multi-user model, auth, mandatory env var, required host path, external service, or deployment dependency is introduced. Jobs coordinate local/provider work only. |
|
||||
| II. Vanilla Frontend - No Frameworks | PASS | Plan uses source-served JavaScript, existing globals, existing capability/event modules, and existing DOM inspector surfaces only. |
|
||||
| III. Plugins Are the Extension Point | PASS | Providers keep ownership of conversion/import/update/preview/studio work. Core coordinates the shared jobs control plane and compatibility accounting. |
|
||||
| IV. Backwards-Compatible CDLC Library | PASS | The feature does not alter sloppak formats, DLC scan behavior, arrangement ids, or highway WebSocket payloads. Jobs that mutate files require explicit user approval. |
|
||||
| V. Pure-Function Core Libraries, Tested | PASS | No new Python library architecture is required. Any backend helper changes, if needed, must remain side-effect-light and covered by focused pytest. |
|
||||
| VI. Observability Over Chattiness | PASS | The slice improves observability through bounded job state, outcomes, bridge hits, progress, and redaction-safe diagnostics without raw payloads. |
|
||||
| VII. Versioned, Migration-Aware Settings | PASS | No settings import/export schema change is required. Optional selected/default provider preference is local and migration-safe. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/009-jobs/
|
||||
|-- plan.md
|
||||
|-- research.md
|
||||
|-- data-model.md
|
||||
|-- quickstart.md
|
||||
|-- checklists/
|
||||
| `-- requirements.md
|
||||
|-- contracts/
|
||||
| |-- jobs-control-plane.md
|
||||
| |-- diagnostics-schema.md
|
||||
| |-- migration-notes.md
|
||||
| `-- testing-contract.md
|
||||
`-- tasks.md # Created later by /speckit-tasks
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
static/
|
||||
|-- capabilities.js # Existing dispatch/outcome/diagnostics primitives; promote jobs active metadata/outcomes as needed
|
||||
|-- capabilities/
|
||||
| `-- jobs.js # New jobs provider-coordinator host, state, scheduler, diagnostics, bridge hits
|
||||
`-- index.html # Load jobs capability after capability runtime
|
||||
|
||||
plugins/
|
||||
`-- capability_inspector/screen.js # Surface providers, active/queued/paused/recent jobs, progress, actions, outcomes, bridges
|
||||
|
||||
docs/
|
||||
|-- capability-domains.md # Jobs commands/events/provider migration guidance
|
||||
|-- capability-recipes.md # Provider/requester recipe for jobs
|
||||
|-- capability-roadmap.md # 009 migration status and bridge removal gates
|
||||
`-- capability-safety-matrix.md # Jobs active privileged provider-coordinator row
|
||||
|
||||
tests/
|
||||
|-- js/
|
||||
| |-- jobs_domain.test.js
|
||||
| |-- jobs_scheduling.test.js
|
||||
| |-- jobs_diagnostics.test.js
|
||||
| |-- jobs_compat.test.js
|
||||
| |-- jobs_test_harness.js
|
||||
| `-- capability_inspector_render.test.js
|
||||
`-- browser/
|
||||
`-- check-errors.spec.ts # Focused smoke if visible inspector/runtime wiring changes
|
||||
```
|
||||
|
||||
**Structure Decision**: Add `static/capabilities/jobs.js` as the domain owner/coordinator and keep actual long-running work inside providers. The jobs host stores safe state, enforces approval/scheduling rules, normalizes provider updates, emits lifecycle events, records bridges, contributes diagnostics, and feeds the Capability Inspector. Backend routes, media import/export semantics, plugin install/update policy, external-service trust, recording, and audio-effects processing remain out of scope except for safe job summaries.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitutional violations are introduced. No complexity exceptions are required.
|
||||
|
||||
## Phase 0: Research Summary
|
||||
|
||||
See [research.md](research.md). Key decisions:
|
||||
|
||||
- Implement `jobs` as a privileged provider-coordinator capability domain.
|
||||
- Keep actual work provider-owned and expose only redaction-safe job state.
|
||||
- Require explicit user approval for privileged enqueue scope.
|
||||
- Auto-select only when exactly one compatible provider exists; otherwise use selected/default provider or return `provider-selection-required`.
|
||||
- Schedule user-approved interactive jobs before background/maintenance jobs, FIFO within each priority and provider capacity.
|
||||
- Treat cancellation as requested until the provider reports a terminal state.
|
||||
- Restore only jobs with provider-declared recovery support after reload.
|
||||
- Bound diagnostics to all active jobs, at least five recent terminal jobs, and capped per-job history.
|
||||
- Use compatibility bridge hits for legacy queues and job-like route flows.
|
||||
|
||||
## Phase 1: Design Summary
|
||||
|
||||
Design artifacts created:
|
||||
|
||||
- [data-model.md](data-model.md) defines job providers, requesters, approval scopes, selected providers, jobs, attempts, scheduling policies, progress snapshots, terminal outcomes, diagnostic logs, bridge hits, validation rules, and state transitions.
|
||||
- [contracts/jobs-control-plane.md](contracts/jobs-control-plane.md) defines the jobs domain commands, provider metadata, provider operations, lifecycle events, approval rules, provider selection, scheduling, cancellation, pause/resume, retry, and bridge accounting.
|
||||
- [contracts/diagnostics-schema.md](contracts/diagnostics-schema.md) defines exported/local diagnostics payloads, retention limits, redaction rules, provider summaries, job summaries, outcomes, and bridge hits.
|
||||
- [contracts/migration-notes.md](contracts/migration-notes.md) defines native provider migration, compatibility bridges, provider adoption examples, removal gates, and out-of-scope privileged domains.
|
||||
- [contracts/testing-contract.md](contracts/testing-contract.md) defines validation scenarios for providers, enqueue, scheduling, progress, terminal states, cancel/pause/resume/retry, reload recovery, diagnostics, compatibility, and inspector rendering.
|
||||
- [quickstart.md](quickstart.md) defines manual validation flows and representative commands.
|
||||
|
||||
## Post-Design Constitution Check
|
||||
|
||||
| Principle | Result | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Self-Hosted, Single-User, Docker-First | PASS | Design remains local/single-user and adds no deployment input. |
|
||||
| II. Vanilla Frontend - No Frameworks | PASS | Runtime and inspector changes stay in source-served JS and existing DOM/CSS. |
|
||||
| III. Plugins Are the Extension Point | PASS | Plugins/providers own real work; core owns coordination and diagnostics. |
|
||||
| IV. Backwards-Compatible CDLC Library | PASS | Song formats, DLC files, and existing playback/library contracts remain stable. File-mutating jobs require user approval. |
|
||||
| V. Pure-Function Core Libraries, Tested | PASS | No Python core library change is required by the design; any touched helpers remain focused and testable. |
|
||||
| VI. Observability Over Chattiness | PASS | Diagnostics distinguish provider, job type, state, progress, retryability, failure category, bridges, and outcomes without raw privileged data. |
|
||||
| VII. Versioned, Migration-Aware Settings | PASS | No settings schema change; optional selected/default provider preference can be stored as normal client preference and is not a backup schema. |
|
||||
@@ -0,0 +1,128 @@
|
||||
# Quickstart: Jobs Control Plane
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this quickstart to validate the jobs control-plane slice during implementation. The feature promotes `jobs` into an active privileged provider-coordinator domain for long-running conversion, import, update, preview, and studio work.
|
||||
|
||||
## Expected Files
|
||||
|
||||
Planned implementation surface:
|
||||
|
||||
- `static/capabilities/jobs.js` - jobs domain owner, provider registry, scheduling, state, diagnostics, bridge hits.
|
||||
- `static/capabilities.js` - promote `jobs` from reserved to active domain metadata and add any domain outcomes not already supported.
|
||||
- `static/index.html` - load the jobs capability host after the capability runtime.
|
||||
- `plugins/capability_inspector/screen.js` - render jobs providers, active jobs, recent terminal jobs, progress, outcomes, and bridge hits.
|
||||
- `docs/capability-domains.md` - document jobs commands/events/provider migration.
|
||||
- `docs/capability-roadmap.md` - mark jobs slice status and removal gates.
|
||||
- `docs/capability-safety-matrix.md` - mark jobs as active privileged provider-coordinator.
|
||||
- `docs/capability-recipes.md` - add provider/requester recipe.
|
||||
- `tests/js/jobs_*.test.js` - domain, scheduling, diagnostics, compatibility, and inspector tests.
|
||||
|
||||
## Manual Validation Scenarios
|
||||
|
||||
### 1. Provider Registration
|
||||
|
||||
1. Register a fake jobs provider with one job type and capacity 1.
|
||||
2. Inspect jobs providers.
|
||||
3. Confirm one provider appears with safe label, actions, availability, capacity, and current load.
|
||||
4. Re-register the same provider repeatedly.
|
||||
5. Confirm it updates in place and does not duplicate.
|
||||
|
||||
### 2. User-Approved Enqueue
|
||||
|
||||
1. Dispatch a privileged enqueue with `authorization: user-action`.
|
||||
2. Confirm the job enters queued or running state.
|
||||
3. Send progress updates.
|
||||
4. Confirm `list` and `inspect` show current state, step, progress, requester, provider, and action availability.
|
||||
5. Complete the job.
|
||||
6. Confirm the job becomes terminal and remains in recent terminal diagnostics.
|
||||
|
||||
### 3. Approval Boundary
|
||||
|
||||
1. Dispatch a privileged enqueue from a background requester with no user action.
|
||||
2. Confirm the command returns denied or user-action-required.
|
||||
3. Confirm no provider work starts.
|
||||
4. Retry a failed job with the same provider, job type, target, requester, and inputs when provider declared retry support.
|
||||
5. Confirm retry is accepted as approved continuation.
|
||||
6. Change provider, job type, target, requester, or inputs and confirm new approval is required.
|
||||
|
||||
### 4. Provider Selection
|
||||
|
||||
1. Register exactly one compatible provider for a job type.
|
||||
2. Enqueue without explicit provider and confirm it proceeds.
|
||||
3. Register a second compatible provider.
|
||||
4. Enqueue without selected/default provider and confirm provider-selection-required.
|
||||
5. Select/default one provider and confirm enqueue proceeds through that provider.
|
||||
|
||||
### 5. Scheduling
|
||||
|
||||
1. Set provider capacity to one running job.
|
||||
2. Enqueue a background/maintenance job.
|
||||
3. Enqueue a user-approved interactive job before capacity opens.
|
||||
4. Complete the running job or release capacity.
|
||||
5. Confirm user-approved interactive job starts before background/maintenance work.
|
||||
6. Confirm FIFO order within each priority.
|
||||
|
||||
### 6. Cancellation / Pause / Resume / Retry
|
||||
|
||||
1. Cancel a queued job and confirm it never starts.
|
||||
2. Cancel a running job and confirm cancellation-requested appears before provider terminal result.
|
||||
3. Pause a supported running job and confirm same job identity enters paused.
|
||||
4. Resume it and confirm it returns to queued/running.
|
||||
5. Retry a retryable failed job and confirm a linked attempt is created.
|
||||
|
||||
### 7. Reload Recovery
|
||||
|
||||
1. Simulate reload with a provider-recoverable queued/running/paused job.
|
||||
2. Confirm it restores according to provider recovery metadata.
|
||||
3. Simulate reload with a non-recoverable non-terminal job.
|
||||
4. Confirm it becomes orphaned or provider-unavailable with safe reason.
|
||||
|
||||
### 8. Diagnostics Redaction
|
||||
|
||||
1. Force provider logs and reasons that include local paths, raw filenames, command lines, tokens, URLs, and raw artifact-like strings.
|
||||
2. Export or inspect diagnostics.
|
||||
3. Confirm exported jobs diagnostics preserve all active jobs, at least five recent terminal jobs, and no more than 50 progress/log entries per job.
|
||||
4. Confirm sensitive data is redacted and no handles/artifacts/private payloads are present.
|
||||
|
||||
### 9. Compatibility Bridge Hits
|
||||
|
||||
1. Exercise a legacy plugin queue or status route while the jobs domain is active.
|
||||
2. Record a bridge hit.
|
||||
3. Confirm diagnostics show bridge id, plugin, operation, and safe reason.
|
||||
4. Confirm native and compatibility-backed representations of the same logical job do not duplicate user-visible jobs.
|
||||
|
||||
## Suggested Validation Commands
|
||||
|
||||
```bash
|
||||
node --check static/capabilities.js
|
||||
node --check static/capabilities/jobs.js
|
||||
node --check plugins/capability_inspector/screen.js
|
||||
npm run test:js
|
||||
```
|
||||
|
||||
If backend diagnostics, plugin loading, or redaction helpers are touched:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_diagnostics_bundle.py tests/test_diagnostics_redact.py tests/test_plugins.py tests/test_plugin_runtime_idempotence.py -q
|
||||
```
|
||||
|
||||
For a browser smoke after UI wiring:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=lib:. uv run uvicorn server:app --host 127.0.0.1 --port 8000
|
||||
npm test -- tests/browser/check-errors.spec.ts
|
||||
```
|
||||
|
||||
## Out Of Scope Checks
|
||||
|
||||
During review, confirm this slice does not implement or redefine:
|
||||
|
||||
- media import/export file semantics
|
||||
- plugin install/update trust and rollback policy
|
||||
- external service trust policy
|
||||
- recording capture or take storage
|
||||
- audio-effects processing, model loading, or IR inventory
|
||||
- backend route privilege review
|
||||
|
||||
Those workflows may submit/report jobs, but their own trust and data contracts belong to later specs.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Research: Jobs Control Plane
|
||||
|
||||
## Decision: Implement `jobs` as a privileged provider-coordinator capability domain
|
||||
|
||||
**Rationale**: Long-running work is owned by different plugins and core-adjacent workflows, but users need one place to inspect state, progress, cancellation, retryability, and terminal failures. A provider-coordinator matches the existing `library`, `audio-input`, and `audio-monitoring` patterns: core owns public command normalization, provider registration, scheduling, diagnostics, and bridge accounting while providers own the actual conversion, import, update, preview, or studio work.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Exclusive core owner for all job execution: rejected because plugins own the real work and should not move conversion/update/studio internals into core.
|
||||
- Leave plugin-specific queues in place only: rejected because support cannot reason about progress, cancellation, or failures across providers.
|
||||
- Backend-only job registry: rejected for this slice because the existing capability runtime and inspector need browser-visible command and diagnostics state; backend routes remain a later privileged domain.
|
||||
|
||||
## Decision: Keep actual work provider-owned and expose only redaction-safe job state
|
||||
|
||||
**Rationale**: Jobs can touch local files, subprocesses, external services, downloads, native bridges, and plugin-private state. The jobs domain should record identity, state, progress, retryability, failure category, and safe summaries, but must not expose raw artifacts, command lines, paths, native handles, or provider-private payloads.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Store provider payloads in the jobs domain for retries: rejected because it risks leaking sensitive paths, tokens, or raw artifacts and duplicates provider ownership.
|
||||
- Expose only textual status: rejected because commands and diagnostics need structured state for scheduling, cancellation, retry, and tests.
|
||||
|
||||
## Decision: Require explicit user approval for privileged enqueue scope
|
||||
|
||||
**Rationale**: Jobs may create, modify, delete, download, export, convert, or publish user-visible files or plugin state. The clarified approval scope is one job request plus provider-declared retries or continuations for the same provider, job type, target, requester, and inputs. This prevents broad background permission while keeping multi-step approved workflows usable.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Session-wide requester approval: rejected because one visible click could authorize unrelated background work.
|
||||
- New approval for every retry/continuation: rejected because it would make recoverable provider-declared workflows unnecessarily noisy.
|
||||
|
||||
## Decision: Use selected/default provider only when multiple compatible providers exist
|
||||
|
||||
**Rationale**: If exactly one provider can handle a job type, enqueue can proceed without extra selection. If multiple compatible providers exist, a user-selected/default provider or explicit provider is required; otherwise the command returns `provider-selection-required`. This avoids hidden arbitrary choices for privileged work.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Always choose by registration order: rejected because registration order is not a user trust signal.
|
||||
- Require provider on every request: rejected because single-provider cases should remain simple for users and plugin authors.
|
||||
|
||||
## Decision: Schedule user-approved interactive jobs before background/maintenance jobs
|
||||
|
||||
**Rationale**: Slopsmith is a single-user app. Work the user just approved should not wait behind background maintenance when both compete for the same provider capacity. FIFO within each priority keeps ordering understandable and testable while provider capacity remains authoritative.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Strict FIFO for all jobs: rejected because background work could delay visible user actions.
|
||||
- Provider-defined ordering only: rejected because cross-provider diagnostics and user expectations would vary unpredictably.
|
||||
|
||||
## Decision: Treat cancellation as requested until the provider reports a terminal state
|
||||
|
||||
**Rationale**: Running work may not stop instantly, especially when subprocesses or external services are involved. A distinct `cancellation-requested` state lets users see that the request was accepted without falsely reporting the job as already cancelled.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Mark running jobs cancelled immediately: rejected because underlying work may still produce completion or failure.
|
||||
- Reject cancellation unless providers can stop instantly: rejected because delayed cancellation is still valuable and common.
|
||||
|
||||
## Decision: Restore only jobs with provider-declared recovery support after reload
|
||||
|
||||
**Rationale**: A page reload or provider rehydration cannot prove that privileged work is still running unless the provider gives a recovery handle or equivalent safe metadata. Jobs without recovery support become orphaned or provider-unavailable with safe reasons.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Resume or rediscover every non-terminal job: rejected because it can invent state and accidentally continue privileged work.
|
||||
- Mark all non-terminal jobs failed after reload: rejected because recoverable providers should be able to restore active or queued work.
|
||||
|
||||
## Decision: Bound diagnostics to active jobs, recent terminal jobs, and capped per-job history
|
||||
|
||||
**Rationale**: Diagnostics are not a log archive. Preserving all active jobs, at least five recent terminal jobs, and at most 50 progress/log entries per job gives support useful context while respecting the existing support snapshot budget.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Preserve the whole session: rejected because long-running jobs can generate excessive progress and logs.
|
||||
- Preserve only active jobs: rejected because most support issues need recent terminal failure context.
|
||||
|
||||
## Decision: Use compatibility bridge hits for legacy queues and job-like route flows
|
||||
|
||||
**Rationale**: Existing plugin-specific queues and status screens must continue during migration. Bridge hits let support and removal gates identify legacy usage without forcing immediate rewrites.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Disable legacy queues when the domain appears: rejected because it would break current plugins.
|
||||
- Ignore legacy usage: rejected because migrations need evidence before removing wrappers or duplicate status surfaces.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Feature Specification: Jobs Control Plane
|
||||
|
||||
**Feature Branch**: `009-jobs`
|
||||
**Created**: 2026-05-31
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Continue with the next slice: jobs"
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-05-31
|
||||
|
||||
- Q: How should queued, running, or paused jobs behave after app reload? → A: Restore only jobs with provider-declared recovery support; mark others orphaned or provider-unavailable with a safe reason.
|
||||
- Q: How should jobs choose a provider when multiple compatible providers can handle the same job type? → A: Auto-select only one compatible provider; otherwise require a selected/default provider and return provider-selection-required.
|
||||
- Q: What scope should explicit user approval cover for privileged jobs and continuations? → A: One job request plus provider-declared retry/continuation attempts for the same provider, job type, target, and requester.
|
||||
- Q: How should queued jobs be ordered when user-approved and background jobs compete for provider capacity? → A: Run user-approved interactive jobs before background or maintenance jobs; use FIFO within each priority and provider capacity.
|
||||
- Q: How much job history and log detail should diagnostics retain? → A: Preserve all active jobs, at least 5 recent terminal jobs, and cap per-job logs/progress at 50 entries or the support snapshot budget.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Track Long-Running Work (Priority: P1)
|
||||
|
||||
A user can see conversion, import, update, preview, and studio work as jobs with clear queued, running, completed, cancelled, failed, or unavailable state instead of hunting through plugin-specific screens or vague progress messages.
|
||||
|
||||
**Why this priority**: The jobs slice is only useful if users and support tooling can answer the basic questions: what is running, who started it, how far along it is, and whether it finished successfully.
|
||||
|
||||
**Independent Test**: Can be tested by registering a representative job provider, enqueueing a long-running job, reporting progress, completing it, and confirming the job list, job inspection view, events, and diagnostics describe the same state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an available job provider, **When** a user starts a supported long-running task, **Then** the system creates a job with provider attribution, requester attribution, job type, status, progress summary, and safe display label.
|
||||
2. **Given** a job is queued or running, **When** the user inspects jobs, **Then** the job appears with current state, step summary, elapsed time, and next expected transition when known.
|
||||
3. **Given** a job reports determinate progress, **When** progress changes, **Then** the system records the latest percentage and step summary without duplicating the job.
|
||||
4. **Given** a job reports indeterminate progress, **When** progress changes, **Then** the system shows that work is active without inventing an inaccurate percentage.
|
||||
5. **Given** a job completes successfully, **When** the user inspects jobs, **Then** the job shows a terminal completed state, completion time, safe result summary, and no active work indicators.
|
||||
6. **Given** no provider can handle a requested job type, **When** a user attempts to enqueue it, **Then** the system returns a clear unavailable or no-owner outcome without creating an orphan running job.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Cancel, Pause, Resume, And Retry Jobs (Priority: P2)
|
||||
|
||||
A user can stop unwanted work, temporarily pause resumable work, resume paused work, and retry failed or cancelled work when the provider supports those actions.
|
||||
|
||||
**Why this priority**: Long-running privileged work needs user control. Without predictable cancellation and retry behavior, conversion or update failures leave users unsure whether data is still changing or whether they need to start over.
|
||||
|
||||
**Independent Test**: Can be tested by running jobs that support cancellation, pause, resume, and retry, then forcing unsupported or late requests and verifying each request reaches an unambiguous outcome.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a queued job, **When** the user cancels it before it starts, **Then** the job reaches cancelled state and the provider does not start the underlying work.
|
||||
2. **Given** a running cancellable job, **When** the user cancels it, **Then** the system records cancellation requested and later records cancelled, failed, or completed according to the provider's final report.
|
||||
3. **Given** a running job that supports pause, **When** the user pauses it, **Then** the job enters paused state and preserves enough state for the provider to resume it.
|
||||
4. **Given** a paused job, **When** the user resumes it, **Then** the job returns to queued or running state with the same job identity.
|
||||
5. **Given** a failed or cancelled retryable job, **When** the user retries it, **Then** the system creates a retry attempt linked to the prior job and shows both the current attempt and previous terminal outcome.
|
||||
6. **Given** a provider does not support pause, resume, cancel, or retry for a job, **When** that action is requested, **Then** the system reports unsupported without changing the job to an inaccurate state.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Coordinate Job Providers And Scheduling (Priority: P3)
|
||||
|
||||
A plugin author can register job capabilities through one shared jobs contract so conversion, import, update, preview, and studio providers can coexist without duplicate queues or conflicting capacity rules.
|
||||
|
||||
**Why this priority**: Several plugins perform long-running work today. A shared provider model prevents each plugin from inventing its own queue, progress, cancellation, and failure vocabulary.
|
||||
|
||||
**Independent Test**: Can be tested by registering multiple providers with different job types and concurrency limits, enqueueing compatible and incompatible jobs, and confirming scheduling, provider selection, duplicate suppression, and lifecycle events are deterministic.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a provider registers supported job types and capacity, **When** jobs are inspected, **Then** the provider appears with availability, supported actions, current load, and safe status.
|
||||
2. **Given** multiple providers support the same job type, **When** no user-selected or default provider exists, **Then** the system returns provider-selection-required instead of choosing arbitrarily.
|
||||
3. **Given** a provider declares limited concurrent capacity, **When** additional jobs are enqueued, **Then** user-approved interactive jobs run before background or maintenance jobs, and excess jobs remain queued FIFO within each priority with a clear reason until capacity is available.
|
||||
4. **Given** a provider rehydrates or registers repeatedly, **When** jobs are inspected, **Then** the provider and its active jobs are not duplicated.
|
||||
5. **Given** a provider disappears while jobs are queued or running, **When** jobs are inspected, **Then** affected jobs become provider-unavailable, orphaned, failed, or waiting with a safe reason rather than remaining silently active.
|
||||
6. **Given** a requester submits malformed, incompatible, or unsupported job parameters, **When** enqueue is attempted, **Then** the system rejects the request with a distinct outcome and no running work begins.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Explain Privileged Job Failures Safely (Priority: P4)
|
||||
|
||||
A user or maintainer troubleshooting long-running work can tell whether a job failed because of provider unavailability, invalid input, missing permission, cancellation, timeout, external dependency failure, storage limits, unsupported operation, or a provider error without exposing private paths, secrets, or raw job artifacts.
|
||||
|
||||
**Why this priority**: Jobs often touch files, downloads, subprocesses, conversion tools, and plugin-owned state. Troubleshooting needs precise outcomes, but support bundles must not leak local libraries or privileged payloads.
|
||||
|
||||
**Independent Test**: Can be tested by forcing each terminal and failure outcome, exporting diagnostics, and confirming the job history identifies provider, job type, state, operation, bounded safe reason, and redacted artifacts.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a job fails, **When** diagnostics are inspected, **Then** the failure includes provider attribution, job type, operation, terminal outcome, safe reason, and retryability without raw paths or secrets.
|
||||
2. **Given** a job produces logs, **When** diagnostics are exported, **Then** logs are bounded, redacted, and tied to job identity without exposing raw command lines, local paths, tokens, or unreviewed artifacts.
|
||||
3. **Given** a job uses external services or subprocess-backed work, **When** the job changes state, **Then** the system records safe dependency status and user-visible failure category without leaking privileged invocation details.
|
||||
4. **Given** several completed or failed jobs exist, **When** diagnostics are exported, **Then** all active jobs and at least the five most recent terminal jobs are preserved, while per-job logs and progress entries are capped at 50 entries or the support snapshot budget.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A job provider is unavailable, disabled, not hydrated, or incompatible when a job is enqueued.
|
||||
- A provider supports status inspection but not cancellation, pause, resume, or retry.
|
||||
- A user cancels a job at the same time it completes or fails.
|
||||
- A cancellation request is accepted but the provider cannot stop immediately.
|
||||
- A queued job is cancelled before it starts.
|
||||
- User-approved interactive jobs and background or maintenance jobs compete for the same provider capacity.
|
||||
- A running job becomes orphaned because its provider disappears, reloads, or changes contract version.
|
||||
- Multiple providers support the same job type and no default or explicit provider is available.
|
||||
- A provider registers the same logical job or provider identity repeatedly during script hydration.
|
||||
- A provider reports progress out of order, decreases determinate progress unexpectedly, reports progress after a terminal state, or reports completion without a prior running event.
|
||||
- A job reports indeterminate progress or unknown total work.
|
||||
- A job has child steps, attempts, or retries that must stay linked to the user-visible parent job.
|
||||
- A retry is requested for a non-retryable job or while another retry attempt is already active.
|
||||
- A pause or resume request is made after the job is already terminal.
|
||||
- A job request would overwrite, delete, download, convert, or export user files without explicit user approval for the same provider, job type, target, and requester.
|
||||
- A job depends on unavailable storage, external services, subprocess tools, native bridges, or plugin-owned resources.
|
||||
- A provider returns sensitive labels, local paths, raw command lines, secret-bearing URLs, tokens, raw artifacts, or overly large logs.
|
||||
- Diagnostics are requested while jobs are active, while providers are hydrating, or after many terminal jobs have accumulated.
|
||||
- A user reloads the app while jobs are queued, running, paused, or terminal; only jobs with provider-declared recovery support are restored, while other non-terminal jobs become orphaned or provider-unavailable with a safe reason.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide an authoritative jobs control plane for registering job providers, enqueueing jobs, listing jobs, inspecting a job, cancelling jobs, pausing jobs, resuming jobs, and retrying jobs where supported.
|
||||
- **FR-002**: System MUST represent each job provider with provider attribution, supported job types, supported actions, availability, capacity summary, current load, and bounded safe reason when unavailable or degraded.
|
||||
- **FR-003**: System MUST represent each job with stable job identity, provider attribution, requester attribution, job type, priority or scheduling class, current state, progress summary, timestamps, safe display label, and terminal outcome when applicable.
|
||||
- **FR-004**: System MUST support job states including queued, running, paused, cancellation-requested, cancelled, completed, failed, provider-unavailable, and orphaned.
|
||||
- **FR-005**: System MUST expose explicit outcomes including 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.
|
||||
- **FR-006**: System MUST allow users and authorized requesters to inspect current jobs and providers without triggering privileged work, file writes, downloads, subprocesses, or external-service calls.
|
||||
- **FR-007**: System MUST require explicit user action before enqueueing any job that can create, modify, delete, download, export, convert, or publish user-visible files or plugin state; approval applies only to one job request and provider-declared retry or continuation attempts for the same provider, job type, target, requester, and inputs.
|
||||
- **FR-008**: System MUST reject background or plugin-initiated privileged jobs without user approval using a distinct denied or user-action-required outcome, including approved-continuation attempts whose inputs do not match the original approved scope.
|
||||
- **FR-009**: System MUST apply provider-declared scheduling limits so queued jobs do not exceed provider capacity and running jobs do not silently overrun declared concurrency; user-approved interactive jobs MUST run before background or maintenance jobs, with FIFO ordering within each priority.
|
||||
- **FR-010**: System MUST make provider registration idempotent so repeated hydration updates provider state without duplicate providers or duplicated active jobs.
|
||||
- **FR-011**: System MUST auto-select only when exactly one compatible provider can handle the job type; when multiple compatible providers exist, the system MUST use a user-selected/default provider or return provider-selection-required.
|
||||
- **FR-012**: System MUST validate job requests before they begin and report validation failures without starting provider work.
|
||||
- **FR-013**: System MUST emit observable lifecycle events when jobs are queued, started, progress, log, pause, resume, cancellation-requested, cancelled, completed, failed, retried, orphaned, or provider-unavailable.
|
||||
- **FR-014**: System MUST allow determinate progress, indeterminate progress, current-step summaries, and bounded safe messages without requiring providers to expose raw job internals.
|
||||
- **FR-015**: System MUST ignore or flag stale provider updates that arrive after a job reaches a terminal state or after a newer retry attempt supersedes the prior attempt.
|
||||
- **FR-016**: System MUST treat cancellation as a request for running work and MUST preserve a distinct cancellation-requested state until the provider reports the terminal result.
|
||||
- **FR-017**: System MUST cancel queued jobs immediately when requested and prevent them from starting later.
|
||||
- **FR-018**: System MUST report unsupported pause, resume, cancel, or retry actions without changing job state inaccurately.
|
||||
- **FR-019**: System MUST support retry only for terminal jobs that the provider marks retryable or that the system can safely re-enqueue with the same approved provider, job type, target, requester, and inputs.
|
||||
- **FR-020**: System MUST link retry attempts to their original job so users and diagnostics can see attempt history and current active attempt.
|
||||
- **FR-021**: System MUST handle provider disappearance, disablement, timeout, or incompatible version by marking affected jobs provider-unavailable, orphaned, failed, or waiting with a safe reason.
|
||||
- **FR-022**: System MUST distinguish invalid input, permission denied, provider unavailable, unsupported operation, timeout, cancellation, external dependency failure, storage failure, and provider failure in job outcomes.
|
||||
- **FR-023**: System MUST preserve existing plugin-specific job queues and status screens during migration by recording compatibility bridge hits where feasible.
|
||||
- **FR-024**: System MUST prevent duplicate user-visible jobs when native job providers and compatibility-backed legacy surfaces describe the same logical job.
|
||||
- **FR-025**: System MUST include providers, active jobs, queued jobs, paused jobs, recent terminal jobs, retry attempts, bridge hits, recent outcomes, and bounded safe logs in diagnostics.
|
||||
- **FR-026**: System MUST redact or pseudonymize local paths, raw filenames when sensitive, secret-bearing URLs, tokens, command lines, environment values, raw artifacts, plugin-private payloads, and user library details in support surfaces and diagnostics.
|
||||
- **FR-027**: System MUST NOT expose raw media files, converted artifacts, audio buffers, recordings, downloaded payloads, subprocess handles, native handles, or plugin-private objects through job state or diagnostics.
|
||||
- **FR-028**: System MUST bound retained job history, progress messages, and logs so support snapshots remain small while preserving all active jobs, at least the five most recent terminal jobs, and no more than 50 progress or log entries per job unless the support snapshot budget requires stricter trimming.
|
||||
- **FR-029**: System MUST give support tooling enough safe metadata to distinguish conversion, import, update, preview, studio, and compatibility-backed job paths.
|
||||
- **FR-030**: System MUST document the migration path for job provider and requester plugins, including provider registration, enqueue behavior, progress reporting, cancellation, retry, diagnostics, legacy bridge behavior, and removal gates.
|
||||
- **FR-031**: System MUST leave media import/export semantics, plugin installation policy, external-service trust policy, actual conversion algorithms, recording capture, audio-effects processing, and backend route privilege review outside this feature except for the job state and outcome summaries they report.
|
||||
- **FR-032**: System MUST keep app reload behavior explicit: queued, running, and paused jobs MUST be restored only when the provider declares recovery support for that job; all other non-terminal jobs MUST be marked orphaned or provider-unavailable with a safe reason rather than silently assumed running.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Job Provider**: A participant that can accept one or more long-running job types and report availability, capacity, supported actions, progress, and terminal outcomes.
|
||||
- **Job Requester**: A user action, plugin, or app workflow that asks for long-running work and is attributed in job state and outcomes.
|
||||
- **Job Approval Scope**: The exact provider, job type, target, requester, and inputs covered by one explicit user approval, including provider-declared retry or continuation attempts that do not widen that scope.
|
||||
- **Job**: A user-visible unit of long-running work with identity, type, provider, requester, state, progress, timestamps, safe label, scheduling priority, and terminal outcome.
|
||||
- **Job Type**: A category of work such as conversion, import, update, preview generation, studio processing, or compatibility-backed work.
|
||||
- **Selected Job Provider**: The user-selected or default provider used when multiple compatible providers can handle the same job type.
|
||||
- **Scheduling Policy**: Provider-declared and coordinator-enforced limits for queued and running work, including capacity, user-approved-interactive-before-background priority, FIFO ordering within each priority, and provider selection behavior.
|
||||
- **Progress Snapshot**: The latest safe summary of determinate or indeterminate progress, current step, bounded message, and time information.
|
||||
- **Job Action**: A user or requester operation such as enqueue, cancel, pause, resume, retry, list, or inspect.
|
||||
- **Job Attempt**: A specific run of a job, including retry attempts linked to an original job.
|
||||
- **Job Recovery Support**: Provider-declared metadata describing whether a queued, running, or paused job can be restored after app reload and what safe state should be shown if it cannot.
|
||||
- **Terminal Outcome**: The final state and safe reason for completed, cancelled, failed, unavailable, timeout, or orphaned work.
|
||||
- **Job Diagnostic Log Entry**: A bounded and redacted status or log message associated with a provider, job, attempt, and step, capped with progress history at 50 entries per job or the support snapshot budget.
|
||||
- **Compatibility Bridge Hit**: A record that a legacy plugin queue, status surface, or job-like backend route was used during migration.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of registered job providers appear with provider attribution, supported job types, availability, capacity, and redaction-safe status in jobs diagnostics.
|
||||
- **SC-002**: 100% of queued, running, paused, cancellation-requested, cancelled, completed, failed, provider-unavailable, and orphaned states are distinguishable in focused validation scenarios.
|
||||
- **SC-003**: 100% of the canonical job outcomes listed in FR-005 produce distinct results in focused validation scenarios.
|
||||
- **SC-004**: A user can determine what job is running, who owns it, what step it is on, and whether it can be cancelled or retried in one inspection step in 100% of tested cases.
|
||||
- **SC-005**: Determinate progress updates are reflected in the job view and diagnostics within 1 second in focused validation scenarios.
|
||||
- **SC-006**: Queued jobs cancelled before start never begin provider work in 100% of focused validation scenarios.
|
||||
- **SC-007**: Running cancellable jobs enter cancellation-requested within 1 second and reach a terminal state reported by the provider in 100% of focused validation scenarios.
|
||||
- **SC-008**: Provider concurrency limits are honored in 100% of scheduling validation scenarios; user-approved interactive jobs run before background or maintenance jobs, and excess jobs remain queued FIFO within each priority until capacity is available.
|
||||
- **SC-009**: Rehydrating a provider five times in one session creates one provider record and no duplicate active jobs for each logical provider/job pair.
|
||||
- **SC-010**: Native and compatibility-backed representations of the same logical job never create duplicate user-visible jobs in representative migration scenarios.
|
||||
- **SC-011**: 100% of exported job diagnostics contain zero unredacted local paths, secret-bearing URLs, tokens, raw command lines, environment values, raw artifacts, media buffers, recordings, subprocess handles, native handles, or plugin-private objects.
|
||||
- **SC-012**: Support maintainers can identify provider, job type, current state, last safe progress, retryability, and failure category for a representative failed job in under 5 minutes using diagnostics or the inspector.
|
||||
- **SC-013**: Privileged job enqueue requests without explicit user approval are denied before work begins in 100% of focused validation scenarios.
|
||||
- **SC-014**: After app reload, queued/running/paused jobs are restored, resumed, orphaned, or marked unavailable according to provider-declared recovery support in 100% of focused validation scenarios.
|
||||
- **SC-015**: Retained job diagnostics stay within the support snapshot budget while preserving all active jobs, at least the five most recent terminal jobs, and no more than 50 progress or log entries per job in focused validation scenarios.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Slopsmith remains a self-hosted, single-user app, but multiple plugins or workflows may submit job requests during one session.
|
||||
- The jobs control plane coordinates state, scheduling, progress, actions, and diagnostics; providers still own the actual conversion, import, update, preview, or studio work.
|
||||
- Jobs are privileged because they may touch files, downloads, subprocesses, external services, native bridges, or plugin-owned state.
|
||||
- Listing and inspecting jobs are safe, prompt-free operations; starting privileged work requires an explicit user-approved workflow whose approval scope does not widen across providers, job types, targets, requesters, or inputs.
|
||||
- Existing plugin-specific queues, status views, and backend routes may coexist during migration.
|
||||
- Some jobs can survive app reload or provider rehydration, while others must become unavailable or orphaned; providers declare recovery support.
|
||||
- Progress may be determinate, indeterminate, or step-only depending on the provider.
|
||||
- Media import/export contracts, plugin install/update policy, external-service trust decisions, and recording/audio processing algorithms will be specified by their own future slices and referenced by jobs only through safe job type, progress, and outcome summaries.
|
||||
@@ -0,0 +1,295 @@
|
||||
# Tasks: Jobs Control Plane
|
||||
|
||||
**Input**: Design documents from `/specs/009-jobs/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
**Tests**: Tests are included because the plan and testing contract explicitly require focused Node JS tests, diagnostics/redaction tests, inspector rendering tests, and smoke validation.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and tested as an independent increment.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel because it touches different files and does not depend on incomplete tasks
|
||||
- **[Story]**: Which user story this task belongs to (`US1`, `US2`, `US3`, `US4`)
|
||||
- Every task includes at least one exact repository file path
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Create the jobs capability host entry points and test harness scaffolding used by all stories.
|
||||
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish shared capability metadata, normalized state primitives, provider registration, and snapshot plumbing that every user story depends on.
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Track Long-Running Work (Priority: P1) MVP
|
||||
|
||||
**Goal**: A user can see long-running work as jobs with provider/requester attribution, current state, progress, completion, and safe diagnostics.
|
||||
|
||||
**Independent Test**: Register a representative provider, enqueue a user-approved job, report progress, complete it, and confirm `list`, `inspect`, events, and diagnostics describe the same state.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [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
|
||||
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Cancel, Pause, Resume, And Retry Jobs (Priority: P2)
|
||||
|
||||
**Goal**: A user can cancel queued/running jobs, pause/resume supported jobs, and retry retryable terminal jobs without ambiguous state.
|
||||
|
||||
**Independent Test**: Run jobs that support or reject cancellation, pause, resume, and retry, then verify each command produces a distinct outcome and preserves job/attempt relationships.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [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
|
||||
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Coordinate Job Providers And Scheduling (Priority: P3)
|
||||
|
||||
**Goal**: Providers can coexist with deterministic selection, capacity limits, priority ordering, reload recovery, and duplicate suppression.
|
||||
|
||||
**Independent Test**: Register multiple providers with different job types/capacity, enqueue compatible and incompatible jobs, simulate reload/recovery, and confirm scheduling and lifecycle events are deterministic.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [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
|
||||
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Explain Privileged Job Failures Safely (Priority: P4)
|
||||
|
||||
**Goal**: Users and maintainers can troubleshoot job failures through bounded, redaction-safe diagnostics and compatibility bridge records.
|
||||
|
||||
**Independent Test**: Force each failure/terminal outcome, export or inspect diagnostics, and confirm provider, job type, state, operation, safe reason, retryability, bridge usage, and redaction rules are correct.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [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
|
||||
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Documentation, migration guidance, validation, and cleanup across all stories.
|
||||
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; creates shell files and test harness scaffolding.
|
||||
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories because each story needs active domain metadata, shared state, provider registry, and diagnostics skeleton.
|
||||
- **User Stories (Phase 3+)**: Depend on Foundational. Stories are organized by priority and can be implemented incrementally.
|
||||
- **Polish (Phase 7)**: Depends on all desired user stories for final documentation and validation.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational; this is the MVP and has no dependency on other stories.
|
||||
- **User Story 2 (P2)**: Can start after Foundational but depends on US1 job state and terminal outcome primitives for meaningful cancel/pause/resume/retry behavior.
|
||||
- **User Story 3 (P3)**: Can start after Foundational and can run mostly in parallel with US2 after US1 establishes basic enqueue/list/inspect behavior.
|
||||
- **User Story 4 (P4)**: Can start after Foundational but depends on state/outcome data produced by US1-US3 for complete diagnostics coverage.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests are listed before implementation and should be written first.
|
||||
- Runtime tasks in static/capabilities/jobs.js should follow data-model order: providers, jobs, progress, outcomes, actions, scheduling, diagnostics.
|
||||
- Inspector tasks should follow runtime snapshot shape for that story.
|
||||
- Story checkpoint should pass before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T003-T005 can run in parallel after T001/T002 because they touch separate test files.
|
||||
- T014 can run in parallel with static/capabilities/jobs.js foundational work once outcome names are known.
|
||||
- Inspector tests for each story can run in parallel with runtime tests because they target tests/js/capability_inspector_render.test.js fixtures.
|
||||
- US2 and US3 can run partly in parallel after US1 because they touch different tests and separate command areas, but both converge on static/capabilities/jobs.js.
|
||||
- Documentation tasks T063-T067 can run in parallel after story behavior is stable.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```text
|
||||
Task: T015 Add provider registration tests in tests/js/jobs_domain.test.js
|
||||
Task: T018 Add Capability Inspector jobs rendering tests in tests/js/capability_inspector_render.test.js
|
||||
```
|
||||
|
||||
After tests are in place, implement runtime work sequentially in static/capabilities/jobs.js because provider registration, enqueue, progress, and terminal state share the same state machine.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```text
|
||||
Task: T027 Add cancellation tests in tests/js/jobs_domain.test.js
|
||||
Task: T030 Add inspector action/attempt tests in tests/js/capability_inspector_render.test.js
|
||||
```
|
||||
|
||||
Runtime cancel, pause, resume, retry, and approval-scope work should be sequenced in T031-T036 because each command depends on consistent job/action state.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```text
|
||||
Task: T038 Add provider-selection tests in tests/js/jobs_scheduling.test.js
|
||||
Task: T042 Add selected/default provider persistence tests in tests/js/jobs_scheduling.test.js
|
||||
```
|
||||
|
||||
Runtime scheduling work should be sequenced in T043-T049 because selection, capacity, rehydration, and recovery all mutate provider/job state.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```text
|
||||
Task: T051 Add diagnostics retention tests in tests/js/jobs_diagnostics.test.js
|
||||
Task: T054 Add compatibility bridge tests in tests/js/jobs_compat.test.js
|
||||
Task: T055 Add inspector diagnostics render tests in tests/js/capability_inspector_render.test.js
|
||||
```
|
||||
|
||||
Runtime diagnostics work should be sequenced in T056-T061 because redaction, retention, failure categories, and bridges all feed one diagnostics snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Stop and validate provider registration, user-approved enqueue, progress, completion/failure, list, inspect, events, diagnostics, and inspector rendering.
|
||||
5. Demo MVP as a single-provider jobs control plane.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational -> domain loads and empty diagnostics are available.
|
||||
2. Add US1 -> track long-running work as jobs.
|
||||
3. Add US2 -> user control actions and retries.
|
||||
4. Add US3 -> multi-provider selection, scheduling, and reload recovery.
|
||||
5. Add US4 -> safe failure explanation, retention, redaction, and compatibility bridges.
|
||||
6. Finish Polish -> docs, changelog, validation, and quickstart.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together.
|
||||
2. Developer A owns US1 runtime and jobs_domain tests.
|
||||
3. Developer B prepares US2 command tests once US1 state primitives land.
|
||||
4. Developer C prepares US3 scheduling tests and provider-selection fixtures.
|
||||
5. Developer D prepares US4 diagnostics/compatibility/inspector tests and docs.
|
||||
6. Integrate through static/capabilities/jobs.js in priority order to avoid state-machine conflicts.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
This task list intentionally excludes actual media import/export semantics, plugin install/update trust policy, external-service trust policy, recording capture, audio-effects processing, backend route privilege review, and moving plugin-owned work into core. Those workflows may report jobs, but their own data and trust contracts belong to future slices.
|
||||
|
||||
## Format Validation
|
||||
|
||||
- All executable tasks use `- [ ] T###` checklist format.
|
||||
- Story-phase tasks include `[US1]`, `[US2]`, `[US3]`, or `[US4]` labels.
|
||||
- Setup, Foundational, and Polish tasks intentionally omit story labels.
|
||||
- `[P]` appears only on tasks that touch different files or separable documentation/test surfaces.
|
||||
- Every task description includes at least one exact repository file path.
|
||||
+12
-6
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
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.sloppak', filename: 'Secret Song_p.sloppak' },
|
||||
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('caller-supplied refs and fingerprints are exported as safe correlation keys', async () => {
|
||||
const window = loadJobs();
|
||||
const { provider } = makeProvider();
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({
|
||||
target: { targetRef: 'Secret Song_p.sloppak', id: 'Private Library Entry' },
|
||||
inputs: { fingerprint: 'Secret Input Cache.sloppak' },
|
||||
logicalJobKey: 'Secret Song_p.sloppak',
|
||||
}));
|
||||
const bridge = await dispatch(window, 'record-bridge-hit', { logicalJobKey: 'Secret Song_p.sloppak', safeReason: 'legacy path observed' });
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const job = snapshot.jobs.active[0];
|
||||
|
||||
assert.equal(bridge.payload.bridge.jobId, enqueued.payload.job.jobId);
|
||||
assert.equal(job.targetRef.startsWith('target-'), true);
|
||||
assert.equal(job.inputFingerprint.startsWith('input-'), true);
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /Secret Song|Secret Input|Private Library|\.sloppak/);
|
||||
});
|
||||
|
||||
test('raw-only target and input fields hash distinctly without exporting raw values', 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({ target: { path: '/Users/example/DLC/Secret A.sloppak' }, inputs: { token: 'secret-a' }, logicalJobKey: '' }));
|
||||
const second = await dispatch(window, 'enqueue', enqueuePayload({ target: { path: '/Users/example/DLC/Secret B.sloppak' }, inputs: { token: 'secret-b' }, logicalJobKey: '' }));
|
||||
const json = JSON.stringify(diagnosticsSnapshot(window));
|
||||
|
||||
assert.notEqual(first.payload.job.targetRef, second.payload.job.targetRef);
|
||||
assert.notEqual(first.payload.job.inputFingerprint, second.payload.job.inputFingerprint);
|
||||
assert.doesNotMatch(json, /Secret A|Secret B|secret-a|secret-b|\.sloppak/);
|
||||
});
|
||||
|
||||
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('async recovery handlers are awaited before restoring jobs', async () => {
|
||||
const window = loadJobs();
|
||||
const initial = makeProvider({ providerId: 'provider.async-recover', recoverySupport: { queued: true, running: true, paused: true } });
|
||||
await dispatch(window, 'register-provider', { provider: initial.provider });
|
||||
await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'async-recover', safeLabel: 'Async Recover' }));
|
||||
|
||||
window.slopsmith.jobs.resetForTests({ clearStorage: false });
|
||||
const recovered = makeProvider({
|
||||
providerId: 'provider.async-recover',
|
||||
recoverySupport: { queued: true, running: true, paused: true },
|
||||
operationHandlers: {
|
||||
'job.recover': async () => ({ outcome: 'handled', state: 'paused' }),
|
||||
},
|
||||
});
|
||||
await dispatch(window, 'register-provider', { provider: recovered.provider });
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(snapshot.jobs.paused.length, 1);
|
||||
assert.equal(snapshot.jobs.paused[0].safeLabel, 'Async Recover');
|
||||
});
|
||||
|
||||
test('async recovery rejections become provider-unavailable terminal jobs', async () => {
|
||||
const window = loadJobs();
|
||||
const initial = makeProvider({ providerId: 'provider.recover-reject', recoverySupport: { queued: true, running: true, paused: true } });
|
||||
await dispatch(window, 'register-provider', { provider: initial.provider });
|
||||
await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'recover-reject', safeLabel: 'Reject Recover' }));
|
||||
|
||||
window.slopsmith.jobs.resetForTests({ clearStorage: false });
|
||||
const rejecting = makeProvider({
|
||||
providerId: 'provider.recover-reject',
|
||||
recoverySupport: { queued: true, running: true, paused: true },
|
||||
operationHandlers: {
|
||||
'job.recover': async () => { throw new Error('failed near /Users/example/private/recovery.db'); },
|
||||
},
|
||||
});
|
||||
await dispatch(window, 'register-provider', { provider: rejecting.provider });
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].state, 'provider-unavailable');
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].terminalOutcome.category, 'provider-unavailable');
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /Users\/example|recovery\.db/);
|
||||
});
|
||||
|
||||
test('recovery handlers can restore terminal provider-owned jobs', async () => {
|
||||
const window = loadJobs();
|
||||
const initial = makeProvider({ providerId: 'provider.recover-terminal', recoverySupport: { queued: true, running: true, paused: true } });
|
||||
await dispatch(window, 'register-provider', { provider: initial.provider });
|
||||
await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'recover-terminal', safeLabel: 'Terminal Recover' }));
|
||||
|
||||
window.slopsmith.jobs.resetForTests({ clearStorage: false });
|
||||
const recovered = makeProvider({
|
||||
providerId: 'provider.recover-terminal',
|
||||
recoverySupport: { queued: true, running: true, paused: true },
|
||||
operationHandlers: {
|
||||
'job.recover': async () => ({ outcome: 'handled', state: 'completed', resultSummary: 'Backend finished while reloading' }),
|
||||
},
|
||||
});
|
||||
await dispatch(window, 'register-provider', { provider: recovered.provider });
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(snapshot.jobs.active.length, 0);
|
||||
assert.equal(snapshot.jobs.queued.length, 0);
|
||||
assert.equal(snapshot.jobs.recentTerminal.length, 1);
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].state, 'completed');
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].terminalOutcome.resultSummary, 'Backend finished while reloading');
|
||||
});
|
||||
|
||||
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}.sloppak`);
|
||||
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));
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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 continuation enqueue matches the stored approval scope key', async () => {
|
||||
const window = loadJobs();
|
||||
const { calls, provider } = makeProvider({ capacity: { maxRunning: 2, maxQueued: 10 } });
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
const request = enqueuePayload({ target: { path: '/Users/example/DLC/Song A.sloppak' }, inputs: { token: 'secret-a' }, logicalJobKey: '' });
|
||||
const first = await dispatch(window, 'enqueue', request);
|
||||
const approvalScopeKey = window.slopsmith.jobs._test.jobs.get(first.payload.job.jobId).approvalScopeKey;
|
||||
|
||||
const rejected = await dispatch(window, 'enqueue', { ...request, authorization: 'approved-continuation', approvalScopeKey: `${approvalScopeKey}-mismatch` });
|
||||
const continued = await dispatch(window, 'enqueue', { ...request, authorization: 'approved-continuation', approvalScopeKey });
|
||||
|
||||
assert.equal(rejected.status, 'blocked');
|
||||
assert.equal(rejected.outcome, 'denied');
|
||||
assert.equal(continued.status, 'applied');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
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.sloppak' }, 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\.sloppak|token|secret/);
|
||||
});
|
||||
|
||||
test('provider-private enqueue payload reaches only the provider callback', async () => {
|
||||
const window = loadJobs();
|
||||
let privatePayload = null;
|
||||
const { provider } = makeProvider({
|
||||
operationHandlers: {
|
||||
'job.enqueue': request => {
|
||||
privatePayload = request.providerPayload;
|
||||
return { outcome: 'handled' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
|
||||
const result = await dispatch(window, 'enqueue', enqueuePayload({
|
||||
providerPayload: { filename: '/Users/example/DLC/Secret Song.sloppak', token: 'abc123' },
|
||||
target: { safeRef: 'target-secret-song' },
|
||||
inputs: { safeFingerprint: 'input-secret-song' },
|
||||
}));
|
||||
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.deepEqual(privatePayload, { filename: '/Users/example/DLC/Secret Song.sloppak', token: 'abc123' });
|
||||
assert.doesNotMatch(JSON.stringify(result.payload.job), /Secret Song|abc123|filename/);
|
||||
assert.doesNotMatch(JSON.stringify(diagnosticsSnapshot(window)), /Secret Song|abc123|filename/);
|
||||
});
|
||||
|
||||
test('adopted provider-owned jobs do not invoke enqueue handlers', async () => {
|
||||
const window = loadJobs();
|
||||
const { calls, provider } = makeProvider({ providerId: 'provider.backend', capacity: { maxRunning: 1, maxQueued: 10 } });
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
|
||||
const adopted = await dispatch(window, 'adopt', enqueuePayload({
|
||||
providerId: 'provider.backend',
|
||||
jobId: 'backend-job-1',
|
||||
state: 'queued',
|
||||
logicalJobKey: 'legacy-backend-job-1',
|
||||
safeLabel: 'Convert existing backend row',
|
||||
}));
|
||||
const native = await dispatch(window, 'enqueue', enqueuePayload({ providerId: 'provider.backend', logicalJobKey: 'native-after-adopt' }));
|
||||
|
||||
assert.equal(adopted.status, 'applied');
|
||||
assert.equal(adopted.payload.job.externallyManaged, true);
|
||||
assert.equal(adopted.payload.job.state, 'queued');
|
||||
assert.equal(native.status, 'applied');
|
||||
assert.equal(native.payload.job.state, 'running');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][1].job.jobId, native.payload.job.jobId);
|
||||
});
|
||||
|
||||
test('provider can report a running job as cancelled', async () => {
|
||||
const window = loadJobs();
|
||||
const { provider } = makeProvider({ providerId: 'provider.cancelled' });
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ providerId: provider.providerId }));
|
||||
|
||||
const result = window.slopsmith.jobs.cancelled(provider.providerId, enqueued.payload.job.jobId, { safeReason: 'User stopped backend conversion' });
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(result.outcome, 'cancelled');
|
||||
assert.equal(snapshot.jobs.active.length, 0);
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].state, 'cancelled');
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].terminalOutcome.category, 'cancellation');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
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('provider queue capacity rejects excess queued work before creating a job', async () => {
|
||||
const window = loadJobs();
|
||||
const { provider } = makeProvider({ capacity: { maxRunning: 1, maxQueued: 1 } });
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
|
||||
const running = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'capacity-running' }));
|
||||
const queued = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'capacity-queued' }));
|
||||
const rejected = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'capacity-rejected' }));
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(running.status, 'applied');
|
||||
assert.equal(queued.status, 'queued');
|
||||
assert.equal(rejected.status, 'unavailable');
|
||||
assert.equal(snapshot.jobs.active.length, 1);
|
||||
assert.equal(snapshot.jobs.queued.length, 1);
|
||||
});
|
||||
|
||||
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('provider enqueue exceptions fail safely without leaking private details', async () => {
|
||||
const window = loadJobs();
|
||||
const { provider } = makeProvider({
|
||||
operationHandlers: {
|
||||
'job.enqueue': () => { throw new Error('failed near /Users/example/private/song.sloppak token=abc123'); },
|
||||
},
|
||||
});
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
|
||||
const result = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'throws-on-start' }));
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.outcome, 'failed');
|
||||
assert.equal(snapshot.jobs.active.length, 0);
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].terminalOutcome.category, 'provider-failure');
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /Users\/example|song\.sloppak|abc123/);
|
||||
});
|
||||
|
||||
test('provider enqueue failed results become terminal failures', async () => {
|
||||
const window = loadJobs();
|
||||
const { provider } = makeProvider({
|
||||
operationHandlers: {
|
||||
'job.enqueue': () => ({ outcome: 'failed', category: 'external-dependency', safeReason: 'tool failed near /Users/example/private/cache.bin' }),
|
||||
},
|
||||
});
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
|
||||
const result = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'failed-result' }));
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.outcome, 'failed');
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].terminalOutcome.category, 'external-dependency');
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /Users\/example|cache\.bin/);
|
||||
});
|
||||
|
||||
test('async provider enqueue rejections become terminal provider failures', async () => {
|
||||
const window = loadJobs();
|
||||
const { provider } = makeProvider({
|
||||
operationHandlers: {
|
||||
'job.enqueue': () => Promise.reject(new Error('secret path /Users/example/private/cache.bin')),
|
||||
},
|
||||
});
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
|
||||
const result = await dispatch(window, 'enqueue', enqueuePayload({ logicalJobKey: 'rejects-after-start' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.equal(snapshot.jobs.active.length, 0);
|
||||
assert.equal(snapshot.jobs.recentTerminal[0].terminalOutcome.category, 'provider-failure');
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /Users\/example|cache\.bin/);
|
||||
});
|
||||
|
||||
test('retry accepts approved continuation using the stored scope key', async () => {
|
||||
const window = loadJobs();
|
||||
const { provider } = makeProvider({ providerId: 'provider.retry-continuation' });
|
||||
await dispatch(window, 'register-provider', { provider });
|
||||
const enqueued = await dispatch(window, 'enqueue', enqueuePayload({ providerId: provider.providerId, logicalJobKey: 'retry-continuation' }));
|
||||
const jobId = enqueued.payload.job.jobId;
|
||||
const approvalScopeKey = window.slopsmith.jobs._test.jobs.get(jobId).approvalScopeKey;
|
||||
|
||||
window.slopsmith.jobs.fail(provider.providerId, jobId, { category: 'provider-failure', safeReason: 'retryable failure', retryable: true });
|
||||
const rejected = await dispatch(window, 'retry', { jobId, authorization: 'approved-continuation', approvalScopeKey: `${approvalScopeKey}-mismatch` });
|
||||
const retried = await dispatch(window, 'retry', { jobId, authorization: 'approved-continuation', approvalScopeKey });
|
||||
|
||||
assert.equal(rejected.status, 'blocked');
|
||||
assert.equal(rejected.outcome, 'denied');
|
||||
assert.equal(retried.status, 'retry-started');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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',
|
||||
jobId: overrides.jobId,
|
||||
logicalJobKey: overrides.logicalJobKey,
|
||||
providerId: overrides.providerId,
|
||||
state: overrides.state,
|
||||
status: overrides.status,
|
||||
providerPayload: overrides.providerPayload,
|
||||
privatePayload: overrides.privatePayload,
|
||||
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,
|
||||
};
|
||||
@@ -112,6 +112,17 @@ def test_demo_on_read_routes_not_blocked(tmp_path, monkeypatch):
|
||||
_cleanup(server, client)
|
||||
|
||||
|
||||
def test_demo_on_ws_jobs_blocked(tmp_path, monkeypatch):
|
||||
"""Jobs WebSocket should not expose control-plane state in demo mode."""
|
||||
server, client = _make_client(tmp_path, monkeypatch, demo=True)
|
||||
try:
|
||||
with client.websocket_connect("/ws/jobs") as ws:
|
||||
msg = ws.receive_json()
|
||||
assert msg == {"error": "demo mode: read-only"}
|
||||
finally:
|
||||
_cleanup(server, client)
|
||||
|
||||
|
||||
# ── Demo cookie: set on first GET /, not on subsequent requests ───────────────
|
||||
|
||||
def test_demo_cookie_set_on_first_get_root(tmp_path, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from jobs_backend import BackendJobs
|
||||
import asyncio
|
||||
|
||||
|
||||
def test_backend_jobs_adopt_progress_and_complete_redacts_raw_payloads():
|
||||
jobs = BackendJobs()
|
||||
jobs.register_provider({
|
||||
"providerId": "provider.test",
|
||||
"pluginId": "plugin_test",
|
||||
"label": "Test Provider",
|
||||
"jobTypes": ["test.convert"],
|
||||
"actions": ["status"],
|
||||
})
|
||||
|
||||
adopted = jobs.adopt(
|
||||
provider_id="provider.test",
|
||||
job_type="test.convert",
|
||||
job_id="backend-1",
|
||||
state="queued",
|
||||
requester_id="plugin_test",
|
||||
safe_label="Convert Example Song",
|
||||
target={"safeRef": "target-abc", "backendJobId": "backend-1"},
|
||||
inputs={"safeFingerprint": "input-abc"},
|
||||
)
|
||||
assert adopted["outcome"] == "handled"
|
||||
|
||||
progressed = jobs.update_progress(
|
||||
"provider.test",
|
||||
"backend-1",
|
||||
{"mode": "determinate", "percent": 42, "step": "convert", "message": "/Users/example/Secret.sloppak token=abc"},
|
||||
)
|
||||
assert progressed["outcome"] == "handled"
|
||||
|
||||
completed = jobs.complete("provider.test", "backend-1", {"resultSummary": "Wrote /Users/example/out.sloppak"})
|
||||
assert completed["outcome"] == "completed"
|
||||
|
||||
snapshot = jobs.snapshot()
|
||||
text = str(snapshot)
|
||||
assert "provider.test" in text
|
||||
assert "target-abc" in text
|
||||
assert "Secret.sloppak" not in text
|
||||
assert "token=abc" not in text
|
||||
assert "/Users/example" not in text
|
||||
assert snapshot["jobs"]["recentTerminal"][0]["state"] == "completed"
|
||||
|
||||
|
||||
def test_backend_jobs_provider_unavailable_settles_active_jobs():
|
||||
jobs = BackendJobs()
|
||||
jobs.register_provider({
|
||||
"providerId": "provider.test",
|
||||
"jobTypes": ["test.convert"],
|
||||
})
|
||||
jobs.adopt(provider_id="provider.test", job_type="test.convert", job_id="backend-2", state="running")
|
||||
|
||||
result = jobs.mark_provider_unavailable("provider.test", "backend stopped")
|
||||
snapshot = jobs.snapshot()
|
||||
|
||||
assert result["outcome"] == "provider-unavailable"
|
||||
assert snapshot["jobs"]["active"] == []
|
||||
assert snapshot["jobs"]["recentTerminal"][0]["state"] == "provider-unavailable"
|
||||
|
||||
|
||||
def test_backend_jobs_dispatches_private_provider_action_and_redacts_payload():
|
||||
calls = []
|
||||
jobs = BackendJobs()
|
||||
|
||||
def cancel_handler(request):
|
||||
calls.append(request)
|
||||
return {
|
||||
"outcome": "cancelled",
|
||||
"payload": {
|
||||
"jobId": request["job"]["jobId"],
|
||||
"rawPath": "/Users/example/Secret.sloppak",
|
||||
},
|
||||
}
|
||||
|
||||
jobs.register_provider({
|
||||
"providerId": "provider.test",
|
||||
"jobTypes": ["test.convert"],
|
||||
"actions": ["job.status"],
|
||||
"callbacks": {"job.cancel": cancel_handler},
|
||||
})
|
||||
jobs.adopt(provider_id="provider.test", job_type="test.convert", job_id="backend-3", state="queued")
|
||||
|
||||
result = asyncio.run(jobs.dispatch_action("backend-3", "cancel", {"requesterId": "test"}))
|
||||
|
||||
assert result["outcome"] == "cancelled"
|
||||
assert result["payload"] == {"jobId": "backend-3"}
|
||||
assert calls[0]["job"]["jobId"] == "backend-3"
|
||||
assert "rawPath" not in str(result)
|
||||
|
||||
|
||||
def test_backend_jobs_retry_requires_user_action_and_retryable_failed_job():
|
||||
jobs = BackendJobs()
|
||||
jobs.register_provider({
|
||||
"providerId": "provider.test",
|
||||
"jobTypes": ["test.convert"],
|
||||
"callbacks": {"job.retry": lambda request: {"outcome": "retry-started", "payload": {"jobId": "backend-4b", "sourceJobId": request["job"]["jobId"]}}},
|
||||
})
|
||||
jobs.adopt(provider_id="provider.test", job_type="test.convert", job_id="backend-4", state="queued")
|
||||
jobs.fail("provider.test", "backend-4", {"retryable": True, "safeReason": "failed"})
|
||||
|
||||
denied = asyncio.run(jobs.dispatch_action("backend-4", "retry", {"requesterId": "test"}))
|
||||
allowed = asyncio.run(jobs.dispatch_action("backend-4", "retry", {"requesterId": "test", "authorization": "user-action"}))
|
||||
|
||||
assert denied["outcome"] == "user-action-required"
|
||||
assert allowed["outcome"] == "retry-started"
|
||||
assert allowed["payload"] == {"jobId": "backend-4b", "sourceJobId": "backend-4"}
|
||||
|
||||
|
||||
def test_backend_jobs_provider_unavailable_and_orphaned_are_applied_outcomes():
|
||||
jobs = BackendJobs()
|
||||
jobs.register_provider({
|
||||
"providerId": "provider.test",
|
||||
"jobTypes": ["test.convert"],
|
||||
})
|
||||
jobs.adopt(provider_id="provider.test", job_type="test.convert", job_id="backend-5", state="running")
|
||||
unavailable = jobs.mark_provider_unavailable("provider.test", "offline")
|
||||
|
||||
assert unavailable["outcome"] == "provider-unavailable"
|
||||
assert unavailable["status"] == "applied"
|
||||
|
||||
|
||||
def test_backend_jobs_unregister_provider_orphans_active_jobs():
|
||||
jobs = BackendJobs()
|
||||
jobs.register_provider({
|
||||
"providerId": "provider.test",
|
||||
"jobTypes": ["test.convert"],
|
||||
})
|
||||
jobs.adopt(provider_id="provider.test", job_type="test.convert", job_id="backend-6", state="queued")
|
||||
|
||||
removed = jobs.unregister_provider("provider.test")
|
||||
inspect = jobs.inspect("backend-6")
|
||||
|
||||
assert removed["outcome"] == "handled"
|
||||
assert removed["status"] == "applied"
|
||||
assert removed["payload"]["orphanedJobs"][0]["jobId"] == "backend-6"
|
||||
assert inspect["payload"]["job"]["state"] == "orphaned"
|
||||
|
||||
|
||||
def test_backend_jobs_adopt_emits_cancellation_requested_event():
|
||||
jobs = BackendJobs()
|
||||
jobs.register_provider({
|
||||
"providerId": "provider.test",
|
||||
"jobTypes": ["test.convert"],
|
||||
})
|
||||
seen = []
|
||||
unsubscribe = jobs.subscribe(lambda event: seen.append(event.get("type")))
|
||||
try:
|
||||
jobs.adopt(
|
||||
provider_id="provider.test",
|
||||
job_type="test.convert",
|
||||
job_id="backend-7",
|
||||
state="cancellation-requested",
|
||||
)
|
||||
finally:
|
||||
unsubscribe()
|
||||
|
||||
assert "cancellation-requested" in seen
|
||||
assert "started" not in seen
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user