Skip to content
Merged
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
27 changes: 21 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ The app is no longer just a scaffold. It currently provides a read-first TUI tha
`~/.config/spacetop/config.yaml` and persist per-workflow TUI session state
under `$XDG_STATE_HOME/spacetop/session.yaml` or
`~/.local/state/spacetop/session.yaml`; relative env-derived roots are ignored.
- Sync from the workflow's git remote with the explicit `Y` action, limited to
`git pull --ff-only` and guarded by tests.
- Sync the definition repository and a verified attached split-root state
checkout with the explicit `Y` action, limited to `git pull --ff-only` and
guarded by tests.

## CTO Development Policy

Expand All @@ -67,8 +68,10 @@ shape while making the internals easier to reason about and test.

- **Read-only by default:** never mutate Spacedock workflow markdown unless a
future task explicitly adds audited write support. The existing `Y` sync action
is the only sanctioned workflow-adjacent write path and must stay
`git pull --ff-only`. Config/session writes are allowed only under absolute
is the only sanctioned workflow-adjacent write path and may run only
`git pull --ff-only` against the definition repository and a verified attached
split-root state checkout. It must never repair checkout topology.
Config/session writes are allowed only under absolute
user config/state paths, never inside workflow directories.
- **Clean Code is required, not aspirational:** small functions, clear names,
typed boundaries, limited side effects, no hidden parsing in UI code, and no
Expand Down Expand Up @@ -109,6 +112,9 @@ Keep module boundaries clear and testable:
single pure helper that turns `(definition_dir, state)` into the entity
directory, and `load_workflow_dir` / `sources.rs::load_archive` thread that
resolved dir to the active and archive scans.
- `crates/spacetop-core/src/state_checkout.rs` owns the two-backend storage
classifier and read-only Git probes for attached, detached, wrong-branch,
missing, and probe-failed split-root state checkouts.
- `crates/spacetop-core/src/index.rs`, `query.rs`, and `sources.rs` own the
v2 index/query spine; TUI code must consume `WorkflowIndex` through query
methods instead of inferring schema rules from raw vectors.
Expand All @@ -117,7 +123,9 @@ Keep module boundaries clear and testable:
- `crates/spacetop-core/src/watcher.rs` owns filesystem watching, event
filtering, debounce, fallback backend selection, and refresh signaling.
- `crates/spacetop-core/src/git_sync.rs` owns the explicit read-refresh sync
path and must remain limited to audited fast-forward pulls.
helper and must remain limited to audited fast-forward pulls. Top-level sync
may call it for the definition repository and only for a verified attached
split-root state checkout.
- `crates/spacetop-core/src/session_activity.rs` is the local agent-session
facade; its `session_activity/{projection,state,codex,claude,reducer}.rs`
modules project privacy-safe typed facts, retain coherent scan evidence,
Expand Down Expand Up @@ -184,10 +192,17 @@ Preserve the current parsing contracts unless the task explicitly changes them:
(single-root). Resolution is always relative to the definition directory; an
absolute `state:` or one with a `..` parent-traversal component is unsupported
and falls back to single-root rather than escaping the definition directory.
A relative state path whose canonical target escapes the canonical definition
directory remains a typed split-root but is unverified before any Git probe;
its available entities remain readable and it is never sync-eligible.
Discovery, the watcher, and `WorkflowDefinition.root` stay on the definition
directory; only entity/archive scans follow `state:`. A declared-but-absent
state checkout yields no entities rather than erroring (mirrors missing
`_archive/`).
`_archive/`). Storage backend and checkout disposition are separate typed
facts: split-root state is attached, detached, on the wrong branch, missing,
or unverified after a failed probe. Detached and wrong-branch snapshots remain
readable. `state-branch:` overrides the expected branch; otherwise it is
`spacedock-state/<definition-directory-basename>`.
- Active item loading ignores `README.md`, `_mods`, `_archive`, and nested non-item files.
- Status values must match stages from the workflow README.
- Archived parsing skips malformed archived entries but surfaces archive IO errors.
Expand Down
43 changes: 29 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,26 @@ render workflow graphs, show selected worktree state, derive entity activity
from structured local Codex and Claude Code events, open query-backed search,
timeline, metrics, activity, and relation views, auto-refresh filesystem
changes, read YAML user config, restore per-workflow session state, expose
headless query/export commands, and explicitly sync with `git pull --ff-only`.

