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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

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

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# The root stays a real package (not a virtual manifest): `flake.nix` reads
# `package.version` out of this file as the single source of truth for the
# build, and a virtual root has no `[package]` to read.
[workspace]
members = ["crates/agent-spec"]
Comment thread
schickling-assistant marked this conversation as resolved.
default-members = [".", "crates/agent-spec"]

[package]
name = "st2"
version = "0.1.0"
Expand All @@ -16,6 +23,7 @@ path = "src/lib.rs"
[dependencies]
# Sync by design: the runner is I/O-light (shell-outs to `pty` plus a
# folder watch + a sleep), so no tokio. Deps grow per milestone; M0 needs only parse + CLI.
agent-spec = { path = "crates/agent-spec" }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
Expand Down
16 changes: 16 additions & 0 deletions crates/agent-spec/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "agent-spec"
version = "0.1.0"
edition = "2024"
description = "Parse a catalog of rendered agent specs (KDL/TOML/JSON) into the runner-normative agent job model."
license = "MIT"

[dependencies]
anyhow = "1"
kdl = "6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.9"

[dev-dependencies]
tempfile = "3"
36 changes: 33 additions & 3 deletions src/discovery.rs → crates/agent-spec/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,40 @@ fn collect_spec_files(root: &Path, dir: &Path, acc: &mut Vec<PathBuf>) {
}
}

/// What a declaration literally *says*, before lowering normalizes it away.
///
/// Lowering is lossy by design: a typo'd `type = "srvice"` becomes `JobType::Service`, and an
/// identity omitted from the content is filled in from the path. Both are invisible in the resolved
/// [`AgentSpec`], so a linter that wants to fault them has to see the declared form. This is that
/// view — deliberately narrow, so the permissive on-disk shape itself stays private and is free to
/// gain fields without breaking readers.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Declared {
/// `identity` as written in the file. `None` when the file relies on [`path_defaults`].
pub identity: Option<String>,
/// `type` as written, before it is normalized to `JobType::Service`. `None` when unset.
pub job_type: Option<String>,
}

/// Read the declared (pre-lowering) values of every agent in a file — one per `agent` node for KDL,
/// 0-or-1 for TOML/JSON, empty for a non-spec extension.
///
/// One entry per parsed node, *including* nodes [`discover`] skips as non-specs, so this is not
/// positionally paired with that file's [`Discovered::specs`].
pub fn parse_declared(path: &Path) -> anyhow::Result<Vec<Declared>> {
Ok(parse_raw_file(path)?
.into_iter()
.map(|raw| Declared {
identity: raw.identity,
job_type: raw.job_type,
})
.collect())
}

