Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions crates/homeboy-core/src/http_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ pub fn route(method: HttpMethod, path: &str) -> Result<HttpEndpoint> {
(HttpMethod::Get, ["activity", id]) => Ok(HttpEndpoint::ActivityItem {
id: (*id).to_string(),
}),
(HttpMethod::Get, ["agent-task", "runs", id]) => Ok(HttpEndpoint::AgentTaskRun {
id: (*id).to_string(),
}),
(HttpMethod::Get, ["jobs"]) => Ok(HttpEndpoint::Jobs),
(HttpMethod::Get, ["jobs", id]) => Ok(HttpEndpoint::Job {
id: (*id).to_string(),
Expand Down Expand Up @@ -145,6 +148,7 @@ pub fn route(method: HttpMethod, path: &str) -> Result<HttpEndpoint> {
"GET /bench/runs".to_string(),
"GET /activity".to_string(),
"GET /activity/:id".to_string(),
"GET /agent-task/runs/:id".to_string(),
"GET /jobs".to_string(),
"GET /jobs/:id".to_string(),
"GET /jobs/:id/events".to_string(),
Expand Down Expand Up @@ -274,6 +278,7 @@ where
"command": "api.activity.show",
"activity": activity::show_activity(id)?,
}),
HttpEndpoint::AgentTaskRun { id } => agent_task_run(id)?,
HttpEndpoint::Jobs => {
let active_runner_jobs = job_store.active_runner_jobs();
let stale_runner_jobs = job_store.stale_runner_jobs();
Expand Down Expand Up @@ -345,6 +350,119 @@ where
})
}

/// Upper bound on an accepted agent-task run id.
///
/// The id arrives as a raw URL path segment and is handed to a durable-store
/// lookup. Every real id is a slug or a UUID-suffixed Cook attempt well under
/// this, so the bound costs nothing and keeps an adversarial multi-kilobyte
/// segment from reaching the record store — the same discipline
/// `daemon_endpoint_identity` applies to its nonce.
const MAX_AGENT_TASK_RUN_ID_LEN: usize = 256;

