Skip to content
Open
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,15 @@ record. It also does not serialize catalog or runtime writers, reconcile tasks,
control-plane cutover. Consumers that require a zero-write boundary under concurrent root deletion
or a transactional declaration boundary need a separate protocol.

For cleanup planners that need the same evidence aggregated by explicit Agent Spec workspace, use:

```sh
st2 workspace-activity --catalog "$CATALOG" --host <host> --ttl 60 --json
```

The short-lived `st2.workspace-activity.v1` envelope is read-only and fail-closed. It is not deletion
authority; see [the workspace activity contract](docs/workspace-activity.md).

### Staged control-plane replacement gate

`st2 up` is a replaceable control plane, not the lifetime owner of an agent. Stopping it normally
Expand Down
34 changes: 34 additions & 0 deletions docs/workspace-activity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Workspace activity snapshot

`st2 workspace-activity --json` emits short-lived, read-only evidence about activity in explicit
Agent Spec `workspace` paths on one host. Suspended and retired declarations remain in the snapshot
so a retained live generation cannot disappear from cleanup evidence. The command reuses st2's PTY
and exec generation observers; it does not scan arbitrary processes, reconcile tasks, authorize
cleanup, or delete anything.

The `st2.workspace-activity.v1` envelope contains `schemaVersion`, `producer`, an `epoch` bound to
the canonical catalog, host, and catalog generation, `capturedAt`, `expiresAt`, `complete`, `errors`,
and lexically sorted `claims`. Each claim contains a canonical workspace path, sorted owning agent
IDs, sorted positively running runtime IDs, and the derived `active` boolean.

Consumers must fail closed unless `complete` is true, the snapshot has not expired, and the epoch is
the one they admitted. An inactive claim means only that st2 observed no running generation for its
declared tasks in this snapshot. Cleanup still needs its own filesystem/process liveness checks and
must revalidate immediately before mutation.

The TTL must be between 1 and 300 seconds. Out-of-range values emit an incomplete envelope whose
`expiresAt` equals `capturedAt`, then exit non-zero.

This v1 precursor identifies active runtime IDs but is not a generation-bound lease: generation
PID/creation evidence remains available from `st2 tasks --json`. A deletion transaction must obtain
and revalidate that stronger evidence rather than treating this snapshot as a lock.

Example:

```console
st2 --catalog "$CATALOG" workspace-activity --host dev3 --ttl 60 --json
```

Catalog discovery errors, an unavailable PTY/exec backend, declaration drift, catalog-generation
drift, and an unresolvable declared workspace all make the envelope incomplete and the command exits
non-zero after printing the JSON evidence.
8 changes: 8 additions & 0 deletions src/catalog_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ pub const GENERATION_INTENT_FILE: &str = "catalog-generation-incomplete";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogReadFence(Option<u64>);

impl CatalogReadFence {
/// The declaration generation observed by this read fence. `None` means the catalog predates
/// generation receipts; read-only consumers must treat that as an unversioned epoch.
pub fn generation(self) -> Option<u64> {
self.0
}
}

pub fn read_fence(catalog: &Path) -> Result<CatalogReadFence> {
let first = read_generation(catalog)?;
ensure_authoring_complete(catalog)?;
Expand Down
2 changes: 1 addition & 1 deletion src/exec_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,7 @@ fn process_created_at(start_time_ticks: u64) -> anyhow::Result<SystemTime> {
Ok(UNIX_EPOCH + Duration::from_micros(start_time_ticks))
}

pub(crate) fn rfc3339_utc(time: SystemTime) -> anyhow::Result<String> {
pub fn rfc3339_utc(time: SystemTime) -> anyhow::Result<String> {
let duration = time.duration_since(UNIX_EPOCH)?;
let seconds = duration.as_secs() as libc::time_t;
let millis = duration.subsec_millis();
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod status;
pub mod task_inventory;
pub mod validate;
pub mod version;
pub mod workspace_activity;
mod watch;

// The declaration model and the catalog walk live in the `agent-spec` crate, so st2 and any other
Expand Down
32 changes: 32 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,20 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Emit a short-lived, fail-closed snapshot of runtime activity for Agent Spec workspaces.
/// This is read-only evidence for external cleanup planners, never deletion authority.
WorkspaceActivity {
/// Host whose declared workspaces and task generations to inspect. Defaults to this host.
#[arg(long)]
host: Option<String>,
/// Snapshot lifetime in seconds (1..=300). Consumers must reject a snapshot after
/// `expiresAt`.
#[arg(long, default_value_t = 60)]
ttl: u64,
/// Emit the versioned machine-readable envelope. Required in v1.
#[arg(long)]
json: bool,
},
/// Clear one task's park after fixing what crash-looped it. A task parked by its `restart{}`
/// policy (mode=fail) stays parked for the rest of the supervisor run, and this is its per-task
/// exit: the running supervisor relaunches exactly this task on its next pass, leaving every
Expand Down Expand Up @@ -996,6 +1010,13 @@ fn main() -> Result<()> {
let catalog = catalog_arg(None)?;
tasks_cmd(&catalog, host)
}
Command::WorkspaceActivity { host, ttl, json } => {
if !json {
anyhow::bail!("`st2 workspace-activity` v1 requires --json");
}
let catalog = catalog_arg(None)?;
workspace_activity_cmd(&catalog, host, ttl)
}
Command::Unpark { task, host } => {
let catalog = catalog_arg(None)?;
unpark_cmd(&catalog, &task, host)
Expand Down Expand Up @@ -1574,6 +1595,17 @@ fn tasks_cmd(root: &Path, host: Option<String>) -> Result<()> {
}
}

fn workspace_activity_cmd(root: &Path, host: Option<String>, ttl: u64) -> Result<()> {
let host = host.unwrap_or_else(detect_host);
let snapshot = st2::workspace_activity::snapshot(root, &host, Duration::from_secs(ttl));
println!("{}", snapshot.to_json());
if snapshot.complete() {
Ok(())
} else {
anyhow::bail!("workspace activity snapshot incomplete")
}
}

/// Ask this host's supervisor to release one parked task.
///
/// The request is a file the supervisor drains at the top of its next pass, not a direct mutation:
Expand Down
Loading
Loading