/// Parse a spec file into its raw (pre-resolution) shape — one per `agent` node for KDL, 0-or-1 for
/// TOML/JSON. Non-spec extensions yield an empty vec. Shared by discovery and `validate` (which needs
/// the *raw* `type` string before it is normalized away, to catch a typo'd `type = "srvice"`).
pub(crate) fn parse_raw_file(path: &Path) -> anyhow::Result<Vec<RawSpec>> {
/// TOML/JSON. Non-spec extensions yield an empty vec. Shared by discovery and [`parse_declared`]
/// (which exposes the *raw* `type` and `identity` before normalization, without leaking [`RawSpec`]).
fn parse_raw_file(path: &Path) -> anyhow::Result<Vec<RawSpec>> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let text = fs::read_to_string(path)?;
Ok(match ext {
Expand Down
File renamed without changes.
28 changes: 28 additions & 0 deletions crates/agent-spec/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! agent-spec — read a catalog of rendered agent declarations.
//!
//! One agent is one declarative file, Nomad-style: the agent is the job and its `pty`/`exec` blocks
//! are the tasks. This crate owns the two halves any reader of that catalog needs and nothing else:
//!
//! - [`spec`] — the runner-normative model a declaration lowers to ([`AgentSpec`], [`Task`], …).
//! - [`discovery`] — the catalog walk: parse every `*.{kdl,toml,json}` that looks like a
//! declaration, and resolve each one's `identity`/`host` with the catalog's precedence rule
//! (content wins, the path supplies defaults, a mismatch is a warning).
//!
//! KDL is the canonical on-disk format; TOML and JSON lower to the same model. The KDL parser is a
//! private implementation detail — [`discovery`] is the only supported entry point, so every reader
//! resolves identity and host the same way rather than re-deriving it from filenames.
//!
//! st2 consumes this crate, which is what keeps it a reference implementation rather than a copy:
//! a second reader (a TUI, a linter) sees exactly the fields the runner sees, including the ones
//! the runner's roster JSON does not carry (`supervisor`, `role`, `workspace`, `host`).
//!
//! Render-only fields (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`,
//! `meta{}`) are read by the render layer and deliberately dropped here — that is what keeps a
//! consumer render-agnostic.

pub mod discovery;
mod kdl_format;
pub mod spec;

pub use discovery::{Declared, Discovered, SpecError, discover, parse_declared, path_defaults};
pub use spec::{AgentSpec, JobType, Restart, RestartMode, Task, TaskKind, parse_duration};
File renamed without changes.
13 changes: 8 additions & 5 deletions tests/discovery.rs → crates/agent-spec/tests/discovery.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
//! M1 correctness net: discovery + lowering of VRS `agent.kdl` jobs (spec.md §1–2, §4).
//!
//! Builds throwaway catalog folders, writes real job files (KDL/TOML/JSON, services), and
//! asserts st2 lowers them per the spec: `pty`/`exec` task split, `restart{}`, `type`, `workspace`,
//! asserts they lower per the spec: `pty`/`exec` task split, `restart{}`, `type`, `workspace`,
//! `supervisor`; render-only fields ignored; content/path precedence; malformed → error, not halt.

use std::fs;
use std::path::Path;
use std::time::Duration;

use st2::spec::TaskKind;
use st2::{AgentSpec, JobType, discover};
use agent_spec::spec::TaskKind;
use agent_spec::{AgentSpec, JobType, discover};

fn write(root: &Path, rel: &str, contents: &str) {
let path = root.join(rel);
Expand Down Expand Up @@ -91,7 +91,7 @@ fn parses_full_kdl_service_job() {
assert_eq!(r.attempts, 5);
assert_eq!(r.interval, Duration::from_secs(90));
assert_eq!(r.delay, Duration::from_secs(5));
assert_eq!(r.mode, st2::RestartMode::Fail);
assert_eq!(r.mode, agent_spec::RestartMode::Fail);

// tasks: pty "agent" + exec "ding" (sorted by name)
assert_eq!(s.tasks.len(), 2);
Expand Down Expand Up @@ -213,7 +213,10 @@ command = "st2 ding hetz.fetcher"
let s = &found.specs[0];
assert_eq!(s.identity, "fetcher");
assert_eq!(s.job_type, JobType::Service);
assert_eq!(s.restart.clone().unwrap().mode, st2::RestartMode::Delay);
assert_eq!(
s.restart.clone().unwrap().mode,
agent_spec::RestartMode::Delay
);
assert_eq!(s.tasks.len(), 2);
assert_eq!(
s.tasks.iter().find(|t| t.name == "agent").unwrap().kind,
Expand Down
16 changes: 11 additions & 5 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,21 @@
--fish completions-fish
'';

# Run the hermetic unit tests plus the real lifecycle-hook integration
# test. The remaining integration tests assume facilities the Nix build
# sandbox deliberately lacks: `/usr/bin/git` on a hardcoded `PATH`,
# live PTY backends, or a systemd `--user` manager. They remain native
# gates, while the flake proves that its own packaged hooks execute.
# Run the hermetic unit tests plus agent-spec's discovery test and the
# real lifecycle-hook integration tests. The remaining root integration
# tests assume facilities the Nix build sandbox deliberately lacks:
# `/usr/bin/git` on a hardcoded `PATH`, live PTY backends, or a systemd
# `--user` manager. They remain native gates, while the flake proves
# that its parser and packaged hooks execute.
# `--workspace` because the root is a real package: without it cargo
# selects only `st2` and silently skips the `agent-spec` crate.
cargoTestFlags = [
"--workspace"
"--lib"
"--bins"
"--test"
"discovery"
"--test"
"codex_hooks"
"--test"
"hooks"
Expand Down
2 changes: 1 addition & 1 deletion src/eval_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::expand::expand_catalog;
use crate::flapping::FlappingCap;
use crate::reconcile::reconcile;
use crate::run::{Runner, SystemRunner, UpReport, detect_host, execute};
use crate::spec::{AgentSpec, JobType, Task, TaskKind};
use agent_spec::spec::{AgentSpec, JobType, Task, TaskKind};

macro_rules! eval_log {
($($arg:tt)*) => {
Expand Down
2 changes: 1 addition & 1 deletion src/eval_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use std::time::Duration;

use kdl::{KdlDocument, KdlNode, KdlValue};

use crate::spec::{Restart, RestartMode, parse_duration};
use agent_spec::spec::{Restart, RestartMode, parse_duration};

/// A parsed st2 spec: a base team (`st2 up` boots this) plus an optional `eval` (`st2 eval` runs it).
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down
2 changes: 1 addition & 1 deletion src/flapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::time::Instant;

use crate::spec::{Restart, RestartMode};
use agent_spec::spec::{Restart, RestartMode};

/// What to do with a would-be (re)launch this pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down
4 changes: 2 additions & 2 deletions src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,14 @@ pub fn versioned_hooks_dir_at(root: &Path) -> PathBuf {
}

/// Whether this host has a Codex agent whose rendered settings and next launch need this hook set.
pub fn required_by_codex(specs: &[crate::spec::AgentSpec], this_host: &str) -> bool {
pub fn required_by_codex(specs: &[agent_spec::spec::AgentSpec], this_host: &str) -> bool {
specs
.iter()
.any(|spec| required_by_codex_agent(spec, this_host))
}

/// Whether one local declaration owns a Codex agent task.
pub fn required_by_codex_agent(spec: &crate::spec::AgentSpec, this_host: &str) -> bool {
pub fn required_by_codex_agent(spec: &agent_spec::spec::AgentSpec, this_host: &str) -> bool {
spec.host.as_deref().is_none_or(|host| host == this_host)
&& spec.tasks.iter().any(|task| {
task.name == "agent" && task.command.as_deref().is_some_and(command_invokes_codex)
Expand Down
14 changes: 9 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ pub mod agents;
pub mod compile_agent;
pub mod context;
pub mod ding;
pub mod discovery;
pub mod eval_run;
pub mod eval_spec;
pub mod exec_backend;
Expand All @@ -17,21 +16,27 @@ pub mod flapping;
pub mod hooks;
pub mod host_lock;
pub mod isolate;
mod kdl_format;
pub mod materialize;
pub mod message;
pub mod pretrust;
pub mod reconcile;
pub mod resource;
pub mod run;
pub mod service;
pub mod spec;
pub mod status;
pub mod validate;
pub mod version;
mod watch;

pub use discovery::{Discovered, SpecError, discover};
// The declaration model and the catalog walk live in the `agent-spec` crate, so st2 and any other
// reader of the same catalog share one implementation. Re-exported under their original paths:
// `st2::spec::…` / `st2::discovery::…` keep working for the binary and the test suite.
pub use agent_spec::{discovery, spec};

pub use agent_spec::discovery::{Discovered, SpecError, discover};
pub use agent_spec::spec::{
AgentSpec, JobType, Restart, RestartMode, Task, TaskKind, parse_duration,
};
pub use exec_backend::ExecBackend;
pub use expand::{expand_env, expand_vars};
pub use flapping::FlappingCap;
Expand All @@ -41,4 +46,3 @@ pub use run::{
PtyCli, Runner, SystemRunner, UpReport, detect_host, down, down_specs, exec_state_dir, execute,
up_loop, up_loop_specs, up_once, up_once_specs,
};
pub use spec::{AgentSpec, JobType, Restart, RestartMode, Task, TaskKind, parse_duration};
2 changes: 1 addition & 1 deletion src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::process::Command;
use anyhow::{Context, Result};
use kdl::{KdlDocument, KdlNode};

use crate::spec::AgentSpec;
use agent_spec::spec::AgentSpec;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderOp {
Expand Down
2 changes: 1 addition & 1 deletion src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use std::collections::BTreeMap;
use std::collections::HashMap;

use crate::spec::{AgentSpec, TaskKind};
use agent_spec::spec::{AgentSpec, TaskKind};

/// ACTUAL state: one running/known task as st2 observes it (unioned across backends).
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down
18 changes: 9 additions & 9 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use crate::exec_backend::ExecBackend;
use crate::flapping::FlappingCap;
use crate::message;
use crate::reconcile::{ReconcilePlan, Session, TaskTarget};
use crate::spec::TaskKind;
use agent_spec::spec::TaskKind;

const PTY_LIST_TIMEOUT: Duration = Duration::from_secs(2);
const PTY_DAEMON_SHUTDOWN_WAIT: Duration = Duration::from_secs(6);
Expand Down Expand Up @@ -896,7 +896,7 @@ pub fn up_once(root: &Path, this_host: &str, runner: &dyn Runner) -> anyhow::Res
/// once (the carried cap rate-limits a same-episode second respawn; the carried debounce absorbs a
/// transient `pty list` misread of a healthy seat).
pub fn reconcile_pass_specs(
specs: &[crate::spec::AgentSpec],
specs: &[agent_spec::spec::AgentSpec],
this_host: &str,
runner: &dyn Runner,
cap: &mut FlappingCap,
Expand All @@ -921,7 +921,7 @@ pub fn reconcile_pass_specs(
/// process can exit between two `pty list` calls, be reaped by the second call, then look like a
/// vanished crash on the next tick.
pub(crate) fn reconcile_pass_specs_with_sessions(
specs: &[crate::spec::AgentSpec],
specs: &[agent_spec::spec::AgentSpec],
sessions: &[Session],
this_host: &str,
runner: &dyn Runner,
Expand All @@ -940,7 +940,7 @@ pub(crate) fn reconcile_pass_specs_with_sessions(
/// One reconcile pass over an in-memory spec team (`st2 up <spec> --once`). Throwaway cap+debounce
/// (a single pass has no flicker history); never `Err` — failures collect in `report.errors`.
pub fn up_once_specs(
specs: &[crate::spec::AgentSpec],
specs: &[agent_spec::spec::AgentSpec],
this_host: &str,
runner: &dyn Runner,
) -> UpReport {
Expand All @@ -960,7 +960,7 @@ pub fn up_once_specs(
/// (a spec is one static file — no folder to watch; edit + restart to change it). `root` roots
/// `$CATALOG` + crash-loop surfacing. Runs until SIGINT/SIGTERM.
pub fn up_loop_specs(
specs: &[crate::spec::AgentSpec],
specs: &[agent_spec::spec::AgentSpec],
root: &Path,
this_host: &str,
runner: &dyn Runner,
Expand Down Expand Up @@ -1027,7 +1027,7 @@ pub fn down(root: &Path, this_host: &str, runner: &dyn Runner) -> anyhow::Result
/// Sessions persist across an `st2 up` supervisor exit (nomad-decoupled), so this is how you actually
/// stop them. `specs` are the already-resolved [`AgentSpec`]s (from `spec_to_agent_specs`).
pub fn down_specs(
specs: &[crate::spec::AgentSpec],
specs: &[agent_spec::spec::AgentSpec],
this_host: &str,
runner: &dyn Runner,
) -> anyhow::Result<UpReport> {
Expand All @@ -1040,7 +1040,7 @@ pub fn down_specs(
/// derived identically to how reconcile spawns them (explicit `task.id`, else `<bus_id>.<task>`), so
/// the catalog `down` and the spec `down_specs` tear down exactly what `up`/`up_*_specs` launched.
fn teardown_specs(
specs: &[crate::spec::AgentSpec],
specs: &[agent_spec::spec::AgentSpec],
this_host: &str,
runner: &dyn Runner,
report: &mut UpReport,
Expand Down Expand Up @@ -1238,7 +1238,7 @@ pub fn detect_host() -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::TaskKind;
use agent_spec::spec::TaskKind;
use std::collections::BTreeMap;
use std::ffi::OsStr;

Expand Down Expand Up @@ -1312,7 +1312,7 @@ mod tests {
// destructively GC/relaunch a HEALTHY agent; a stable death must still be reaped ──────────────

use crate::reconcile::Launch;
use crate::spec::{AgentSpec, JobType};
use agent_spec::spec::{AgentSpec, JobType};

fn sess(id: &str, alive: bool) -> Session {
Session {
Expand Down
Loading
Loading