/// `GET /agent-task/runs/:id` — the durable agent-task run projection.
///
/// # This is a pure read, deliberately
///
/// The CLI's `agent-task status` is `agent_task_lifecycle::status()`, and it is
/// a *reconciling read that writes*: it rewrites the durable record on the way
/// out (admission status, candidate adoption, aggregate projection, terminal
/// model repair) and, for a record that is not controller-local, performs a
/// **live network probe of the runner**. Neither belongs behind this route:
///
/// 1. The daemon accept loop is serial — one connection is handled inline
/// before the next is accepted. A read whose latency is a remote round trip
/// stalls every other client of a long-lived shared process.
/// 2. This module is the read-only contract. `require_run` already refuses the
/// same reconciling facade for the same reason (#6768); a GET that mutates
/// would contradict a decision this file has already made once.
///
/// So this route resolves through the **activity agent-task provider**, whose
/// `probe_by_id` is documented as an indexed, non-mutating lookup precisely
/// because `activity` is a read model that must not reconcile (#10308). It
/// already understands Cook-id aliasing, so the id an operator was handed
/// resolves here too.
///
/// The cost is honesty about staleness, not silence about it: the response
/// carries `reconciles: false` and names the command that does reconcile.
///
/// # Bounding
///
/// Exactly one indexed probe. This is not `activity::show_activity`, which
/// falls back to a full-corpus scan of up to 1000 records across three stores
/// when the probes miss — unbounded work on a serial daemon, and wrong here
/// anyway, since a non-agent-task id has no business resolving on an
/// agent-task route.
///
/// # Redaction
///
/// The `ActivityItem` projection is a typed, field-by-field allowlist built by
/// the agent-task provider, matching the discipline the controller-job
/// `public_*` projections apply to cook job state. It carries ids, timestamps,
/// state, and evidence *references* — `command` and `cwd` are `None` for an
/// agent-task record, and no provider output, prompt, or error text is
/// reachable through it.
fn agent_task_run(run_id: &str) -> Result<Value> {
if run_id.len() > MAX_AGENT_TASK_RUN_ID_LEN {
return Err(Error::validation_invalid_argument(
"run_id",
format!("agent-task run id exceeds {MAX_AGENT_TASK_RUN_ID_LEN} bytes"),
None,
None,
));
}

// A failing probe is reported as a miss with a flag, never with its message.
// Error text from this subsystem can quote durable record contents, and the
// daemon copies `message`/`details` straight into the response body. The
// caller still learns that the lookup itself failed — that is what
// `probe_failed` is for — without being handed the text.
let probe = activity::agent_task_provider::probe_by_id(run_id);
let probe_failed = probe.is_err();
let Some(run) = probe.unwrap_or(None) else {
return Err(Error::validation_invalid_argument(
"run_id",
format!("agent-task run not found: {run_id}"),
Some(run_id.to_string()),
Some(vec![
if probe_failed {
"The agent-task record lookup failed; run `homeboy agent-task status <id>` for the reconciling read."
} else {
"Run `homeboy agent-task active` to list agent-task runs."
}
.to_string(),
]),
));
};

Ok(json!({
"command": "api.agent_task.runs.show",
// The resolved id, which is not always the requested one: a Cook id is
// an alias for its latest attempt record.
"run_id": run.id.clone(),
"requested_id": run_id,
"run": run,
"projection": {
"source": "agent-task.lifecycle",
// A GET that mutates is a decision, not an accident. This one does
// not, and says so rather than leaving a caller to assume freshness.
"reconciles": false,
"probe_failed": probe_failed,
"reconcile_with": "homeboy agent-task status",
},
// A run is not a job: the job supervises the run. Cook and fanout are
// both controller jobs, so watching and cancelling a detached run is
// already the generic controller-job surface — named here so an
// orchestrator does not have to rediscover it.
"job_surface": {
"list": "/jobs",
"show": "/jobs/:job_id",
"events": "/jobs/:job_id/events",
"cancel": "/controller/jobs/:job_id/cancel",
"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.",
},
}))
}

