Skip to content

Commit 521a716

Browse files
authored
fix: discover retained Cook artifacts by task id (#10668)
* fix: discover retained Cook artifacts by task id * fix: classify retained artifact commands * docs: update retained artifact CLI reference * docs: preserve generated CLI reference EOF
1 parent 2e5a96b commit 521a716

8 files changed

Lines changed: 302 additions & 5 deletions

File tree

crates/homeboy-agents/src/agent_task_lifecycle/workspace_authority.rs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ pub struct WorkspaceTerminalAuthorityReceipt {
2020
pub remote_workspace: String,
2121
}
2222

23+
/// A retained Lab workspace that remains authoritatively bound to one terminal
24+
/// agent task. Callers address files below this root relatively.
25+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26+
pub struct RetainedWorkspace {
27+
pub run_id: String,
28+
pub runner_id: String,
29+
pub runner_job_id: String,
30+
pub remote_workspace: String,
31+
}
32+
2333
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2434
struct WorkspaceTerminalAuthorityRelease {
2535
schema: String,
@@ -39,6 +49,15 @@ fn authority_digest(run_id: &str, runner_id: &str, remote_workspace: &str) -> St
3949
format!("{:x}", digest.finalize())
4050
}
4151

52+
fn run_index_path(run_id: &str) -> Result<PathBuf> {
53+
let mut digest = Sha256::new();
54+
digest.update(run_id.as_bytes());
55+
Ok(paths::homeboy_data()?
56+
.join("workspace-terminal-authority")
57+
.join("by-run")
58+
.join(format!("{:x}.json", digest.finalize())))
59+
}
60+
4261
fn receipt_path(run_id: &str, runner_id: &str, remote_workspace: &str) -> Result<PathBuf> {
4362
Ok(paths::homeboy_data()?
4463
.join("workspace-terminal-authority")
@@ -112,7 +131,10 @@ fn persist_workspace_terminal_authority(receipt: WorkspaceTerminalAuthorityRecei
112131
Error::internal_json(error.to_string(), Some(path.display().to_string()))
113132
})?;
114133
if existing == receipt {
115-
return Ok(());
134+
return homeboy_core::engine::local_files::write_json_file_owner_only(
135+
&run_index_path(&receipt.run_id)?,
136+
&existing,
137+
);
116138
}
117139
return Err(Error::validation_invalid_argument(
118140
"workspace_terminal_authority",
@@ -121,7 +143,11 @@ fn persist_workspace_terminal_authority(receipt: WorkspaceTerminalAuthorityRecei
121143
None,
122144
));
123145
}
124-
homeboy_core::engine::local_files::write_json_file_owner_only(&path, &receipt)
146+
homeboy_core::engine::local_files::write_json_file_owner_only(&path, &receipt)?;
147+
homeboy_core::engine::local_files::write_json_file_owner_only(
148+
&run_index_path(&receipt.run_id)?,
149+
&receipt,
150+
)
125151
})
126152
}
127153

@@ -187,6 +213,49 @@ pub fn resolve_workspace_terminal_authority(
187213
})
188214
}
189215