Entity and archive loading honors a split-root layout: when a workflow README
declares `state:` (a relative path such as `state: .spacedock-state`), active
entities and `_archive/` are read from that state checkout while the README and
discovery stay on the definition directory. `$inline`, an empty value, or an
absent `state:` keeps entities beside the README (single-root, unchanged); an
absolute `state:` or one with `..` parent traversal is unsupported and also falls
back to single-root. A declared-but-absent state checkout renders an empty list
rather than failing.
headless query/export commands, and explicitly fast-forward sync verified Git
checkouts with `git pull --ff-only`.

Workflow storage has two backends. `$inline`, an empty value, or an absent
`state:` is single-root: entities live beside the README. A supported relative
`state:` such as `.spacedock-state` is split-root: active entities and
`_archive/` live in that contained state checkout while the README and discovery
remain on the definition directory. Absolute paths and paths with `..` are
unsupported and fail closed to single-root. A relative path whose canonical
target escapes the definition directory is unverified: available entities stay
readable, but Spacetop will not run Git sync operations against that target.

A split-root checkout then has a separate runtime disposition. Attached means
it holds the expected `state-branch:` (or the default
`spacedock-state/<workflow-name>`) and needs no warning. Detached and
wrong-branch checkouts remain fully readable, but the footer warns that their
snapshot may be stale or names the actual and expected branches. Missing state
shows an empty list together with “State checkout missing; no state loaded.” A
Git probe failure shows “State topology unverified” instead of claiming the
workflow is healthy.

The product contract remains read-only by default: Spacedock markdown files are
the source of truth, and state-changing features must be explicit and auditable.
Expand Down Expand Up @@ -147,8 +157,12 @@ Spacedock workflow directories.

Spacetop should be read-only by default. The only current writes are the explicit
`Y` sync action (`git pull --ff-only`) and session persistence under the user
state path described above. Future workflow-state write features should make
state changes explicit and easy to audit through git.
state path described above. `Y` refreshes the definition repository first and
then a split-root state checkout only when it is verified attached to the
expected branch. Detached, wrong-branch, missing, and unverified state are never
checked out or repaired; the footer reports that only the definition was
refreshed. Future workflow-state write features should make state changes
explicit and easy to audit through git.

## Development

Expand Down Expand Up @@ -202,8 +216,9 @@ cargo run -p spacetop -- export --workflow-dir docs/spacetop-dev --json

### Workspace Layout

- `crates/spacetop-core/` contains domain, parser, discovery, watcher, git sync,
and editor helpers. It has no terminal UI dependencies.
- `crates/spacetop-core/` contains domain, parser, split-root checkout topology,
discovery, watcher, git sync, and editor helpers. It has no terminal UI
dependencies.
- `crates/spacetop/` contains the CLI, TUI app state, rendering, terminal event
loop, and release-only Sentry setup.
- `tests/fixtures/` contains shared integration-test fixtures.
Expand Down
39 changes: 39 additions & 0 deletions crates/spacetop-core/src/domain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ use serde::{Deserialize, Serialize};
const STAGE_LIGHTNESS: f32 = 0.78;
const STAGE_CHROMA: f32 = 0.12;

/// Where a workflow's entity markdown is stored, separate from the runtime
/// disposition of a materialized split-root checkout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum WorkflowStorage {
/// Entities live beside the workflow README. This covers absent, blank,
/// and `$inline` declarations, plus unsupported paths that fail closed.
#[default]
SingleRoot,
/// Entities live in a contained relative state checkout.
SplitRoot {
entity_dir: PathBuf,
expected_branch: String,
disposition: StateCheckoutDisposition,
},
}

impl WorkflowStorage {
pub fn entity_dir<'a>(&'a self, definition_root: &'a std::path::Path) -> &'a std::path::Path {
match self {
Self::SingleRoot => definition_root,
Self::SplitRoot { entity_dir, .. } => entity_dir,
}
}
}

