Skip to content

Commit 0c7a94f

Browse files
committed
fix(stream): close review edge cases
1 parent 3963eb8 commit 0c7a94f

5 files changed

Lines changed: 118 additions & 10 deletions

File tree

docs/vrs/04-stream/spec.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,11 @@ archive-wide event-identity lookup.
155155

156156
## Supersession (STREAM-R07)
157157

158-
`--supersede` archives the most recent retained predecessor for the same
159-
`(stream, key)` before publishing the successor; without `--key`, it archives
160-
the stream-wide head — log-compaction semantics. The archive move uses the ordinary archive path, so
158+
`--supersede` archives the most recent retained unread predecessor for the
159+
same `(stream, key)` before publishing the successor; without `--key`, it
160+
archives the latest retained unread stream-wide predecessor — log-compaction
161+
semantics. Already archived receipts are skipped rather than masking an older
162+
unread predecessor. The archive move uses the ordinary archive path, so
161163
a DING-staged predecessor resolves through the existing archive-receipt rule:
162164
pasted at most once ever, never re-pasted, successor delivers next. Proven:
163165
24 supersedes in 146 ms produced one fresh poke, zero staged retries, one

src/agent_author.rs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,14 @@ fn author_stream(
241241
));
242242
}
243243
let target = resolve_target(&found.specs, selector, this_host)?;
244+
let actor = actor
245+
.map(|actor| resolve_target(&found.specs, actor, this_host).map(|target| target.identity))
246+
.transpose()?;
244247
authorize_actor(
245248
&found.specs,
246249
&target.identity,
247250
this_host,
248-
actor,
251+
actor.as_deref(),
249252
"stream-not-authorized",
250253
)?;
251254
let result = edit_stream_declaration(
@@ -631,6 +634,8 @@ fn edit_stream_declaration(
631634
catalog,
632635
path,
633636
&replacement,
637+
expected_identity,
638+
expected_host,
634639
expected_agent,
635640
name,
636641
launch,
@@ -744,6 +749,8 @@ fn verify_stream_candidate(
744749
catalog: &Path,
745750
path: &Path,
746751
candidate: &str,
752+
expected_identity: &str,
753+
expected_host: &str,
747754
expected_agent: &str,
748755
name: &str,
749756
launch: Option<&StreamLaunch>,
@@ -778,7 +785,9 @@ fn verify_stream_candidate(
778785
.map_err(|error| AuthorError::new("invalid-stream", error.to_string()))?;
779786
let spec = specs
780787
.iter()
781-
.find(|spec| spec.identity == expected_agent)
788+
.find(|spec| {
789+
spec.identity == expected_agent && spec.bus_id(expected_host) == expected_identity
790+
})
782791
.ok_or_else(|| {
783792
AuthorError::new(
784793
"unsafe-source-edit",
@@ -1891,6 +1900,45 @@ mod tests {
18911900
assert_eq!(fs::read_to_string(path).unwrap(), original);
18921901
}
18931902

1903+
#[test]
1904+
fn stream_candidate_verification_matches_the_exact_host_agent() {
1905+
let temporary = tempfile::tempdir().unwrap();
1906+
let root = temporary.path();
1907+
let path = write(
1908+
root,
1909+
"agents.kdl",
1910+
"agent \"worker\" { host \"alpha\"; command \"sleep 60\"; stream \"existing\" {} }\nagent \"worker\" { host \"beta\"; command \"sleep 60\"; stream \"existing\" {} }\n",
1911+
);
1912+
1913+
assert_eq!(
1914+
add_stream(
1915+
root,
1916+
"beta.worker",
1917+
"beta",
1918+
Some("beta.worker"),
1919+
"webhook",
1920+
None,
1921+
)
1922+
.unwrap()
1923+
.result,
1924+
AuthorOutcome::Changed
1925+
);
1926+
assert_eq!(
1927+
remove_stream(root, "beta.worker", "beta", Some("beta.worker"), "existing",)
1928+
.unwrap()
1929+
.result,
1930+
AuthorOutcome::Changed
1931+
);
1932+
1933+
let authored = fs::read_to_string(path).unwrap();
1934+
let document = KdlDocument::parse(&authored).unwrap();
1935+
let agents = document.nodes();
1936+
assert!(agents[0].to_string().contains("stream \"existing\""));
1937+
assert!(!agents[0].to_string().contains("stream \"webhook\""));
1938+
assert!(!agents[1].to_string().contains("stream \"existing\""));
1939+
assert!(agents[1].to_string().contains("stream \"webhook\""));
1940+
}
1941+
18941942
#[test]
18951943
fn stream_authoring_enforces_authority_nix_ownership_and_canonical_validation() {
18961944
let temporary = tempfile::tempdir().unwrap();

src/event.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,25 +261,31 @@ pub fn emit(
261261
write_record(&record_path, &record)?;
262262
}
263263

264-
let predecessor = if supersede {
264+
let predecessor_candidates = if supersede {
265265
record
266266
.recent
267267
.iter()
268-
.find(|entry| {
268+
.filter(|entry| {
269269
entry.filename != filename
270270
&& key.is_none_or(|key| entry.key.as_deref() == Some(key))
271271
})
272272
.map(|entry| entry.filename.clone())
273+
.collect::<Vec<_>>()
273274
} else {
274-
None
275+
Vec::new()
275276
};
277+
let mut predecessor = None;
276278
let created = message::with_resolved_message_boxes(
277279
root,
278280
&canonical_recipient,
279281
this_host,
280282
|inbox, archive| {
281-
if let Some(predecessor) = &predecessor {
282-
message::archive_msg(inbox, archive, predecessor)?;
283+
if let Some(unread) = predecessor_candidates
284+
.iter()
285+
.find(|candidate| inbox.join(candidate).is_file())
286+
{
287+
message::archive_msg(inbox, archive, unread)?;
288+
predecessor = Some(unread.clone());
283289
}
284290
// An archive filename is the bus's authoritative durable receipt. A crash after
285291
// materializing an external archive, but before advancing this state, must not restore

tests/event_e2e.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,28 @@ fn supersede_collapses_only_the_matching_key_and_preserves_archive_receipts() {
347347
assert!(!inbox.join(&pr_1_old.filename).exists());
348348
}
349349

350+
#[test]
351+
fn supersede_skips_an_archived_head_and_retires_the_latest_unread_predecessor() {
352+
let catalog = tempfile::tempdir().unwrap();
353+
let agent = declare_agent(catalog.path(), "\"running\"", " stream \"gh-ci\" {}\n");
354+
let older = emit(catalog.path(), "pr1-queued", Some("pr-1"), false);
355+
let archived_head = emit(catalog.path(), "pr1-running", Some("pr-1"), false);
356+
let inbox = message::inbox_dir(&agent);
357+
let archive = message::archive_dir(&agent);
358+
message::archive_msg(&inbox, &archive, &archived_head.filename).unwrap();
359+
360+
let successor = emit(catalog.path(), "pr1-pass", Some("pr-1"), true);
361+
362+
assert_eq!(
363+
successor.superseded.as_deref(),
364+
Some(older.filename.as_str())
365+
);
366+
assert!(!inbox.join(&older.filename).exists());
367+
assert!(archive.join(&older.filename).exists());
368+
assert!(archive.join(&archived_head.filename).exists());
369+
assert!(inbox.join(&successor.filename).exists());
370+
}
371+
350372
#[test]
351373
fn keyless_supersede_replaces_the_stream_wide_head() {
352374
let catalog = tempfile::tempdir().unwrap();

tests/stream_authoring_cli.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,33 @@ fn a_direct_adapter_launch_executes_the_exact_event_cli_contract() {
164164
1
165165
);
166166
}
167+
168+
#[test]
169+
fn a_bare_actor_can_self_author_on_the_selected_host() {
170+
let catalog = tempfile::tempdir().unwrap();
171+
write_agent(catalog.path());
172+
173+
let add = st2(
174+
catalog.path(),
175+
&[
176+
"stream", "add", "webhook", "--as", "worker", "--host", "hetz",
177+
],
178+
);
179+
assert!(
180+
add.status.success(),
181+
"{}",
182+
String::from_utf8_lossy(&add.stderr)
183+
);
184+
185+
let remove = st2(
186+
catalog.path(),
187+
&[
188+
"stream", "rm", "webhook", "--as", "worker", "--host", "hetz",
189+
],
190+
);
191+
assert!(
192+
remove.status.success(),
193+
"{}",
194+
String::from_utf8_lossy(&remove.stderr)
195+
);
196+
}

0 commit comments

Comments
 (0)