216+
/// Resolve the retained Lab workspace for a terminal agent task without
217+
/// relying on a caller-provided runner path. A released receipt deliberately
218+
/// fails closed so callers can distinguish a reaped workspace from an empty
219+
/// discovery result.
220+
pub fn resolve_retained_workspace(run_id: &str) -> Result<RetainedWorkspace> {
221+
let index_path = run_index_path(run_id)?;
222+
if !index_path.exists() {
223+
return Err(Error::validation_invalid_argument(
224+
"run_id",
225+
"agent task has no retained Lab workspace record",
226+
Some(run_id.to_string()),
227+
None,
228+
));
229+
}
230+
let receipt: WorkspaceTerminalAuthorityReceipt =
231+
serde_json::from_slice(&std::fs::read(&index_path).map_err(|error| {
232+
Error::internal_io(error.to_string(), Some(index_path.display().to_string()))
233+
})?)
234+
.map_err(|error| {
235+
Error::internal_json(error.to_string(), Some(index_path.display().to_string()))
236+
})?;
237+
let receipt = resolve_workspace_terminal_authority(
238+
run_id,
239+
&receipt.runner_id,
240+
&receipt.remote_workspace,
241+
Some(&receipt.runner_job_id),
242+
)?
243+
.ok_or_else(|| {
244+
Error::validation_invalid_argument(
245+
"run_id",
246+
"retained Lab workspace authority is unavailable; the workspace may have been reaped before artifacts were attached",
247+
Some(run_id.to_string()),
248+
None,
249+
)
250+
})?;
251+
Ok(RetainedWorkspace {
252+
run_id: receipt.run_id,
253+
runner_id: receipt.runner_id,
254+
runner_job_id: receipt.runner_job_id,
255+
remote_workspace: receipt.remote_workspace,
256+
})
257+
}
258+
190259
pub fn workspace_terminal_authority_release_is_pending(
191260
run_id: &str,
192261
runner_id: &str,
@@ -335,6 +404,11 @@ mod tests {
335404
persist_workspace_terminal_authority(receipt)
336405
.expect("terminal progression is idempotent");
337406

407+
assert!(resolve_retained_workspace("run-1")
408+
.expect("discover from run id after record compaction")
409+
.remote_workspace
410+
.ends_with("workspace-1"));
411+
338412
assert!(resolve_workspace_terminal_authority(
339413
"run-1",
340414
"reverse-or-direct",
@@ -350,6 +424,7 @@ mod tests {
350424
Some("other-job")
351425
)
352426
.is_err());
427+
assert!(resolve_retained_workspace("run-1").is_err());
353428
begin_workspace_terminal_authority_release(
354429
"run-1",
355430
"reverse-or-direct",

crates/homeboy-cli/src/commands/agent_task.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod doctor;
1818
pub mod fanout;
1919
pub mod loop_definition;
2020
pub mod prompts;
21+
pub mod retained_artifacts;
2122
pub mod review;
2223
pub mod run;
2324
pub mod status;
@@ -39,8 +40,8 @@ pub use args::{
3940
CompileLoopArgs, ContractArgs, ContractFormat, CookContinueArgs, DiagnoseArgs, EvidenceArgs,
4041
FinalizePrArgs, GateFeedbackArgs, LatestArgs, ListArgs, LogsArgs, PromoteArgs,
4142
PromotionProviderArgs, ProvidersArgs, ReconcileRecordsArgs, ReplayProviderBoundaryArgs,
42-
RetryArgs, ReviewArgs, RunPlanArgs, RuntimeRecoverArgs, RuntimeValidateArgs, StatusArgs,
43-
SubmitArgs, VerifyGateArgs,
43+
RetainedArtifactsArgs, RetainedArtifactsCommand, RetryArgs, ReviewArgs, RunPlanArgs,
44+
RuntimeRecoverArgs, RuntimeValidateArgs, StatusArgs, SubmitArgs, VerifyGateArgs,
4445
};
4546
pub(crate) use status::diagnostic_summary_from_aggregate;
4647

@@ -142,6 +143,7 @@ pub(crate) fn run_with_cook_progress(
142143
),
143144
AgentTaskCommand::Logs(status_args) => status::logs(status_args),
144145
AgentTaskCommand::Artifacts(status_args) => status::artifacts(status_args),
146+
AgentTaskCommand::RetainedArtifacts(args) => retained_artifacts::run(args),
145147
AgentTaskCommand::Evidence(evidence_args) => status::evidence(evidence_args),
146148
AgentTaskCommand::Diagnose(diagnose_args) => status::diagnose(diagnose_args),
147149
AgentTaskCommand::RuntimeRecover(args) => status::recover_runtime(args),

crates/homeboy-cli/src/commands/agent_task/args/definitions/command.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ pub enum AgentTaskCommand {
6969
Latest(LatestArgs),
7070
Logs(LogsArgs),
7171
Artifacts(StatusArgs),
72+
/// Discover or attach selected outputs retained in a terminal Lab Cook workspace.
73+
RetainedArtifacts(RetainedArtifactsArgs),
7274
Evidence(EvidenceArgs),
7375
Diagnose(DiagnoseArgs),
7476
/// Recover a missing or corrupted immutable controller runtime pin.
@@ -180,6 +182,28 @@ pub struct AgentTaskControllerArgs {
180182
#[command(subcommand)]
181183
pub command: AgentTaskControllerCommand,
182184
}
185+
186+
#[derive(Args, Debug)]
187+
pub struct RetainedArtifactsArgs {
188+
#[command(subcommand)]
189+
pub command: RetainedArtifactsCommand,
190+
}
191+
192+
#[derive(Subcommand, Debug)]
193+
pub enum RetainedArtifactsCommand {
194+
/// Resolve the retained workspace and print bounded, run-ID-only attach guidance.
195+
Discover { run_id: String },
196+
/// Attach one repository-relative file or directory from the retained workspace.
197+
Attach {
198+
run_id: String,
199+
/// Repository-relative path below the retained workspace.
200+
#[arg(long)]
201+
path: String,
202+
/// Durable artifact name to record on the owning run.
203+
#[arg(long)]
204+
name: String,
205+
},
206+
}
183207
#[derive(Args, Debug)]
184208
pub struct ProvidersArgs {
185209
#[arg(long = "backend", value_name = "BACKEND")]
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! Retained Lab Cook output discovery and adoption.
2+
//!
3+
//! This command deliberately accepts only an owning agent-task ID and a path
4+
//! relative to its immutable retained workspace receipt. It never exposes a
5+
//! caller-supplied runner root or creates a diagnostic runner job to inspect it.
6+
7+
use std::path::{Component, Path};
8+
9+
use serde::Serialize;
10+
use serde_json::{json, Value};
11+
12+
use homeboy::agents::agent_task_lifecycle::resolve_retained_workspace;
13+
use homeboy::core::Error;
14+
15+
use super::super::CmdResult;
16+
use super::args::{RetainedArtifactsArgs, RetainedArtifactsCommand};
17+
18+
const RETAINED_ARTIFACTS_SCHEMA: &str = "homeboy/retained-agent-task-artifacts/v1";
19+
20+
#[derive(Serialize)]
21+
struct RetainedWorkspaceOutput {
22+
schema: &'static str,
23+
command: &'static str,
24+
run_id: String,
25+
runner_id: String,
26+
runner_job_id: String,
27+
workspace_status: &'static str,
28+
attachment_root: String,
29+
attachment_path_policy: &'static str,
30+
commands: RetainedArtifactCommands,
31+
}
32+
33+
#[derive(Serialize)]
34+
struct RetainedArtifactCommands {
35+
attach: String,
36+
artifacts: String,
37+
}
38+
39+
pub(super) fn run(args: RetainedArtifactsArgs) -> CmdResult<Value> {
40+
match args.command {
41+
RetainedArtifactsCommand::Discover { run_id } => discover(&run_id),
42+
RetainedArtifactsCommand::Attach { run_id, path, name } => attach(&run_id, &path, &name),
43+
}
44+
}
45+
46+
fn discover(run_id: &str) -> CmdResult<Value> {
47+
let workspace = resolve_retained_workspace(run_id)?;
48+
let output = RetainedWorkspaceOutput {
49+
schema: RETAINED_ARTIFACTS_SCHEMA,
50+
command: "agent-task.retained-artifacts.discover",
51+
run_id: workspace.run_id,
52+
runner_id: workspace.runner_id,
53+
runner_job_id: workspace.runner_job_id,
54+
workspace_status: "retained",
55+
attachment_root: ".".to_string(),
56+
attachment_path_policy: "repository-relative; absolute and parent paths are rejected",
57+
commands: RetainedArtifactCommands {
58+
attach: format!(
59+
"homeboy agent-task retained-artifacts attach {run_id} --path <relative-path> --name <artifact-name>"
60+
),
61+
artifacts: format!("homeboy runs artifacts {run_id}"),
62+
},
63+
};
64+
Ok((serde_json::to_value(output).unwrap_or(Value::Null), 0))
65+
}
66+
67+
fn attach(run_id: &str, relative_path: &str, name: &str) -> CmdResult<Value> {
68+
validate_relative_path(relative_path)?;
69+
let workspace = resolve_retained_workspace(run_id)?;
70+
let source_path = format!(
71+
"{}/{}",
72+
workspace.remote_workspace.trim_end_matches('/'),
73+
relative_path
74+
);
75+
let artifact = crate::commands::runs::attach_runner_artifact(
76+
workspace.run_id.clone(),
77+
workspace.runner_id.clone(),
78+
source_path,
79+
name.to_string(),
80+
)?;
81+
Ok((
82+
json!({
83+
"schema": RETAINED_ARTIFACTS_SCHEMA,
84+
"command": "agent-task.retained-artifacts.attach",
85+
"run_id": workspace.run_id,
86+
"runner_id": workspace.runner_id,
87+
"runner_job_id": workspace.runner_job_id,
88+
"workspace_status": "retained",
89+
"relative_path": relative_path,
90+
"artifact": artifact,
91+
}),
92+
0,
93+
))
94+
}
95+
96+
fn validate_relative_path(path: &str) -> homeboy::core::Result<()> {
97+
let path = Path::new(path);
98+
if path.as_os_str().is_empty()
99+
|| path.is_absolute()
100+
|| path
101+
.components()
102+
.any(|component| matches!(component, Component::ParentDir | Component::RootDir))
103+
{
104+
return Err(Error::validation_invalid_argument(
105+
"path",
106+
"retained artifact path must be a non-empty repository-relative path without parent components",
107+
Some(path.display().to_string()),
108+
None,
109+
));
110+
}
111+
Ok(())
112+
}
113+
114+
#[cfg(test)]
115+
mod tests {
116+
use super::*;
117+
118+
#[test]
119+
fn retained_artifact_paths_are_bounded_to_the_workspace() {
120+
assert!(validate_relative_path("artifacts/result.json").is_ok());
121+
assert!(validate_relative_path("/tmp/result.json").is_err());
122+
assert!(validate_relative_path("artifacts/../secret.txt").is_err());
123+
assert!(validate_relative_path("").is_err());
124+
}
125+
}

crates/homeboy-cli/src/commands/runs/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ pub use dispatch::{global_runner_error, run, run_markdown};
4747
pub use handlers::list_runs;
4848
pub use types::{RunsArgs, RunsOutput, HOSTED_BLUEPRINT_VIEWER};
4949

50+
/// Attach a runner artifact from another command surface without exposing the
51+
/// `runs artifact` clap types outside this module.
52+
pub(crate) fn attach_runner_artifact(
53+
run_id: String,
54+
runner: String,
55+
path: String,
56+
name: String,
57+
) -> homeboy::core::Result<serde_json::Value> {
58+
let (output, _) = remote_artifact::attach(types::RunsArtifactAttachArgs {
59+
run_id,
60+
runner,
61+
path,
62+
name,
63+
})?;
64+
serde_json::to_value(output)
65+
.map_err(|error| homeboy::core::Error::internal_json(error.to_string(), None))
66+
}
67+
5068
// Intra-module re-exports so sibling submodules (and the test modules) can
5169
// reference shared items via `super::` without depending on each other's
5270
// internal module paths. `pub(super)` items are re-exported with a private

crates/homeboy-cli/src/commands/utils/resource_policy/classification.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ pub(super) fn agent_task_resource_behavior(
7474
// the answer describes (#9763).
7575
| agent_task::AgentTaskCommand::Providers(_)
7676
| agent_task::AgentTaskCommand::Review(_) => AgentTaskResourceBehavior::BoundedMetadataRead,
77+
agent_task::AgentTaskCommand::RetainedArtifacts(retained) => match &retained.command {
78+
// Discovery reads only the local immutable workspace receipt.
79+
agent_task::RetainedArtifactsCommand::Discover { .. } => {
80+
AgentTaskResourceBehavior::BoundedMetadataRead
81+
}
82+
// Attach may transfer a selected runner-side artifact and persists it.
83+
agent_task::RetainedArtifactsCommand::Attach { .. } => {
84+
AgentTaskResourceBehavior::AdmittedWorkload
85+
}
86+
},
7787
agent_task::AgentTaskCommand::Active(active) if !active.reconcile => {
7888
AgentTaskResourceBehavior::BoundedMetadataRead
7989
}

0 commit comments

Comments
 (0)