Skip to content

Commit a238463

Browse files
committed
feat(http-api): add GET /agent-task/runs/:id as a pure read
An out-of-process orchestrator could already list, inspect, watch and cancel a detached cook through the generic controller-job surface — cook and fanout are both `ControllerJobDriver`s (#11857, #11871) — but had no way to read the durable agent-task *run* the job supervises. A run is not a job. Resolve it through the activity agent-task provider's `probe_by_id`, which is already documented as an indexed, non-mutating lookup (#10308) and already understands Cook-id aliasing. Deliberately NOT `agent_task_lifecycle::status()`, the reconciling read behind the CLI: - it rewrites the durable record on the way out, and a GET that mutates is a decision, not something to replicate silently - for a non-controller-local record it performs a live runner round trip. The daemon accept loop handles one connection inline before accepting the next, so an unbounded read stalls every other client of a shared process - `require_run` in this module already refuses the same reconciling facade for the same reason (#6768) The response says `reconciles: false` and names the command that does, rather than letting a caller assume freshness. Bounding: exactly one indexed probe, no fallback. `activity::show_activity` would fall back to a full-corpus scan of up to 1000 records across three stores; that is unbounded work on a serial loop, and wrong on an agent-task route anyway. The id is length-capped before it reaches the record store. Redaction: the `ActivityItem` projection is a typed field-by-field allowlist — ids, timestamps, state, and evidence *references*. `command`/`cwd` are `None` for an agent-task record, so no prompt or provider output is reachable. A failing probe is reported as a flag, never as its message, because error text from this subsystem can quote record contents and the daemon copies `message`/`details` straight into the response body. Auth posture is identical to its neighbours: the read-only API takes no bearer token and is protected by the daemon's hard loopback-only bind validation. No submit or retry route: submission has its own safety review.
1 parent 0cad5a5 commit a238463

4 files changed

Lines changed: 211 additions & 1 deletion

File tree

crates/homeboy-core/src/http_api.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ pub fn route(method: HttpMethod, path: &str) -> Result<HttpEndpoint> {
8686
(HttpMethod::Get, ["activity", id]) => Ok(HttpEndpoint::ActivityItem {
8787
id: (*id).to_string(),
8888
}),
89+
(HttpMethod::Get, ["agent-task", "runs", id]) => Ok(HttpEndpoint::AgentTaskRun {
90+
id: (*id).to_string(),
91+
}),
8992
(HttpMethod::Get, ["jobs"]) => Ok(HttpEndpoint::Jobs),
9093
(HttpMethod::Get, ["jobs", id]) => Ok(HttpEndpoint::Job {
9194
id: (*id).to_string(),
@@ -145,6 +148,7 @@ pub fn route(method: HttpMethod, path: &str) -> Result<HttpEndpoint> {
145148
"GET /bench/runs".to_string(),
146149
"GET /activity".to_string(),
147150
"GET /activity/:id".to_string(),
151+
"GET /agent-task/runs/:id".to_string(),
148152
"GET /jobs".to_string(),
149153
"GET /jobs/:id".to_string(),
150154
"GET /jobs/:id/events".to_string(),
@@ -274,6 +278,7 @@ where
274278
"command": "api.activity.show",
275279
"activity": activity::show_activity(id)?,
276280
}),
281+
HttpEndpoint::AgentTaskRun { id } => agent_task_run(id)?,
277282
HttpEndpoint::Jobs => {
278283
let active_runner_jobs = job_store.active_runner_jobs();
279284
let stale_runner_jobs = job_store.stale_runner_jobs();
@@ -345,6 +350,119 @@ where
345350
})
346351
}
347352

353+
/// Upper bound on an accepted agent-task run id.
354+
///
355+
/// The id arrives as a raw URL path segment and is handed to a durable-store
356+
/// lookup. Every real id is a slug or a UUID-suffixed Cook attempt well under
357+
/// this, so the bound costs nothing and keeps an adversarial multi-kilobyte
358+
/// segment from reaching the record store — the same discipline
359+
/// `daemon_endpoint_identity` applies to its nonce.
360+
const MAX_AGENT_TASK_RUN_ID_LEN: usize = 256;
361+
362+
/// `GET /agent-task/runs/:id` — the durable agent-task run projection.
363+
///
364+
/// # This is a pure read, deliberately
365+
///
366+
/// The CLI's `agent-task status` is `agent_task_lifecycle::status()`, and it is
367+
/// a *reconciling read that writes*: it rewrites the durable record on the way
368+
/// out (admission status, candidate adoption, aggregate projection, terminal
369+
/// model repair) and, for a record that is not controller-local, performs a
370+
/// **live network probe of the runner**. Neither belongs behind this route:
371+
///
372+
/// 1. The daemon accept loop is serial — one connection is handled inline
373+
/// before the next is accepted. A read whose latency is a remote round trip
374+
/// stalls every other client of a long-lived shared process.
375+
/// 2. This module is the read-only contract. `require_run` already refuses the
376+
/// same reconciling facade for the same reason (#6768); a GET that mutates
377+
/// would contradict a decision this file has already made once.
378+
///
379+
/// So this route resolves through the **activity agent-task provider**, whose
380+
/// `probe_by_id` is documented as an indexed, non-mutating lookup precisely
381+
/// because `activity` is a read model that must not reconcile (#10308). It
382+
/// already understands Cook-id aliasing, so the id an operator was handed
383+
/// resolves here too.
384+
///
385+
/// The cost is honesty about staleness, not silence about it: the response
386+
/// carries `reconciles: false` and names the command that does reconcile.
387+
///
388+
/// # Bounding
389+
///
390+
/// Exactly one indexed probe. This is not `activity::show_activity`, which
391+
/// falls back to a full-corpus scan of up to 1000 records across three stores
392+
/// when the probes miss — unbounded work on a serial daemon, and wrong here
393+
/// anyway, since a non-agent-task id has no business resolving on an
394+
/// agent-task route.
395+
///
396+
/// # Redaction
397+
///
398+
/// The `ActivityItem` projection is a typed, field-by-field allowlist built by
399+
/// the agent-task provider, matching the discipline the controller-job
400+
/// `public_*` projections apply to cook job state. It carries ids, timestamps,
401+
/// state, and evidence *references* — `command` and `cwd` are `None` for an
402+
/// agent-task record, and no provider output, prompt, or error text is
403+
/// reachable through it.
404+
fn agent_task_run(run_id: &str) -> Result<Value> {
405+
if run_id.len() > MAX_AGENT_TASK_RUN_ID_LEN {
406+
return Err(Error::validation_invalid_argument(
407+
"run_id",
408+
format!("agent-task run id exceeds {MAX_AGENT_TASK_RUN_ID_LEN} bytes"),
409+
None,
410+
None,
411+
));
412+
}
413+
414+
// A failing probe is reported as a miss with a flag, never with its message.
415+
// Error text from this subsystem can quote durable record contents, and the
416+
// daemon copies `message`/`details` straight into the response body. The
417+
// caller still learns that the lookup itself failed — that is what
418+
// `probe_failed` is for — without being handed the text.
419+
let probe = activity::agent_task_provider::probe_by_id(run_id);
420+
let probe_failed = probe.is_err();
421+
let Some(run) = probe.unwrap_or(None) else {
422+
return Err(Error::validation_invalid_argument(
423+
"run_id",
424+
format!("agent-task run not found: {run_id}"),
425+
Some(run_id.to_string()),
426+
Some(vec![
427+
if probe_failed {
428+
"The agent-task record lookup failed; run `homeboy agent-task status <id>` for the reconciling read."
429+
} else {
430+
"Run `homeboy agent-task active` to list agent-task runs."
431+
}
432+
.to_string(),
433+
]),
434+
));
435+
};
436+
437+
Ok(json!({
438+
"command": "api.agent_task.runs.show",
439+
// The resolved id, which is not always the requested one: a Cook id is
440+
// an alias for its latest attempt record.
441+
"run_id": run.id.clone(),
442+
"requested_id": run_id,
443+
"run": run,
444+
"projection": {
445+
"source": "agent-task.lifecycle",
446+
// A GET that mutates is a decision, not an accident. This one does
447+
// not, and says so rather than leaving a caller to assume freshness.
448+
"reconciles": false,
449+
"probe_failed": probe_failed,
450+
"reconcile_with": "homeboy agent-task status",
451+
},
452+
// A run is not a job: the job supervises the run. Cook and fanout are
453+
// both controller jobs, so watching and cancelling a detached run is
454+
// already the generic controller-job surface — named here so an
455+
// orchestrator does not have to rediscover it.
456+
"job_surface": {
457+
"list": "/jobs",
458+
"show": "/jobs/:job_id",
459+
"events": "/jobs/:job_id/events",
460+
"cancel": "/controller/jobs/:job_id/cancel",
461+
"note": "POST /jobs/:id/cancel refuses controller jobs; controller-owned work is cancelled through its driver so the driver can stop the work it owns.",
462+
},
463+
}))
464+
}
465+
348466
fn activity_scope_for_path(path: &str) -> activity::ActivityScope {
349467
if query_value(path, "all").is_some_and(|value| value == "1" || value == "true") {
350468
activity::ActivityScope::All

crates/homeboy-core/src/http_api/types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pub enum HttpEndpoint {
4646
BenchRuns,
4747
Activity,
4848
ActivityItem { id: String },
49+
AgentTaskRun { id: String },
4950
Jobs,
5051
Job { id: String },
5152
JobEvents { id: String },
@@ -115,6 +116,7 @@ impl HttpEndpoint {
115116
Self::BenchRuns => "bench.runs",
116117
Self::Activity => "activity.list",
117118
Self::ActivityItem { .. } => "activity.show",
119+
Self::AgentTaskRun { .. } => "agent_task.runs.show",
118120
Self::Jobs => "jobs.list",
119121
Self::Job { .. } => "jobs.show",
120122
Self::JobEvents { .. } => "jobs.events",

docs/architecture/headless-daemon-api.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,19 @@ A useful headless UI can be built from this read/query surface:
9797
`GET /runs/:id/artifacts/:artifact_id/content`, and `GET /runs/:id/findings`
9898
for persisted evidence
9999
- `GET /audit/runs` and `GET /bench/runs` for analysis-specific run history
100+
- `GET /agent-task/runs/:id` for the durable agent-task run projection. A
101+
**pure read**: it resolves one indexed, non-mutating probe and never
102+
reconciles, because the reconciling read (`homeboy agent-task status`) writes
103+
durable state and can perform a live runner round trip, which a serial
104+
accept loop cannot afford. Cook ids resolve through the same alias index the
105+
CLI uses. A run is not a job — the job *supervises* the run, so watching and
106+
cancelling stay on the controller-job surface below.
100107
- `GET /jobs`, `GET /jobs/:id`, `GET /jobs/:id/events`, and
101-
`POST /jobs/:id/cancel` for long-running work
108+
`POST /jobs/:id/cancel` for long-running work. Cook and fanout are both
109+
controller jobs, so a detached cook or wave is already visible and
110+
cancellable here — but through `POST /controller/jobs/:id/cancel`, not
111+
`POST /jobs/:id/cancel`, which fails closed on a controller job so the
112+
driver can stop the work it owns.
102113
- `GET /tools`, `GET /tools/:id`, and `POST /tools/:id/run` for sandbox agents
103114
that need typed Homeboy tool execution without arbitrary shell access
104115

tests/core/http_api_test.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,85 @@ fn routes_activity_endpoints() {
236236
);
237237
}
238238

239+
#[test]
240+
fn routes_agent_task_run_endpoint() {
241+
assert_eq!(
242+
http_api::route(HttpMethod::Get, "/agent-task/runs/cook-abc").expect("route"),
243+
HttpEndpoint::AgentTaskRun {
244+
id: "cook-abc".to_string()
245+
}
246+
);
247+
// Trailing slashes and query strings are stripped by the shared segment
248+
// parser, exactly as they are for every neighbouring route.
249+
assert_eq!(
250+
http_api::route(HttpMethod::Get, "/agent-task/runs/cook-abc/?x=1").expect("route"),
251+
HttpEndpoint::AgentTaskRun {
252+
id: "cook-abc".to_string()
253+
}
254+
);
255+
}
256+
257+
#[test]
258+
fn agent_task_run_route_is_read_only_and_exact() {
259+
// This change adds a read route and nothing else. Submission and retry have
260+
// their own safety review, so nothing under /agent-task may be reachable by
261+
// POST, and the collection path must not resolve.
262+
http_api::route(HttpMethod::Post, "/agent-task/runs/cook-abc")
263+
.expect_err("agent-task runs are not writable through this route");
264+
http_api::route(HttpMethod::Post, "/agent-task/runs")
265+
.expect_err("there is no agent-task submit route");
266+
http_api::route(HttpMethod::Get, "/agent-task/runs")
267+
.expect_err("there is no agent-task run collection route");
268+
http_api::route(HttpMethod::Get, "/agent-task").expect_err("there is no agent-task root route");
269+
http_api::route(HttpMethod::Post, "/agent-task/runs/cook-abc/cancel")
270+
.expect_err("cancellation stays on the controller-job surface");
271+
http_api::route(HttpMethod::Post, "/agent-task/runs/cook-abc/retry")
272+
.expect_err("retry is out of scope for this change");
273+
}
274+
275+
#[test]
276+
fn agent_task_run_lookup_miss_is_a_structured_not_found() {
277+
// No agent-task provider is registered in this process, so the probe is the
278+
// no-op and every id misses. That is the 404 path, and it must carry the
279+
// same structured validation error shape as its neighbours rather than an
280+
// opaque failure.
281+
let error = http_api::handle(HttpApiRequest {
282+
method: HttpMethod::Get,
283+
path: "/agent-task/runs/no-such-agent-task-run".to_string(),
284+
body: None,
285+
})
286+
.expect_err("an unknown agent-task run is not found");
287+
288+
assert_eq!(error.code, crate::ErrorCode::ValidationInvalidArgument);
289+
assert_eq!(error.details["field"], "run_id");
290+
assert!(
291+
error.message.contains("no-such-agent-task-run"),
292+
"the caller's own id is echoed back: {}",
293+
error.message
294+
);
295+
}
296+
297+
#[test]
298+
fn agent_task_run_id_is_bounded_before_the_record_lookup() {
299+
// The id is a raw URL path segment handed to a durable-store lookup. An
300+
// oversized segment is rejected by length alone, before the probe runs, and
301+
// the rejection must not echo the oversized value back into the response.
302+
let oversized = "a".repeat(4096);
303+
let error = http_api::handle(HttpApiRequest {
304+
method: HttpMethod::Get,
305+
path: format!("/agent-task/runs/{oversized}"),
306+
body: None,
307+
})
308+
.expect_err("an oversized agent-task run id is rejected");
309+
310+
assert_eq!(error.code, crate::ErrorCode::ValidationInvalidArgument);
311+
assert_eq!(error.details["field"], "run_id");
312+
assert!(
313+
!error.message.contains(&oversized),
314+
"an oversized id must not be reflected into the error message",
315+
);
316+
}
317+
239318
#[test]
240319
fn activity_endpoint_exposes_activity_report_and_show() {
241320
with_isolated_home(|_home| {

0 commit comments

Comments
 (0)