fn activity_scope_for_path(path: &str) -> activity::ActivityScope {
if query_value(path, "all").is_some_and(|value| value == "1" || value == "true") {
activity::ActivityScope::All
Expand Down
2 changes: 2 additions & 0 deletions crates/homeboy-core/src/http_api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub enum HttpEndpoint {
BenchRuns,
Activity,
ActivityItem { id: String },
AgentTaskRun { id: String },
Jobs,
Job { id: String },
JobEvents { id: String },
Expand Down Expand Up @@ -115,6 +116,7 @@ impl HttpEndpoint {
Self::BenchRuns => "bench.runs",
Self::Activity => "activity.list",
Self::ActivityItem { .. } => "activity.show",
Self::AgentTaskRun { .. } => "agent_task.runs.show",
Self::Jobs => "jobs.list",
Self::Job { .. } => "jobs.show",
Self::JobEvents { .. } => "jobs.events",
Expand Down
13 changes: 12 additions & 1 deletion docs/architecture/headless-daemon-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,19 @@ A useful headless UI can be built from this read/query surface:
`GET /runs/:id/artifacts/:artifact_id/content`, and `GET /runs/:id/findings`
for persisted evidence
- `GET /audit/runs` and `GET /bench/runs` for analysis-specific run history
- `GET /agent-task/runs/:id` for the durable agent-task run projection. A
**pure read**: it resolves one indexed, non-mutating probe and never
reconciles, because the reconciling read (`homeboy agent-task status`) writes
durable state and can perform a live runner round trip, which a serial
accept loop cannot afford. Cook ids resolve through the same alias index the
CLI uses. A run is not a job — the job *supervises* the run, so watching and
cancelling stay on the controller-job surface below.
- `GET /jobs`, `GET /jobs/:id`, `GET /jobs/:id/events`, and
`POST /jobs/:id/cancel` for long-running work
`POST /jobs/:id/cancel` for long-running work. Cook and fanout are both
controller jobs, so a detached cook or wave is already visible and
cancellable here — but through `POST /controller/jobs/:id/cancel`, not
`POST /jobs/:id/cancel`, which fails closed on a controller job so the
driver can stop the work it owns.
- `GET /tools`, `GET /tools/:id`, and `POST /tools/:id/run` for sandbox agents
that need typed Homeboy tool execution without arbitrary shell access

Expand Down
79 changes: 79 additions & 0 deletions tests/core/http_api_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,85 @@ fn routes_activity_endpoints() {
);
}

#[test]
fn routes_agent_task_run_endpoint() {
assert_eq!(
http_api::route(HttpMethod::Get, "/agent-task/runs/cook-abc").expect("route"),
HttpEndpoint::AgentTaskRun {
id: "cook-abc".to_string()
}
);
// Trailing slashes and query strings are stripped by the shared segment
// parser, exactly as they are for every neighbouring route.
assert_eq!(
http_api::route(HttpMethod::Get, "/agent-task/runs/cook-abc/?x=1").expect("route"),
HttpEndpoint::AgentTaskRun {
id: "cook-abc".to_string()
}
);
}

#[test]
fn agent_task_run_route_is_read_only_and_exact() {
// This change adds a read route and nothing else. Submission and retry have
// their own safety review, so nothing under /agent-task may be reachable by
// POST, and the collection path must not resolve.
http_api::route(HttpMethod::Post, "/agent-task/runs/cook-abc")
.expect_err("agent-task runs are not writable through this route");
http_api::route(HttpMethod::Post, "/agent-task/runs")
.expect_err("there is no agent-task submit route");
http_api::route(HttpMethod::Get, "/agent-task/runs")
.expect_err("there is no agent-task run collection route");
http_api::route(HttpMethod::Get, "/agent-task").expect_err("there is no agent-task root route");
http_api::route(HttpMethod::Post, "/agent-task/runs/cook-abc/cancel")
.expect_err("cancellation stays on the controller-job surface");
http_api::route(HttpMethod::Post, "/agent-task/runs/cook-abc/retry")
.expect_err("retry is out of scope for this change");
}

#[test]
fn agent_task_run_lookup_miss_is_a_structured_not_found() {
// No agent-task provider is registered in this process, so the probe is the
// no-op and every id misses. That is the 404 path, and it must carry the
// same structured validation error shape as its neighbours rather than an
// opaque failure.
let error = http_api::handle(HttpApiRequest {
method: HttpMethod::Get,
path: "/agent-task/runs/no-such-agent-task-run".to_string(),
body: None,
})
.expect_err("an unknown agent-task run is not found");

assert_eq!(error.code, crate::ErrorCode::ValidationInvalidArgument);
assert_eq!(error.details["field"], "run_id");
assert!(
error.message.contains("no-such-agent-task-run"),
"the caller's own id is echoed back: {}",
error.message
);
}

#[test]
fn agent_task_run_id_is_bounded_before_the_record_lookup() {
// The id is a raw URL path segment handed to a durable-store lookup. An
// oversized segment is rejected by length alone, before the probe runs, and
// the rejection must not echo the oversized value back into the response.
let oversized = "a".repeat(4096);
let error = http_api::handle(HttpApiRequest {
method: HttpMethod::Get,
path: format!("/agent-task/runs/{oversized}"),
body: None,
})
.expect_err("an oversized agent-task run id is rejected");

assert_eq!(error.code, crate::ErrorCode::ValidationInvalidArgument);
assert_eq!(error.details["field"], "run_id");
assert!(
!error.message.contains(&oversized),
"an oversized id must not be reflected into the error message",
);
}

#[test]
fn activity_endpoint_exposes_activity_report_and_show() {
with_isolated_home(|_home| {
Expand Down
Loading