Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
6d74741
feat(channel): snapshot telegram team integration state
3300160875 Jul 16, 2026
fa7c1e6
feat(project): add project persistence foundation
3300160875 Jul 16, 2026
a429d4e
feat(agent): expose complete capability snapshots
3300160875 Jul 16, 2026
ce74a91
feat(project): add safe project preflight service
3300160875 Jul 16, 2026
4b2adb1
feat(project): add authenticated project APIs
3300160875 Jul 16, 2026
f90641a
test(project): cover resource link replacement
3300160875 Jul 16, 2026
de934db
feat(workspace): persist agent workspace leases
3300160875 Jul 16, 2026
4957f0d
feat(workspace): manage isolated git worktrees
3300160875 Jul 16, 2026
8f08979
feat(team): provision isolated agent worktrees
3300160875 Jul 16, 2026
1ca636b
test(workspace): cover retained commits and symlink escapes
3300160875 Jul 16, 2026
a3e192c
feat(development): add quality evidence schema
3300160875 Jul 16, 2026
00a08f2
feat(development): persist quality evidence
3300160875 Jul 16, 2026
7f4c0be
feat(team): enforce development task graph states
3300160875 Jul 16, 2026
fcd2996
feat(development): enforce evidence-backed quality gates
3300160875 Jul 16, 2026
8c34df0
test(development): cover authenticated board flow
3300160875 Jul 16, 2026
b38c9e1
feat(development): persist separated team roles
3300160875 Jul 16, 2026
f145bf4
test(development): cover gate and artifact failure modes
3300160875 Jul 16, 2026
853740c
feat(development): enforce task state transitions
3300160875 Jul 16, 2026
fdad242
test(team): import expanded task status fixtures
3300160875 Jul 16, 2026
44eb965
feat(approval): persist single-use approval requests
3300160875 Jul 17, 2026
6d66a75
feat(approval): add unified approval service and routes
3300160875 Jul 17, 2026
0548806
feat(telegram): relay agent permissions through approvals
3300160875 Jul 17, 2026
83e88ac
feat(telegram): add project development commands
3300160875 Jul 17, 2026
18455dc
fix(telegram): scope approvals to development runs
3300160875 Jul 17, 2026
0942165
feat(delivery): persist git and ci state
3300160875 Jul 17, 2026
e5a5173
feat(delivery): orchestrate safe git and github flow
3300160875 Jul 17, 2026
4c49b0b
feat(operations): persist policy audit and recovery state
3300160875 Jul 17, 2026
29bed27
feat(operations): enforce budgets and audit recovery
3300160875 Jul 17, 2026
90fe60d
feat(operations): isolate development command execution
3300160875 Jul 17, 2026
a052c3d
feat(operations): expose recovery control plane
3300160875 Jul 17, 2026
44d1c34
fix(operations): reconcile team workspace leases
3300160875 Jul 17, 2026
f648daa
chore(operations): satisfy strict development lint
3300160875 Jul 17, 2026
59e077a
feat(agent): add dynamic capability preflight
3300160875 Jul 20, 2026
aa875a5
feat(project): complete repository onboarding lifecycle
3300160875 Jul 20, 2026
d79e8dc
feat(project): integrate codebase knowledge lifecycle
3300160875 Jul 20, 2026
9b4d3e7
feat(development): add requirement evidence and single-run isolation
3300160875 Jul 20, 2026
00887be
feat(runtime): add leased agent execution and recovery
3300160875 Jul 20, 2026
589b368
feat(operations): close secrets and budget controls
3300160875 Jul 20, 2026
ebf0ab9
feat(delivery): add gitlab tags and approved deployments
3300160875 Jul 20, 2026
8b6d236
feat(platform): complete telegram remote loop and board
3300160875 Jul 20, 2026
f365f7a
fix(team): close real lifecycle recovery gaps
3300160875 Jul 20, 2026
7438821
test(team): seed dynamic probe in api e2e
3300160875 Jul 20, 2026
7e928f3
feat(platform): add safe portability and release evaluation history
3300160875 Jul 20, 2026
5811080
fix(agent): restore maintained Codex ACP probing
3300160875 Jul 21, 2026
9d22f60
test(platform): add live container runner acceptance
3300160875 Jul 21, 2026
8068b1a
test(channel): include topic in dingtalk fixtures
3300160875 Jul 21, 2026
44b239e
merge(main): integrate upstream 0.1.49
3300160875 Jul 21, 2026
58f3f42
fix(delivery): accept only GitHub repositories
3300160875 Jul 22, 2026
d2afb6a
refactor(delivery): remove GitLab provider
3300160875 Jul 22, 2026
18ee126
fix(db): reconcile legacy roadmap migration versions
3300160875 Jul 22, 2026
1d18fb9
merge: sync upstream 0.1.50
3300160875 Jul 22, 2026
61c1ba5
test: align fixtures with merged data models
3300160875 Jul 22, 2026
93ef1d3
fix(runtime): enforce login shell probe timeout
3300160875 Jul 22, 2026
123ee21
fix(development): unblock real GitHub delivery sync
3300160875 Jul 22, 2026
9785541
feat(development): enforce agent budgets and recovery fencing
3300160875 Jul 22, 2026
36f143a
fix(telegram): expose ACP config option models
3300160875 Jul 23, 2026
cf9f9ec
fix(telegram): surface approval and handoff results
3300160875 Jul 23, 2026
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
56 changes: 56 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ members = [
"crates/aionui-team-prompts",
"crates/aionui-team",
"crates/aionui-cron",
"crates/aionui-project",
"crates/aionui-development",
"crates/aionui-assistant",
"crates/aionui-app",
]
Expand Down Expand Up @@ -50,6 +52,8 @@ aionui-channel = { path = "crates/aionui-channel" }
aionui-team-prompts = { path = "crates/aionui-team-prompts" }
aionui-team = { path = "crates/aionui-team" }
aionui-cron = { path = "crates/aionui-cron" }
aionui-project = { path = "crates/aionui-project" }
aionui-development = { path = "crates/aionui-development" }
aionui-assistant = { path = "crates/aionui-assistant" }
aionui-app = { path = "crates/aionui-app" }

