swarm: keep an agent's terminal rows and replay them on attach
swarm-controller now creates `term-main-<agent>` for every live agent, in the same one-minute pass and with the same config style as `term-sub-<agent>` (#4897): subjects `$SWARM.term.<agent>` only, max_age 24h, max_bytes 64 MiB, discard Old, max_message_size from the queue's max_payload. An existing stream is opened as it is. The agent keeps its plain publish on `$SWARM.term.<agent>` and gets no JetStream subject. `GET /api/agents/{name}/term/stream` creates an ephemeral push consumer on that stream starting 100 rows back, so a swarm-ui terminal opens with recent history and then follows new rows, in order, from one subscription. Each SSE event carries the row's stream sequence as its id, and a reconnect's Last-Event-ID resumes after it. Without the stream the route falls back to the live core subscription it used before. The stream is `term-main-<agent>`, not `term-<agent>`: an agent may be named `sub-x`, whose `term-<agent>` would be agent `x`'s subagent stream. Its one literal subject cannot match `$SWARM.term.<agent>.sub.>`, which needs more tokens, so the two streams never overlap. The reader gains `$JS.API.CONSUMER.CREATE.*.*.$SWARM.term.*`, the extended create form whose last tokens are the consumer's filter. Checked against nats-server 2.14.4, the trailing `*` also admits a literal `*` or `>` filter, so a consumer under it reaches any terminal row, main or subagent; those are rows the reader already subscribes to and reads with `STREAM.MSG.GET.*`. Closes #4826
This commit is contained in:
parent
609657f1ff
commit
ae9ca4331e
12 changed files with 533 additions and 38 deletions
|
|
@ -538,6 +538,16 @@ impl Policy {
|
|||
// `MSG.GET`, the same width as `STREAM.INFO.*` above. A refused
|
||||
// publish reaches the client as a timeout.
|
||||
"$JS.API.STREAM.MSG.GET.*".to_owned(),
|
||||
// Replaying an agent's terminal from `term-main-<agent>` to
|
||||
// swarm-ui. Consumer create on every stream, but only in the
|
||||
// extended form whose last tokens are the consumer's filter,
|
||||
// which the server checks against the payload. So a consumer
|
||||
// delivers only rows below `$SWARM.term`, every agent's and every
|
||||
// subagent's included (the `*` also matches a literal `*` or `>`),
|
||||
// which this client already subscribes to and reads by
|
||||
// `MSG.GET` above. No bare `CONSUMER.CREATE.<stream>`, whose
|
||||
// filter is unchecked.
|
||||
swarm_queue_client::agent_term::consumer_create_grant(),
|
||||
"$KV.*.>".to_owned(),
|
||||
]);
|
||||
// The per-agent status bucket, read-side only. Same five subjects as
|
||||
|
|
@ -1215,6 +1225,47 @@ mod tests {
|
|||
assert!(p.publish.contains(&"$JS.API.STREAM.CREATE.*".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_controller_may_replay_terminal_rows_and_no_other_subject() {
|
||||
use swarm_queue_client::agent_term::stream_name;
|
||||
use swarm_queue_client::{subagent_term, term_subject};
|
||||
|
||||
let p = policy()
|
||||
.permissions("swarm-controller")
|
||||
.expect("a reader is admitted");
|
||||
let may_publish = |s: &str| p.publish.iter().any(|g| covers(g, s));
|
||||
let create = |stream: &str, filter: &str| {
|
||||
format!("$JS.API.CONSUMER.CREATE.{stream}.term-replay-n1.{filter}")
|
||||
};
|
||||
assert!(may_publish(&create(
|
||||
&stream_name("atlas"),
|
||||
&term_subject("atlas")
|
||||
)));
|
||||
for refused in [
|
||||
// The bare form, whose filter rides in the payload alone.
|
||||
format!("$JS.API.CONSUMER.CREATE.{}", stream_name("atlas")),
|
||||
create(
|
||||
&subagent_term::stream_name("atlas"),
|
||||
&subagent_term::subject("atlas", "scout"),
|
||||
),
|
||||
create("hive-notices", "hive-notices.alpha"),
|
||||
create(&stream_name("atlas"), "$SWARM.agent-state.atlas"),
|
||||
create(
|
||||
&stream_name("atlas"),
|
||||
&format!("{}.x", term_subject("atlas")),
|
||||
),
|
||||
] {
|
||||
assert!(!may_publish(&refused), "the reader may publish {refused}");
|
||||
}
|
||||
// Control: no hive holds the grant.
|
||||
assert!(
|
||||
!policy()
|
||||
.hive_subjects("alpha")
|
||||
.iter()
|
||||
.any(|s| covers(s, &create(&stream_name("atlas"), &term_subject("atlas"))))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_controller_may_read_a_subagents_newest_row() {
|
||||
let p = policy()
|
||||
|
|
|
|||
Loading…
Reference in a new issue