Skip to content

Commit d85e72f

Browse files
feat: add transactional catalog snapshot and apply
agent-session-id: dev3.dotfiles-cos-misc-agent-runtime-simplification agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /home/schickling/.config/coding-agents/profile.json agent-skills-manifest: /nix/store/nk9iml2841l1yjjg0f6f0d3y60zkg1nn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@4a0515f
1 parent 57bb745 commit d85e72f

17 files changed

Lines changed: 2889 additions & 201 deletions

README.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,31 @@ mkdir -p "$bundle/assets"
9797
cp examples/native/agent-codex.kdl "$bundle/agent.kdl"
9898
cp ./composed-AGENTS.md "$bundle/assets/AGENTS.md"
9999
${EDITOR:-vi} "$bundle/agent.kdl"
100-
st2 agent publish --catalog "$CATALOG" --bundle "$bundle" --expect-absent --json
100+
input_sha256="$(st2 agent digest --bundle "$bundle")"
101+
st2 agent publish --catalog "$CATALOG" --bundle "$bundle" \
102+
--input-sha256 "$input_sha256" --expect-absent --json
101103
```
102104

103105
Replace `<host>`, `<identity>`, `<workspace>`, and `<boot prompt>`. Include every file referenced by
104106
`copy` in the bundle. For a later declaration-only update, publish `agent.kdl` with the current
105107
declaration's SHA-256 via `--spec ... --expect-sha256 HEX`; sibling assets and state are preserved.
108+
Bind either operation to the exact captured source with the SHA-256 returned by
109+
`st2 agent digest`.
110+
111+
To prepare and apply a complete declaration-plane replacement without copying
112+
runtime state or workspaces:
113+
114+
```sh
115+
st2 catalog snapshot --catalog "$CATALOG" --output ./prepared --json
116+
# Edit/render ./prepared, then retain the rootSha256 from the snapshot receipt.
117+
st2 catalog apply --catalog "$CATALOG" --prepared ./prepared \
118+
--expect-sha256 <rootSha256> --json
119+
```
120+
121+
`catalog apply` is policy-free. It rejects state/control content, symlinks,
122+
unprojected workspaces, and effective PTY-root changes. A crash leaves a durable
123+
marker and content-addressed stage; rerunning the same prepared root and
124+
original expectation resumes it.
106125

107126
The compact declaration shape is:
108127

@@ -364,7 +383,8 @@ ls, up, down, validate, doctor
364383
message, ding, agents, status, context, resource
365384
env, pty, shell, pretrust
366385
hooks, service, eval
367-
agent publish
386+
agent digest, agent publish
387+
catalog snapshot, catalog apply
368388
completions
369389
```
370390

@@ -424,6 +444,8 @@ interviewer reply at-or-after the exact kickoff receipt completes it. Canonical
424444
verdict. Without the directive, Agent Spec-shaped files inside a fixture remain inert and compact
425445
evals retain their flat bus and completion semantics.
426446

427-
`st2 agent publish --catalog ROOT (--spec FILE | --bundle DIR) (--expect-absent |
428-
--expect-sha256 HEX)` is the sole catalog-declaration writer. The publisher admits the complete
447+
`st2 agent publish --catalog ROOT (--spec FILE | --bundle DIR) --input-sha256 HEX
448+
(--expect-absent | --expect-sha256 HEX)` is the single-agent declaration writer.
449+
`st2 catalog apply --catalog ROOT --prepared DIR --expect-sha256 ROOT_HEX` is the
450+
complete declaration-plane writer. Each admits the complete
429451
prospective catalog under a compare-and-swap lock before making one atomic change.

crates/agent-spec/src/discovery.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ pub fn is_catalog_path(root: &Path, path: &Path) -> bool {
8080
return false;
8181
}
8282