/// Runtime condition of a declared split-root checkout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StateCheckoutDisposition {
Attached,
Detached,
WrongBranch { actual_branch: String },
Missing,
ProbeFailed { reason: String },
}

/// A plain RGB color owned by the core (no terminal-crate dependency).
/// The UI layer converts this to its terminal color type at render time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
Expand Down Expand Up @@ -101,6 +136,9 @@ pub struct WorkflowDefinition {
/// split-root state checkout. Resolve it through the parser's
/// `resolve_entity_dir` helper rather than reading this field directly.
pub state: Option<String>,
/// Typed storage backend and, for split-root workflows, the current
/// checkout disposition established by read-only Git probes.
pub storage: WorkflowStorage,
pub stages: Vec<StageDefinition>,
pub id_style: Option<String>,
pub entity_type: Option<String>,
Expand Down Expand Up @@ -429,6 +467,7 @@ mod tests {
WorkflowDefinition {
root: PathBuf::new(),
state: None,
storage: Default::default(),
stages,
id_style: None,
entity_type: None,
Expand Down
6 changes: 6 additions & 0 deletions crates/spacetop-core/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};

use crate::domain::{
Entity, EntityActivity, EntityParseError, SessionScanReport, WorkflowDefinition,
WorkflowStorage,
};
use crate::entity_identity::entity_slug;
pub use crate::metrics::Metrics;
Expand Down Expand Up @@ -169,6 +170,10 @@ impl WorkflowIndex {
&self.definition
}

pub fn storage(&self) -> &WorkflowStorage {
&self.definition.storage
}

pub fn active_parse_errors(&self) -> &[EntityParseError] {
&self.active_parse_errors
}
Expand Down Expand Up @@ -432,6 +437,7 @@ mod tests {
WorkflowDefinition {
root: PathBuf::from("/tmp/workflow"),
state: None,
storage: Default::default(),
stages: vec![
StageDefinition {
name: "plan".to_string(),
Expand Down
1 change: 1 addition & 0 deletions crates/spacetop-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ pub mod relations;
pub mod session_activity;
pub mod session_state;
pub mod sources;
pub mod state_checkout;
pub mod watcher;
1 change: 0 additions & 1 deletion crates/spacetop-core/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ pub(crate) use frontmatter::{split_frontmatter, top_level_scalar, SplitFrontmatt
pub use item::parse_work_item;
pub use readme::parse_workflow_readme;
pub use snapshot::load_workflow_dir;
pub(crate) use snapshot::resolve_entity_dir;

pub use crate::domain::EntityParseError;

Expand Down
30 changes: 25 additions & 5 deletions crates/spacetop-core/src/parser/readme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use std::path::Path;
use serde::Deserialize;

use crate::domain::{StageDefinition, StageTransition, WorkflowDefinition};
use crate::git::StdGitRunner;
use crate::state_checkout::classify_storage;

use super::frontmatter::extract_frontmatter;
use super::{display_path, required, ParseError};
Expand Down Expand Up @@ -63,9 +65,17 @@ pub fn parse_workflow_readme(path: &Path) -> Result<WorkflowDefinition, ParseErr
_ => None,
})
.collect();
let root = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
let storage = classify_storage(
&StdGitRunner,
&root,
raw.state.as_deref(),
raw.state_branch.as_deref(),
);
Ok(WorkflowDefinition {
root: path.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
root,
state: raw.state,
storage,
stages,
id_style: raw.id_style,
entity_type: raw.entity_type,
Expand Down Expand Up @@ -249,6 +259,8 @@ struct RawWorkflowFrontmatter {
/// keep entities beside the README. See `parser::snapshot::resolve_entity_dir`.
#[serde(default)]
state: Option<String>,
#[serde(rename = "state-branch")]
state_branch: Option<String>,
stages: Option<RawStageBlock>,
}

Expand Down Expand Up @@ -526,9 +538,8 @@ mod tests {
}
}

/// AC-1: a top-level `state:` scalar round-trips into
/// `WorkflowDefinition.state`; absence leaves it `None`. `root` stays the
/// README's parent regardless.
/// AC-1: `state:` selects split-root and `state-branch:` overrides the
/// branch derived from the workflow directory name.
#[test]
fn state_field_round_trips_from_frontmatter() {
let tmp = tempdir_path("state_field");
Expand All @@ -537,12 +548,20 @@ mod tests {
let with_state = tmp.join("README.md");
std::fs::write(
&with_state,
"---\nstate: .spacedock-state\nstages:\n states:\n - name: plan\n---\n",
"---\nstate: .spacedock-state\nstate-branch: custom/state\nstages:\n states:\n - name: plan\n---\n",
)
.expect("write readme");
let wf = parse_workflow_readme(&with_state).expect("parse");
assert_eq!(wf.state.as_deref(), Some(".spacedock-state"));
assert_eq!(wf.root, tmp);
assert!(matches!(
wf.storage,
crate::domain::WorkflowStorage::SplitRoot {
ref expected_branch,
disposition: crate::domain::StateCheckoutDisposition::Missing,
..
} if expected_branch == "custom/state"
));

let without = tmp.join("nested");
std::fs::create_dir_all(&without).expect("mkdir nested");
Expand All @@ -554,6 +573,7 @@ mod tests {
.expect("write readme");
let wf2 = parse_workflow_readme(&without_path).expect("parse");
assert_eq!(wf2.state, None);
assert_eq!(wf2.storage, crate::domain::WorkflowStorage::SingleRoot);
}

fn tempdir_path(label: &str) -> PathBuf {
Expand Down
14 changes: 2 additions & 12 deletions crates/spacetop-core/src/parser/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,8 @@ pub fn load_workflow_dir(path: &Path, repo_root: &Path) -> Result<WorkflowSnapsh
/// (single-root). This keeps an untrusted or misconfigured README from pointing
/// entity/archive loading at arbitrary filesystem paths.
pub(crate) fn resolve_entity_dir(definition_dir: &Path, state: Option<&str>) -> PathBuf {
let rel = match state.map(str::trim) {
None | Some("") | Some("$inline") => return definition_dir.to_path_buf(),
Some(rel) => Path::new(rel),
};
if rel.is_absolute()
|| rel
.components()
.any(|component| component == std::path::Component::ParentDir)
{
return definition_dir.to_path_buf();
}
definition_dir.join(rel)
crate::state_checkout::split_root_entity_dir(definition_dir, state)
.unwrap_or_else(|| definition_dir.to_path_buf())
}

pub(crate) fn entity_parse_error_from(path: &Path, err: &ParseError) -> EntityParseError {
Expand Down
9 changes: 4 additions & 5 deletions crates/spacetop-core/src/sources.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
use std::path::Path;

use crate::domain::{Entity, EntityParseError, WorkflowDefinition, WorkflowSnapshot};
use crate::parser::{
load_archived_items_with_errors, load_workflow_dir, resolve_entity_dir, ParseError,
};
use crate::parser::{load_archived_items_with_errors, load_workflow_dir, ParseError};

#[derive(Debug, Clone, PartialEq)]
pub struct ArchiveSnapshot {
Expand Down Expand Up @@ -56,9 +54,9 @@ impl WorkflowSources {
// `workflow_dir` (the discovered definition dir) and `definition.root`
// name the same directory, so resolution is consistent with the active
// scan in `load_workflow_dir`.
let entity_dir = resolve_entity_dir(workflow_dir, definition.state.as_deref());
let entity_dir = definition.storage.entity_dir(workflow_dir);
ArchiveSource::load(
&entity_dir,
entity_dir,
&allowed_statuses,
definition.id_style.as_deref(),
)
Expand Down Expand Up @@ -113,6 +111,7 @@ mod tests {
definition: crate::domain::WorkflowDefinition {
root: PathBuf::from("/tmp/workflow"),
state: None,
storage: Default::default(),
stages: Vec::new(),
id_style: None,
entity_type: None,
Expand Down
Loading
Loading