Edit the admin-owned half of a job's schedule.
const url = 'http://localhost:8080/v1/admin/jobs/example';const options = { method: 'PATCH', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"enabled":true,"interval_secs":1,"window_start":"example","window_end":"example","window_days":[1],"timezone":"example","history_limit":1}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}use std::str::FromStr;use serde_json::json;use reqwest;
#[tokio::main]pub async fn main() { let url = "http://localhost:8080/v1/admin/jobs/example";
let payload = json!({ "enabled": true, "interval_secs": 1, "window_start": "example", "window_end": "example", "window_days": (1), "timezone": "example", "history_limit": 1 });
let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Authorization", "Bearer <token>".parse().unwrap()); headers.insert("Content-Type", "application/json".parse().unwrap());
let client = reqwest::Client::new(); let response = client.request(reqwest::Method::from_str("PATCH").unwrap(), url) .headers(headers) .json(&payload) .send() .await;
let results = response.unwrap() .json::<serde_json::Value>() .await .unwrap();
dbg!(results);}curl --request PATCH \ --url http://localhost:8080/v1/admin/jobs/example \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "enabled": true, "interval_secs": 1, "window_start": "example", "window_end": "example", "window_days": [ 1 ], "timezone": "example", "history_limit": 1 }'The jobs row is split by OWNERSHIP: identity (name, description, plane, scope, managed) is code-owned and reconciled at every boot, and the schedule fields below are ADMIN-OWNED and never overwritten by that reconcile. An all-optional body, the repo’s PATCH convention, so “absent” is distinguishable from “zero” — but an UNKNOWN key is rejected with 400 rather than silently ignored, because a typo’d field name accepted with a 200 is the “did my edit take effect” confusion this surface exists to end. window_start and window_end move together: both set, or both null to clear. 400 validation_failed = malformed body, unknown key, or a value of the wrong JSON type; 422 validation_failed = a well-formed value the schedule refuses (interval below 60s, interval_secs on a non-interval job, window_start == window_end, a window_days value outside 0..6, an unknown IANA zone, history_limit outside 1..500). 409 job_unmanaged = the job is not adopted and has no schedule to edit. 409 schedule_locked = an environment variable is authoritative over this job’s interval; the edit is refused rather than silently accepted and overruled. Every accepted PATCH writes an admin_activity row (job.update, {"keys": [...]}).
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Jobs framework (2026-08-12): a job’s id. NOT a uuid — it is the code-owned dotted identifier from the registry (“artwork.sweep”, “template.warmup”), which is also the jobs primary key, so the id in a URL is the same string an operator reads in a log line. That is deliberate: a job’s identity is authored in code and reconciled at boot, never minted by a database default.
Request Bodyrequired
Section titled “Request Bodyrequired”Every field optional; an absent field is left alone. AN UNKNOWN KEY IS A 400, not a silent ignore. window_start and window_end must be supplied together - both as strings to set a window, both as null to clear it - because a half-set window is a schedule nobody can reason about.
object
Only for an interval job (422 otherwise), never below 60, and refused with 409 schedule_locked while an env override is in force.
“HH:MM” or “HH:MM:SS”, or null to clear (with window_end). 24:00 is rejected rather than normalized - a bound that means two things is the seed of a missed run nobody can explain.
As window_start. Equal start and end is 422: an empty window would never open (a wrapping 22:00 -> 04:00 window, by contrast, is legal).
0 = Sunday .. 6 = Saturday; [] means every day.
IANA zone name. An unknown zone is 422, never silently coerced to UTC - an operator who typed “Europe/Londn” and got UTC would see their window fire at the wrong time with nothing saying why.
Examplegenerated
{ "enabled": true, "interval_secs": 1, "window_start": "example", "window_end": "example", "window_days": [ 1 ], "timezone": "example", "history_limit": 1}Responses
Section titled “Responses”Updated; the job in its post-edit shape.
One registered background job. The row is split by OWNERSHIP: identity (name, description, plane, scope, managed) is code-owned and reconciled at every boot, and the schedule is admin-owned and never overwritten by that reconcile - a boot that clobbered an operator’s 02:00-06:00 window because a developer edited a literal would make the whole surface untrustworthy. THREE SHAPES, DISCRIMINATED BY managed AND scope. A managed instance-scoped job carries top-level running / next_run_at / last_run / consecutive_failures. A managed host-scoped job carries targets INSTEAD, one entry per host. An unmanaged job of either scope carries NEITHER, plus unmanaged_note. A run-derived field is OMITTED rather than sent as null when it does not apply or is not known (Go omitempty on a nil pointer); a client must read absent as null.
object
The code-owned dotted identifier, e.g. “artwork.sweep”. Also the URL path segment and the id in every log line.
Operator-facing display name, code-owned.
What this job does, code-owned. For an UNMANAGED job it is also the text of unmanaged_note.
WHERE the job’s work executes. ‘control’ runs in the control-plane process; ‘agent’ is claimed over the /v1/agent/jobs/* pull channel and executed by a node agent. It is not a hint - it decides which of the two dispatch paths ever sees the run.
WHAT a run is about. ‘instance’ = one run for the whole deployment (its job_runs rows carry no host_id). ‘host’ = one INDEPENDENT run per host, which is why a host-scoped job reports targets[] instead of top-level run state: “last run” is not a single fact about the job, it is a fact about the job ON A HOST.
False = “listed but not adopted”: background work that exists in code and runs on a hard-coded timer, shown here so an operator can SEE it, with no schedule, no run history and no Run-now. The list of unmanaged rows is therefore also the adoption backlog. Omitting them would reproduce the exact problem the Jobs page was built to fix - a page of six rows next to twelve invisible goroutines.
The operator’s kill switch, and it MEANS it: a disabled job never runs, not even from Run now (409 job_disabled).
The ADMIN-OWNED half of a job (the code-owned half is its identity), resolved exactly as the dispatcher resolves it - one implementation of “which source won”, so the viewer and the scheduler cannot disagree about when a job will next run.
object
‘interval’ fires every interval_secs measured from the END of the previous run (timer-reset-after-pass, so overlap is impossible by construction rather than unlikely). ‘event’ never fires on a clock - a run row is created by an explicit trigger - which is how event-driven work is represented WITHOUT pretending it is periodic, while still showing last-run and result. ‘manual’ only ever runs from an admin trigger.
Null for a non-interval schedule. Floor of 60 (a CHECK in the migration and a guard in the handler): a job that wants to run more often than once a minute is not a background job. When locked is true this is the ENVIRONMENT’s value, not the stored one.
HH:MM:SS in timezone, or null for ‘any time’. Paired with window_end by a CHECK - both set or both null.
HH:MM:SS in timezone, or null. A window that WRAPS MIDNIGHT (22:00 -> 04:00) is legal and normal. The window governs STARTING a run and never stopping one: a run in flight when the window closes is not killed, because killing a half-finished dedupe pass or a half-built template is worse than overrunning by minutes.
0 = Sunday .. 6 = Saturday (Go’s time.Weekday). EMPTY MEANS EVERY DAY, and empty is what an unconstrained job reports - never null. A day constrains the instant the window OPENS, so a wrapping window on {5} runs Friday 22:00 -> Saturday 04:00.
IANA zone name; windows are evaluated in it. Intervals are NOT: an interval is a duration in absolute time and has no opinion about the clock on the wall, so a DST transition can shift when a windowed run lands but can never lengthen an interval.
True when an environment variable is authoritative over this job’s interval. The env override stays the winner (the pre-existing documented behaviour of knobs like QUASAR_LIBRARY_SCAN_INTERVAL); the API says so rather than silently accepting an edit the environment will overrule - a PATCH of interval_secs on a locked job is 409 schedule_locked.
The environment variable that is in force, e.g. “QUASAR_LIBRARY_SCAN_INTERVAL”. Null when not locked.
MANAGED scope=instance only: an open run is currently in flight. Absent for a host-scoped or unmanaged job.
MANAGED scope=instance only: the open PENDING run’s scheduled_for. A pending row IS the next run - there is no denormalized next-run column to drift out of sync.
One record of one run - the shape that appears as last_run and as an item of the run-history page.
object
Null for an instance-scoped job’s run; the target host for a host-scoped one.
The run lifecycle: pending -> running -> one terminal state. ‘deferred’ means the job’s OWN gate refused (a host with live sessions, say) - an outcome, not an error, and the dispatcher schedules the retry on a persisted backoff ladder. ‘skipped’ means there was nothing to do. ‘aborted’ is the claim-timeout reaper’s verdict on a run nobody reported; it is the one state an agent may never report about itself.
What created the run row: the schedule, an admin’s Run now, or an explicit event.
Null while the run is still pending (it has not started).
Null until the run is finished; derived from started_at/finished_at rather than stored.
The runner’s own per-job result blob - what it actually did, in its own vocabulary ({“apps_considered”: 412, “artwork_resolved”: 3}). OPAQUE TO THE FRAMEWORK, which never interprets it, and {} rather than null when a run reported nothing. Bounded at 4096 bytes by a CHECK; a summary that blows the bound fails the REPORT, never the run.
object
The failure text for a failed run; null otherwise.
MANAGED scope=instance only: terminal failed runs since the last non-failed terminal outcome. DERIVED from history rather than stored, so it cannot drift from the rows it describes.
MANAGED scope=host only: per-host run state, one entry per known host. Absent for an instance-scoped or unmanaged job.
One host’s independent run state for a MANAGED host-scoped job. Computed by the same code path as the instance-scoped top-level fields, so the two shapes cannot drift apart.
object
Hosts.node_name - display only. An unresolved name renders as “” rather than failing the list: a stale host row must not break the whole page.
The open PENDING run’s scheduled_for. Null when nothing is queued for this host.
One record of one run - the shape that appears as last_run and as an item of the run-history page.
object
Null for an instance-scoped job’s run; the target host for a host-scoped one.
The run lifecycle: pending -> running -> one terminal state. ‘deferred’ means the job’s OWN gate refused (a host with live sessions, say) - an outcome, not an error, and the dispatcher schedules the retry on a persisted backoff ladder. ‘skipped’ means there was nothing to do. ‘aborted’ is the claim-timeout reaper’s verdict on a run nobody reported; it is the one state an agent may never report about itself.
What created the run row: the schedule, an admin’s Run now, or an explicit event.
Null while the run is still pending (it has not started).
Null until the run is finished; derived from started_at/finished_at rather than stored.
The runner’s own per-job result blob - what it actually did, in its own vocabulary ({“apps_considered”: 412, “artwork_resolved”: 3}). OPAQUE TO THE FRAMEWORK, which never interprets it, and {} rather than null when a run reported nothing. Bounded at 4096 bytes by a CHECK; a summary that blows the bound fails the REPORT, never the run.
object
The failure text for a failed run; null otherwise.
How many run rows this job retains (1..500).
UNMANAGED jobs only: a human sentence naming the file that hard-codes this work and saying no history is recorded. Authored in ONE place (the registry Definition’s description) rather than duplicated as a second field.
Example
{ "plane": "control", "scope": "instance", "schedule": { "kind": "interval" }, "last_run": { "state": "pending", "trigger": "schedule" }, "targets": [ { "last_run": { "state": "pending", "trigger": "schedule" } } ]}Malformed or invalid request.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Missing/invalid/expired/revoked token.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Authenticated but insufficient role / not the owner (precedes resource lookup).
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}No such resource.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Job_unmanaged (not adopted) or schedule_locked (an env var is authoritative).
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Validation_failed — a well-formed value the schedule model refuses.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}