Expand Down Expand Up @@ -98,6 +102,7 @@ rusqlite = { version = "0.32", features = ["bundled"] }
jsonwebtoken = "9"
bcrypt = "0.17"
aes-gcm = "0.10"
zeroize = "1"

# UUID
uuid = { version = "1", features = ["v7"] }
Expand Down
223 changes: 188 additions & 35 deletions crates/aionui-ai-agent/src/capability/cli_process/spawn_sdk.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use aionui_common::{CommandSpec, ErrorChain};
use aionui_runtime::Builder as CmdBuilder;
#[cfg(test)]
use aionui_runtime::{Builder as CmdBuilder, ProcessLeaseSpec};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Child;
use tokio::sync::{Mutex, watch};
use tracing::{debug, error, info, warn};

Expand All @@ -23,12 +22,17 @@ impl CliAgentProcess {
/// - stderr buffering
/// - Process exit monitoring
pub async fn spawn_for_sdk(config: CommandSpec) -> Result<Self, AgentError> {
let mut cmd = CmdBuilder::new(&config.command);
Self::spawn_for_sdk_in_data_dir(config, &std::env::temp_dir()).await
}

pub async fn spawn_for_sdk_in_data_dir(config: CommandSpec, data_dir: &Path) -> Result<Self, AgentError> {
let (mut cmd, environment_kind, environment_id, lease_id) = Self::sdk_runner_command(&config)?;
let agent_env = aionui_runtime::agent_process_env().await;
cmd.args(&config.args)
.env_clear()
cmd.env_clear()
.envs(agent_env)
.envs(Self::agent_spawn_env(data_dir))
.envs(config.env.iter().map(|e| (&e.name, &e.value)))
.env("AIONUI_RUNNER_ENVIRONMENT_ID", &environment_id)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
Expand All @@ -37,32 +41,61 @@ impl CliAgentProcess {
cmd.current_dir(prepare_command_cwd(cwd)?);
}
let preview = Self::sdk_spawn_preview(&config);
info!(command = %preview, "Spawning CLI process (SDK mode)");
let mut child: Child = cmd.spawn().map_err(|e| {
error!(command = %preview, error = %ErrorChain(&e), "Failed to spawn CLI process");
AgentError::internal(format!("Failed to spawn CLI process '{preview}': {e}"))
})?;

let pid = child.id().ok_or_else(|| {
error!(command = %preview, "Failed to obtain PID from spawned process");
AgentError::internal("Failed to obtain PID from spawned process")
})?;
info!(pid, command = %preview, "CLI process spawned (SDK mode)");

let stdout = child.stdout.take().ok_or_else(|| {
error!(pid, "Failed to capture stdout from child process");
AgentError::internal("Failed to capture stdout from child process")
})?;
let stderr = child.stderr.take().ok_or_else(|| {
error!(pid, "Failed to capture stderr from child process");
AgentError::internal("Failed to capture stderr from child process")
})?;
let stdin = child.stdin.take().ok_or_else(|| {
error!(pid, "Failed to capture stdin for child process");
AgentError::internal("Failed to capture stdin for child process")
})?;
info!(
lease_id,
environment_kind, environment_id, "spawning leased ACP process"
);
let mut child = cmd
.spawn_leased(ProcessLeaseSpec::new(
&lease_id,
&environment_id,
Duration::from_secs(24 * 60 * 60),
))
.map_err(|e| {
error!(lease_id, environment_kind, error = %ErrorChain(&e), "failed to spawn leased ACP process");
AgentError::internal(format!("Failed to spawn CLI process '{preview}': {e}"))
})?;

let pid = match child.id() {
Some(pid) => pid,
None => {
error!(lease_id, environment_kind, "failed to obtain leased ACP process id");
let _ = child.terminate_tree().await;
return Err(AgentError::internal("Failed to obtain PID from spawned process"));
}
};
info!(
pid,
lease_id, environment_kind, environment_id, "leased ACP process spawned"
);

let stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => {
error!(pid, "Failed to capture stdout from child process");
let _ = child.terminate_tree().await;
return Err(AgentError::internal("Failed to capture stdout from child process"));
}
};
let stderr = match child.stderr.take() {
Some(stderr) => stderr,
None => {
error!(pid, "Failed to capture stderr from child process");
let _ = child.terminate_tree().await;
return Err(AgentError::internal("Failed to capture stderr from child process"));
}
};
let stdin = match child.stdin.take() {
Some(stdin) => stdin,
None => {
error!(pid, "Failed to capture stdin for child process");
let _ = child.terminate_tree().await;
return Err(AgentError::internal("Failed to capture stdin from child process"));
}
};

let (exit_tx, exit_rx) = watch::channel(None);
let process_lease = child.lease().clone();

// Background task: read stderr → ring buffer + log
let stderr_buffer = Arc::new(Mutex::new(String::new()));
Expand All @@ -74,7 +107,7 @@ impl CliAgentProcess {
while let Ok(Some(line)) = lines.next_line().await {
let trimmed = line.trim();
if !trimmed.is_empty() {
warn!(pid, stderr = trimmed, "CLI process stderr");
warn!(pid, stderr_bytes = line.len(), "ACP process emitted stderr");
}
let mut buf = stderr_buf_clone.lock().await;
buf.push_str(&line);
Expand All @@ -85,12 +118,20 @@ impl CliAgentProcess {
debug!(pid, "Stderr reader finished");
});

tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
while !process_lease.is_terminal() {
interval.tick().await;
process_lease.heartbeat();
}
});