83+
// Canonical state has a stable address independent of whether a declaration is currently
84+
// present. This keeps orphan state after retirement/removal out of discovery and out of a
85+
// whole-catalog transaction's declaration identity.
86+
if components.first().and_then(|name| name.to_str()) == Some("agents")
87+
&& components.len() >= 4
88+
&& matches!(
89+
components[3].to_str(),
90+
Some("resources" | "archive" | "inbox" | "status")
91+
)
92+
{
93+
return false;
94+
}
95+
8396
let mut parent = root.to_path_buf();
8497
for name in components {
8598
if matches!(name.to_str(), Some("resources" | "archive" | "inbox"))

docs/vrs/requirements.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,12 @@ accepted.
134134
but performs zero lifecycle actions until the transaction is completed.
135135
Presence, messages, context, and Resource state remain independently writable
136136
and are never serialized behind catalog authoring.
137+
A caller binds single-agent publication to the exact no-follow source capture
138+
with an authoritative input digest. A canonical whole-catalog snapshot
139+
externalizes the declaration-root digest while excluding runtime state and
140+
catalog-contained workspaces. Whole-catalog apply accepts only that closed
141+
declaration projection, rechecks the root digest under the exclusive lock,
142+
durably stages the desired bytes, and converges after interruption from a
143+
closed marker record. Version 1 rejects effective PTY-root changes. It never
144+
deletes or relocates state, and newly created canonical identity directories
145+
become visible only as complete bundles.

docs/vrs/spec.md

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,11 @@ and the portable Agent Spec envelope in
9191

9292
## Transactional catalog authoring
9393

94+
`st2 agent digest (--spec FILE | --bundle DIR)` captures a source through
95+
retained no-follow file descriptors and returns its authoritative digest.
9496
`st2 agent publish --catalog ROOT (--spec FILE | --bundle DIR)
95-
(--expect-absent | --expect-sha256 HEX) --json` is the sole supported
96-
declaration writer. It accepts exactly one canonical KDL `agent` node with an
97+
--input-sha256 HEX (--expect-absent | --expect-sha256 HEX) --json` binds
98+
publication to that exact capture. It accepts exactly one canonical KDL `agent` node with an
9799
explicit, path-safe host and identity. st2 no longer exposes an intent compiler:
98100
external renderers own the transformation from human intent to exact Agent Spec
99101
bytes or a create-only publication bundle.
@@ -104,18 +106,45 @@ read/write transaction domain:
104106
```text
105107
publisher (EX) : snapshot input -> CAS -> full-catalog admission -> atomic publish + fsync
106108
reader (SH) : discover -> materialize/observe -> plan -> execute
107-
state plane : message | context | Resource | status (unlocked)
109+
bulk apply (EX) : root CAS -> durable stage+marker -> converge -> verify+clear
110+
state plane : message | context | Resource | status (unlocked)
108111
```
109112

110113
The publisher derives the destination from the captured declaration, replaces
111114
only `agent.kdl` for a hash-authorized update, and preserves all sibling runtime
112115
state. A bundle is create-only and is renamed from a hidden same-filesystem
113116
stage; retry reports `unchanged` only when every projected bundle file already
114117
matches. `--expect-absent` is idempotent for identical input.
115-
`--expect-sha256` rejects a stale writer. Full-catalog admission rejects any
118+
`--input-sha256` rejects a caller/source swap and `--expect-sha256` rejects a
119+
stale declaration writer. Full-catalog admission rejects any
116120
structural validation error before publication. The typed result is
117121
`published` or `unchanged`.
118122

123+
`st2 catalog snapshot --catalog ROOT --output DIR --json` holds SH while it
124+
captures the canonical declaration projection: `catalog.kdl`, exact
125+
`agents/<host>/<identity>/agent.kdl` files, static files inside those bounded
126+
agent bundles, and catalog-owned files statically referenced by `render {
127+
copy ... }`. Runtime state, `.git`, `.st2`, the native `pty` registry, and
128+
arbitrary catalog-contained workspaces are excluded. The output is a
129+
create-only durable directory; an identical retry is `unchanged`. Its
130+
domain-separated, path-sorted root SHA-256 covers normalized relative paths,
131+
file bytes, and executable bits.
132+
133+
`st2 catalog apply --catalog ROOT --prepared DIR --expect-sha256 HEX --json`
134+
rejects any prepared state/control path, symlink, special node, unprojected
135+
file/directory, malformed declaration, or effective PTY-root change. It
136+
captures and validates exact prepared bytes, takes EX, rechecks the canonical
137+
live root, and either reports `unchanged` or creates a durable content-addressed
138+
stage before publishing the marker. It changes declaration leaves only;
139+
canonical state directories and arbitrary workspaces are never traversed,
140+
deleted, or hashed. New identity bundles use an exclusive directory rename, so
141+
no empty identity becomes state-addressable. Applied leaves and their parents
142+
are fsynced, the live root is re-hashed and fully admitted, then the marker is
143+
unlinked and `.st2` is fsynced.
144+
The catalog parent is fsynced when `.st2` is first created, including the
145+
concurrent create/observe race. Retained source capture rejects a staging
146+
destination contained by its source before enumerating that source.
147+
119148
The lock file is never removed: replacing its inode would split the transaction
120149
domain for processes that already hold it open. Reconciliation holds SH from
121150
discovery through execution. Validation, doctor, roster, listing,
@@ -128,18 +157,28 @@ transaction fence. Any presence is authoritative, including malformed content.
128157
The reserved canonical record is:
129158

130159
```json
131-
{"schema":"st2.catalog-apply-incomplete.v1","desiredStorePath":"/nix/store/..."}
160+
{"schema":"st2.catalog-apply-incomplete.v1","stageName":"catalog-apply-stage-<prepared-root-sha256>","expectedRootSha256":"<previous-root-sha256>","preparedRootSha256":"<prepared-root-sha256>","originalPaths":["<sorted-owned-declaration-leaf>", "..."]}
132161
```
133162

134163
After taking its authoring lock, st2 refuses publication, validation,
135164
materialization, teardown, roster, doctor, and catalog listing while the marker
136165
exists. One-shot and selected reconcile fail explicitly. A resident supervisor
137166
instead remains alive, reports a skipped/incomplete pass, and performs no
138167
runtime observation or lifecycle action, avoiding a service restart storm.
139-
Message, context, Resource, and status operations remain available. The future
140-
whole-catalog apply command owns marker creation, admission, durable apply,
141-
verification, and clearing inside one transaction; external lock execution and
142-
bypass flags are not part of the contract.
168+
Message, context, Resource, and status operations remain available. While the
169+
marker exists they resolve canonical state independently of live declaration
170+
discovery; an existing state-only orphan remains addressable, an incomplete new
171+
identity does not fall back to a flat bus. A dotted bare identity is tried as
172+
the complete local identity alongside every possible qualified bus-address
173+
split; exactly one distinct canonical address must exist. Only real state
174+
directories and a real regular status file can establish marker-time addressability. Only
175+
`catalog apply` may open an existing marker. A retry must name the same prepared
176+
and expected roots; marker
177+
authority proves the original CAS already passed, so recovery converges the
178+
partial live tree from the durable desired stage and original owned-leaf list
179+
without re-enforcing that stale precondition. Malformed or
180+
mismatched records remain fenced. External lock execution and bypass flags are
181+
not part of the contract.
143182

144183
## Host-local scheduling and supervision
145184

examples/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ and gate the result before starting a process:
1313
```sh
1414
st2 hooks install
1515
st2 hooks verify
16-
st2 agent publish --catalog <catalog> --bundle <bundle> --expect-absent --json
16+
input_sha256="$(st2 agent digest --bundle <bundle>)"
17+
st2 agent publish --catalog <catalog> --bundle <bundle> \
18+
--input-sha256 "$input_sha256" --expect-absent --json
1719
st2 validate --catalog <catalog>
1820
st2 up --catalog <catalog> --host <host> --materialize-only
1921
st2 up --catalog <catalog> --host <host> --once

examples/native/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ Use this sequence:
2525
```sh
2626
st2 hooks install
2727
st2 hooks verify
28-
st2 agent publish --catalog <catalog> --bundle <bundle> --expect-absent --json
28+
input_sha256="$(st2 agent digest --bundle <bundle>)"
29+
st2 agent publish --catalog <catalog> --bundle <bundle> \
30+
--input-sha256 "$input_sha256" --expect-absent --json
2931
st2 validate <catalog>
3032
st2 up <catalog> --host <host> --materialize-only
3133
st2 up <catalog> --host <host> --once

0 commit comments

Comments
 (0)