Summary
Add GET /kntnt-extractor/v1/extractions (no id) — list the caller's own non-terminal extraction jobs — so a client can find and cancel a job stranded by a crashed earlier run.
This restores a belt-and-braces the kntnt-wp-skills#24 cutover otherwise loses. kntnt-wp-skills's health check historically swept stranded remote workspaces from an aborted earlier session (its user story 9), so a crashed run never left something behind that blocked the next one.
The problem it solves
Extraction concurrency defaults to one global non-terminal job (max_active_jobs). If a run crashes after POST /extractions but before consume/cancel, that job sits queued/running/ready and holds the only slot until the TTL heartbeat window reclaims it — one hour by default (Sweeper::DEFAULT_TTL). Until then, the next run's POST /extractions is refused 429.
Today the client cannot clear it: jobs are addressable only by their 32-hex id (which a crashed run may never have persisted), and they are owner-bound with no way to enumerate them. So a single crash locks the operator out for up to an hour, with no recourse but to wait.
A listing of the caller's own live jobs lets the health check detect the stray and DELETE /extractions/{id} it — exactly the sweep the old flow did, now within the plugin's ownership model.
Decision J1 — what to list
Recommended: the caller's own non-terminal jobs only (queued, running, ready) — the ones that hold the slot and are the recovery target. Terminal jobs (consumed/failed/cancelled/expired) are done and irrelevant to the stranded-slot problem, and listing them turns this into an audit surface the audit-log endpoint already owns.
A ?state= filter or an ?include_terminal=true option is a clean later extension if a use-case appears; not needed now.
Proposed contract
GET /kntnt-extractor/v1/extractions (permission: shared Authorizer — both caps, 403 otherwise)
200 →
{
"jobs": [
{
"id": "0a1b2c...", // 32-hex
"state": "ready", // queued | running | ready (non-terminal only)
"created_at": 1737500000, // unix seconds, as persisted
"updated_at": 1737500123,
"progress": { "tables_done": 2, "tables_total": 2, "files_done": 1, "files_total": 3 } // optional, same shape as poll
}
]
}
- Owner-scoped:
Job_Store::all() filtered to owner === get_current_user_id(). A caller never sees another user's jobs, so this does not weaken the ownership model or become an existence oracle for others' jobs.
- No
download_url — fetching an artifact stays the per-job GET /extractions/{id} poll's job; this is a slot-management listing, not a delivery path.
- Empty
jobs array when the caller has none.
The considered alternative — just raising max_active_jobs so a stray does not block the next run — is a config stopgap that never cleans the stray up (it keeps consuming disk and its slot until TTL) and does not give the operator recourse. Rejected as the primary fix; it remains available as an independent tuning knob.
Acceptance criteria
- AC1 — an authorized
GET /extractions returns the caller's own non-terminal jobs, each with id, state, created_at, updated_at (and progress where the job has advanced).
- AC2 — terminal jobs (
consumed/failed/cancelled/expired) are omitted.
- AC3 — a job created by a different user never appears in this caller's listing.
- AC4 — an anonymous or single-capability caller is refused 403 (same gate as
/tables).
- AC5 — the listing is
{ "jobs": [] } when the caller has no live jobs.
- AC6 — after the caller
consumes or cancels a listed job, it no longer appears.
- AC7 — increments the REST API version to 2 (a new caller-visible endpoint). (Single bump to 2 if the sibling cutover tickets ship together.)
Testing (test-first)
- Playground integration: create a job →
GET /extractions lists it with the right shape; cancel it → it drops from the listing; a capable non-owner's listing omits it (reuse the ownership-binding setup from extractions-test.php); an unauthorized caller gets 403.
References
- Origin:
kntnt-wp-skills#24 cutover; restores that project's user story 9 (no stranded workspace outlives a crashed session) within this plugin's ownership + TTL model.
- ADR-0002 (both-caps
Authorizer gate), ADR-0004 (job lifecycle / concurrency slot / TTL sweep — the 1-hour default window this endpoint lets the caller short-circuit), ADR-0005 (API version).
- Code:
classes/Rest/Extractions_Controller.php (add the collection GET route + callback), classes/Job_Store.php (all() already exists; filter by owner), classes/Job_State.php (is_terminal()).
Ready for agent. J1 has a clear recommended default (non-terminal, owner-scoped) with a documented optional extension; no blocking open question. The only cross-ticket coordination is the shared API-version bump to 2 if the three kntnt-wp-skills#24 cutover tickets ship in one release.
Summary
Add
GET /kntnt-extractor/v1/extractions(no id) — list the caller's own non-terminal extraction jobs — so a client can find and cancel a job stranded by a crashed earlier run.This restores a belt-and-braces the kntnt-wp-skills#24 cutover otherwise loses.
kntnt-wp-skills's health check historically swept stranded remote workspaces from an aborted earlier session (its user story 9), so a crashed run never left something behind that blocked the next one.The problem it solves
Extraction concurrency defaults to one global non-terminal job (
max_active_jobs). If a run crashes afterPOST /extractionsbut beforeconsume/cancel, that job sitsqueued/running/readyand holds the only slot until the TTL heartbeat window reclaims it — one hour by default (Sweeper::DEFAULT_TTL). Until then, the next run'sPOST /extractionsis refused 429.Today the client cannot clear it: jobs are addressable only by their 32-hex id (which a crashed run may never have persisted), and they are owner-bound with no way to enumerate them. So a single crash locks the operator out for up to an hour, with no recourse but to wait.
A listing of the caller's own live jobs lets the health check detect the stray and
DELETE /extractions/{id}it — exactly the sweep the old flow did, now within the plugin's ownership model.Decision J1 — what to list
Recommended: the caller's own non-terminal jobs only (
queued,running,ready) — the ones that hold the slot and are the recovery target. Terminal jobs (consumed/failed/cancelled/expired) are done and irrelevant to the stranded-slot problem, and listing them turns this into an audit surface theaudit-logendpoint already owns.A
?state=filter or an?include_terminal=trueoption is a clean later extension if a use-case appears; not needed now.Proposed contract
Job_Store::all()filtered toowner === get_current_user_id(). A caller never sees another user's jobs, so this does not weaken the ownership model or become an existence oracle for others' jobs.download_url— fetching an artifact stays the per-jobGET /extractions/{id}poll's job; this is a slot-management listing, not a delivery path.jobsarray when the caller has none.The considered alternative — just raising
max_active_jobsso a stray does not block the next run — is a config stopgap that never cleans the stray up (it keeps consuming disk and its slot until TTL) and does not give the operator recourse. Rejected as the primary fix; it remains available as an independent tuning knob.Acceptance criteria
GET /extractionsreturns the caller's own non-terminal jobs, each withid,state,created_at,updated_at(andprogresswhere the job has advanced).consumed/failed/cancelled/expired) are omitted./tables).{ "jobs": [] }when the caller has no live jobs.consumes orcancels a listed job, it no longer appears.Testing (test-first)
GET /extractionslists it with the right shape;cancelit → it drops from the listing; a capable non-owner's listing omits it (reuse the ownership-binding setup fromextractions-test.php); an unauthorized caller gets 403.References
kntnt-wp-skills#24cutover; restores that project's user story 9 (no stranded workspace outlives a crashed session) within this plugin's ownership + TTL model.Authorizergate), ADR-0004 (job lifecycle / concurrency slot / TTL sweep — the 1-hour default window this endpoint lets the caller short-circuit), ADR-0005 (API version).classes/Rest/Extractions_Controller.php(add the collectionGETroute + callback),classes/Job_Store.php(all()already exists; filter by owner),classes/Job_State.php(is_terminal()).