Skip to content

Commit 348f815

Browse files
committed
Merge remote-tracking branch 'origin/main' into agent/issue-29-targeted-reconcile
# Conflicts: # src/run.rs
2 parents 41c7ee5 + 14fe7dc commit 348f815

31 files changed

Lines changed: 549 additions & 171 deletions

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# The root stays a real package (not a virtual manifest): `flake.nix` reads
2+
# `package.version` out of this file as the single source of truth for the
3+
# build, and a virtual root has no `[package]` to read.
4+
[workspace]
5+
members = ["crates/agent-spec"]
6+
default-members = [".", "crates/agent-spec"]
7+
18
[package]
29
name = "st2"
310
version = "0.1.0"
@@ -16,6 +23,7 @@ path = "src/lib.rs"
1623
[dependencies]
1724
# Sync by design: the runner is I/O-light (shell-outs to `pty` plus a
1825
# folder watch + a sleep), so no tokio. Deps grow per milestone; M0 needs only parse + CLI.
26+
agent-spec = { path = "crates/agent-spec" }
1927
anyhow = "1"
2028
clap = { version = "4", features = ["derive"] }
2129
clap_complete = "4"

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ Prerequisites:
2121
- Rust and Cargo;
2222
- `pty` on `PATH`;
2323
- at least one supported harness on `PATH`: `codex` or `claude`;
24-
- Git when a declaration materializes workspace files.
24+
- Git when a declaration materializes workspace files;
25+
- Bash and `jq` on `PATH` when lifecycle hooks are enabled.
2526

2627
From a checkout:
2728

@@ -44,15 +45,18 @@ location.
4445

4546
Lifecycle hooks are installed only by the explicit `st2 hooks install` command. The installer
4647
publishes an immutable content-addressed set, then atomically selects it with a receipt. `st2 up`
47-
verifies that receipt for Codex launches; any local workspace render that actually references
48-
`$ST_HOOKS` verifies it before writing. Hook-free materialization does not require an installed
49-
set. These checks never create, refresh, or rewrite hooks. An intentional rollback to an older set
50-
requires `st2 hooks install --allow-downgrade`.
48+
verifies its own immutable set for Codex launches; any local workspace render that actually
49+
references `$ST_HOOKS` verifies that set before writing. Selecting a successor does not invalidate
50+
an older running binary's installed set during cutover. Hook-free materialization does not require
51+
an installed set. These checks never create, refresh, or rewrite hooks. To select this binary's
52+
exact hook set when the installed and candidate builds are older or cannot be ordered, use `st2
53+
hooks install --replace`. `st2 hooks verify-own` is the read-only cutover probe for an installed
54+
binary that may no longer be selected.
5155

5256
`ST_HOOKS` overrides the machine-local hook root for installation, verification, and managed tasks.
53-
During materialization, hook commands such as `$ST_HOOKS/codex-stop.sh` resolve to the selected
54-
immutable set, so rendered settings are versioned without embedding a machine-specific root in the
55-
declaration.
57+
During materialization, hook commands such as `$ST_HOOKS/codex-stop.sh` resolve to the invoking
58+
binary's immutable set, so rendered settings are versioned without embedding a machine-specific
59+
root in the declaration.
5660

5761
The hooks have a small operational purpose: session-start restores durable context and exposes the
5862
current inbox; pre-compact preserves a recovery breadcrumb when no context was written; stop and

build.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44
//! NixStamp can never be overridden by this (see src/version.rs). A hermetic Nix
55
//! build has no `.git`, so this yields nothing there and the NixStamp is used.
66
//!
7-
//! A full revision and commit timestamp are also exposed privately to the
8-
//! receipt-bearing lifecycle-hook installer. They do not affect the shared
9-
//! human/machine version contract.
107
use std::process::Command;
118

129
fn git(args: &[&str]) -> Option<String> {
@@ -29,14 +26,6 @@ fn main() {
2926
let stamp =
3027
format!(r#"{{"type":"local","rev":"{rev}","commitTs":{commit_ts},"dirty":{dirty}}}"#);
3128
println!("cargo:rustc-env=ST2_BUILD_STAMP_LOCAL={stamp}");
32-
println!(
33-
"cargo:rustc-env=ST2_GIT_SHA_FULL={}",
34-
git(&["rev-parse", "HEAD"]).unwrap_or(rev)
35-
);
36-
println!("cargo:rustc-env=ST2_GIT_COMMIT_UNIX={commit_ts}");
37-
} else {
38-
println!("cargo:rustc-env=ST2_GIT_SHA_FULL=unknown");
39-
println!("cargo:rustc-env=ST2_GIT_COMMIT_UNIX=0");
4029
}
4130
// Rebuild the stamp when HEAD moves or the working tree changes (dirty flag).
4231
println!("cargo:rerun-if-changed=.git/HEAD");

crates/agent-spec/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "agent-spec"
3+
version = "0.1.0"
4+
edition = "2024"
5+
description = "Parse a catalog of rendered agent specs (KDL/TOML/JSON) into the runner-normative agent job model."
6+
license = "MIT"
7+
8+
[dependencies]
9+
anyhow = "1"
10+
kdl = "6"
11+
serde = { version = "1", features = ["derive"] }
12+
serde_json = "1"
13+
toml = "0.9"
14+
15+
[dev-dependencies]
16+
tempfile = "3"
Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,40 @@ fn collect_spec_files(root: &Path, dir: &Path, acc: &mut Vec<PathBuf>) {
9292
}
9393
}
9494

95+
/// What a declaration literally *says*, before lowering normalizes it away.
96+
///
97+
/// Lowering is lossy by design: a typo'd `type = "srvice"` becomes `JobType::Service`, and an
98+
/// identity omitted from the content is filled in from the path. Both are invisible in the resolved
99+
/// [`AgentSpec`], so a linter that wants to fault them has to see the declared form. This is that
100+
/// view — deliberately narrow, so the permissive on-disk shape itself stays private and is free to
101+
/// gain fields without breaking readers.
102+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
103+
pub struct Declared {
104+
/// `identity` as written in the file. `None` when the file relies on [`path_defaults`].
105+
pub identity: Option<String>,
106+
/// `type` as written, before it is normalized to `JobType::Service`. `None` when unset.
107+
pub job_type: Option<String>,
108+
}
109+
110+
/// Read the declared (pre-lowering) values of every agent in a file — one per `agent` node for KDL,
111+
/// 0-or-1 for TOML/JSON, empty for a non-spec extension.
112+
///
113+
/// One entry per parsed node, *including* nodes [`discover`] skips as non-specs, so this is not
114+
/// positionally paired with that file's [`Discovered::specs`].
115+
pub fn parse_declared(path: &Path) -> anyhow::Result<Vec<Declared>> {
116+
Ok(parse_raw_file(path)?
117+
.into_iter()
118+
.map(|raw| Declared {
119+
identity: raw.identity,
120+
job_type: raw.job_type,
121+
})
122+
.collect())
123+
}
124+
95125
/// Parse a spec file into its raw (pre-resolution) shape — one per `agent` node for KDL, 0-or-1 for
96-
/// TOML/JSON. Non-spec extensions yield an empty vec. Shared by discovery and `validate` (which needs
97-
/// the *raw* `type` string before it is normalized away, to catch a typo'd `type = "srvice"`).
98-
pub(crate) fn parse_raw_file(path: &Path) -> anyhow::Result<Vec<RawSpec>> {
126+
/// TOML/JSON. Non-spec extensions yield an empty vec. Shared by discovery and [`parse_declared`]
127+
/// (which exposes the *raw* `type` and `identity` before normalization, without leaking [`RawSpec`]).
128+
fn parse_raw_file(path: &Path) -> anyhow::Result<Vec<RawSpec>> {
99129
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
100130
let text = fs::read_to_string(path)?;
101131
Ok(match ext {

crates/agent-spec/src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//! agent-spec — read a catalog of rendered agent declarations.
2+
//!
3+
//! One agent is one declarative file, Nomad-style: the agent is the job and its `pty`/`exec` blocks
4+
//! are the tasks. This crate owns the two halves any reader of that catalog needs and nothing else:
5+
//!
6+
//! - [`spec`] — the runner-normative model a declaration lowers to ([`AgentSpec`], [`Task`], …).
7+
//! - [`discovery`] — the catalog walk: parse every `*.{kdl,toml,json}` that looks like a
8+
//! declaration, and resolve each one's `identity`/`host` with the catalog's precedence rule
9+
//! (content wins, the path supplies defaults, a mismatch is a warning).
10+
//!
11+
//! KDL is the canonical on-disk format; TOML and JSON lower to the same model. The KDL parser is a
12+
//! private implementation detail — [`discovery`] is the only supported entry point, so every reader
13+
//! resolves identity and host the same way rather than re-deriving it from filenames.
14+
//!
15+
//! st2 consumes this crate, which is what keeps it a reference implementation rather than a copy:
16+
//! a second reader (a TUI, a linter) sees exactly the fields the runner sees, including the ones
17+
//! the runner's roster JSON does not carry (`supervisor`, `role`, `workspace`, `host`).
18+
//!
19+
//! Render-only fields (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`,
20+
//! `meta{}`) are read by the render layer and deliberately dropped here — that is what keeps a
21+
//! consumer render-agnostic.
22+
23+
pub mod discovery;
24+
mod kdl_format;
25+
pub mod spec;
26+
27+
pub use discovery::{Declared, Discovered, SpecError, discover, parse_declared, path_defaults};
28+
pub use spec::{AgentSpec, JobType, Restart, RestartMode, Task, TaskKind, parse_duration};
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
//! M1 correctness net: discovery + lowering of VRS `agent.kdl` jobs (spec.md §1–2, §4).
22
//!
33
//! Builds throwaway catalog folders, writes real job files (KDL/TOML/JSON, services), and
4-
//! asserts st2 lowers them per the spec: `pty`/`exec` task split, `restart{}`, `type`, `workspace`,
4+
//! asserts they lower per the spec: `pty`/`exec` task split, `restart{}`, `type`, `workspace`,
55
//! `supervisor`; render-only fields ignored; content/path precedence; malformed → error, not halt.
66
77
use std::fs;
88
use std::path::Path;
99
use std::time::Duration;
1010

11-
use st2::spec::TaskKind;
12-
use st2::{AgentSpec, JobType, discover};
11+
use agent_spec::spec::TaskKind;
12+
use agent_spec::{AgentSpec, JobType, discover};
1313

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

9696
// tasks: pty "agent" + exec "ding" (sorted by name)
9797
assert_eq!(s.tasks.len(), 2);
@@ -213,7 +213,10 @@ command = "st2 ding hetz.fetcher"
213213
let s = &found.specs[0];
214214
assert_eq!(s.identity, "fetcher");
215215
assert_eq!(s.job_type, JobType::Service);
216-
assert_eq!(s.restart.clone().unwrap().mode, st2::RestartMode::Delay);
216+
assert_eq!(
217+
s.restart.clone().unwrap().mode,
218+
agent_spec::RestartMode::Delay
219+
);
217220
assert_eq!(s.tasks.len(), 2);
218221
assert_eq!(
219222
s.tasks.iter().find(|t| t.name == "agent").unwrap().kind,

0 commit comments

Comments
 (0)