Synced from monorepo
Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
26
crates/codegen/xai-workflow/Cargo.toml
Normal file
26
crates/codegen/xai-workflow/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-workflow"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Rhai-scripted dynamic workflow engine: scripts orchestrate agents through a host channel"
|
||||
|
||||
[dependencies]
|
||||
rhai = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
23
crates/codegen/xai-workflow/examples/validate.rs
Normal file
23
crates/codegen/xai-workflow/examples/validate.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use std::io::Read as _;
|
||||
|
||||
fn main() {
|
||||
let mut script = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut script)
|
||||
.expect("read stdin");
|
||||
|
||||
match xai_workflow::validate_script(&script, None) {
|
||||
Ok(report) => {
|
||||
println!("META OK: name={} phases={}", report.name, report.phases);
|
||||
println!("RUN OK: {}", report.outcome_summary);
|
||||
}
|
||||
Err(xai_workflow::ValidationError::Meta(e)) => {
|
||||
println!("META FAIL: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(xai_workflow::ValidationError::Run(e)) => {
|
||||
println!("RUN FAIL: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
1779
crates/codegen/xai-workflow/src/engine.rs
Normal file
1779
crates/codegen/xai-workflow/src/engine.rs
Normal file
File diff suppressed because it is too large
Load diff
128
crates/codegen/xai-workflow/src/host.rs
Normal file
128
crates/codegen/xai-workflow/src/host.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AgentOpts {
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub agent_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub capability_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub isolation_worktree: bool,
|
||||
#[serde(default)]
|
||||
pub fork_context: bool,
|
||||
#[serde(default)]
|
||||
pub resume_from: Option<String>,
|
||||
#[serde(default)]
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub phase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentResult {
|
||||
pub agent_id: String,
|
||||
pub success: bool,
|
||||
pub output: serde_json::Value,
|
||||
pub cancelled: bool,
|
||||
pub tokens_used: u64,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct BudgetState {
|
||||
pub total: Option<u64>,
|
||||
pub spent: u64,
|
||||
pub reserved: u64,
|
||||
pub remaining: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum HostError {
|
||||
#[error("workflow agent-call quota exceeded: requested {requested}, maximum {maximum}")]
|
||||
AgentCallQuotaExceeded { requested: u64, maximum: u64 },
|
||||
#[error("workflow token budget exceeded")]
|
||||
BudgetExceeded,
|
||||
#[error("workflow cancelled")]
|
||||
Cancelled,
|
||||
#[error("unsupported in this context: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("host failure: {0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WorkflowHostRequest {
|
||||
ReserveAgentCalls {
|
||||
count: u64,
|
||||
reply: oneshot::Sender<Result<(), HostError>>,
|
||||
},
|
||||
ReleaseAgentCalls {
|
||||
count: u64,
|
||||
reply: oneshot::Sender<Result<(), HostError>>,
|
||||
},
|
||||
SpawnAgent {
|
||||
opts: AgentOpts,
|
||||
reply: oneshot::Sender<Result<AgentResult, HostError>>,
|
||||
},
|
||||
Phase {
|
||||
title: String,
|
||||
replayed: bool,
|
||||
},
|
||||
Log {
|
||||
message: String,
|
||||
replayed: bool,
|
||||
},
|
||||
Telemetry {
|
||||
name: String,
|
||||
fields: serde_json::Value,
|
||||
replayed: bool,
|
||||
},
|
||||
BudgetQuery {
|
||||
reply: oneshot::Sender<Result<BudgetState, HostError>>,
|
||||
},
|
||||
RenderTemplate {
|
||||
name: String,
|
||||
vars: serde_json::Value,
|
||||
reply: oneshot::Sender<Result<String, HostError>>,
|
||||
},
|
||||
WriteScratchFile {
|
||||
name: String,
|
||||
content: String,
|
||||
reply: oneshot::Sender<Result<String, HostError>>,
|
||||
},
|
||||
ReadScratchFile {
|
||||
name: String,
|
||||
reply: oneshot::Sender<Result<String, HostError>>,
|
||||
},
|
||||
GitDiffSince {
|
||||
commit: String,
|
||||
reply: oneshot::Sender<Result<String, HostError>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkflowHostRequest {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ReserveAgentCalls { .. } => "reserve_agent_calls",
|
||||
Self::ReleaseAgentCalls { .. } => "release_agent_calls",
|
||||
Self::SpawnAgent { .. } => "spawn_agent",
|
||||
Self::Phase { .. } => "phase",
|
||||
Self::Log { .. } => "log",
|
||||
Self::Telemetry { .. } => "telemetry",
|
||||
Self::BudgetQuery { .. } => "budget",
|
||||
Self::RenderTemplate { .. } => "render_template",
|
||||
Self::WriteScratchFile { .. } => "write_scratch_file",
|
||||
Self::ReadScratchFile { .. } => "read_scratch_file",
|
||||
Self::GitDiffSince { .. } => "git_diff_since",
|
||||
}
|
||||
}
|
||||
}
|
||||
498
crates/codegen/xai-workflow/src/journal.rs
Normal file
498
crates/codegen/xai-workflow/src/journal.rs
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
use std::io::{Read as _, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::Digest as _;
|
||||
|
||||
pub const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
|
||||
pub const MAX_JOURNAL_ENTRIES: usize = crate::MAX_HOST_CALLS as usize;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct JournalEntry {
|
||||
pub seq: u64,
|
||||
pub kind: String,
|
||||
pub req_hash: String,
|
||||
pub result: serde_json::Value,
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JournalError {
|
||||
#[error("journal io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("journal parse at line {line}: {error}")]
|
||||
Parse { line: usize, error: String },
|
||||
#[error("journal restore rejected (limit {limit}): {reason}")]
|
||||
UnsafeRestore { limit: u64, reason: String },
|
||||
#[error(
|
||||
"journal full: appending seq {seq} would exceed the {limit}-byte cap \
|
||||
that restore enforces, which would strand the run unresumable"
|
||||
)]
|
||||
Full { seq: u64, limit: u64 },
|
||||
#[error("journal is not dense at entry {index}: expected sequence {expected}, found {actual}")]
|
||||
Sequence {
|
||||
index: usize,
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
#[error(
|
||||
"replay divergence at seq {seq} ({kind}): the script issued a different call than the \
|
||||
recorded run — the workflow script is nondeterministic or was edited mid-run"
|
||||
)]
|
||||
Divergence { seq: u64, kind: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Journal {
|
||||
entries: Vec<JournalEntry>,
|
||||
path: Option<PathBuf>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl Journal {
|
||||
pub fn new(path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
path,
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: PathBuf) -> Result<Self, JournalError> {
|
||||
let content = match read_journal_bounded(&path) {
|
||||
Ok(content) => content,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::InvalidData => {
|
||||
return Err(JournalError::UnsafeRestore {
|
||||
limit: MAX_JOURNAL_BYTES,
|
||||
reason: error.to_string(),
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
let mut line_number = 0usize;
|
||||
let mut bytes = content.len() as u64;
|
||||
while offset < content.len() {
|
||||
line_number += 1;
|
||||
let Some(relative_newline) = content[offset..].iter().position(|byte| *byte == b'\n')
|
||||
else {
|
||||
let tail = &content[offset..];
|
||||
if tail.iter().all(u8::is_ascii_whitespace) {
|
||||
truncate_tail(&path, offset as u64)?;
|
||||
bytes = offset as u64;
|
||||
break;
|
||||
}
|
||||
match serde_json::from_slice::<JournalEntry>(tail) {
|
||||
Ok(entry) => {
|
||||
if entries.len() >= MAX_JOURNAL_ENTRIES {
|
||||
return Err(JournalError::UnsafeRestore {
|
||||
limit: MAX_JOURNAL_ENTRIES as u64,
|
||||
reason: "too many journal entries".into(),
|
||||
});
|
||||
}
|
||||
validate_sequence(&entries, &entry)?;
|
||||
entries.push(entry);
|
||||
terminate_line(&path)?;
|
||||
bytes = bytes.saturating_add(1);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
line = line_number,
|
||||
%error,
|
||||
"truncating torn workflow journal tail"
|
||||
);
|
||||
truncate_tail(&path, offset as u64)?;
|
||||
bytes = offset as u64;
|
||||
}
|
||||
}
|
||||
break;
|
||||
};
|
||||
let end = offset + relative_newline;
|
||||
let line = &content[offset..end];
|
||||
offset = end + 1;
|
||||
if line.iter().all(u8::is_ascii_whitespace) {
|
||||
continue;
|
||||
}
|
||||
let entry = serde_json::from_slice::<JournalEntry>(line).map_err(|error| {
|
||||
JournalError::Parse {
|
||||
line: line_number,
|
||||
error: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
if entries.len() >= MAX_JOURNAL_ENTRIES {
|
||||
return Err(JournalError::UnsafeRestore {
|
||||
limit: MAX_JOURNAL_ENTRIES as u64,
|
||||
reason: "too many journal entries".into(),
|
||||
});
|
||||
}
|
||||
validate_sequence(&entries, &entry)?;
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(Self {
|
||||
entries,
|
||||
path: Some(path),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn agent_reservation_count(&self) -> u64 {
|
||||
u64::try_from(
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.kind == "spawn_agent")
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
pub fn covers(&self, seq: u64) -> bool {
|
||||
usize::try_from(seq).is_ok_and(|seq| seq < self.entries.len())
|
||||
}
|
||||
|
||||
pub fn replay(
|
||||
&self,
|
||||
seq: u64,
|
||||
kind: &str,
|
||||
req_hash: &str,
|
||||
) -> Result<Option<serde_json::Value>, JournalError> {
|
||||
let Some(entry) = usize::try_from(seq)
|
||||
.ok()
|
||||
.and_then(|seq| self.entries.get(seq))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.seq != seq || entry.kind != kind || entry.req_hash != req_hash {
|
||||
return Err(JournalError::Divergence {
|
||||
seq,
|
||||
kind: kind.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(Some(entry.result.clone()))
|
||||
}
|
||||
|
||||
pub fn record(
|
||||
&mut self,
|
||||
seq: u64,
|
||||
kind: &str,
|
||||
req_hash: String,
|
||||
result: serde_json::Value,
|
||||
) -> Result<(), JournalError> {
|
||||
let entry = JournalEntry {
|
||||
seq,
|
||||
kind: kind.to_string(),
|
||||
req_hash,
|
||||
result,
|
||||
at_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
};
|
||||
validate_sequence(&self.entries, &entry)?;
|
||||
let mut line = serde_json::to_string(&entry)
|
||||
.map_err(|error| JournalError::Io(std::io::Error::other(error)))?;
|
||||
line.push('\n');
|
||||
if self.bytes.saturating_add(line.len() as u64) > MAX_JOURNAL_BYTES {
|
||||
return Err(JournalError::Full {
|
||||
seq,
|
||||
limit: MAX_JOURNAL_BYTES,
|
||||
});
|
||||
}
|
||||
if let Some(path) = &self.path {
|
||||
append_line(path, &line)?;
|
||||
}
|
||||
self.bytes = self.bytes.saturating_add(line.len() as u64);
|
||||
self.entries.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_journal_bounded(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("journal is not a regular file: {}", path.display()),
|
||||
));
|
||||
}
|
||||
if metadata.len() > MAX_JOURNAL_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("journal exceeds {MAX_JOURNAL_BYTES} bytes"),
|
||||
));
|
||||
}
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
let opened = file.metadata()?;
|
||||
if !opened.is_file() || opened.len() > MAX_JOURNAL_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"journal changed during open",
|
||||
));
|
||||
}
|
||||
let mut content = Vec::with_capacity(opened.len() as usize);
|
||||
file.take(MAX_JOURNAL_BYTES.saturating_add(1))
|
||||
.read_to_end(&mut content)?;
|
||||
if content.len() as u64 > MAX_JOURNAL_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("journal exceeds {MAX_JOURNAL_BYTES} bytes"),
|
||||
));
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn validate_sequence(entries: &[JournalEntry], entry: &JournalEntry) -> Result<(), JournalError> {
|
||||
let expected = entries.len() as u64;
|
||||
if entry.seq != expected {
|
||||
return Err(JournalError::Sequence {
|
||||
index: entries.len(),
|
||||
expected,
|
||||
actual: entry.seq,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate_tail(path: &Path, len: u64) -> std::io::Result<()> {
|
||||
let file = std::fs::OpenOptions::new().write(true).open(path)?;
|
||||
file.set_len(len)?;
|
||||
file.sync_data()
|
||||
}
|
||||
|
||||
fn terminate_line(path: &Path) -> std::io::Result<()> {
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
|
||||
file.write_all(b"\n")?;
|
||||
file.sync_data()
|
||||
}
|
||||
|
||||
fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.sync_data()
|
||||
}
|
||||
|
||||
fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut entries: Vec<(&String, &serde_json::Value)> = map.iter().collect();
|
||||
entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
|
||||
serde_json::Value::Object(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.clone(), canonical_json(v)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
serde_json::Value::Array(items.iter().map(canonical_json).collect())
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_hash(kind: &str, payload: &serde_json::Value) -> String {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(kind.as_bytes());
|
||||
hasher.update([0u8]);
|
||||
hasher.update(canonical_json(payload).to_string().as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let mut out = String::with_capacity(32);
|
||||
for byte in digest.iter().take(16) {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn record_and_replay_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("journal.jsonl");
|
||||
let mut journal = Journal::new(Some(path.clone()));
|
||||
let hash = request_hash("spawn_agent", &serde_json::json!({"prompt": "hi"}));
|
||||
journal
|
||||
.record(
|
||||
0,
|
||||
"spawn_agent",
|
||||
hash.clone(),
|
||||
serde_json::json!({"ok": true}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = Journal::load(path).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
let replayed = loaded.replay(0, "spawn_agent", &hash).unwrap();
|
||||
assert_eq!(replayed, Some(serde_json::json!({"ok": true})));
|
||||
assert!(loaded.replay(1, "spawn_agent", &hash).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergence_on_hash_mismatch() {
|
||||
let mut journal = Journal::new(None);
|
||||
journal
|
||||
.record(0, "spawn_agent", "aaaa".into(), serde_json::json!(1))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
journal.replay(0, "spawn_agent", "bbbb"),
|
||||
Err(JournalError::Divergence { seq: 0, .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
journal.replay(0, "budget", "aaaa"),
|
||||
Err(JournalError::Divergence { seq: 0, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torn_tail_is_truncated_before_the_next_append() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("journal.jsonl");
|
||||
let first = "{\"seq\":0,\"kind\":\"log\",\"req_hash\":\"x\",\"result\":null,\"at_ms\":1}\n";
|
||||
std::fs::write(&path, format!("{first}{{\"seq\":1,\"kind")).unwrap();
|
||||
|
||||
let mut journal = Journal::load(path.clone()).unwrap();
|
||||
assert_eq!(journal.len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), first);
|
||||
journal
|
||||
.record(1, "log", "y".into(), serde_json::Value::Null)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(Journal::load(path).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_unterminated_tail_is_kept_and_terminated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("journal.jsonl");
|
||||
let line = "{\"seq\":0,\"kind\":\"log\",\"req_hash\":\"x\",\"result\":null,\"at_ms\":1}";
|
||||
std::fs::write(&path, line).unwrap();
|
||||
|
||||
assert_eq!(Journal::load(path.clone()).unwrap().len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), format!("{line}\n"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn load_rejects_symlink_journal() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("target.jsonl");
|
||||
let linked = dir.path().join("journal.jsonl");
|
||||
std::fs::write(&target, "").unwrap();
|
||||
symlink(&target, &linked).unwrap();
|
||||
assert!(matches!(
|
||||
Journal::load(linked),
|
||||
Err(JournalError::UnsafeRestore { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_oversize_journal_before_reading() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("journal.jsonl");
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
file.set_len(MAX_JOURNAL_BYTES + 1).unwrap();
|
||||
assert!(matches!(
|
||||
Journal::load(path),
|
||||
Err(JournalError::UnsafeRestore { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_refuses_to_grow_past_the_restore_cap() {
|
||||
let mut journal = Journal::new(None);
|
||||
let big = "x".repeat(MAX_JOURNAL_BYTES as usize + 1);
|
||||
let hash = request_hash("spawn_agent", &serde_json::json!({}));
|
||||
let err = journal
|
||||
.record(0, "spawn_agent", hash.clone(), serde_json::json!(big))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, JournalError::Full { seq: 0, .. }), "{err}");
|
||||
journal
|
||||
.record(0, "spawn_agent", hash, serde_json::json!({"ok": true}))
|
||||
.unwrap();
|
||||
assert_eq!(journal.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_malformed_line_is_not_treated_as_torn() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("journal.jsonl");
|
||||
std::fs::write(&path, b"not-json\n").unwrap();
|
||||
assert!(matches!(
|
||||
Journal::load(path),
|
||||
Err(JournalError::Parse { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_and_record_require_dense_sequences() {
|
||||
let mut journal = Journal::new(None);
|
||||
assert!(matches!(
|
||||
journal.record(1, "log", "x".into(), serde_json::Value::Null),
|
||||
Err(JournalError::Sequence {
|
||||
expected: 0,
|
||||
actual: 1,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("journal.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"{\"seq\":1,\"kind\":\"log\",\"req_hash\":\"x\",\"result\":null,\"at_ms\":1}\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
Journal::load(path),
|
||||
Err(JournalError::Sequence {
|
||||
expected: 0,
|
||||
actual: 1,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_error_does_not_advance_memory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut journal = Journal::new(Some(dir.path().join("journal.jsonl")));
|
||||
std::fs::create_dir(dir.path().join("journal.jsonl")).unwrap();
|
||||
assert!(matches!(
|
||||
journal.record(0, "log", "x".into(), serde_json::Value::Null),
|
||||
Err(JournalError::Io(_))
|
||||
));
|
||||
assert!(journal.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_hash_is_stable() {
|
||||
let a = request_hash("k", &serde_json::json!({"b": 2, "a": 1}));
|
||||
let b = request_hash("k", &serde_json::json!({"a": 1, "b": 2}));
|
||||
assert_eq!(a, b, "map key order must not affect the hash");
|
||||
}
|
||||
}
|
||||
44
crates/codegen/xai-workflow/src/lib.rs
Normal file
44
crates/codegen/xai-workflow/src/lib.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
pub mod engine;
|
||||
pub mod host;
|
||||
pub mod journal;
|
||||
pub mod meta;
|
||||
pub mod run;
|
||||
pub mod validate;
|
||||
|
||||
pub const MAX_WORKFLOW_NAME_LEN: usize = 64;
|
||||
pub const MAX_WORKFLOW_DESCRIPTION_LEN: usize = 1_024;
|
||||
pub const MAX_WORKFLOW_WHEN_TO_USE_LEN: usize = 2_048;
|
||||
pub const MAX_WORKFLOW_PHASES: usize = 64;
|
||||
pub const MAX_PHASE_TITLE_LEN: usize = 128;
|
||||
pub const MAX_PHASE_DETAIL_LEN: usize = 1_024;
|
||||
pub const MAX_PARALLEL: usize = 1_024;
|
||||
pub const DEFAULT_AGENT_BUDGET: u64 = 128;
|
||||
pub const MAX_AGENT_BUDGET: u64 = 1_024;
|
||||
pub const MAX_HOST_CALLS: u64 = 10_000;
|
||||
|
||||
pub(crate) fn with_rhai_hint(msg: String) -> String {
|
||||
let hint = if msg.contains("Expression exceeds maximum complexity") {
|
||||
"a single expression nests too deep — usually one long chained `+` string \
|
||||
concatenation. Split it into multiple `+=` statements."
|
||||
} else if msg.contains("reserved keyword") {
|
||||
"Rhai reserves identifiers it doesn't use — `shared`, `sync`, `async`, `await`, \
|
||||
`spawn`, `go`, `thread`, `new`, `match`, `case`, `default`, `void`, `null`, \
|
||||
`nil`, `exit`, `static`, `var` — rename the variable (`shared` → `has_shared`)."
|
||||
} else if msg.contains("getter is not registered for type 'char'") {
|
||||
"indexing a string yields a `char`, so field access on it fails — you likely \
|
||||
indexed a string you expected to be an array (e.g. unparsed JSON in an agent \
|
||||
output). Check with `type_of(x)`; slice strings with `s.sub_string(start, len)`."
|
||||
} else {
|
||||
return msg;
|
||||
};
|
||||
format!("{msg}\nhint: {hint}")
|
||||
}
|
||||
|
||||
pub use engine::{WorkflowRunParams, run_workflow};
|
||||
pub use host::{AgentOpts, AgentResult, BudgetState, HostError, WorkflowHostRequest};
|
||||
pub use journal::{Journal, JournalEntry, JournalError};
|
||||
pub use meta::{MetaError, PhaseMeta, WorkflowMeta, extract_meta};
|
||||
pub use run::{PauseKind, WorkflowOutcome};
|
||||
pub use validate::{
|
||||
ValidationError, ValidationReport, validate_script, validate_script_with_agent_budget,
|
||||
};
|
||||
331
crates/codegen/xai-workflow/src/meta.rs
Normal file
331
crates/codegen/xai-workflow/src/meta.rs
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
MAX_PHASE_DETAIL_LEN, MAX_PHASE_TITLE_LEN, MAX_WORKFLOW_DESCRIPTION_LEN, MAX_WORKFLOW_NAME_LEN,
|
||||
MAX_WORKFLOW_PHASES, MAX_WORKFLOW_WHEN_TO_USE_LEN,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkflowMeta {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub when_to_use: Option<String>,
|
||||
#[serde(default)]
|
||||
pub phases: Vec<PhaseMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PhaseMeta {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MetaError {
|
||||
#[error("script failed to parse: {0}")]
|
||||
Parse(String),
|
||||
#[error("first statement must be `let meta = #{{ ... }};`")]
|
||||
MetaNotFirst,
|
||||
#[error("meta is not a valid map: {0}")]
|
||||
InvalidShape(String),
|
||||
#[error("{0} must be a non-empty string")]
|
||||
MissingField(&'static str),
|
||||
#[error("meta.name must be lowercase ASCII letters or digits separated by single hyphens")]
|
||||
InvalidName,
|
||||
#[error("{field} must be at most {max} UTF-8 bytes (got {actual})")]
|
||||
StringTooLong {
|
||||
field: String,
|
||||
max: usize,
|
||||
actual: usize,
|
||||
},
|
||||
#[error("meta.phases must contain at most {max} entries (got {actual})")]
|
||||
TooManyPhases { max: usize, actual: usize },
|
||||
}
|
||||
|
||||
const META_PROBE_MAX_OPS: u64 = 100_000;
|
||||
|
||||
pub fn extract_meta(script: &str) -> Result<WorkflowMeta, MetaError> {
|
||||
if !first_statement_is_meta(script) {
|
||||
return Err(MetaError::MetaNotFirst);
|
||||
}
|
||||
|
||||
let mut engine = rhai::Engine::new();
|
||||
engine.set_max_operations(META_PROBE_MAX_OPS);
|
||||
engine.set_max_expr_depths(128, 64);
|
||||
engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver::new());
|
||||
engine.disable_symbol("eval");
|
||||
|
||||
engine
|
||||
.compile(script)
|
||||
.map_err(|e| MetaError::Parse(crate::with_rhai_hint(e.to_string())))?;
|
||||
|
||||
let mut scope = rhai::Scope::new();
|
||||
scope.push_dynamic("args", rhai::Dynamic::UNIT);
|
||||
|
||||
let _ = engine.eval_with_scope::<rhai::Dynamic>(&mut scope, script);
|
||||
|
||||
let meta_dyn = scope
|
||||
.get_value::<rhai::Map>("meta")
|
||||
.ok_or(MetaError::MetaNotFirst)?;
|
||||
|
||||
let meta: WorkflowMeta = rhai::serde::from_dynamic(&meta_dyn.into())
|
||||
.map_err(|e| MetaError::InvalidShape(e.to_string()))?;
|
||||
|
||||
validate_meta(&meta)?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
fn validate_meta(meta: &WorkflowMeta) -> Result<(), MetaError> {
|
||||
if meta.name.trim().is_empty() {
|
||||
return Err(MetaError::MissingField("meta.name"));
|
||||
}
|
||||
validate_len("meta.name", &meta.name, MAX_WORKFLOW_NAME_LEN)?;
|
||||
if !valid_workflow_name(&meta.name) {
|
||||
return Err(MetaError::InvalidName);
|
||||
}
|
||||
|
||||
if meta.description.trim().is_empty() {
|
||||
return Err(MetaError::MissingField("meta.description"));
|
||||
}
|
||||
validate_len(
|
||||
"meta.description",
|
||||
&meta.description,
|
||||
MAX_WORKFLOW_DESCRIPTION_LEN,
|
||||
)?;
|
||||
if let Some(when_to_use) = &meta.when_to_use {
|
||||
validate_len(
|
||||
"meta.when_to_use",
|
||||
when_to_use,
|
||||
MAX_WORKFLOW_WHEN_TO_USE_LEN,
|
||||
)?;
|
||||
}
|
||||
|
||||
if meta.phases.len() > MAX_WORKFLOW_PHASES {
|
||||
return Err(MetaError::TooManyPhases {
|
||||
max: MAX_WORKFLOW_PHASES,
|
||||
actual: meta.phases.len(),
|
||||
});
|
||||
}
|
||||
let mut phase_titles = std::collections::HashSet::with_capacity(meta.phases.len());
|
||||
for (index, phase) in meta.phases.iter().enumerate() {
|
||||
if phase.title.trim().is_empty() {
|
||||
return Err(MetaError::MissingField("meta.phases[].title"));
|
||||
}
|
||||
if !phase_titles.insert(phase.title.as_str()) {
|
||||
return Err(MetaError::InvalidShape(format!(
|
||||
"duplicate meta.phases[].title: {:?}",
|
||||
phase.title
|
||||
)));
|
||||
}
|
||||
validate_len(
|
||||
&format!("meta.phases[{index}].title"),
|
||||
&phase.title,
|
||||
MAX_PHASE_TITLE_LEN,
|
||||
)?;
|
||||
if let Some(detail) = &phase.detail {
|
||||
validate_len(
|
||||
&format!("meta.phases[{index}].detail"),
|
||||
detail,
|
||||
MAX_PHASE_DETAIL_LEN,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_len(field: &str, value: &str, max: usize) -> Result<(), MetaError> {
|
||||
if value.len() > max {
|
||||
return Err(MetaError::StringTooLong {
|
||||
field: field.to_string(),
|
||||
max,
|
||||
actual: value.len(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_workflow_name(name: &str) -> bool {
|
||||
let bytes = name.as_bytes();
|
||||
!bytes.is_empty()
|
||||
&& bytes
|
||||
.first()
|
||||
.is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
|
||||
&& bytes
|
||||
.last()
|
||||
.is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
|
||||
&& !bytes.windows(2).any(|pair| pair == b"--")
|
||||
}
|
||||
|
||||
fn first_statement_is_meta(script: &str) -> bool {
|
||||
let mut rest = script;
|
||||
loop {
|
||||
rest = rest.trim_start();
|
||||
if let Some(after) = rest.strip_prefix("//") {
|
||||
rest = after.split_once('\n').map(|(_, r)| r).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if let Some(after) = rest.strip_prefix("/*") {
|
||||
match after.split_once("*/") {
|
||||
Some((_, r)) => {
|
||||
rest = r;
|
||||
continue;
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
rest.starts_with("let meta") || rest.starts_with("const meta")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_valid_meta() {
|
||||
let meta = extract_meta(
|
||||
r#"
|
||||
// A workflow.
|
||||
let meta = #{
|
||||
name: "demo",
|
||||
description: "does things",
|
||||
phases: [#{ title: "Scan" }, #{ title: "Fix", detail: "apply" }],
|
||||
};
|
||||
let x = agent("hi");
|
||||
"#,
|
||||
)
|
||||
.expect("valid meta");
|
||||
assert_eq!(meta.name, "demo");
|
||||
assert_eq!(meta.phases.len(), 2);
|
||||
assert_eq!(meta.phases[1].detail.as_deref(), Some("apply"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_meta() {
|
||||
assert!(matches!(
|
||||
extract_meta("let x = 1;"),
|
||||
Err(MetaError::MetaNotFirst)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_meta_not_first() {
|
||||
assert!(matches!(
|
||||
extract_meta(r#"let x = 1; let meta = #{ name: "n", description: "d" };"#),
|
||||
Err(MetaError::MetaNotFirst)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_name() {
|
||||
assert!(matches!(
|
||||
extract_meta(r#"let meta = #{ name: "", description: "d" };"#),
|
||||
Err(MetaError::MissingField("meta.name"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_kebab_case_names() {
|
||||
for name in [
|
||||
"Upper",
|
||||
"under_score",
|
||||
"-leading",
|
||||
"trailing-",
|
||||
"two--hyphens",
|
||||
"-1",
|
||||
] {
|
||||
let script = format!(r#"let meta = #{{ name: "{name}", description: "d" }};"#);
|
||||
assert!(
|
||||
matches!(extract_meta(&script), Err(MetaError::InvalidName)),
|
||||
"accepted invalid name {name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_name_bounds() {
|
||||
let name = format!("1{}", "a".repeat(MAX_WORKFLOW_NAME_LEN - 1));
|
||||
let script = format!(r#"let meta = #{{ name: "{name}", description: "d" }};"#);
|
||||
assert_eq!(extract_meta(&script).unwrap().name, name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_meta_strings_and_phases() {
|
||||
let name = "a".repeat(MAX_WORKFLOW_NAME_LEN + 1);
|
||||
let script = format!(r#"let meta = #{{ name: "{name}", description: "d" }};"#);
|
||||
assert!(matches!(
|
||||
extract_meta(&script),
|
||||
Err(MetaError::StringTooLong { field, .. }) if field == "meta.name"
|
||||
));
|
||||
|
||||
let phases = std::iter::repeat_n(r#"#{ title: "phase" }"#, MAX_WORKFLOW_PHASES + 1)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let script =
|
||||
format!(r#"let meta = #{{ name: "valid", description: "d", phases: [{phases}] }};"#);
|
||||
assert!(matches!(
|
||||
extract_meta(&script),
|
||||
Err(MetaError::TooManyPhases { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_phase_title() {
|
||||
let error = extract_meta(
|
||||
r#"let meta = #{ name: "valid", description: "d", phases: [#{ title: " " }] };"#,
|
||||
);
|
||||
assert!(matches!(
|
||||
error,
|
||||
Err(MetaError::MissingField("meta.phases[].title"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_phase_titles() {
|
||||
let error = extract_meta(
|
||||
r#"let meta = #{ name: "valid", description: "d", phases: [#{ title: "Scan" }, #{ title: "Scan" }] };"#,
|
||||
);
|
||||
assert!(matches!(
|
||||
error,
|
||||
Err(MetaError::InvalidShape(message)) if message.contains("duplicate meta.phases[].title")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_meta_fields() {
|
||||
assert!(matches!(
|
||||
extract_meta(r#"let meta = #{ name: "valid", description: "d", typo: "ignored?" };"#,),
|
||||
Err(MetaError::InvalidShape(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
extract_meta(
|
||||
r#"let meta = #{ name: "valid", description: "d", phases: [#{ title: "p", typo: true }] };"#,
|
||||
),
|
||||
Err(MetaError::InvalidShape(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_syntax_errors() {
|
||||
assert!(matches!(
|
||||
extract_meta(r#"let meta = #{ name: "n", description: "d" }; fn {"#),
|
||||
Err(MetaError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_before_meta_are_fine() {
|
||||
let meta = extract_meta(
|
||||
"/* header\ncomment */\n// line\nlet meta = #{ name: \"n\", description: \"d\" };",
|
||||
);
|
||||
assert!(meta.is_ok());
|
||||
}
|
||||
}
|
||||
48
crates/codegen/xai-workflow/src/run.rs
Normal file
48
crates/codegen/xai-workflow/src/run.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PauseKind {
|
||||
User,
|
||||
BackOff,
|
||||
NoProgress,
|
||||
Verification,
|
||||
Infra,
|
||||
}
|
||||
|
||||
impl PauseKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::BackOff => "back_off",
|
||||
Self::NoProgress => "no_progress",
|
||||
Self::Verification => "verification",
|
||||
Self::Infra => "infra",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for PauseKind {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"user" => Ok(Self::User),
|
||||
"back_off" | "backoff" => Ok(Self::BackOff),
|
||||
"no_progress" => Ok(Self::NoProgress),
|
||||
"verification" | "blocked" => Ok(Self::Verification),
|
||||
"infra" => Ok(Self::Infra),
|
||||
other => Err(format!("unknown pause kind: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum WorkflowOutcome {
|
||||
Completed { result: serde_json::Value },
|
||||
Paused { kind: PauseKind, message: String },
|
||||
BudgetExceeded { message: String },
|
||||
Cancelled,
|
||||
Failed { error: String },
|
||||
}
|
||||
297
crates/codegen/xai-workflow/src/validate.rs
Normal file
297
crates/codegen/xai-workflow/src/validate.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
use crate::host::{AgentResult, BudgetState, WorkflowHostRequest};
|
||||
use crate::{Journal, WorkflowOutcome, WorkflowRunParams, extract_meta, run_workflow};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationReport {
|
||||
pub name: String,
|
||||
pub phases: usize,
|
||||
pub outcome_ok: bool,
|
||||
pub outcome_summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ValidationError {
|
||||
#[error("meta: {0}")]
|
||||
Meta(#[from] crate::MetaError),
|
||||
#[error("dry-run: {0}")]
|
||||
Run(String),
|
||||
}
|
||||
|
||||
pub fn default_probe_args() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"objective": "stub objective",
|
||||
"query": "stub query",
|
||||
"breadth": 2,
|
||||
"target": "stub target",
|
||||
"skeptic_count": 1,
|
||||
"max_verify_attempts": 1,
|
||||
"baseline_commit": "",
|
||||
"test_command": "cargo test",
|
||||
"diff_summary": "stub diff",
|
||||
"since_commit": "abc123",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_script(
|
||||
script: &str,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<ValidationReport, ValidationError> {
|
||||
validate_script_with_agent_budget(script, args, crate::DEFAULT_AGENT_BUDGET)
|
||||
}
|
||||
|
||||
pub fn validate_script_with_agent_budget(
|
||||
script: &str,
|
||||
args: Option<serde_json::Value>,
|
||||
agent_budget: u64,
|
||||
) -> Result<ValidationReport, ValidationError> {
|
||||
let meta = extract_meta(script)?;
|
||||
|
||||
let (host_tx, mut host_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let host = std::thread::spawn(move || {
|
||||
use WorkflowHostRequest as R;
|
||||
let mut agent_calls = 0u64;
|
||||
while let Some(req) = host_rx.blocking_recv() {
|
||||
match req {
|
||||
R::ReserveAgentCalls { count, reply } => {
|
||||
let requested = agent_calls.saturating_add(count);
|
||||
if requested > agent_budget {
|
||||
let _ = reply.send(Err(crate::HostError::AgentCallQuotaExceeded {
|
||||
requested,
|
||||
maximum: agent_budget,
|
||||
}));
|
||||
} else {
|
||||
agent_calls = requested;
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
}
|
||||
R::ReleaseAgentCalls { count, reply } => {
|
||||
agent_calls = agent_calls.saturating_sub(count);
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
R::SpawnAgent { reply, .. } => {
|
||||
let _ = reply.send(Ok(AgentResult {
|
||||
agent_id: "stub".into(),
|
||||
success: true,
|
||||
output: serde_json::json!({
|
||||
"achieved": true,
|
||||
"gaps": "",
|
||||
"evidence": "stub evidence",
|
||||
"questions": ["q1", "q2"],
|
||||
"claims": [],
|
||||
"uncertainties": [],
|
||||
"verdicts": [],
|
||||
"failures": ["test_a"],
|
||||
"issues": "none",
|
||||
"stub": true
|
||||
}),
|
||||
cancelled: false,
|
||||
tokens_used: 1,
|
||||
duration_ms: 1,
|
||||
}));
|
||||
}
|
||||
R::BudgetQuery { reply } => {
|
||||
let _ = reply.send(Ok(BudgetState {
|
||||
total: None,
|
||||
spent: 0,
|
||||
reserved: 0,
|
||||
remaining: None,
|
||||
}));
|
||||
}
|
||||
R::RenderTemplate { reply, .. } => {
|
||||
let _ = reply.send(Ok("stub template".into()));
|
||||
}
|
||||
R::WriteScratchFile { name, reply, .. } => {
|
||||
let _ = reply.send(Ok(format!("scratch/{name}")));
|
||||
}
|
||||
R::ReadScratchFile { reply, .. } => {
|
||||
let _ = reply.send(Ok("stub content".into()));
|
||||
}
|
||||
R::GitDiffSince { reply, .. } => {
|
||||
let _ = reply.send(Ok("".into()));
|
||||
}
|
||||
R::Phase { .. } | R::Log { .. } | R::Telemetry { .. } => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let outcome = run_workflow(WorkflowRunParams {
|
||||
script: script.to_string(),
|
||||
args: args.unwrap_or_else(default_probe_args),
|
||||
journal: Journal::new(None),
|
||||
host_tx,
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
max_ops: 10_000_000,
|
||||
});
|
||||
drop(host);
|
||||
|
||||
let (outcome_ok, outcome_summary) = match &outcome {
|
||||
WorkflowOutcome::Completed { result } => (
|
||||
true,
|
||||
format!("completed: {}", truncate(&result.to_string())),
|
||||
),
|
||||
WorkflowOutcome::Paused { kind, message } => {
|
||||
(true, format!("paused ({kind:?}): {}", truncate(message)))
|
||||
}
|
||||
WorkflowOutcome::Failed { error } => (false, format!("failed: {error}")),
|
||||
other => (false, format!("{other:?}")),
|
||||
};
|
||||
if !outcome_ok {
|
||||
return Err(ValidationError::Run(outcome_summary));
|
||||
}
|
||||
|
||||
Ok(ValidationReport {
|
||||
name: meta.name,
|
||||
phases: meta.phases.len(),
|
||||
outcome_ok,
|
||||
outcome_summary,
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate(s: &str) -> String {
|
||||
if s.chars().count() > 200 {
|
||||
let head: String = s.chars().take(200).collect();
|
||||
format!("{head}…")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_script_passes() {
|
||||
let report = validate_script(
|
||||
"let meta = #{ name: \"t\", description: \"d\" };\nlet r = agent(\"work\");\ncomplete(r.output);",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report.name, "t");
|
||||
assert!(report.outcome_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_meta_fails() {
|
||||
assert!(matches!(
|
||||
validate_script("let x = 1;", None),
|
||||
Err(ValidationError::Meta(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_probe_args_exercise_bundled_and_authoring_examples() {
|
||||
let args = default_probe_args();
|
||||
assert!(
|
||||
args["objective"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
assert!(
|
||||
args["query"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
assert!(
|
||||
args["target"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
assert!(args["breadth"].as_u64().is_some_and(|value| value >= 2));
|
||||
assert!(
|
||||
args["skeptic_count"]
|
||||
.as_u64()
|
||||
.is_some_and(|value| value >= 1)
|
||||
);
|
||||
assert!(
|
||||
args["max_verify_attempts"]
|
||||
.as_u64()
|
||||
.is_some_and(|value| value >= 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_misuse_fails() {
|
||||
let err = validate_script(
|
||||
"let meta = #{ name: \"t\", description: \"d\" };\nnot_a_host_fn();",
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ValidationError::Run(_)), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_counts_as_valid() {
|
||||
let report = validate_script(
|
||||
"let meta = #{ name: \"t\", description: \"d\" };\npause(\"verification\", \"needs input\");",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(report.outcome_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_limits_are_reported_as_dry_run_failures() {
|
||||
let script = format!(
|
||||
r#"
|
||||
let meta = #{{ name: "t", description: "d" }};
|
||||
let jobs = [];
|
||||
for i in 0..{} {{ jobs.push(#{{ prompt: "job" + i.to_string() }}); }}
|
||||
parallel(jobs);
|
||||
"#,
|
||||
crate::MAX_PARALLEL + 1
|
||||
);
|
||||
let error = validate_script(&script, None).unwrap_err().to_string();
|
||||
assert!(error.contains("parallel() accepts at most"), "got: {error}");
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
let meta = #{{ name: "t", description: "d" }};
|
||||
let jobs = [];
|
||||
for i in 0..{} {{ jobs.push(#{{ prompt: "job" + i.to_string() }}); }}
|
||||
parallel(jobs);
|
||||
agent("synthesize");
|
||||
"#,
|
||||
crate::DEFAULT_AGENT_BUDGET
|
||||
);
|
||||
let error = validate_script(&script, None).unwrap_err().to_string();
|
||||
assert!(
|
||||
error.contains(&format!(
|
||||
"agent budget exceeded: requested {}, maximum {}",
|
||||
crate::DEFAULT_AGENT_BUDGET + 1,
|
||||
crate::DEFAULT_AGENT_BUDGET
|
||||
)),
|
||||
"got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoring_landmines_are_fixed_or_hinted() {
|
||||
let concat = |terms: usize| {
|
||||
let chain = (0..terms)
|
||||
.map(|i| format!("\"part{i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" + ");
|
||||
format!(
|
||||
"let meta = #{{ name: \"t\", description: \"d\" }};\nlet p = {chain};\ncomplete(p);"
|
||||
)
|
||||
};
|
||||
assert!(validate_script(&concat(100), None).unwrap().outcome_ok);
|
||||
|
||||
let hinted = |script: &str, expect: &[&str]| {
|
||||
let msg = validate_script(script, None).unwrap_err().to_string();
|
||||
for e in expect {
|
||||
assert!(msg.contains(e), "missing {e:?} in: {msg}");
|
||||
}
|
||||
};
|
||||
hinted(&concat(300), &["maximum complexity", "`+=` statements"]);
|
||||
hinted(
|
||||
"let meta = #{ name: \"t\", description: \"d\" };\nlet shared = false;\ncomplete(shared);",
|
||||
&["reserved keyword", "rename the variable"],
|
||||
);
|
||||
hinted(
|
||||
"let meta = #{ name: \"t\", description: \"d\" };\nlet s = \"abc\";\ncomplete(s[0].severity);",
|
||||
&["type 'char'", "indexing a string"],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue