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:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View 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

View 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);
}
}
}

File diff suppressed because it is too large Load diff

View 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",
}
}
}

View 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");
}
}

View 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,
};

View 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());
}
}

View 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 },
}

View 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"],
);
}
}