// Background task: monitor process exit
let exit_handle = tokio::spawn(async move {
match child.wait().await {
Ok(status) => {
info!(pid, ?status, "CLI process exited");
let _ = exit_tx.send(Some(status));
match child.wait_with_timeout().await {
Ok(exit) => {
info!(pid, lease_id, timed_out = exit.timed_out, "leased ACP process exited");
let _ = exit_tx.send(exit.status);
}
Err(e) => {
error!(pid, error = %ErrorChain(&e), "Failed to wait on CLI process");
Expand All @@ -111,6 +152,96 @@ impl CliAgentProcess {
})
}

fn sdk_runner_command(config: &CommandSpec) -> Result<(CmdBuilder, String, String, String), AgentError> {
let environment_kind = config_env(config, "AIONUI_RUNNER_ENVIRONMENT_KIND").unwrap_or("host");
let environment_id = config_env(config, "AIONUI_RUNNER_ENVIRONMENT_ID")
.map(str::to_owned)
.unwrap_or_else(|| "host:local".into());
let lease_id = config_env(config, "AIONUI_EXECUTION_LEASE_ID")
.map(str::to_owned)
.unwrap_or_else(|| format!("agent:{}", uuid::Uuid::now_v7()));
let command = match environment_kind {
"host" => {
let mut command = CmdBuilder::new(&config.command);
command.args(&config.args);
command
}
"docker" => {
let image = config_env(config, "AIONUI_RUNNER_CONTAINER_IMAGE")
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| AgentError::bad_request("Docker Agent execution requires a container image"))?;
let cwd = config
.cwd
.as_deref()
.ok_or_else(|| AgentError::bad_request("Docker Agent execution requires a workspace"))?;
let container_name = format!("aion-agent-{}", safe_runner_id(&lease_id));
let mut args = vec![
"run".into(),
"--rm".into(),
"--init".into(),
"-i".into(),
"--name".into(),
container_name,
"--workdir".into(),
"/workspace".into(),
"--volume".into(),
format!("{cwd}:/workspace:rw"),
];
for entry in config.env.iter().filter(|entry| !entry.name.starts_with("AIONUI_")) {
args.push("--env".into());
args.push(entry.name.clone());
}
args.push(image.into());
args.push(config.command.to_string_lossy().into_owned());
args.extend(config.args.clone());
let mut command = CmdBuilder::new("docker");
command.args(args);
command
}
"devcontainer" => {
let cwd = config
.cwd
.as_deref()
.ok_or_else(|| AgentError::bad_request("Dev Container Agent execution requires a workspace"))?;
let devcontainer_config = config_env(config, "AIONUI_RUNNER_DEVCONTAINER_CONFIG")
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| AgentError::bad_request("Dev Container Agent execution requires a config path"))?;
if config.env.iter().any(|entry| !entry.name.starts_with("AIONUI_")) {
return Err(AgentError::bad_request(
"Dev Container Agent credentials must be provisioned inside the selected container",
));
}
let mut args = vec![
"exec".into(),
"--workspace-folder".into(),
cwd.into(),
"--config".into(),
devcontainer_config.into(),
config.command.to_string_lossy().into_owned(),
];
args.extend(config.args.clone());
let mut command = CmdBuilder::new("devcontainer");
command.args(args);
command
}
other => {
return Err(AgentError::bad_request(format!(
"Unsupported Agent execution environment: {other}"
)));
}
};
Ok((command, environment_kind.into(), environment_id, lease_id))
}

fn agent_spawn_env(data_dir: &Path) -> Vec<(String, String)> {
let bun_cache = data_dir.join("bun-cache");
let bun_tmp = data_dir.join("bun-tmp");
vec![
("BUN_INSTALL_CACHE_DIR".into(), bun_cache.to_string_lossy().into_owned()),
("BUN_TMPDIR".into(), bun_tmp.to_string_lossy().into_owned()),
]
}

fn sdk_spawn_preview(config: &CommandSpec) -> String {
let explicit_env_key_names: Vec<&str> = config.env.iter().map(|entry| entry.name.as_str()).collect();
format!(
Expand All @@ -124,6 +255,28 @@ impl CliAgentProcess {
}
}

fn config_env<'a>(config: &'a CommandSpec, name: &str) -> Option<&'a str> {
config
.env
.iter()
.find(|entry| entry.name == name)
.map(|entry| entry.value.as_str())
}

fn safe_runner_id(value: &str) -> String {
let value = value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
character.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
value.trim_matches('-').chars().take(48).collect()
}

#[cfg(test)]
mod tests {
use super::super::tests::simple_script_config;
Expand Down
Loading