diff --git a/Cargo.lock b/Cargo.lock index 5494c5a697..9904680828 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5330,7 +5330,6 @@ dependencies = [ "criterion", "dirs", "filetime", - "fs2", "futures-util", "gix", "glob", @@ -5416,7 +5415,6 @@ version = "0.1.0" dependencies = [ "cap-fs-ext", "cap-std", - "fs2", "getrandom 0.2.17", "gix", "hex", @@ -5485,7 +5483,6 @@ version = "0.1.0" dependencies = [ "cap-std", "criterion", - "fs2", "getrandom 0.2.17", "gix", "glob", @@ -5551,7 +5548,6 @@ version = "0.1.0" dependencies = [ "cap-fs-ext", "cap-std", - "fs2", "getrandom 0.2.17", "hex", "hotpath", @@ -5608,7 +5604,6 @@ dependencies = [ "crossterm", "dirs", "flate2", - "fs2", "futures-util", "hex", "hotpath", @@ -5721,7 +5716,6 @@ version = "0.1.0" dependencies = [ "cap-fs-ext", "cap-std", - "fs2", "gix", "hotpath", "libc", @@ -5843,7 +5837,6 @@ dependencies = [ name = "tracedecay-daemon-identity" version = "0.1.0" dependencies = [ - "fs2", "getrandom 0.2.17", "hex", "hotpath", @@ -5881,7 +5874,6 @@ name = "tracedecay-daemon-service" version = "0.1.0" dependencies = [ "axum", - "fs2", "getrandom 0.2.17", "hex", "hotpath", @@ -6040,7 +6032,6 @@ name = "tracedecay-graph-db" version = "0.1.0" dependencies = [ "criterion", - "fs2", "grafeo-adapters", "grafeo-common", "grafeo-core", @@ -6421,7 +6412,6 @@ name = "tracedecay-runtime-core" version = "0.1.0" dependencies = [ "criterion", - "fs2", "getrandom 0.2.17", "gix", "hex", diff --git a/crates/tracedecay-agent-hosts/Cargo.toml b/crates/tracedecay-agent-hosts/Cargo.toml index 62c59ebe49..ca7b493e5c 100644 --- a/crates/tracedecay-agent-hosts/Cargo.toml +++ b/crates/tracedecay-agent-hosts/Cargo.toml @@ -27,7 +27,6 @@ test-transport = [ [dependencies] cap-fs-ext = "4.0.2" cap-std = "4.0.2" -fs2 = "0.4" same-file = "1.0.6" getrandom = "0.2" hex = "0.4" diff --git a/crates/tracedecay-agent-hosts/src/agents/host_bundle/writer.rs b/crates/tracedecay-agent-hosts/src/agents/host_bundle/writer.rs index 6056811051..6b2daf4ecf 100644 --- a/crates/tracedecay-agent-hosts/src/agents/host_bundle/writer.rs +++ b/crates/tracedecay-agent-hosts/src/agents/host_bundle/writer.rs @@ -10,7 +10,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; use cap_std::ambient_authority; use cap_std::fs::{Dir, OpenOptions as CapOpenOptions}; -use fs2::FileExt; use sha2::{Digest, Sha256}; use tracedecay_domain::canonical_json_bytes; use tracedecay_host_integration::host_bundle_recovery_required; @@ -1312,7 +1311,8 @@ fn open_host_writer_lock( .open_with(&name, &options) .map_err(|_| HostBundleError::UnsafeInstallPath)? .into_std(); - file.try_lock_exclusive() + file.try_lock() + .map_err(std::io::Error::from) .map_err(|_| host_bundle_recovery_required!())?; Ok(HostWriterLock { host, file }) } diff --git a/crates/tracedecay-agent-hosts/src/agents/text_file_transaction.rs b/crates/tracedecay-agent-hosts/src/agents/text_file_transaction.rs index 6a84c4fa05..68567e0ae2 100644 --- a/crates/tracedecay-agent-hosts/src/agents/text_file_transaction.rs +++ b/crates/tracedecay-agent-hosts/src/agents/text_file_transaction.rs @@ -9,7 +9,6 @@ use std::path::Path; use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; use cap_std::ambient_authority; use cap_std::fs::{Dir, OpenOptions as CapOpenOptions}; -use fs2::FileExt; use same_file::Handle; use tracedecay_domain::canonical_text::sha256_hex; @@ -46,7 +45,7 @@ impl Drop for HostFileWriteLock { "host config lock file could not be unlinked while held" ); } - if let Err(error) = FileExt::unlock(self.handle.as_file()) { + if let Err(error) = self.handle.as_file().unlock() { tracing::warn!( lock_name = %self.lock_name, error = %error, @@ -141,10 +140,9 @@ pub(super) fn lock_host_file_write(path: &Path) -> Result { ), }); } - lock.lock_exclusive() - .map_err(|error| TraceDecayError::Config { - message: format!("failed to lock host config {}: {error}", path.display()), - })?; + lock.lock().map_err(|error| TraceDecayError::Config { + message: format!("failed to lock host config {}: {error}", path.display()), + })?; let locked = Handle::from_file(lock).map_err(|error| TraceDecayError::Config { message: format!( "failed to identify host config lock {}: {error}", diff --git a/crates/tracedecay-application/Cargo.toml b/crates/tracedecay-application/Cargo.toml index f024a981a7..91e84bf916 100644 --- a/crates/tracedecay-application/Cargo.toml +++ b/crates/tracedecay-application/Cargo.toml @@ -30,7 +30,6 @@ test-transport = [ [dependencies] cap-std = "4.0.2" -fs2 = "0.4" getrandom = "0.2" gix = { version = "=0.86.0", default-features = false, features = ["revision", "blob-diff", "parallel", "sha1", "sha256", "status"] } glob = "0.3" diff --git a/crates/tracedecay-application/src/pr_tracking/worktrees.rs b/crates/tracedecay-application/src/pr_tracking/worktrees.rs index 2e55936579..d671021392 100644 --- a/crates/tracedecay-application/src/pr_tracking/worktrees.rs +++ b/crates/tracedecay-application/src/pr_tracking/worktrees.rs @@ -2,7 +2,6 @@ use std::path::{Path, PathBuf}; -use fs2::FileExt; use tracedecay_domain::canonical_text::sha256_hex; use tracedecay_runtime_core::branch::BranchAddOutcome; @@ -80,7 +79,7 @@ impl ManualBranchArtifactsV1 { /// lease is taken before the branch identity is resolved, so a typed /// pre-mutation refusal (missing ref, unavailable Git authority) must not /// leave the worktree root behind as evidence of an activation that never - /// happened — and nothing enumerating branch worktrees has to filter a + /// happened, and nothing enumerating branch worktrees has to filter a /// non-worktree entry out. fn lifecycle_lock_path(&self, data_root: &Path) -> PathBuf { data_root @@ -132,12 +131,14 @@ pub fn try_acquire_manual_branch_lifecycle( lock_path.display() )) })?; - lock.try_lock_exclusive().map_err(|error| { - ManualBranchActivationError::lifecycle_contended(format!( - "branch '{branch}' lifecycle is already active at '{}': {error}", - lock_path.display() - )) - })?; + lock.try_lock() + .map_err(std::io::Error::from) + .map_err(|error| { + ManualBranchActivationError::lifecycle_contended(format!( + "branch '{branch}' lifecycle is already active at '{}': {error}", + lock_path.display() + )) + })?; Ok(ManualBranchLifecycleLeaseV1 { branch: branch.to_owned(), _lock: lock, diff --git a/crates/tracedecay-automation-runtime/Cargo.toml b/crates/tracedecay-automation-runtime/Cargo.toml index 5bf650e56d..6241b1fe00 100644 --- a/crates/tracedecay-automation-runtime/Cargo.toml +++ b/crates/tracedecay-automation-runtime/Cargo.toml @@ -28,7 +28,6 @@ test-helpers = [ [dependencies] cap-fs-ext = "4.0.2" cap-std = "4.0.2" -fs2 = "0.4" getrandom = "0.2" hex = "0.4" hotpath.workspace = true diff --git a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/journal.rs b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/journal.rs index 4114390d10..65eeae011b 100644 --- a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/journal.rs +++ b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/journal.rs @@ -1058,7 +1058,8 @@ fn with_journal_lock(path: &Path, operation: impl FnOnce() -> Result) -> R let lock = open_lock_nofollow(&lock_path) .map_err(|error| contract_error(format!("automation terminal lock failed: {error}")))?; let result = operation(); - let unlock = fs2::FileExt::unlock(&lock) + let unlock = lock + .unlock() .map_err(|error| contract_error(format!("automation terminal unlock failed: {error}"))); match (result, unlock) { (Err(error), _) => Err(error), @@ -1242,7 +1243,7 @@ fn open_lock_nofollow(path: &Path) -> std::io::Result { )); } let file = file.into_std(); - fs2::FileExt::lock_exclusive(&file)?; + file.lock()?; Ok(file) } diff --git a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/recovery_index.rs b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/recovery_index.rs index 534d81af86..b319176577 100644 --- a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/recovery_index.rs +++ b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/recovery_index.rs @@ -1349,12 +1349,12 @@ fn with_index_lock(path: &Path, operation: impl FnOnce() -> Result) -> Res "automation pending index lock is not a regular file", )); } - fs2::FileExt::lock_exclusive(&file)?; + file.lock()?; Ok(file) }) .map_err(|error| contract_error(format!("automation pending index lock failed: {error}")))?; let result = operation(); - let unlock = fs2::FileExt::unlock(&lock).map_err(|error| { + let unlock = lock.unlock().map_err(|error| { contract_error(format!("automation pending index unlock failed: {error}")) }); match (result, unlock) { diff --git a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/retirement.rs b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/retirement.rs index 298e154cf3..dd40a03510 100644 --- a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/retirement.rs +++ b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/retirement.rs @@ -374,7 +374,7 @@ fn complete_after_pending_removal_with( )), } })(); - let unlock = fs2::FileExt::unlock(&lock).map_err(|error| { + let unlock = lock.unlock().map_err(|error| { contract_error(format!( "shipped proposal retirement unlock failed: {error}" )) @@ -661,7 +661,7 @@ fn restore_existing_capture(source_path: &Path, captured_path: &Path) -> Result< })?; restore_captured_source(&parent, captured_name, source_name, source_path) })(); - let unlock = fs2::FileExt::unlock(&lock).map_err(|error| { + let unlock = lock.unlock().map_err(|error| { contract_error(format!( "shipped proposal retirement unlock failed: {error}" )) @@ -746,7 +746,7 @@ fn capture_exact_source_with_capture( } Ok(Some(tombstone_path)) })(); - let unlock = fs2::FileExt::unlock(&lock).map_err(|error| { + let unlock = lock.unlock().map_err(|error| { contract_error(format!( "shipped proposal retirement unlock failed: {error}" )) diff --git a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs index 3e70d3a90f..0ec35d6f33 100644 --- a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs +++ b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs @@ -2252,7 +2252,6 @@ async fn dropping_retained_waiter_does_not_abort_blocking_owner() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn retained_projector_panic_finishes_recovery_before_releasing_task_lock() { - use fs2::FileExt; use std::time::{Duration, Instant}; let temp = tempfile::tempdir().expect("tempdir"); @@ -2278,7 +2277,7 @@ async fn retained_projector_panic_finishes_recovery_before_releasing_task_lock() .open(&journal_lock_path) .expect("projector-panic journal lock"); journal_lock - .lock_exclusive() + .lock() .expect("block projector-panic recovery terminal"); let (projected_tx, projected_rx) = std::sync::mpsc::channel(); @@ -2302,7 +2301,9 @@ async fn retained_projector_panic_finishes_recovery_before_releasing_task_lock() assert!(task_lock_is_denied(dashboard_root, job_id).await); - FileExt::unlock(&journal_lock).expect("release projector-panic recovery terminal"); + journal_lock + .unlock() + .expect("release projector-panic recovery terminal"); let deadline = Instant::now() + Duration::from_secs(5); loop { let terminal = read_indexed_record_blocking(&journal_path) @@ -2467,8 +2468,6 @@ async fn dropping_request_waiting_pair_before_submit_abandons_both_authorities() #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn partial_pair_submit_abandons_the_closed_sibling_under_shared_guard_ownership() { - use fs2::FileExt; - let temp = tempfile::tempdir().expect("tempdir"); let dashboard_root = temp.path(); let mut fixture = request_waiting_pair_fixture(dashboard_root, "pair-partial-submit").await; @@ -2480,7 +2479,7 @@ async fn partial_pair_submit_abandons_the_closed_sibling_under_shared_guard_owne )) .expect("open second request-waiting journal lock"); second_journal_lock - .lock_exclusive() + .lock() .expect("block closed sibling abandonment"); let submission = fixture .submission @@ -2512,7 +2511,9 @@ async fn partial_pair_submit_abandons_the_closed_sibling_under_shared_guard_owne task_lock_is_denied(dashboard_root, &fixture.job_ids[1]).await, "closed sibling released its lock before durable abandonment" ); - FileExt::unlock(&second_journal_lock).expect("release closed sibling abandonment"); + second_journal_lock + .unlock() + .expect("release closed sibling abandonment"); assert_request_waiting_pair_abandoned_cleanly(dashboard_root, &fixture).await; } diff --git a/crates/tracedecay-automation-runtime/src/automation/host_receipts.rs b/crates/tracedecay-automation-runtime/src/automation/host_receipts.rs index e73d7c7bc6..37e02145a4 100644 --- a/crates/tracedecay-automation-runtime/src/automation/host_receipts.rs +++ b/crates/tracedecay-automation-runtime/src/automation/host_receipts.rs @@ -3,7 +3,6 @@ use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; -use fs2::FileExt; use serde::{Deserialize, Serialize}; use super::config_error; @@ -114,7 +113,7 @@ fn with_locked_state( .truncate(false) .open(&lock_path) .map_err(|error| config_error(format!("failed to open host receipt lock: {error}")))?; - lock.lock_exclusive() + lock.lock() .map_err(|error| config_error(format!("failed to lock host receipts: {error}")))?; let mut state = std::fs::read(&state_path) .ok() diff --git a/crates/tracedecay-automation-runtime/src/automation/managed_skills.rs b/crates/tracedecay-automation-runtime/src/automation/managed_skills.rs index 32a2521ad2..1bf299607c 100644 --- a/crates/tracedecay-automation-runtime/src/automation/managed_skills.rs +++ b/crates/tracedecay-automation-runtime/src/automation/managed_skills.rs @@ -4,7 +4,6 @@ use std::io::Write; use std::path::{Path, PathBuf}; use super::config_error; -use fs2::FileExt; use serde::{Deserialize, Serialize}; use tracedecay_automation::run_labels::SKILL_OVERLAP_REMOVAL_TOMBSTONE; use tracedecay_domain::errors::Result; @@ -107,7 +106,7 @@ struct SkillStoreLock(File); impl Drop for SkillStoreLock { fn drop(&mut self) { - let _ = FileExt::unlock(&self.0); + let _ = self.0.unlock(); } } @@ -132,7 +131,7 @@ fn lock_skill_store(profile_root: &Path) -> Result { path.display() )) })?; - file.lock_exclusive().map_err(|e| { + file.lock().map_err(|e| { config_error(format!( "failed to lock skill store '{}': {e}", path.display() diff --git a/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs b/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs index db4a958ae0..0b46e65a17 100644 --- a/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs +++ b/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs @@ -403,7 +403,7 @@ fn append_jsonl_line_locked(path: &Path, line: &str) -> std::io::Result<()> { } Ok(()) })(); - let unlock_result = fs2::FileExt::unlock(&lock); + let unlock_result = lock.unlock(); write_result?; unlock_result?; Ok(()) @@ -972,8 +972,8 @@ fn validate_requested_task_key(task_key: &str) -> Result<()> { /// A read must not mint the dashboard directory: acquiring the lock creates /// it, and a root that does not exist has no ledger. Absence is therefore /// answered from `absent` alone. Running `read` there would open whatever a -/// first writer created in the meantime — outside the lock and outside -/// `ensure_no_exact_append_intent` — and expose a row whose publication has +/// first writer created in the meantime, outside the lock and outside +/// `ensure_no_exact_append_intent`, and expose a row whose publication has /// not settled; the directory's absence at the time of check says nothing /// about the ledger at the time of use. /// @@ -1007,7 +1007,7 @@ fn with_run_ledger_read_lock( })?; hotpath::measure_block!("automation.run_ledger.read_lock.body", read()) })(); - let unlock = fs2::FileExt::unlock(&lock).map_err(TraceDecayError::from); + let unlock = lock.unlock().map_err(TraceDecayError::from); result.and_then(|value| unlock.map(|()| value)) } diff --git a/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_lookup.rs b/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_lookup.rs index e3f9294ed9..2c2c077394 100644 --- a/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_lookup.rs +++ b/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_lookup.rs @@ -311,8 +311,9 @@ pub fn find_run_record_exact_bounded_blocking( .map_err(tracedecay_domain::errors::TraceDecayError::from)?; read_exact_run_record_bounded(&path, run_id) })(); - let unlock = - fs2::FileExt::unlock(&lock).map_err(tracedecay_domain::errors::TraceDecayError::from); + let unlock = lock + .unlock() + .map_err(tracedecay_domain::errors::TraceDecayError::from); result.and_then(|record| unlock.map(|()| record)) } diff --git a/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_publication.rs b/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_publication.rs index 88aba76481..25922ca12b 100644 --- a/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_publication.rs +++ b/crates/tracedecay-automation-runtime/src/automation/run_ledger/exact_publication.rs @@ -91,7 +91,7 @@ fn acquire_nofollow_lock(lock_path: &Path) -> std::io::Result { )); } let file = file.into_std(); - fs2::FileExt::lock_exclusive(&file)?; + file.lock()?; Ok(file) } @@ -331,7 +331,7 @@ fn bind_staged_run_record_exact_with_publisher( Err(error) => Err(error), } })(); - let unlock = fs2::FileExt::unlock(&ledger_lock).map_err(TraceDecayError::from); + let unlock = ledger_lock.unlock().map_err(TraceDecayError::from); result.and_then(|bound| unlock.map(|()| bound)) }) } @@ -494,7 +494,7 @@ fn publish_staged_run_record_exact_blocking_with_publisher( publication, publish_file, ); - let unlock = fs2::FileExt::unlock(&lock).map_err(TraceDecayError::from); + let unlock = lock.unlock().map_err(TraceDecayError::from); result.and_then(|outcome| unlock.map(|()| outcome)) }) } @@ -551,7 +551,7 @@ fn repair_corrupt_run_ledger_append_intent_impl( })?; repair_corrupt_append_intent(dashboard_root, &ledger_path, &mut ledger, &bytes) })(); - let unlock = fs2::FileExt::unlock(&ledger_lock).map_err(TraceDecayError::from); + let unlock = ledger_lock.unlock().map_err(TraceDecayError::from); result.and(unlock) }) } @@ -653,7 +653,7 @@ where } remove_canonical_spool_durable(&path) })(); - let unlock = fs2::FileExt::unlock(&ledger_lock).map_err(TraceDecayError::from); + let unlock = ledger_lock.unlock().map_err(TraceDecayError::from); result.and(unlock) }) } @@ -1653,7 +1653,7 @@ fn with_spool_lock(dashboard_root: &Path, operation: impl FnOnce() -> Result< let lock_path = dashboard_root.join(EXACT_RUN_SPOOL_LOCK); let lock = acquire_nofollow_lock(&lock_path).map_err(TraceDecayError::from)?; let result = cleanup_abandoned_exact_spool_temps(&directory).and_then(|()| operation()); - let unlock = fs2::FileExt::unlock(&lock).map_err(TraceDecayError::from); + let unlock = lock.unlock().map_err(TraceDecayError::from); result.and_then(|value| unlock.map(|()| value)) } diff --git a/crates/tracedecay-automation-runtime/src/automation/run_ledger/scheduler_diagnostic.rs b/crates/tracedecay-automation-runtime/src/automation/run_ledger/scheduler_diagnostic.rs index 43c2976f14..25c71aa5f4 100644 --- a/crates/tracedecay-automation-runtime/src/automation/run_ledger/scheduler_diagnostic.rs +++ b/crates/tracedecay-automation-runtime/src/automation/run_ledger/scheduler_diagnostic.rs @@ -72,7 +72,7 @@ fn append_or_reuse_blocking( super::sync_run_ledger_file_and_parent(path, &file)?; Ok(candidate.clone()) })(); - let unlock = fs2::FileExt::unlock(&lock).map_err(TraceDecayError::from); + let unlock = lock.unlock().map_err(TraceDecayError::from); result.and_then(|record| unlock.map(|()| record)) } @@ -395,9 +395,9 @@ mod tests { fn multi_megabyte_anchor_allows_exact_reuse_and_append() { // The reverse scan must handle a multi-megabyte anchor with bounded // memory and still support exact reuse plus a later append. The - // candidate is newer than the anchor — the realistic order for a + // candidate is newer than the anchor, the realistic order for a // scheduler skip diagnostic computed against the latest effectful - // run — because a scan that stops at the anchor cannot reuse an + // run, because a scan that stops at the anchor cannot reuse an // older candidate without walking the full ledger. let temp = tempfile::TempDir::new().unwrap(); let path = run_ledger_path(temp.path()); diff --git a/crates/tracedecay-automation-runtime/src/automation/scheduler.rs b/crates/tracedecay-automation-runtime/src/automation/scheduler.rs index 46d8d1d761..668195c401 100644 --- a/crates/tracedecay-automation-runtime/src/automation/scheduler.rs +++ b/crates/tracedecay-automation-runtime/src/automation/scheduler.rs @@ -277,15 +277,15 @@ impl AutomationTaskLock { /// Task-lock release must be synchronous: callers (and tests such as /// `retained_settlement_guard_owns_task_lock_until_drop`) rely on the lock file /// being gone the instant `drop` returns. The cleanup itself is genuinely -/// blocking — an fs2 coordination lock, `sync_all`/parent-directory fsyncs, and -/// `std::thread::sleep` backoff between retries — so it is tempting to hand the +/// blocking, a file coordination lock, `sync_all`/parent-directory fsyncs, and +/// `std::thread::sleep` backoff between retries, so it is tempting to hand the /// owning worker's run queue away with `tokio::task::block_in_place`. /// /// That is not sound here, because this guard is reachable from inside tokio's /// own blocking-pool spawn path. When a runtime has begun shutting down, /// `blocking::pool::Spawner::spawn_task` (tokio 1.53.1) shuts a refused task /// down *while holding* the pool's non-reentrant `parking_lot` mutex, which -/// drops the task's future — and with it any `AutomationTaskLock` the future +/// drops the task's future, and with it any `AutomationTaskLock` the future /// owned, such as the `Arc` captured by /// `start_retained_automation_settlement_inner`. `block_in_place` re-enters /// `spawn_task` on that same thread to hand off the worker core, so the release @@ -630,7 +630,7 @@ enum BackendFailureStanding { /// each rules out a different kind of false positive: /// /// 1. The failure class is deterministic under a fixed backend and -/// configuration — typed `Permanent` only. `Unavailable`, `Denied`, +/// configuration, typed `Permanent` only. `Unavailable`, `Denied`, /// `Disconnected`, `MalformedOutput`, `Timeout`, and `Retryable` keep the /// ordinary failure cooldown. /// 2. Every attempt the backend made failed that same way. A class that got @@ -1256,7 +1256,7 @@ fn acquire_task_lock_coordination(path: &Path) -> std::io::Result } file }; - fs2::FileExt::lock_exclusive(&file)?; + file.lock()?; file.sync_all()?; tracedecay_private_fs::framed_log::sync_parent_directory( &coordination_path, @@ -1703,7 +1703,7 @@ disconnected: config error: codex app-server closed stdout before completing"; )]; // The failure cooldown is the only thing that used to gate this, and - // it elapses. Every tick after it — including a full day later — must + // it elapses. Every tick after it, including a full day later, must // still refuse to spawn the backend again. for now_secs in [ 2_001, @@ -2600,7 +2600,7 @@ evidence about it", /// shutting down, and tokio's `blocking::pool::Spawner::spawn_task` shuts /// the refused task down *while holding* the pool's non-reentrant mutex. /// The refused closure owns this guard, so its release runs under that - /// mutex — and a release that re-enters the runtime never returns, leaving + /// mutex, and a release that re-enters the runtime never returns, leaving /// `BlockingPool::shutdown` waiting for the thread forever. /// /// Ordering is fixed by channels, not timing: the worker is parked until diff --git a/crates/tracedecay-automation-runtime/src/automation/skill_materialization.rs b/crates/tracedecay-automation-runtime/src/automation/skill_materialization.rs index 91c39900e7..038ecde07b 100644 --- a/crates/tracedecay-automation-runtime/src/automation/skill_materialization.rs +++ b/crates/tracedecay-automation-runtime/src/automation/skill_materialization.rs @@ -7,7 +7,7 @@ //! discoverable but never *natively loaded*: the host does not treat a managed //! skill as one of its own skills. //! -//! This module closes that gap the way Hermes does — by writing each active +//! This module closes that gap the way Hermes does, by writing each active //! managed skill as a real, host-loadable `SKILL.md` into the host's own skills //! directory (`/.claude/skills//SKILL.md` for Claude Code, the //! `.codex` twin for Codex), so the agent loads it like any other skill. @@ -233,7 +233,7 @@ struct MaterializationManifest { /// Stable id of the profile/installation that materialized this package. /// Absent on manifests written before this field existed (and on packages /// re-derived from disk without a known installation); such packages are - /// never auto-removed from a *project* scope — they may be another user's + /// never auto-removed from a *project* scope, they may be another user's /// committed files. #[serde(default, skip_serializing_if = "Option::is_none")] materialized_by: Option, @@ -346,7 +346,7 @@ fn collect_on_disk_support_files(dir: &Path) -> Result)>> /// disk (package-hash domain). Reconstructs the render placeholder by /// swapping the recorded `content-hash` back to ``, folds in the /// on-disk support files exactly as [`ManagedSkill::materialized_package_hash`] -/// does, and — when the result matches the recorded hash — returns a re-derived +/// does. When the result matches the recorded hash, it returns a re-derived /// manifest proving the package is pristine (safe to treat as owned). Returns /// `Ok(None)` when the file is missing, has no content-hash, or has drifted. fn recompute_on_disk_package( @@ -379,7 +379,7 @@ fn recompute_on_disk_package( for (relative, bytes) in &supports { // Hash the slash-normalized key, not Path display form. On Windows, // `strip_prefix` relatives stringify with `\`, while authoring hashes - // use forward-slash support paths — a mismatch would make every + // use forward-slash support paths, a mismatch would make every // pristine lost-manifest package look forked. let key = support_relative_key(relative)?; hasher.update(b"\0file:"); @@ -427,7 +427,7 @@ const INSTALLATION_ID_FILE: &str = ".materialization-installation-id"; /// Returns a stable id for the local profile/installation, persisting a random /// token in the profile root on first use. Stamped into every manifest this /// installation writes so orphan cleanup in *project* scopes only removes files -/// this installation authored — never another user's committed materialization. +/// this installation authored, never another user's committed materialization. /// /// Best-effort: if the token cannot be read or written (read-only profile), a /// per-process fallback is returned. A fallback id never matches a persisted @@ -496,7 +496,7 @@ struct PackageLock(fs::File); impl Drop for PackageLock { fn drop(&mut self) { - let _ = fs2::FileExt::unlock(&self.0); + let _ = self.0.unlock(); } } @@ -507,7 +507,6 @@ fn package_lock_path(package_dir: &Path) -> PathBuf { #[hotpath::measure(label = "hosts.automation.skill_materialization.lock")] fn lock_package(package_dir: &Path) -> Result { - use fs2::FileExt; let path = package_lock_path(package_dir); let file = fs::OpenOptions::new() .read(true) @@ -515,7 +514,7 @@ fn lock_package(package_dir: &Path) -> Result { .create(true) .truncate(false) .open(&path)?; - tracedecay_runtime_core::storage::retry_transient_file_op(|| file.lock_exclusive())?; + tracedecay_runtime_core::storage::retry_transient_file_op(|| file.lock())?; Ok(PackageLock(file)) } @@ -545,7 +544,7 @@ fn ensure_not_symlink(path: &Path) -> Result<()> { } } -/// Rejects a symlink at any component the reconciler itself creates — i.e. only +/// Rejects a symlink at any component the reconciler itself creates, i.e. only /// the `relative` components under `base`. `base` (the host skills directory and /// everything above it) may legitimately traverse symlinks: dotfile managers /// (stow, chezmoi, nix-home-manager) routinely symlink `~/.claude`, `.codex`, or @@ -1200,9 +1199,9 @@ fn safe_support_relative(path: &Path) -> Result<&Path> { /// Whether this installation may auto-remove an owned package. Global (home) /// packages are the local user's own and are always removable. Project-scope /// packages live inside a repo working tree and are routinely committed and -/// shared, so they are removed only when this installation authored them — -/// never another developer's committed materialization (or a manifest-less -/// package whose author is unknown). +/// shared, so they are removed only when this installation authored them, +/// never another developer's committed materialization or a manifest-less +/// package whose author is unknown. fn may_remove_owned( scope: &MaterializationScope, manifest: &MaterializationManifest, @@ -1439,7 +1438,7 @@ fn managed_slugs_in_scope(scope: &MaterializationScope) -> Result Result> { crate::automation::skill_targets::load_active_managed_skills(profile_root) diff --git a/crates/tracedecay-automation-runtime/src/automation/skill_usage/store.rs b/crates/tracedecay-automation-runtime/src/automation/skill_usage/store.rs index 3b125d43e4..4bd7cade39 100644 --- a/crates/tracedecay-automation-runtime/src/automation/skill_usage/store.rs +++ b/crates/tracedecay-automation-runtime/src/automation/skill_usage/store.rs @@ -10,7 +10,6 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use fs2::FileExt; use serde::Serialize; use super::{SkillUsageLedger, SkillUsageRecord, config_error, skill_usage_ledger_path}; @@ -164,7 +163,7 @@ fn with_skill_lock( lock_path.display() )) })?; - lock.lock_exclusive().map_err(|error| { + lock.lock().map_err(|error| { config_error(format!( "failed to lock skill usage record '{skill_id}': {error}" )) @@ -211,7 +210,7 @@ fn migrate_legacy(profile_root: &Path) -> Result<()> { lock_path.display() )) })?; - lock.lock_exclusive() + lock.lock() .map_err(|error| config_error(format!("failed to lock skill usage migration: {error}")))?; let result = migrate_legacy_locked(profile_root, &legacy_path); let _ = lock.unlock(); diff --git a/crates/tracedecay-cli/Cargo.toml b/crates/tracedecay-cli/Cargo.toml index c26163dbeb..c12e758518 100644 --- a/crates/tracedecay-cli/Cargo.toml +++ b/crates/tracedecay-cli/Cargo.toml @@ -51,7 +51,7 @@ default = ["production"] # owners whose compiled behaviour changes, and cargo's feature unification # lights every other crate's hotpath macros from `hotpath/hotpath` alone. # `tracedecay-sdk` mirrors the selected backend onto its build-script (host) -# context — see its manifest. `dep:regex` backs the `HOTPATH_FOCUS` validation +# context, see its manifest. `dep:regex` backs the `HOTPATH_FOCUS` validation # in `main.rs`. hotpath = [ "dep:regex", @@ -77,7 +77,6 @@ hotpath-mcp = ["hotpath", "hotpath/hotpath-mcp"] production = ["tracedecay/production"] # Opt-in tiered graph storage; not part of `production`. graph-disk-tier = ["tracedecay/graph-disk-tier"] -graph-tiered-storage = ["tracedecay/graph-tiered-storage"] token-counting = ["tracedecay/token-counting"] lite = ["tracedecay/lite"] medium = ["tracedecay/medium"] @@ -200,7 +199,6 @@ libc = "0.2" [dev-dependencies] axum = "0.8" dirs = "6" -fs2 = "0.4" regex = "1.12.3" tokio = { version = "1", features = ["full", "test-util"] } toml = "1" @@ -221,13 +219,6 @@ xattr = "1" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tracedecay_observation_fault_harness)"] } -# `fs2` is consumed only by `../tracedecay/tests/common/fixture.rs`, which -# this crate's integration test binaries pull in through relative `#[path]` -# attributes. Cargo-shear's per-crate scan cannot see files outside this -# crate's directory tree, so it reports a false positive. -[package.metadata.cargo-shear] -ignored = ["fs2"] - [build-dependencies] serde_json = "1" sha2 = "0.11" diff --git a/crates/tracedecay-code-index-retention/Cargo.toml b/crates/tracedecay-code-index-retention/Cargo.toml index 2c9ae940fc..0a99112087 100644 --- a/crates/tracedecay-code-index-retention/Cargo.toml +++ b/crates/tracedecay-code-index-retention/Cargo.toml @@ -10,7 +10,6 @@ repository = "https://github.com/ScriptedAlchemy/tracedecay" [dependencies] cap-fs-ext = "4.0.2" cap-std = "4.0.2" -fs2 = "0.4" gix = { version = "=0.86.0", default-features = false, features = ["revision", "parallel", "sha1", "sha256", "status"] } hotpath.workspace = true serde = { version = "1", features = ["derive"] } diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs index eadd9dfdee..8d53fed465 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs @@ -1,8 +1,6 @@ use std::fs::{File, OpenOptions}; use std::path::{Path, PathBuf}; -use fs2::FileExt; - use super::{CodeGenerationRetentionErrorV1, SCOPE_RETENTION_LOCK_FILE, STORE_LOCK_FILE, storage}; pub struct CodeGenerationStoreLockV1 { @@ -31,7 +29,7 @@ impl CodeGenerationStoreLockV1 { impl Drop for CodeGenerationStoreLockV1 { fn drop(&mut self) { - let _ = FileExt::unlock(&self.file); + let _ = self.file.unlock(); } } @@ -49,7 +47,10 @@ pub fn try_acquire_code_generation_store_read_lock( ) -> Result, CodeGenerationRetentionErrorV1> { let store_root = canonical_store_root(store_root)?; let lock = open_lock_file(&store_root.join(STORE_LOCK_FILE))?; - match FileExt::try_lock_shared(&lock) { + match lock + .try_lock_shared() + .map_err(std::io::Error::from) + { Ok(()) => Ok(Some(CodeGenerationStoreLockV1 { file: lock, store_root, @@ -66,7 +67,7 @@ pub fn try_acquire_code_generation_store_lock( ) -> Result, CodeGenerationRetentionErrorV1> { let store_root = canonical_store_root(store_root)?; let lock = open_lock_file(&store_root.join(STORE_LOCK_FILE))?; - match lock.try_lock_exclusive() { + match lock.try_lock().map_err(std::io::Error::from) { Ok(()) => Ok(Some(CodeGenerationStoreLockV1 { file: lock, store_root, @@ -94,7 +95,7 @@ fn lock_file( ) -> Result { let store_root = canonical_store_root(store_root)?; let lock = open_lock_file(&store_root.join(lock_file))?; - lock.lock_exclusive().map_err(storage)?; + lock.lock().map_err(storage)?; Ok(CodeGenerationStoreLockV1 { file: lock, store_root, diff --git a/crates/tracedecay-daemon-identity/Cargo.toml b/crates/tracedecay-daemon-identity/Cargo.toml index 287171abbd..1944e762a9 100644 --- a/crates/tracedecay-daemon-identity/Cargo.toml +++ b/crates/tracedecay-daemon-identity/Cargo.toml @@ -11,7 +11,6 @@ repository = "https://github.com/ScriptedAlchemy/tracedecay" doctest = false [dependencies] -fs2 = "0.4" getrandom = "0.2" hex = "0.4" hotpath.workspace = true diff --git a/crates/tracedecay-daemon-identity/src/authority.rs b/crates/tracedecay-daemon-identity/src/authority.rs index 06154aeb69..addb4b59db 100644 --- a/crates/tracedecay-daemon-identity/src/authority.rs +++ b/crates/tracedecay-daemon-identity/src/authority.rs @@ -7,7 +7,6 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use fs2::FileExt; use serde::{Deserialize, Deserializer, Serialize}; use tracedecay_domain::{BrainId, UserProfileId}; use tracedecay_runtime_core::path_safety::{ @@ -124,7 +123,7 @@ impl DaemonAuthority { let lock_path = authority_root.join(LOCK_FILE); let mut lock = open_private_lock(&lock_path)?; - if let Err(error) = lock.try_lock_exclusive() { + if let Err(error) = lock.try_lock().map_err(std::io::Error::from) { if !tracedecay_private_fs::is_lock_contended(&error) { return Err(config_io("lock", &lock_path, &error)); } diff --git a/crates/tracedecay-daemon-service/Cargo.toml b/crates/tracedecay-daemon-service/Cargo.toml index afbe15bdd0..988c369502 100644 --- a/crates/tracedecay-daemon-service/Cargo.toml +++ b/crates/tracedecay-daemon-service/Cargo.toml @@ -81,7 +81,6 @@ url = "2" libc = "0.2" [dev-dependencies] -fs2 = "0.4" tempfile = "3" tokio = { version = "1", features = ["full", "test-util"] } tracedecay-code-index-runtime = { path = "../tracedecay-code-index-runtime", version = "0.1.0", default-features = false, features = ["test-helpers"] } diff --git a/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs b/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs index 7db0e1e17c..03d83ac465 100644 --- a/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs +++ b/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs @@ -1487,7 +1487,6 @@ async fn project_open_truncates_unique_spool_partial_with_empty_pending_index() #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn reused_scheduler_skip_abandons_current_effect_before_observing_exact_prior() { - use fs2::FileExt; use std::sync::Arc; use std::time::Duration; use tracedecay_automation_runtime::automation::AutomationRunControl; @@ -1710,7 +1709,7 @@ async fn reused_scheduler_skip_abandons_current_effect_before_observing_exact_pr .write(true) .open(&journal_lock_path) .expect("current journal lock"); - journal_lock.lock_exclusive().expect("block abandonment"); + journal_lock.lock().expect("block abandonment"); let (observed_tx, observed_rx) = std::sync::mpsc::channel(); let (projected_tx, projected_rx) = std::sync::mpsc::channel(); let waiter = current_authority.start_retained_automation_settlement( @@ -1746,7 +1745,7 @@ async fn reused_scheduler_skip_abandons_current_effect_before_observing_exact_pr ); assert!(fixed_task_lock_is_denied(dashboard_root, AgentTaskKind::MemoryCurator).await); - FileExt::unlock(&journal_lock).expect("release abandonment"); + journal_lock.unlock().expect("release abandonment"); assert_eq!( observed_rx .recv_timeout(Duration::from_secs(5)) diff --git a/crates/tracedecay-graph-db/Cargo.toml b/crates/tracedecay-graph-db/Cargo.toml index 8d56ec7dac..2ef568a829 100644 --- a/crates/tracedecay-graph-db/Cargo.toml +++ b/crates/tracedecay-graph-db/Cargo.toml @@ -51,41 +51,6 @@ eval-helpers = [] # never LPG data. graph-disk-tier = ["grafeo-engine/spill"] -# `graph-tiered-storage` adds grafeo-core's own `tiered-storage` feature on -# top. Despite the name that feature is NOT about disk: it swaps the LPG -# store's `FxHashMap>` for an arena + `VersionIndex` -# + compressed `EpochStore`, all of which are heap-resident (the arena calls -# `std::alloc::alloc`; `EpochStore` holds `RwLock>`). -# -# This was unusable on published grafeo 0.5.42: -# `Arena::alloc_value_with_offset` only ever allocated into `chunks.first()`, -# because `HotVersionRef` addressed records by a u32 offset within a single -# 1 MiB chunk that `LpgStore` had no way to enlarge. `NodeRecord` is 32 -# bytes, so an epoch hit a hard ceiling near 32 Ki records and then panicked -# in `node_ops.rs` on `AllocError::InsufficientSpace`. The fork wired in -# through `[patch.crates-io]` at the workspace root fixes it: the u32 is now -# a flat address over the epoch's chunks, so an epoch grows to the 4 GiB a -# u32 can reach and exhaustion is a typed error. -# -# The fix holds -- `tests/tiered_storage_rss.rs` commits full native staging -# pages (`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`), twice the old ~32 Ki -# ceiling, without panicking. It just does not pay off. The -# arena costs resident memory rather than saving it, because nothing here is -# ever written to disk: measured with that harness on the fork, live VmRSS -# with the store open runs +22% at 100k rows, +32% at 500k, and +28% at 1M -# (1873.8 -> 2405.7 MiB), and peak VmHWM +10%. Commit time is 3.3x worse at -# 100k and roughly a wash by 1M. -# -# So this feature is a NO-GO for enabling, and the ceiling being gone is not -# an argument for it. Keep it off; it exists to keep the fork exercised and -# to make re-measuring cheap if grafeo ever gives `LpgStore` a real on-disk -# representation. -graph-tiered-storage = [ - "graph-disk-tier", - "grafeo-core/tiered-storage", - "grafeo-core/mmap", -] - [dependencies] grafeo-adapters.workspace = true grafeo-common.workspace = true @@ -101,7 +66,6 @@ grafeo-common.workspace = true grafeo-core = { workspace = true, features = ["compact-store"] } grafeo-engine = { workspace = true, features = ["mmap", "compact-store"] } grafeo-storage.workspace = true -fs2 = "0.4" hex = "0.4" hotpath.workspace = true parking_lot = { version = "0.12", features = ["arc_lock", "send_guard"] } @@ -163,7 +127,7 @@ name = "tiered_storage_rss" required-features = ["test-helpers"] # Residency probe for consecutive generation cycles (stage, retire, repeat) -# on one incarnation — the issue #762 cross-cycle retention journey. Also +# on one incarnation, the issue #762 cross-cycle retention journey. Also # `#[ignore]`d. [[test]] name = "generation_cycle_rss" diff --git a/crates/tracedecay-graph-db/src/store_quarantine.rs b/crates/tracedecay-graph-db/src/store_quarantine.rs index 4bfad6e315..eff07461e3 100644 --- a/crates/tracedecay-graph-db/src/store_quarantine.rs +++ b/crates/tracedecay-graph-db/src/store_quarantine.rs @@ -5,7 +5,7 @@ //! relational publication journal and canonical sealed-generation seals, and //! session relation projections re-materialize from the relational session //! store. Permanent container corruption (a torn WAL write, a CRC fault in a -//! serialized block) therefore never destroys canonical data — but before +//! serialized block) therefore never destroys canonical data, but before //! this module it permanently disabled the mount: every open of the same //! bytes failed with the identical typed [`GraphDbError::Corrupt`], every //! activation retried through the same fault, and the store never healed. @@ -18,13 +18,13 @@ //! retryable unavailable state and touches nothing. //! 2. Under the lock, the deciding authority re-runs the identical failing //! open itself. Only a second corruption verdict with the byte-identical -//! fault message — same GRAFEO code, same block, same CRC pair — proves +//! fault message, same GRAFEO code, same block, same CRC pair, proves //! the fault deterministic. A successful reopen is served; a drifting //! fault stays a terminal typed `Corrupt` for the operator, because a //! fault that changes between attempts is hardware-shaped and a rebuild //! onto the same medium would only re-corrupt. -//! 3. Quarantine moves the container family — WAL sidecar, verified marker, -//! spill directory, then the container itself — into one timestamped +//! 3. Quarantine moves the container family, WAL sidecar, verified marker, +//! spill directory, then the container itself, into one timestamped //! sibling directory named with the canonical `.corrupt-` incident-debris //! segment, writes a durable `store-quarantined.json` receipt carrying //! the fault fingerprint, and emits the `store_quarantined` event. @@ -40,9 +40,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use fs2::FileExt; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use tracedecay_domain::canonical_text::sha256_hex; use tracedecay_private_fs::framed_log::{DirectorySyncPolicy, atomic_write, sync_directory}; use crate::{GraphDb, GraphDbError}; @@ -139,7 +138,7 @@ struct QuarantineDecisionLock { impl Drop for QuarantineDecisionLock { fn drop(&mut self) { - let _ = FileExt::unlock(&self.file); + let _ = self.file.unlock(); } } @@ -164,7 +163,7 @@ fn acquire_quarantine_decision_lock( path.display() )) })?; - match file.try_lock_exclusive() { + match file.try_lock().map_err(std::io::Error::from) { Ok(()) => Ok(QuarantineDecisionLock { file }), // Windows LockFileEx reports ERROR_LOCK_VIOLATION (33) instead of // WouldBlock. AccessDenied and sharing violations stay generic @@ -347,7 +346,7 @@ fn quarantine_durability_failure( } fn fault_fingerprint(fault: &str) -> String { - format!("sha256:{}", hex::encode(Sha256::digest(fault.as_bytes()))) + format!("sha256:{}", sha256_hex(fault.as_bytes())) } /// `graph.grafeo` -> `graph.grafeo.wal`, matching Grafeo's sidecar layout. @@ -523,7 +522,10 @@ mod tests { .truncate(false) .open(quarantine_decision_lock_path(&container).unwrap()) .unwrap(); - foreign_holder.try_lock_exclusive().unwrap(); + foreign_holder + .try_lock() + .map_err(std::io::Error::from) + .unwrap(); let verification_attempts = AtomicUsize::new(0); let error = recover_deterministically_corrupt_container(&container, "fault", &|| { @@ -543,7 +545,7 @@ mod tests { "a non-holder must not re-open the store it does not own" ); assert!(container.exists(), "a non-holder must not move bytes"); - FileExt::unlock(&foreign_holder).unwrap(); + foreign_holder.unlock().unwrap(); } #[test] diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/durability_crash_contract.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/durability_crash_contract.rs index 5893e68274..f9be4bc8ab 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/durability_crash_contract.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/durability_crash_contract.rs @@ -9,7 +9,7 @@ //! verified generation. //! 2. Convergence interrupted before the relational compare-and-swap must leave //! the prior verified snapshot serving and must replay to the identical -//! generation once the authority recovers — never half-visible. +//! generation once the authority recovers, never half-visible. //! 3. A foreign, non-final store shape must be typed `ResetRequired` on open, //! and a fresh recreation must republish from the canonical manifest and //! serve the new verified head. @@ -506,7 +506,7 @@ fn marker_of(snapshot: &VerifiedGraphSnapshot, identity: &GraphProjectionIdentit } /// Writes a Grafeo store carrying a TraceDecay format marker whose schema is -/// not the final native scalar schema — the foreign/non-final shape the open +/// not the final native scalar schema, the foreign/non-final shape the open /// path must reject as `ResetRequired`. fn write_non_final_shape(path: &std::path::Path) { let raw = grafeo_engine::GrafeoDB::with_config( @@ -558,7 +558,7 @@ fn quarantine_receipt(quarantine: &std::path::Path) -> serde_json::Value { } /// GitHub issue #763: a deterministic corruption verdict on the durable -/// container was retried identically forever — every mount refaulted, every +/// container was retried identically forever, every mount refaulted, every /// activation refused, and only manual surgery (move the store and WAL aside, /// restart) recovered the project. This pins the automatic form of exactly /// that recovery: the second identical verdict quarantines the container for @@ -669,7 +669,7 @@ fn torn_durable_store_is_quarantined_and_rebuilt_from_the_replay_journal() { ); // The fresh store refuses the recovered head with a typed mismatch until - // the replay journal re-projects it — never a silent empty success. + // the replay journal re-projects it, never a silent empty success. let pending = registered .registry .recover_verified_snapshot( @@ -754,7 +754,7 @@ fn crc_faulted_store_is_quarantined_with_its_wal_sidecar_and_rebuilt() { // Child publishes g1, reaches the durable WAL phase, and exits without // a clean close. That is unclean process exit, not host power-loss. The - // abandoned image is copied only after the child is reaped — never while + // abandoned image is copied only after the child is reaped, never while // a live Windows handle still owns the store (issue #933). let crash = TempDir::new().unwrap(); capture_unclean_crash_image(crash.path()); @@ -781,7 +781,7 @@ fn crc_faulted_store_is_quarantined_with_its_wal_sidecar_and_rebuilt() { // Flip authoritative leading bytes while keeping the length: the store // is still current-sized but its serialized sections no longer match - // their checksums. The physical midpoint is not a valid target — the + // their checksums. The physical midpoint is not a valid target, the // format may leave aligned padding there, outside every section // checksum (see verified_generation_contract/verify_once.rs). let mut bytes = std::fs::read(&crashed_container).unwrap(); @@ -851,13 +851,11 @@ fn crc_faulted_store_is_quarantined_with_its_wal_sidecar_and_rebuilt() { /// The compare-and-swap discipline for the quarantine decision itself: an /// authority that does not hold the decision lock must neither re-verify nor -/// sweep the store — another incarnation may be mid-recovery. The refusal is +/// sweep the store, another incarnation may be mid-recovery. The refusal is /// a retryable typed unavailable, not a retained terminal fault, so the next /// mount attempt (after the holder releases) completes the recovery. #[test] fn held_quarantine_decision_defers_the_mount_and_the_next_attempt_recovers() { - use fs2::FileExt; - let temp = TempDir::new().unwrap(); let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); let (control, probe) = control_and_probe(); @@ -902,7 +900,10 @@ fn held_quarantine_decision_defers_the_mount_and_the_next_attempt_recovers() { .truncate(false) .open(&lock_path) .unwrap(); - foreign_holder.try_lock_exclusive().unwrap(); + foreign_holder + .try_lock() + .map_err(std::io::Error::from) + .unwrap(); let deferred = registered.mount().unwrap_err(); assert!( @@ -919,7 +920,7 @@ fn held_quarantine_decision_defers_the_mount_and_the_next_attempt_recovers() { // Once the holder releases, the same mount request completes the // quarantine and rebuild instead of remaining faulted. - FileExt::unlock(&foreign_holder).unwrap(); + foreign_holder.unlock().unwrap(); registered.mount().unwrap(); let quarantine = quarantine_directory(temp.path()) .expect("the released decision lock lets the next mount quarantine"); @@ -1126,7 +1127,7 @@ fn reset_required_shape_is_recreated_fresh_and_republished_from_the_manifest() { assert_eq!(marker_of(&snapshot, &identity), "g1"); } -/// Highest sequence among non-empty `wal_.log` segments — the same +/// Highest sequence among non-empty `wal_.log` segments, the same /// replay-debt signal the open-time collapse gates on. fn newest_wal_segment(sidecar: &std::path::Path) -> Option { let entries = std::fs::read_dir(sidecar).ok()?; diff --git a/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs b/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs index 38356b3ae8..34c27abacc 100644 --- a/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs +++ b/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs @@ -2,7 +2,7 @@ //! //! Ignored by default: these build six- and seven-figure synthetic generations //! and are measurement harnesses, not contracts. Nothing here asserts on a -//! memory figure — the numbers are printed for a human to compare across +//! memory figure, the numbers are printed for a human to compare across //! feature states. Run them explicitly: //! //! ```text @@ -11,9 +11,6 @@ //! //! cargo test -p tracedecay-graph-db --features test-helpers,graph-disk-tier \ //! --test tiered_storage_rss -- --ignored --nocapture --test-threads=1 -//! -//! cargo test -p tracedecay-graph-db --features test-helpers,graph-tiered-storage \ -//! --test tiered_storage_rss -- --ignored --nocapture --test-threads=1 //! ``` //! //! `--test-threads=1` matters: `VmHWM` and `VmRSS` are process-wide, so two @@ -39,25 +36,9 @@ //! Linux-only: `/proc/self/status` does not exist elsewhere, and the probes //! report that and return rather than failing on other platforms. //! -//! # Why the small probe does not hit the arena ceiling -//! -//! Under `graph-tiered-storage` each `apply_unverified` commit advances the -//! epoch, and every epoch gets its own arena. `SMALL_BATCH_SIZE` entities at 32 -//! bytes per `NodeRecord` fit in one chunk, so `synthetic_generation_peak_rss` -//! never approaches the addressing limit. It is also why the tiered numbers -//! come out *worse* there: 20 batches means 20 arenas holding 20 MiB of largely -//! empty chunks, plus the `VersionIndex` entries layered on the records. -//! -//! `production_page_generation_rss` is the probe that actually stresses the -//! arena, because it commits in pages of -//! [`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`]-many mutations — the real staging -//! page size the native generation runtime uses. At 32 bytes per record that -//! page is 2 MiB, which is exactly what overflowed the published grafeo -//! 0.5.42 arena (a `u32` offset inside a single 1 MiB chunk, ceiling ~32 Ki -//! records) and panicked. The fork this workspace patches in spans an epoch -//! arena across chunks, so the page commits instead of aborting. -//! -//! [`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`]: tracedecay_graph_db::MAX_NATIVE_GENERATION_STAGE_MUTATIONS +//! The grafeo LPG arena (`tiered-storage`) was measured here and removed: it +//! raised live RSS and did not spill node records. This probe still compares +//! the baseline store with `graph-disk-tier`. use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -78,7 +59,7 @@ use support::RegisteredGraph; const ENTITY_COUNT: usize = 100_000; /// Entities per write batch in the small probe. Deliberately far below the -/// production staging page so the two probes bracket the arena behaviour. +/// production staging page so the two probes bracket a small write and a page. const SMALL_BATCH_SIZE: usize = 5_000; /// Total entities in the production-shaped probe. Override with @@ -117,14 +98,12 @@ fn batch(watermark: &str, mutations: Vec) -> GraphWriteBatch { .unwrap() } -/// Which of the three feature states this binary was compiled in. +/// Which spill feature this binary was compiled with. fn feature_state() -> &'static str { - if cfg!(feature = "graph-tiered-storage") { - "graph-tiered-storage (disk tier + grafeo-core LPG arena)" - } else if cfg!(feature = "graph-disk-tier") { + if cfg!(feature = "graph-disk-tier") { "graph-disk-tier (disk tier only)" } else { - "baseline (no tiered feature)" + "baseline (no spill feature)" } } diff --git a/crates/tracedecay-query/src/search_quality/candidate_output.rs b/crates/tracedecay-query/src/search_quality/candidate_output.rs index a69b2df622..bd62d10937 100644 --- a/crates/tracedecay-query/src/search_quality/candidate_output.rs +++ b/crates/tracedecay-query/src/search_quality/candidate_output.rs @@ -157,9 +157,9 @@ pub struct ProfileSpecV1 { /// One need class a workload query belongs to. /// -/// Closed vocabulary: a stratum decides how a query is judged — whether recall -/// must be complete, and whether the query re-runs retrieval at a past commit — -/// so an unrecognized name is refused instead of silently scored as ordinary. +/// Closed vocabulary: a stratum decides how a query is judged, whether recall +/// must be complete, and whether the query re-runs retrieval at a past commit. +/// So an unrecognized name is refused instead of silently scored as ordinary. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub enum QueryStratumV1 { @@ -617,8 +617,8 @@ fn validate_source_bindings( /// /// This attests the fitness of measurement *inputs*: schema, execution /// contract, corpus identity, partitions, labels, and need provenance. It is -/// not a qualification verdict — there is no held-out methodology in schema 1 -/// to qualify against — so a workload passing here still says nothing about +/// not a qualification verdict, there is no held-out methodology in schema 1 +/// to qualify against, so a workload passing here still says nothing about /// whether any retrieval profile may be activated. pub fn validate_workload_for_tuning( workload: &CandidateWorkloadV1, @@ -967,34 +967,8 @@ pub fn canonical_sha256(value: &T) -> Result(value: &T) -> Result, CandidateOutputError> { - let mut bytes = serde_json::to_vec(value) - .map_err(|error| CandidateOutputError::Contract(format!("serialize: {error}")))?; - // Stable formatting: re-parse and dump sorted keys via serde_json Value. - let value: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|error| CandidateOutputError::Contract(format!("reparse: {error}")))?; - bytes = serde_json::to_vec(&sort_value(value)) - .map_err(|error| CandidateOutputError::Contract(format!("reserialize: {error}")))?; - Ok(bytes) -} - -pub fn sort_value(value: serde_json::Value) -> serde_json::Value { - match value { - serde_json::Value::Object(map) => { - let mut ordered = serde_json::Map::new(); - let mut keys: Vec<_> = map.keys().cloned().collect(); - keys.sort(); - for key in keys { - if let Some(child) = map.get(&key) { - ordered.insert(key, sort_value(child.clone())); - } - } - serde_json::Value::Object(ordered) - } - serde_json::Value::Array(items) => { - serde_json::Value::Array(items.into_iter().map(sort_value).collect()) - } - other => other, - } + tracedecay_domain::canonical_json_bytes(value) + .map_err(|error| CandidateOutputError::Contract(format!("canonical json: {error}"))) } #[cfg(test)] diff --git a/crates/tracedecay-runtime-core/Cargo.toml b/crates/tracedecay-runtime-core/Cargo.toml index 6603f95020..55d4092fb5 100644 --- a/crates/tracedecay-runtime-core/Cargo.toml +++ b/crates/tracedecay-runtime-core/Cargo.toml @@ -33,7 +33,6 @@ tracedecay-private-fs = { path = "../tracedecay-private-fs", version = "0.1.0" } tracedecay-rusqlite-runtime = { path = "../tracedecay-rusqlite-runtime", version = "0.1.0" } tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } -fs2 = "0.4" getrandom = "0.2" gix = { version = "=0.86.0", default-features = false, features = ["revision", "blob-diff", "merge", "parallel", "sha1", "sha256", "status"] } hex = "0.4" diff --git a/crates/tracedecay-runtime-core/src/branch.rs b/crates/tracedecay-runtime-core/src/branch.rs index de20813d10..bb20f49034 100644 --- a/crates/tracedecay-runtime-core/src/branch.rs +++ b/crates/tracedecay-runtime-core/src/branch.rs @@ -183,8 +183,6 @@ impl BranchMemo { /// Acquires the shared branch-add lock. pub fn try_acquire_branch_add_lock(tracedecay_dir: &Path) -> Result { - use fs2::FileExt; - std::fs::create_dir_all(tracedecay_dir)?; let lock_path = tracedecay_dir.join(".branch-add.lock"); let file = std::fs::OpenOptions::new() @@ -192,7 +190,8 @@ pub fn try_acquire_branch_add_lock(tracedecay_dir: &Path) -> Result bool { /// short and long names agree, so only the other two hosts ever saw it. /// /// Both sides resolve through their deepest existing ancestor, so a database -/// file whose final component has not been created yet still compares — the +/// file whose final component has not been created yet still compares, the /// same rule [`crate::path_safety::same_canonical_path`] applies to registry /// locators. #[cfg(any(test, feature = "test-helpers", feature = "test-transport"))] @@ -545,7 +542,7 @@ fn under_isolated_root(path: &Path, root: PathBuf) -> bool { /// Returns true when another process currently holds the profile's exclusive /// daemon-authority lock. Used to keep ambient Test opens from mutating a -/// store while a live daemon owner is elected — its sole caller is the +/// store while a live daemon owner is elected, its sole caller is the /// cfg-gated ambient-Test branch, so the probe carries the same gate. #[cfg(any(test, feature = "test-transport"))] fn foreign_daemon_authority_held(profile_root: &Path) -> bool { @@ -562,9 +559,9 @@ fn foreign_daemon_authority_held(profile_root: &Path) -> bool { let Ok(file) = options.open(&lock_path) else { return false; }; - match file.try_lock_exclusive() { + match file.try_lock().map_err(std::io::Error::from) { Ok(()) => { - let _ = FileExt::unlock(&file); + let _ = file.unlock(); false } Err(error) if is_lock_contended(&error) => true, diff --git a/crates/tracedecay-runtime-core/src/lifecycle_lease.rs b/crates/tracedecay-runtime-core/src/lifecycle_lease.rs index e9d9b5bbbf..d7a88ae33d 100644 --- a/crates/tracedecay-runtime-core/src/lifecycle_lease.rs +++ b/crates/tracedecay-runtime-core/src/lifecycle_lease.rs @@ -79,11 +79,11 @@ impl LifecycleLease { let owner = read_owner(file, &self.lock_path); #[cfg(windows)] - fs2::FileExt::unlock(file) + file.unlock() .map_err(|error| lock_error(&self.lock_path, "downgrade", &error))?; - if let Err(error) = fs2::FileExt::lock_shared(file) { + if let Err(error) = file.lock_shared() { let downgrade_error = lock_error(&self.lock_path, "downgrade", &error); - fs2::FileExt::lock_exclusive(file) + file.lock() .and_then(|()| { owner.as_deref().map_or(Ok(()), |owner| { write_owner_metadata(file, &self.lock_path, owner) @@ -97,10 +97,10 @@ impl LifecycleLease { if let Err(error) = clear_owner_metadata(file, &self.lock_path) { let downgrade_error = owner_write_error(&error); #[cfg(windows)] - fs2::FileExt::unlock(file).map_err(|restore_error| { + file.unlock().map_err(|restore_error| { failed_downgrade_restore_error(&downgrade_error, &restore_error) })?; - fs2::FileExt::lock_exclusive(file) + file.lock() .and_then(|()| { owner.as_deref().map_or(Ok(()), |owner| { write_owner_metadata(file, &self.lock_path, owner) @@ -133,7 +133,7 @@ impl Drop for LifecycleLease { if self.exclusive { remove_owner_sidecar_if_current(&self.lock_path, self.token.as_deref()); } - let _ = fs2::FileExt::unlock(file); + let _ = file.unlock(); } } } @@ -170,7 +170,7 @@ pub fn try_acquire_exclusive_for_profile( ) -> Result { let path = lifecycle_lock_path_for_profile(profile_root)?; let mut file = open_lock_file(&path)?; - match fs2::FileExt::try_lock_exclusive(&file) { + match file.try_lock().map_err(std::io::Error::from) { Ok(()) => own_exclusive(file, &path, operation).map(ExclusiveLeaseAttempt::Acquired), Err(error) if is_lock_contended(&error) => Ok(ExclusiveLeaseAttempt::Busy { owner_operation: read_owner(&mut file, &path) @@ -188,7 +188,8 @@ pub fn try_acquire_exclusive_for_profile( pub fn acquire_shared_blocking(operation: &str) -> Result { let path = lifecycle_lock_path()?; let file = open_lock_file(&path)?; - fs2::FileExt::lock_shared(&file).map_err(|error| lock_error(&path, operation, &error))?; + file.lock_shared() + .map_err(|error| lock_error(&path, operation, &error))?; Ok(LifecycleLease { hold: LeaseHold::File(file), token: None, @@ -220,7 +221,10 @@ pub fn acquire_shared_or_inherited(operation: &str) -> Result { fn acquire_shared_or_inherited_at(path: &Path, operation: &str) -> Result { let mut file = open_lock_file(path)?; - match fs2::FileExt::try_lock_shared(&file) { + match file + .try_lock_shared() + .map_err(std::io::Error::from) + { Ok(()) => Ok(LifecycleLease { hold: LeaseHold::File(file), token: None, @@ -271,7 +275,7 @@ fn acquire_exclusive_or_inherited_at( inherited: Option, ) -> Result { let mut file = open_lock_file(path)?; - match fs2::FileExt::try_lock_exclusive(&file) { + match file.try_lock().map_err(std::io::Error::from) { Ok(()) => own_exclusive(file, path, operation), Err(error) if is_lock_contended(&error) => { let owner = read_owner(&mut file, path); @@ -362,7 +366,7 @@ fn acquire_exclusive_at_with_timeout( let mut file = open_lock_file(path)?; let started = Instant::now(); loop { - match fs2::FileExt::try_lock_exclusive(&file) { + match file.try_lock().map_err(std::io::Error::from) { Ok(()) => return own_exclusive(file, path, operation), Err(error) if is_lock_contended(&error) => { let remaining = timeout.saturating_sub(started.elapsed()); @@ -380,7 +384,10 @@ fn acquire_exclusive_at_with_timeout( #[hotpath::measure(label = "runtime_core.lifecycle.acquire_shared")] fn acquire_shared_at(path: &Path, operation: &str) -> Result { let mut file = open_lock_file(path)?; - match fs2::FileExt::try_lock_shared(&file) { + match file + .try_lock_shared() + .map_err(std::io::Error::from) + { Ok(()) => Ok(LifecycleLease { hold: LeaseHold::File(file), token: None, @@ -397,7 +404,10 @@ fn acquire_shared_at(path: &Path, operation: &str) -> Result { fn try_acquire_shared_at(path: &Path, operation: &str) -> Result { let file = open_lock_file(path)?; - match fs2::FileExt::try_lock_shared(&file) { + match file + .try_lock_shared() + .map_err(std::io::Error::from) + { Ok(()) => Ok(SharedLeaseAttempt::Acquired(LifecycleLease { hold: LeaseHold::File(file), token: None, @@ -757,7 +767,7 @@ mod tests { .truncate(false) .open(&path) .unwrap(); - fs2::FileExt::try_lock_exclusive(&external).unwrap(); + external.try_lock().map_err(std::io::Error::from).unwrap(); writeln!(external, "external-token\tmigration\t999").unwrap(); external.flush().unwrap(); @@ -790,7 +800,7 @@ mod tests { let attempt = try_acquire_exclusive_for_profile(&profile, "wipe").unwrap(); // Shared holders record no owner metadata, so the busy state carries - // no operation — the caller must not pretend to know the holder. + // no operation, the caller must not pretend to know the holder. match attempt { ExclusiveLeaseAttempt::Busy { owner_operation } => { assert_eq!(owner_operation, None); @@ -876,7 +886,7 @@ mod tests { .truncate(false) .open(&path) .unwrap(); - fs2::FileExt::try_lock_exclusive(&external).unwrap(); + external.try_lock().map_err(std::io::Error::from).unwrap(); let stale_token = "stale-owner-token"; writeln!(external, "{stale_token}\tupdate\t{}", u32::MAX).unwrap(); external.flush().unwrap(); @@ -899,7 +909,7 @@ mod tests { .truncate(false) .open(&path) .unwrap(); - fs2::FileExt::try_lock_exclusive(&external).unwrap(); + external.try_lock().map_err(std::io::Error::from).unwrap(); let stale_token = "stale-owner-token"; writeln!(external, "{stale_token}\tupdate\t{}\t0", std::process::id()).unwrap(); external.flush().unwrap(); @@ -923,7 +933,7 @@ mod tests { .truncate(false) .open(&path) .unwrap(); - fs2::FileExt::try_lock_exclusive(&external).unwrap(); + external.try_lock().map_err(std::io::Error::from).unwrap(); let token = "matching-live-token"; writeln!(external, "{token}\tupdate\t{}", std::process::id()).unwrap(); external.flush().unwrap(); diff --git a/crates/tracedecay-runtime-core/src/sqlite_read_snapshot.rs b/crates/tracedecay-runtime-core/src/sqlite_read_snapshot.rs index 2695692186..18546beeee 100644 --- a/crates/tracedecay-runtime-core/src/sqlite_read_snapshot.rs +++ b/crates/tracedecay-runtime-core/src/sqlite_read_snapshot.rs @@ -11,7 +11,6 @@ use std::time::{Duration, SystemTime}; #[cfg(test)] use std::cell::RefCell; -use fs2::FileExt; use rusqlite::backup::StepResult; use rusqlite::{Connection, OpenFlags}; use sha2::{Digest, Sha256}; @@ -89,7 +88,7 @@ pub async fn backup_live_sqlite_database(source: &Path, destination: &Path) -> i /// swap that directory themselves. /// /// A WAL family whose transient `-shm` is absent is copied as an offline -/// unlocked family and folded in staging — opening it as a reader would +/// unlocked family and folded in staging, opening it as a reader would /// reconstruct SHM in the source directory. fn backup_live_sqlite_database_sync(source: &Path, destination: &Path) -> io::Result<()> { backup_live_sqlite_database_with(source, destination, || Ok(())) @@ -385,7 +384,7 @@ pub struct SnapshotDatabase { source_state: Vec, /// The `file:...` URI used to ATTACH this snapshot. Percent-encoded and /// carrying `mode=ro`/`immutable=1`, so it is never a valid filesystem - /// path — use `identity_path` for anything that touches the filesystem. + /// path, use `identity_path` for anything that touches the filesystem. path: PathBuf, /// The real on-disk file this snapshot reads: the untouched source in /// direct-immutable mode, or the scratch copy in copy mode. @@ -995,7 +994,7 @@ async fn finish_one( } control.checkpoint()?; // `identity_path` is the real file on disk; `attach_path` is the URI used - // to ATTACH it. They are never interchangeable — the URI is percent-encoded + // to ATTACH it. They are never interchangeable, the URI is percent-encoded // and carries query parameters, so passing it to the filesystem fails. let (open_path, attach_path, identity_path, flags, scratch) = if matches!(prepared.mode, SnapshotMode::DirectImmutable) { @@ -1102,7 +1101,7 @@ fn create_scratch_directory( ) -> io::Result { ensure_private_root(root, expected_uid)?; let cleanup_lock = open_private_lock(&root.join(".cleanup.lock"), true)?; - cleanup_lock.lock_exclusive()?; + cleanup_lock.lock()?; cleanup_stale_directories(root)?; for _ in 0..100 { let id = NEXT_SNAPSHOT.fetch_add(1, Ordering::Relaxed); @@ -1110,8 +1109,8 @@ fn create_scratch_directory( match create_private_directory(&path) { Ok(()) => { let owner_lock = open_private_lock(&path.join(".owner.lock"), true)?; - owner_lock.lock_exclusive()?; - FileExt::unlock(&cleanup_lock)?; + owner_lock.lock()?; + cleanup_lock.unlock()?; return Ok(ScratchDirectory { path, owner_lock: Some(owner_lock), @@ -1318,7 +1317,7 @@ fn cleanup_stale_directories(root: &Path) -> io::Result<()> { continue; } let removable = match open_private_lock(&path.join(".owner.lock"), false) { - Ok(lock) => lock.try_lock_exclusive().is_ok(), + Ok(lock) => lock.try_lock().map_err(std::io::Error::from).is_ok(), Err(error) if error.kind() == io::ErrorKind::NotFound => true, Err(error) => return Err(error), }; diff --git a/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs b/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs index 04006a882a..79dfe20921 100644 --- a/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs +++ b/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs @@ -3,7 +3,6 @@ use std::fs; use std::io::{self, Write}; use std::path::{Component, Path, PathBuf}; -use fs2::FileExt; use tracedecay_private_fs::framed_log::{DirectorySyncPolicy, set_owner_private_file_mode}; use crate::config; @@ -728,7 +727,7 @@ pub fn append_lock_path(path: &Path) -> PathBuf { // should reuse one rather than hand-rolling a third: // // 1. Sidecar advisory lock (this utility). Open a dedicated `.lock` -// handle for read+write and hold an `fs2` `flock` on it while mutating the +// handle for read+write and hold a `File::lock` on it while mutating the // real file. Use it to serialize writers to an append-only log or an // mmap/config file where readers must never see a torn write and a crashed // holder must not leave a stale marker (the OS drops the lock on process @@ -779,7 +778,7 @@ pub(super) fn open_lock_file(lock_path: &Path, private: bool) -> io::Result io::Result> { let file = open_lock_file(lock_path, false)?; - match file.try_lock_exclusive() { + match file.try_lock().map_err(std::io::Error::from) { Ok(()) => Ok(Some(file)), // `is_lock_contended` covers Windows, where contention surfaces as // ERROR_LOCK_VIOLATION rather than a `WouldBlock` error kind. @@ -797,7 +796,7 @@ pub fn acquire_sidecar_lock_blocking(lock_path: &Path) -> io::Result { fn acquire_lock_file_blocking(lock_path: &Path, private: bool) -> io::Result { let file = open_lock_file(lock_path, private)?; - file.lock_exclusive()?; + file.lock()?; Ok(file) } diff --git a/crates/tracedecay/Cargo.toml b/crates/tracedecay/Cargo.toml index c57783cabd..2a7f1cf535 100644 --- a/crates/tracedecay/Cargo.toml +++ b/crates/tracedecay/Cargo.toml @@ -71,7 +71,7 @@ default = ["production"] # single `hotpath` / `hotpath-macros` build that cargo unifies across the whole # dependency graph: with no profiling feature selected every macro is a no-op, # and once a binary selects `hotpath/hotpath` every `#[hotpath::measure]`, -# `measure_block!`, `gauge!`, `future!`, ... in every crate is live — no +# `measure_block!`, `gauge!`, `future!`, ... in every crate is live, no # per-crate feature is involved. The crates listed below are the runtime owners # whose *compiled behaviour* changes under profiling (`cfg(feature = "hotpath")` # sites: allocation/lock/IO wrappers, observation sinks, the axum server layer, @@ -86,7 +86,7 @@ default = ["production"] # `hotpath::tokio_runtime!` and the tokio lock wrappers, and `hotpath/ureq-3` # by the GitHub client reached from here, so no second HTTP stack is # introduced. The axum 0.8 server layer is selected by the crates that mount -# routers — `tracedecay-daemon-service` and `tracedecay-dashboard-api`. +# routers, `tracedecay-daemon-service` and `tracedecay-dashboard-api`. hotpath = [ "hotpath/hotpath", "hotpath/tokio", @@ -108,7 +108,6 @@ hotpath = [ "tracedecay-query/hotpath", "tracedecay-rusqlite-runtime/hotpath", "tracedecay-sdk/hotpath", - "tracedecay-search-eval/hotpath", "tracedecay-session-temporal-store/hotpath", "tracedecay-sessions/hotpath", "tracedecay-store-runtime/hotpath", @@ -118,18 +117,10 @@ hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "tracedecay-sdk/hotpath-cpu"] # Binary-only: the live query server runs in this process, so no library crate # needs a passthrough for it. hotpath-mcp = ["hotpath", "hotpath/hotpath-mcp"] -# Opt-in tiered graph storage. Never in `default` or `production`: both give -# the graph buffer manager a spill directory and a bounded budget, so they are -# deliberate build choices rather than silent defaults. -# -# `graph-disk-tier` is the usable half. `graph-tiered-storage` additionally -# turns on grafeo-core's `tiered-storage` LPG arena, which is known broken at -# grafeo 0.5.42 (1 MiB per-epoch record ceiling, then a panic). -# See `crates/tracedecay-graph-db/Cargo.toml` for the detail. +# Opt-in spill for graph index buffers. Never in `default` or `production`. +# See `crates/tracedecay-graph-db/Cargo.toml` for why the live LPG stays in RAM. graph-disk-tier = ["tracedecay-graph-db/graph-disk-tier"] -graph-tiered-storage = ["tracedecay-graph-db/graph-tiered-storage"] - production = [ "token-counting", "lite", @@ -139,15 +130,15 @@ production = [ # Exact token counting for the Savings & Cost dashboard (tiktoken BPE). # Embeds the o200k_base/cl100k_base vocabularies (~4 MB of binary weight); # vocab decoding is lazy (first count pays it, idle servers never do). -# Disable for a leaner binary — cost estimation falls back to chars/4. +# Disable for a leaner binary, cost estimation falls back to chars/4. token-counting = ["tracedecay-agent-hosts/token-counting", "tracedecay-dashboard-api/token-counting"] -# Lite tier (11 languages) — always compiled, no feature flag needed: +# Lite tier (11 languages), always compiled, no feature flag needed: # Rust, Go, Java, Scala, TypeScript/JavaScript, Python, C, C++, Kotlin, C#, Swift. # Markdown joins them: documentation is architecture content, every repository # has it, and a build that indexes code but not its docs is not a shippable # tier. Its grammar lives in the (monolithic) large bundle, so `lite` now links -# that bundle — the compile-weight cost of making docs unconditional. +# that bundle, the compile-weight cost of making docs unconditional. lite = ["tracedecay-code-index/lite", "tracedecay-code-index-runtime/lite"] medium = ["tracedecay-code-index/medium"] @@ -310,7 +301,6 @@ tracedecay-private-fs = { path = "../tracedecay-private-fs", version = "0.1.0" } tracedecay-project = { path = "../tracedecay-project", version = "0.1.0-beta.37" } tracedecay-query = { path = "../tracedecay-query", version = "0.1.0" } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } -tracedecay-search-eval = { path = "../tracedecay-search-eval", version = "0.1.0" } tracedecay-session-memory = { path = "../tracedecay-session-memory", version = "0.1.0" } tracedecay-session-runtime = { path = "../tracedecay-session-runtime", version = "0.1.0" } tracedecay-store-runtime = { path = "../tracedecay-store-runtime", version = "0.1.0" } @@ -341,7 +331,6 @@ ignore = "0.4" gix.workspace = true dirs = "6" hex = "0.4" -fs2 = "0.4" # Raw filesystem watcher for the daemon git-metadata watcher (src/daemon/git_watch.rs). # We deliberately use the raw watcher (not notify-debouncer-full) and debounce # ourselves so a monorepo's git-ref churn coalesces into a single sync. @@ -359,6 +348,7 @@ rmcp = { version = "3.0.1", default-features = false, features = ["server"] } libc = "0.2" [dev-dependencies] +tracedecay-search-eval = { path = "../tracedecay-search-eval", version = "0.1.0" } tree-sitter = "0.26" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } diff --git a/crates/tracedecay/src/daemon/scheduler/combined_effect.rs b/crates/tracedecay/src/daemon/scheduler/combined_effect.rs index 9320260936..9f557cc39a 100644 --- a/crates/tracedecay/src/daemon/scheduler/combined_effect.rs +++ b/crates/tracedecay/src/daemon/scheduler/combined_effect.rs @@ -800,7 +800,6 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; - use fs2::FileExt; use tempfile::TempDir; use tracedecay_application::observability::RegisteredObservabilityPortV1; use tracedecay_automation_runtime::automation::AutomationRunControl; @@ -1327,7 +1326,7 @@ mod tests { )) .expect("open current skill journal lock"); journal_lock - .lock_exclusive() + .lock() .expect("block current skill abandonment"); let mut first_error = None; let mut effect = Box::pin(run_combined_scheduler_effect( @@ -1383,7 +1382,9 @@ mod tests { assert_eq!(exact_spool_files(&fixture.dashboard_root), spool_before); assert_eq!(pending_journal_files(&fixture.dashboard_root).len(), 1); - FileExt::unlock(&journal_lock).expect("release current skill abandonment"); + journal_lock + .unlock() + .expect("release current skill abandonment"); assert_eq!((&mut effect).await, CombinedEffectOutcome::Handled); drop(effect); assert!(first_error.is_none()); diff --git a/crates/tracedecay/tests/automation_runner_test/scheduler.rs b/crates/tracedecay/tests/automation_runner_test/scheduler.rs index c3d1e667b5..1af818da73 100644 --- a/crates/tracedecay/tests/automation_runner_test/scheduler.rs +++ b/crates/tracedecay/tests/automation_runner_test/scheduler.rs @@ -1394,7 +1394,7 @@ async fn cancelled_task_lock_acquisition_cleans_detached_owner() { &coordination_path, ) .unwrap(); - fs2::FileExt::lock_exclusive(&file).unwrap(); + file.lock().unwrap(); file }; #[cfg(not(windows))] @@ -1424,7 +1424,7 @@ async fn cancelled_task_lock_acquisition_cleans_detached_owner() { submitted_rx.await.unwrap(); acquire.abort(); assert!(acquire.await.unwrap_err().is_cancelled()); - fs2::FileExt::unlock(&coordination).unwrap(); + coordination.unlock().unwrap(); drop(coordination); let acquired = tokio::time::timeout(std::time::Duration::from_secs(10), async { diff --git a/crates/tracedecay/tests/common/fixture.rs b/crates/tracedecay/tests/common/fixture.rs index f1feec854b..e63ce5c298 100644 --- a/crates/tracedecay/tests/common/fixture.rs +++ b/crates/tracedecay/tests/common/fixture.rs @@ -1,9 +1,9 @@ //! The shared fixture authority: one isolated environment, one profile, and //! project identities resolved and registered through the production paths. //! -//! Test setup used to reimplement identity resolution — synthesizing a profile +//! Test setup used to reimplement identity resolution, synthesizing a profile //! root, re-deriving a store layout, writing an enrollment marker by hand, -//! running a fresh `git init` per fixture — and each reimplementation got some +//! running a fresh `git init` per fixture, and each reimplementation got some //! part of it subtly wrong in a different way. The pieces here compose instead, //! and every one of them delegates to the authority production uses: //! @@ -601,7 +601,7 @@ impl GitFixture { /// built once per target directory. /// /// The template carries the `git init`, the branch rename, and the initial - /// commit — including a `.gitignore` for `.tracedecay/`, so a fixture that + /// commit, including a `.gitignore` for `.tracedecay/`, so a fixture that /// stages its working tree can never commit enrollment state. Falls back to /// building in place when the template is unavailable, so a template /// failure can never change what a test exercises. @@ -762,11 +762,11 @@ fn ensure_git_template() -> Option { .write(true) .open(&lock_path) .ok()?; - fs2::FileExt::lock_exclusive(&lock_file).ok()?; + lock_file.lock().ok()?; // Another process may have finished the build while we waited. if shared.join("READY").is_file() { - let _ = fs2::FileExt::unlock(&lock_file); + let _ = lock_file.unlock(); return Some(shared); } @@ -791,7 +791,7 @@ fn ensure_git_template() -> Option { None } }; - let _ = fs2::FileExt::unlock(&lock_file); + let _ = lock_file.unlock(); result } diff --git a/crates/tracedecay/tests/mcp_suite/fixture.rs b/crates/tracedecay/tests/mcp_suite/fixture.rs index 4fcce16c51..a13a9c606a 100644 --- a/crates/tracedecay/tests/mcp_suite/fixture.rs +++ b/crates/tracedecay/tests/mcp_suite/fixture.rs @@ -230,7 +230,7 @@ async fn ensure_template() -> Option { .truncate(false) .write(true) .open(&lock_path)?; - fs2::FileExt::lock_exclusive(&file)?; + file.lock()?; Ok(file) }) .await @@ -239,7 +239,7 @@ async fn ensure_template() -> Option { // Another process may have finished the build while we waited. if shared.join("READY").is_file() { - let _ = fs2::FileExt::unlock(&lock_file); + let _ = lock_file.unlock(); return Some(shared); } @@ -265,7 +265,7 @@ async fn ensure_template() -> Option { None } }; - let _ = fs2::FileExt::unlock(&lock_file); + let _ = lock_file.unlock(); result } diff --git a/crates/tracedecay/tests/storage_suite/support.rs b/crates/tracedecay/tests/storage_suite/support.rs index f0a68eca7f..86ad6c1bd1 100644 --- a/crates/tracedecay/tests/storage_suite/support.rs +++ b/crates/tracedecay/tests/storage_suite/support.rs @@ -2,16 +2,14 @@ //! //! Building a schema from scratch is a large fixed cost per test (especially //! on Windows), so the first test process to need a given fixture builds it -//! once under the system temp dir and every other test — including tests in -//! other processes, since nextest runs one process per test — copies the +//! once under the system temp dir and every other test, including tests in +//! other processes, since nextest runs one process per test, copies the //! finished file instead. use std::fs::{self, OpenOptions}; use std::future::Future; use std::path::{Path, PathBuf}; -use fs2::FileExt; - /// FNV-1a hash of everything that can change a template's contents: the /// schema-defining sources, the template name, and any builder-specific /// fingerprint supplied by the caller (for templates whose contents also @@ -54,8 +52,8 @@ fn template_cache_exists(path: &Path) -> bool { /// it first if this machine has no template for the current schema revision. /// /// `builder_fingerprint` must cover every input to `build` that lives -/// outside the `tracedecay-runtime-core` `db` module — typically -/// `include_bytes!` of the defining test file — +/// outside the `tracedecay-runtime-core` `db` module, typically +/// `include_bytes!` of the defining test file, /// so that editing the fixture-building code invalidates the cached /// template. Pass `&[]` when `build` depends only on the production schema /// code that `template_hash` already covers. @@ -87,9 +85,7 @@ where .write(true) .open(&lock_path) .expect("failed to open template cache lock"); - lock_file - .lock_exclusive() - .expect("failed to lock template cache"); + lock_file.lock().expect("failed to lock template cache"); if template_cache_exists(&template_path) { return template_path; @@ -108,14 +104,14 @@ where template_path } -/// Seeds `dest` with an empty latest-schema graph database — the exact file -/// `Database::initialize` would produce — without paying schema creation. +/// Seeds `dest` with an empty latest-schema graph database, the exact file +/// `Database::initialize` would produce, without paying schema creation. pub async fn seed_latest_graph_db(dest: &Path) { let template = ensure_template_db("graph-empty", &[], |path| async move { // Initialise on a throwaway path, then snapshot the committed schema to // `path`. The registered runtime's `checkpoint` follows a bounded WAL // policy that no-ops below its soft threshold, so a freshly-created - // schema can still live entirely in the WAL — copying the bare `.db` + // schema can still live entirely in the WAL, copying the bare `.db` // file would then capture an empty (v0) database. `VACUUM INTO` writes // a transactionally consistent standalone copy of the closed fixture. let init_path = path.with_file_name("template-init.db"); diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index f2241d52da..c79baf32ce 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -23,7 +23,6 @@ "react-dom": "^19.0.0", "react-router": "^7.18.2", "sigma": "^3.0.3", - "three": "^0.186.0", "zod": "^3.23.8", "zustand": "^5.0.2" }, @@ -38,7 +37,6 @@ "@types/node": "^22.20.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/three": "^0.186.0", "jsdom": "^29.1.1", "msw": "^2.15.0", "pixelmatch": "^7.2.0", @@ -301,13 +299,6 @@ "node": ">=20.19.0" } }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@emnapi/core": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", @@ -2331,13 +2322,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2421,13 +2405,6 @@ "@types/node": "*" } }, - "node_modules/@types/stats.js": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", - "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/statuses": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", @@ -2435,28 +2412,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/three": { - "version": "0.186.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", - "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@dimforge/rapier3d-compat": "~0.12.0", - "@tweenjs/tween.js": "~23.1.3", - "@types/stats.js": "*", - "@types/webxr": ">=0.5.17", - "fflate": "~0.8.3", - "meshoptimizer": "~1.1.1" - } - }, - "node_modules/@types/webxr": { - "version": "0.5.24", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", - "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -2999,13 +2954,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3517,13 +3465,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/meshoptimizer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", - "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", - "dev": true, - "license": "MIT" - }, "node_modules/msw": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", @@ -4151,12 +4092,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/three": { - "version": "0.186.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", - "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index aae4832f2a..552b8a360f 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -33,7 +33,6 @@ "react-dom": "^19.0.0", "react-router": "^7.18.2", "sigma": "^3.0.3", - "three": "^0.186.0", "zod": "^3.23.8", "zustand": "^5.0.2" }, @@ -48,7 +47,6 @@ "@types/node": "^22.20.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/three": "^0.186.0", "jsdom": "^29.1.1", "msw": "^2.15.0", "pixelmatch": "^7.2.0", diff --git a/dashboard/src/viz/scene/labelPriority.test.ts b/dashboard/src/viz/scene/labelPriority.test.ts deleted file mode 100644 index 05482b01f1..0000000000 --- a/dashboard/src/viz/scene/labelPriority.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { labelBudget, selectLabels, type LabelCandidate } from './labelPriority.ts'; - -const VIEWPORT = { width: 600, height: 300 }; - -function candidate( - id: string, - priority: number, - px: number, - py: number, - forced = false, - more: ReadonlyArray<{ px: number; py: number }> = [], -): LabelCandidate { - return { id, priority, placements: [{ px, py }, ...more], width: 80, height: 14, forced }; -} - -describe('label priority', () => { - it('prints the heaviest bodies first and never over one another', () => { - const chosen = selectLabels( - [ - candidate('light', 5, 100, 100), - candidate('heavy', 300, 104, 104), - candidate('far', 50, 400, 200), - ], - VIEWPORT, - 10, - ); - expect(chosen.has('heavy')).toBe(true); - expect(chosen.has('light')).toBe(false); - expect(chosen.has('far')).toBe(true); - expect(chosen.get('heavy')).toEqual({ px: 104, py: 104, placement: 0 }); - }); - - it('spends the budget only on names that print whole inside the viewport', () => { - const chosen = selectLabels( - [candidate('offscreen', 999, -500, -500), candidate('clipped', 500, 560, 10), candidate('visible', 1, 10, 10)], - VIEWPORT, - 1, - ); - expect([...chosen.keys()]).toEqual(['visible']); - }); - - it('always prints a forced label past the budget, and lets it claim its space first', () => { - const chosen = selectLabels( - [ - candidate('a', 100, 10, 10), - candidate('b', 90, 200, 10), - candidate('focused', 1, 12, 12, true), - ], - VIEWPORT, - 1, - ); - expect(chosen.has('focused')).toBe(true); - // The forced label took the top-left slot, so `a` collides and `b` is - // the one ordinary slot the budget allows. - expect(chosen.has('a')).toBe(false); - expect(chosen.has('b')).toBe(true); - }); - - it('falls back to the next placement when the preferred one is taken or clipped', () => { - const chosen = selectLabels( - [ - candidate('hub', 1000, 300, 100, true), - // Prefers the same spot as the hub, then offers below it. - candidate('member', 1, 302, 102, true, [{ px: 302, py: 140 }]), - // Prefers a clipped spot at the right edge, then flips left. - candidate('edge', 2, 560, 200, false, [{ px: 470, py: 200 }]), - ], - VIEWPORT, - 5, - ); - expect(chosen.get('member')).toEqual({ px: 302, py: 140, placement: 1 }); - expect(chosen.get('edge')).toEqual({ px: 470, py: 200, placement: 1 }); - }); - - it('is deterministic under ties', () => { - const tied = [candidate('z', 1, 10, 10), candidate('a', 1, 300, 10)]; - expect([...selectLabels(tied, VIEWPORT, 1).keys()]).toEqual(['a']); - expect([...selectLabels([...tied].reverse(), VIEWPORT, 1).keys()]).toEqual(['a']); - }); - - it('earns more labels as the camera closes in, bounded by the aperture', () => { - expect(labelBudget(1, 40)).toBe(12); - expect(labelBudget(1.6, 40)).toBe(24); - expect(labelBudget(3, 40)).toBe(40); - expect(labelBudget(1, 4)).toBe(6); - expect(labelBudget(3, 40, { width: 320, height: 200 })).toBe(2); - expect(labelBudget(1, 40, { width: 1400, height: 800 })).toBe(12); - }); -}); diff --git a/dashboard/src/viz/scene/labelPriority.ts b/dashboard/src/viz/scene/labelPriority.ts deleted file mode 100644 index 8b4395d515..0000000000 --- a/dashboard/src/viz/scene/labelPriority.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Which bodies get a printed name at the current camera, and where. - * - * Semantic zoom is an information contract: the further in the camera is, the - * more of the registry is named, and a name is never allowed to print over - * another. Priority is the caller's (mass, for the registry); `forced` labels - * — the members of a focused repository, a hovered body — always win a slot - * and do not spend the budget. Each candidate offers its placements in order - * of preference (beside the crown, then the other side, then below, above); - * the first that fits whole inside the aperture and clear of every accepted - * name is the one printed. Deterministic: same inputs, same result. - */ - -export interface LabelPlacement { - /** Top-left of the label box in CSS pixels. */ - readonly px: number; - readonly py: number; -} - -export interface LabelCandidate { - readonly id: string; - /** Higher prints first. */ - readonly priority: number; - /** Placements in order of preference; at least one. */ - readonly placements: readonly LabelPlacement[]; - readonly width: number; - readonly height: number; - readonly forced?: boolean; -} - -export interface PlacedLabel extends LabelPlacement { - /** Index into the candidate's `placements`, so a host can style the side. */ - readonly placement: number; -} - -interface Rect { - x0: number; - y0: number; - x1: number; - y1: number; -} - -function intersects(a: Rect, b: Rect, gap: number): boolean { - return a.x0 < b.x1 + gap && a.x1 + gap > b.x0 && a.y0 < b.y1 + gap && a.y1 + gap > b.y0; -} - -export function selectLabels( - candidates: readonly LabelCandidate[], - viewport: { readonly width: number; readonly height: number }, - budget: number, - gap = 4, -): ReadonlyMap { - const ordered = [...candidates].sort( - (a, b) => - Number(Boolean(b.forced)) - Number(Boolean(a.forced)) - || b.priority - a.priority - || a.id.localeCompare(b.id), - ); - const accepted: Rect[] = []; - const chosen = new Map(); - // Forced labels do not spend the budget: it bounds the ordinary names the - // camera earns, and a focused or emphasised body is printed regardless. - let ordinary = 0; - for (const candidate of ordered) { - if (!candidate.forced && ordinary >= budget) break; - for (let index = 0; index < candidate.placements.length; index += 1) { - const at = candidate.placements[index]!; - const rect: Rect = { - x0: at.px, - y0: at.py, - x1: at.px + candidate.width, - y1: at.py + candidate.height, - }; - // A name has to fit whole: a label cut by the aperture edge is a label - // the reader cannot read, so the next placement is tried instead. - if (rect.x0 < 0 || rect.y0 < 0 || rect.x1 > viewport.width || rect.y1 > viewport.height) continue; - if (accepted.some((other) => intersects(rect, other, gap))) continue; - accepted.push(rect); - chosen.set(candidate.id, { px: at.px, py: at.py, placement: index }); - if (!candidate.forced) ordinary += 1; - break; - } - } - return chosen; -} - -/** Label slots the camera earns: a handful at Fit, every body once the reader - * has closed in, and never more than the aperture has room to print. `zoom` - * is relative to Fit (1 = whole field). */ -export function labelBudget( - zoom: number, - bodyCount: number, - viewport?: { readonly width: number; readonly height: number }, -): number { - const earned = - zoom >= 2.4 - ? bodyCount - : zoom >= 1.5 - ? Math.max(12, Math.ceil(bodyCount * 0.6)) - : Math.max(6, Math.ceil(bodyCount * 0.3)); - if (!viewport) return earned; - const room = Math.max(1, Math.floor((viewport.width * viewport.height) / 25_000)); - return Math.min(earned, room); -} diff --git a/dashboard/src/viz/scene/neuralBody.test.ts b/dashboard/src/viz/scene/neuralBody.test.ts deleted file mode 100644 index 7ad4e5d46a..0000000000 --- a/dashboard/src/viz/scene/neuralBody.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildNeuralBody, dustBudget, hashId, mulberry32 } from './neuralBody.ts'; - -describe('neural body geometry', () => { - it('is deterministic for the same identity and radius', () => { - const a = buildNeuralBody({ id: 'proj_alpha', radius: 0.2, depth: 0 }); - const b = buildNeuralBody({ id: 'proj_alpha', radius: 0.2, depth: 0 }); - expect(a.count).toBe(b.count); - expect(Array.from(a.positions)).toEqual(Array.from(b.positions)); - expect(Array.from(a.filaments)).toEqual(Array.from(b.filaments)); - expect(Array.from(a.glows)).toEqual(Array.from(b.glows)); - }); - - it('draws a different body for a different identity', () => { - const a = buildNeuralBody({ id: 'proj_alpha', radius: 0.2, depth: 0 }); - const b = buildNeuralBody({ id: 'proj_beta', radius: 0.2, depth: 0 }); - expect(Array.from(a.positions)).not.toEqual(Array.from(b.positions)); - }); - - it('fills exactly its dust budget and keeps every particle inside the body envelope', () => { - const radius = 0.24; - const body = buildNeuralBody({ id: 'proj_gamma', radius, depth: 0 }); - expect(body.count).toBe(dustBudget(radius)); - expect(body.positions).toHaveLength(body.count * 3); - expect(body.alphas).toHaveLength(body.count); - expect(body.sizes).toHaveLength(body.count); - // Crown above, filaments below: nothing wanders past a couple of radii. - const envelope = radius * 2.6; - for (let index = 0; index < body.count; index += 1) { - const x = body.positions[index * 3]!; - const y = body.positions[index * 3 + 1]!; - expect(Math.abs(x)).toBeLessThanOrEqual(envelope); - expect(y).toBeLessThanOrEqual(radius * 1.75); - expect(y).toBeGreaterThanOrEqual(-body.depthReach - radius * 0.6); - expect(body.alphas[index]).toBeGreaterThan(0); - expect(body.alphas[index]).toBeLessThanOrEqual(1); - expect(body.sizes[index]).toBeGreaterThan(0); - } - expect(body.depthReach).toBeGreaterThan(radius * 0.8); - expect(body.depthReach).toBeLessThan(radius * 1.4); - }); - - it('carries a filament arbor and emission anchors that scale with the crown', () => { - const small = buildNeuralBody({ id: 'proj_delta', radius: 0.1, depth: 0 }); - const large = buildNeuralBody({ id: 'proj_delta', radius: 0.3, depth: 0 }); - expect(small.filamentSegments).toBeGreaterThan(0); - expect(small.filaments).toHaveLength(small.filamentSegments * 4); - expect(small.glowCount).toBeGreaterThan(3); - expect(large.count).toBeGreaterThan(small.count); - // Same seed, same arbor shape: only the scale differs. - expect(large.filamentSegments).toBe(small.filamentSegments); - }); - - it('thins and softens a body one depth layer back without moving it', () => { - const front = buildNeuralBody({ id: 'proj_epsilon', radius: 0.2, depth: 0 }); - const back = buildNeuralBody({ id: 'proj_epsilon', radius: 0.2, depth: 2 }); - expect(Array.from(front.positions)).toEqual(Array.from(back.positions)); - const mean = (values: Float32Array) => values.reduce((sum, value) => sum + value, 0) / values.length; - expect(mean(back.alphas)).toBeLessThan(mean(front.alphas)); - expect(mean(back.sizes)).toBeGreaterThan(mean(front.sizes)); - }); - - it('seeds from the identity alone', () => { - expect(hashId('a')).not.toBe(hashId('b')); - const random = mulberry32(hashId('stable')); - const first = [random(), random(), random()]; - const again = mulberry32(hashId('stable')); - expect([again(), again(), again()]).toEqual(first); - for (const value of first) { - expect(value).toBeGreaterThanOrEqual(0); - expect(value).toBeLessThan(1); - } - }); -}); diff --git a/dashboard/src/viz/scene/neuralBody.ts b/dashboard/src/viz/scene/neuralBody.ts deleted file mode 100644 index 75786308a5..0000000000 --- a/dashboard/src/viz/scene/neuralBody.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * The luminous body of one project: a particulate crown, a handful of - * dendritic filaments trailing below it, and a few soft emission anchors. - * - * Everything here is composition, not measurement. The ONLY quantities a body - * carries from the registry are its crown radius (indexed mass, set by the - * caller) and — applied later by the renderer — its resting brightness - * (recency) and any admitted heat. Filaments are visual texture inside one - * identity; they are never additional graph relations, and no particle is a - * fact. The generator is seeded from the project id so the same registry - * always draws the same body, on every reload and every renderer. - * - * Units are the caller's field units (columns one unit apart). Positions are - * body-local: the crown centre is the origin, filaments hang toward -y. - */ - -export interface NeuralBodySpec { - /** Stable identity; the sole seed. */ - readonly id: string; - /** Crown radius in field units — the caller's mass measurement. */ - readonly radius: number; - /** Depth layer, 0 (front) to 2 (back). Softens and thins the body. */ - readonly depth: number; -} - -export interface NeuralBodyGeometry { - /** Dust particle count. */ - readonly count: number; - /** xyz per dust particle, body-local; z is always 0 (depth is softening). */ - readonly positions: Float32Array; - /** Resting alpha per dust particle, 0..1. */ - readonly alphas: Float32Array; - /** Sprite diameter per dust particle, field units. */ - readonly sizes: Float32Array; - /** Filament polylines as segment pairs: x0 y0 x1 y1 per segment. */ - readonly filaments: Float32Array; - /** Segment count in {@link filaments}. */ - readonly filamentSegments: number; - /** Soft emission anchors: x y diameter alpha per anchor. */ - readonly glows: Float32Array; - readonly glowCount: number; - /** How far below the crown centre the filaments reach, field units. */ - readonly depthReach: number; -} - -/** Deterministic PRNG; the same seed yields the same body forever. */ -export function mulberry32(seed: number): () => number { - let a = seed >>> 0; - return () => { - a = (a + 0x6d2b79f5) >>> 0; - let t = a; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** FNV-1a over UTF-16 code units. */ -export function hashId(value: string): number { - let hash = 2166136261; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; -} - -/** Normal deviate clipped at three sigma, so a body's envelope is bounded by - * design rather than by luck: no particle wanders off into another column. */ -function gaussian(random: () => number): number { - const u = Math.max(1e-9, random()); - const v = random(); - return Math.max(-3, Math.min(3, Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v))); -} - -/** Particle budget per body: heavier crowns hold more dust, bounded so a large - * registry stays well inside one draw call's comfort. */ -export function dustBudget(radius: number): number { - return Math.round(Math.min(2600, Math.max(700, 9000 * radius))); -} - -/** Depth softens: a body one layer back is thinner and its dust larger and - * fainter, which the eye reads as defocus. */ -function depthFactors(depth: number): { alpha: number; size: number } { - const layer = Math.max(0, Math.min(2, Math.round(depth))); - return { - alpha: [1, 0.74, 0.52][layer]!, - size: [1, 1.3, 1.65][layer]!, - }; -} - -function cubic( - t: number, - p0: number, - p1: number, - p2: number, - p3: number, -): number { - const u = 1 - t; - return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3; -} - -export function buildNeuralBody(spec: NeuralBodySpec): NeuralBodyGeometry { - const random = mulberry32(hashId(spec.id) ^ 0xb0d7); - const r = Math.max(0.01, spec.radius); - const { alpha: depthAlpha, size: depthSize } = depthFactors(spec.depth); - const reach = r * (0.85 + random() * 0.45); - const crownWidth = 0.78 + random() * 0.44; - - const count = dustBudget(r); - const positions = new Float32Array(count * 3); - const alphas = new Float32Array(count); - const sizes = new Float32Array(count); - let cursor = 0; - const dot = (x: number, y: number, alpha: number, size: number): void => { - if (cursor >= count) return; - positions[cursor * 3] = x; - positions[cursor * 3 + 1] = y; - positions[cursor * 3 + 2] = 0; - alphas[cursor] = Math.min(1, alpha * depthAlpha); - sizes[cursor] = size * depthSize; - cursor += 1; - }; - - const filamentPoints: number[] = []; - const glowList: number[] = []; - const glow = (x: number, y: number, diameter: number, alpha: number): void => { - glowList.push(x, y, diameter * depthSize, alpha * depthAlpha); - }; - - // Roots: continuous filaments from a foot below the body up through a neck - // into the crown. Their dust follows the same cubic, so the silhouette is a - // hanging arbor rather than a comet. - const roots = 5 + Math.floor(random() * 3); - const rootShare = Math.floor(count * 0.3 / roots); - for (let root = 0; root < roots; root += 1) { - const footX = gaussian(random) * r * 0.08; - const footY = -reach * (0.88 + random() * 0.14); - const crownX = (root / (roots - 1) - 0.5) * r * 1.5 * crownWidth; - const crownY = r * (0.18 + random() * 0.45); - const neckX = crownX * 0.24; - const c1x = footX + crownX * 0.2; - const c1y = -reach * 0.58; - const c2x = neckX; - const c2y = -reach * 0.34; - const steps = 14; - let px = footX; - let py = footY; - for (let step = 1; step <= steps; step += 1) { - const t = step / steps; - const x = cubic(t, footX, c1x, c2x, neckX); - const y = cubic(t, footY, c1y, c2y, 0); - filamentPoints.push(px, py, x, y); - px = x; - py = y; - } - // Neck to crown: a second, shorter cubic that fans out. - for (let step = 1; step <= 8; step += 1) { - const t = step / 8; - const x = cubic(t, neckX, neckX, crownX * 0.7, crownX); - const y = cubic(t, 0, r * 0.22, crownY * 0.55, crownY); - filamentPoints.push(px, py, x, y); - px = x; - py = y; - } - for (let index = 0; index < rootShare; index += 1) { - const t = random(); - const x = cubic(t, footX, c1x, c2x, neckX); - const y = cubic(t, footY, c1y, c2y, 0); - dot( - x + gaussian(random) * r * 0.045, - y + gaussian(random) * r * 0.03, - 0.14 + random() ** 2 * 0.5, - r * (0.02 + random() * 0.03), - ); - } - // Twigs off the crown branch. - for (let branch = 0; branch < 3; branch += 1) { - const t = 0.25 + branch * 0.25; - const x = neckX + (crownX - neckX) * t * t; - const y = crownY * t; - const endX = crownX + (random() - 0.5) * r * 0.7; - const endY = crownY + random() * r * 0.3; - filamentPoints.push(x, y, endX, endY); - } - glow(footX, footY, r * 0.2, 0.3); - } - - // The crown: a gaussian cloud, elliptical, sitting a little above the - // origin so the filaments read as hanging from it. - const crownShare = Math.floor(count * 0.52); - for (let index = 0; index < crownShare; index += 1) { - const angle = random() * Math.PI * 2; - const radius = Math.abs(gaussian(random)) * r * 0.56; - const x = Math.cos(angle) * radius * crownWidth; - const y = Math.sin(angle) * radius * 0.82 + r * 0.18; - dot(x, y, 0.12 + random() ** 3 * 0.85, r * (0.024 + random() * 0.04)); - } - // Basal knot where the roots meet, so the foot is a place and not a fade. - const basalShare = Math.floor(count * 0.1); - for (let index = 0; index < basalShare; index += 1) { - dot( - gaussian(random) * r * 0.22, - -reach + gaussian(random) * r * 0.1, - 0.08 + random() ** 2 * 0.42, - r * (0.02 + random() * 0.03), - ); - } - // Faint outer scatter to give the body an atmosphere the eye can measure - // the crown against. - while (cursor < count) { - const angle = random() * Math.PI * 2; - const radius = r * (0.9 + random() * 0.9); - dot( - Math.cos(angle) * radius, - Math.sin(angle) * radius * 0.9 + r * 0.1, - 0.03 + random() * 0.12, - r * (0.02 + random() * 0.026), - ); - } - - glow(0, -reach, r * 0.6, 0.4); - glow(0, -reach * 0.5, r * 0.5, 0.18); - glow(0, r * 0.2, r * 2.2, 0.34); - glow(0, r * 0.2, r * 1.1, 0.42); - glow(0, r * 0.2, r * 0.4, 0.85); - - return { - count, - positions, - alphas, - sizes, - filaments: Float32Array.from(filamentPoints), - filamentSegments: filamentPoints.length / 4, - glows: Float32Array.from(glowList), - glowCount: glowList.length / 4, - depthReach: reach, - }; -} diff --git a/dashboard/src/viz/scene/registryRuntime.ts b/dashboard/src/viz/scene/registryRuntime.ts deleted file mode 100644 index d1372162c6..0000000000 --- a/dashboard/src/viz/scene/registryRuntime.ts +++ /dev/null @@ -1,870 +0,0 @@ -/** - * The Three.js runtime for the registry field: one orthographic 2.5D scene - * that draws the luminous bodies, their hanging filaments, the evidenced - * checkout paths, and the heat that admitted activity leaves on them. - * - * This module draws and decides nothing about the registry. Positions, radii, - * relations and depth arrive in a `RegistrySceneModel`; heat arrives through - * the `ActivationField` the page strikes when a real event lands; hover and - * emphasis arrive as identities. The runtime samples all of it per frame and - * runs a frame loop only while something real is unresolved — warm heat, an - * easing focus, a camera in flight — then stops. Under reduced motion no loop - * runs at all: every state change composes one static frame. - * - * Geometry is body-local. Every vertex carries the centre of the body it - * belongs to, and the vertex shader places it at `centre.x * spread + local`, - * so the categorical column spacing can follow the aperture's aspect without - * stretching a single crown and without rebuilding a buffer. - * - * Renderer-specific code stays here so the model, the camera arithmetic and - * the label priority can be exercised without a GPU, and so the React host can - * substitute this whole module in a DOM test. - */ -import { - AdditiveBlending, - BufferGeometry, - DataTexture, - Float32BufferAttribute, - LineBasicMaterial, - LineLoop, - LineSegments, - NearestFilter, - OrthographicCamera, - Points, - RGBAFormat, - SRGBColorSpace, - Scene, - ShaderMaterial, - UnsignedByteType, - Vector3, - WebGLRenderer, -} from 'three'; -import { - approach, - cssColorToRgb, - settled, - type ActivationField, -} from '../graph/activation.ts'; -import { kindColor } from '../graph/kindColor.ts'; -import { palette, type GraphPalette } from '../graph/palette.ts'; -import { buildNeuralBody } from './neuralBody.ts'; -import { - bodiesBounds, - columnSpread, - pickBody, - samplePath, - spreadExtent, - type RegistrySceneModel, - type SceneBody, -} from './registrySceneModel.ts'; -import { - cameraEquals, - clampZoom, - fitBounds, - panBy, - unproject, - zoomAbout, - type CameraState, - type Viewport, -} from './sceneCamera.ts'; -import { bodyState, channelByte, wantsNextFrame } from './sceneState.ts'; - -export interface SceneView { - readonly camera: CameraState; - readonly viewport: Viewport; - /** Column spread in force; overlays must place centres at `x * spread`. */ - readonly spread: number; - /** The Fit camera for this viewport, for zoom readouts. */ - readonly fit: CameraState; -} - -export interface RegistryRuntimeOptions { - readonly container: HTMLElement; - readonly model: RegistrySceneModel; - /** Admitted heat. The runtime only ever reads it. */ - readonly field: ActivationField; - readonly isReduced: () => boolean; - /** The camera moved (fit, zoom, pan, animation frame) or the box changed. */ - readonly onView: (view: SceneView) => void; -} - -export interface RegistryRuntime { - readonly canvas: HTMLCanvasElement; - resize(viewport: Viewport): void; - /** Hover/keyboard inspection. Raises one body and dims the rest. Never heat. */ - focus(id: string | null): void; - /** Camera focus on a set of bodies; everything else recedes to context. - * `null` returns to the whole field. */ - emphasize(ids: ReadonlySet | null): void; - fit(): void; - zoomIn(): void; - zoomOut(): void; - /** Pointer-centred zoom; `factor` > 1 closes in. */ - zoomAt(factor: number, anchor: { px: number; py: number }): void; - panBy(dxPx: number, dyPx: number): void; - view(): SceneView; - pick(px: number, py: number): SceneBody | null; - /** Something real changed off-frame (a strike landed); draw it. */ - wake(): void; - /** Compose the no-motion frame and stop any loop. */ - settle(): void; - retheme(): void; - dispose(): void; -} - -const CAMERA_PAD_PX = 34; -const HOP_PULSE_PERIOD_MS = 1100; - -const STATE_SAMPLE = /* glsl */ ` - uniform sampler2D uState; - uniform float uBodies; - vec4 bodyState(float body) { - return texture2D(uState, vec2((body + 0.5) / uBodies, 0.5)); - } -`; - -const PLACE = /* glsl */ ` - uniform float uSpread; - vec3 place(vec3 local, vec2 centre) { - return vec3(centre.x * uSpread + local.x, centre.y + local.y, local.z); - } -`; - -const DUST_VERTEX = /* glsl */ ` - attribute vec2 aCenter; - attribute float aBody; - attribute float aAlpha; - attribute float aSize; - attribute float aFlare; - attribute vec3 aColor; - uniform float uPixelsPerUnit; - uniform float uMaxPointSize; - uniform vec3 uHot; - uniform vec3 uAlert; - uniform vec3 uDim; - varying vec4 vColor; - ${STATE_SAMPLE} - ${PLACE} - void main() { - vec4 s = bodyState(aBody); - float heat = s.r; - float dim = s.g; - float raise = s.b; - float vitality = s.a; - vec3 tint = mix(aColor, uAlert, heat * 0.85); - tint = mix(tint, uHot, raise * 0.3); - tint = mix(tint, uDim, dim * 0.75); - float resting = aAlpha - * (0.32 + 0.68 * vitality) - * (1.0 - 0.82 * dim) - * (1.0 + 1.6 * heat) - * (1.0 + 0.35 * raise); - // A flare sprite exists only while its body is warm: the impact of an - // admitted event, widening as the heat decays. - float flare = aAlpha * heat * (1.0 - 0.82 * dim); - vColor = vec4(mix(tint, uAlert, aFlare), mix(resting, flare, aFlare)); - gl_Position = projectionMatrix * modelViewMatrix * vec4(place(position, aCenter), 1.0); - float grow = mix(1.0 + 0.6 * heat + 0.1 * raise, 0.7 + 1.1 * (1.0 - heat), aFlare); - float px = aSize * uPixelsPerUnit * grow; - gl_PointSize = clamp(px, 0.75, uMaxPointSize); - } -`; - -const DUST_FRAGMENT = /* glsl */ ` - varying vec4 vColor; - void main() { - vec2 d = gl_PointCoord - vec2(0.5); - float r = length(d) * 2.0; - if (r > 1.0) discard; - float a = 1.0 - r; - a = a * a; - gl_FragColor = vec4(vColor.rgb, vColor.a * a); - } -`; - -const LINE_VERTEX = /* glsl */ ` - attribute vec2 aCenter; - attribute float aBody; - attribute float aAlpha; - attribute vec3 aColor; - uniform vec3 uHot; - uniform vec3 uAlert; - uniform vec3 uDim; - varying vec4 vColor; - ${STATE_SAMPLE} - ${PLACE} - void main() { - vec4 s = bodyState(aBody); - float heat = s.r; - float dim = s.g; - float raise = s.b; - float vitality = s.a; - vec3 tint = mix(aColor, uAlert, heat * 0.8); - tint = mix(tint, uHot, raise * 0.25); - tint = mix(tint, uDim, dim * 0.75); - float alpha = aAlpha * (0.38 + 0.62 * vitality) * (1.0 - 0.85 * dim) * (1.0 + 0.7 * heat); - vColor = vec4(tint, alpha); - gl_Position = projectionMatrix * modelViewMatrix * vec4(place(position, aCenter), 1.0); - } -`; - -const LINE_FRAGMENT = /* glsl */ ` - varying vec4 vColor; - void main() { - gl_FragColor = vColor; - } -`; - -const PATH_VERTEX = /* glsl */ ` - attribute float aFrom; - attribute float aTo; - uniform vec3 uEdge; - uniform vec3 uAlert; - uniform vec3 uDim; - varying vec4 vColor; - ${STATE_SAMPLE} - void main() { - vec4 a = bodyState(aFrom); - vec4 b = bodyState(aTo); - // A relation conducts only when both of its ends are warm: that is what - // makes it a synapse and not a wire. - float conduct = min(a.r, b.r); - float dim = min(a.g, b.g); - float vitality = (a.a + b.a) * 0.5; - vec3 tint = mix(uEdge, uAlert, min(1.0, conduct * 1.6)); - tint = mix(tint, uDim, dim * 0.7); - float alpha = (0.26 + 0.34 * vitality) * (1.0 - 0.85 * dim) + 0.7 * conduct; - vColor = vec4(tint, min(1.0, alpha)); - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } -`; - -interface StateChannels { - readonly texture: DataTexture; - readonly data: Uint8Array; -} - -function unit([r, g, b]: [number, number, number]): [number, number, number] { - return [r / 255, g / 255, b / 255]; -} - -function createState(count: number): StateChannels { - const data = new Uint8Array(Math.max(1, count) * 4); - const texture = new DataTexture(data, Math.max(1, count), 1, RGBAFormat, UnsignedByteType); - texture.magFilter = NearestFilter; - texture.minFilter = NearestFilter; - texture.generateMipmaps = false; - texture.needsUpdate = true; - return { texture, data }; -} - -/** The renderer's own largest point sprite. Glow anchors above it render at - * the cap rather than failing, so a GPU with a small range loses a little - * bloom and nothing else. */ -function maxPointSize(renderer: WebGLRenderer): number { - const gl = renderer.getContext(); - const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array | null; - const max = range?.[1]; - return typeof max === 'number' && max > 1 ? Math.min(max, 512) : 64; -} - -class Batch { - readonly positions: number[] = []; - readonly centers: number[] = []; - readonly colors: number[] = []; - readonly alphas: number[] = []; - readonly sizes: number[] = []; - readonly bodies: number[] = []; - readonly flares: number[] = []; - - push( - body: number, - center: readonly [number, number], - x: number, - y: number, - z: number, - color: readonly [number, number, number], - alpha: number, - size = 0, - flare = 0, - ): void { - this.positions.push(x, y, z); - this.centers.push(center[0], center[1]); - this.colors.push(color[0], color[1], color[2]); - this.alphas.push(alpha); - this.sizes.push(size); - this.bodies.push(body); - this.flares.push(flare); - } - - geometry(withSize: boolean): BufferGeometry { - const geometry = new BufferGeometry(); - geometry.setAttribute('position', new Float32BufferAttribute(this.positions, 3)); - geometry.setAttribute('aCenter', new Float32BufferAttribute(this.centers, 2)); - geometry.setAttribute('aColor', new Float32BufferAttribute(this.colors, 3)); - geometry.setAttribute('aAlpha', new Float32BufferAttribute(this.alphas, 1)); - geometry.setAttribute('aBody', new Float32BufferAttribute(this.bodies, 1)); - if (withSize) { - geometry.setAttribute('aSize', new Float32BufferAttribute(this.sizes, 1)); - geometry.setAttribute('aFlare', new Float32BufferAttribute(this.flares, 1)); - } - return geometry; - } -} - -export function createRegistryRuntime(options: RegistryRuntimeOptions): RegistryRuntime { - const { container, model, field, isReduced, onView } = options; - const canvas = document.createElement('canvas'); - canvas.style.position = 'absolute'; - canvas.style.inset = '0'; - canvas.style.width = '100%'; - canvas.style.height = '100%'; - canvas.style.display = 'block'; - container.appendChild(canvas); - - const renderer = new WebGLRenderer({ - canvas, - antialias: true, - alpha: true, - premultipliedAlpha: true, - powerPreference: 'low-power', - }); - const pixelRatio = Math.min(2, window.devicePixelRatio || 1); - renderer.setPixelRatio(pixelRatio); - renderer.setClearColor(0x000000, 0); - const pointCap = maxPointSize(renderer); - - let colors: GraphPalette = palette(container); - const scene = new Scene(); - const camera3 = new OrthographicCamera(-1, 1, 1, -1, 0.1, 50); - camera3.position.set(0, 0, 10); - camera3.lookAt(new Vector3(0, 0, 0)); - - const bodies = model.bodies; - const index = new Map(bodies.map((body, position) => [body.id, position] as const)); - const state = createState(bodies.length); - - const uniforms = { - uState: { value: state.texture }, - uBodies: { value: Math.max(1, bodies.length) }, - uSpread: { value: 1 }, - uPixelsPerUnit: { value: 1 }, - uMaxPointSize: { value: pointCap }, - uHot: { value: unit(colors.hot) }, - uAlert: { value: unit(colors.alert) }, - uDim: { value: unit(colors.dim) }, - uEdge: { value: unit(colors.edge) }, - }; - const additive = { - uniforms, - blending: AdditiveBlending, - transparent: true, - depthTest: false, - depthWrite: false, - }; - const dustMaterial = new ShaderMaterial({ ...additive, vertexShader: DUST_VERTEX, fragmentShader: DUST_FRAGMENT }); - const lineMaterial = new ShaderMaterial({ ...additive, vertexShader: LINE_VERTEX, fragmentShader: LINE_FRAGMENT }); - const pathMaterial = new ShaderMaterial({ ...additive, vertexShader: PATH_VERTEX, fragmentShader: LINE_FRAGMENT }); - - // ---- geometry ----------------------------------------------------------- - const dustBatch = new Batch(); - const glowBatch = new Batch(); - const lineBatch = new Batch(); - /** Per-body colour ranges (vertex indices), so a theme flip re-tints in place. */ - const slices: Array<{ dust: [number, number]; glow: [number, number]; line: [number, number] }> = []; - - const hueOf = (body: SceneBody): [number, number, number] => - body.kind === 'repository' - ? unit(colors.label) - : unit(cssColorToRgb(kindColor(body.hueKey, colors.light))); - - // Depth is carried by the body geometry (thinner, softer dust one layer - // back), not by z: the scene is additive with depth testing off, so z would - // change nothing. - const z = 0; - bodies.forEach((body, position) => { - const hue = hueOf(body); - const center: readonly [number, number] = [body.x, body.y]; - const dustStart = dustBatch.bodies.length; - const glowStart = glowBatch.bodies.length; - const lineStart = lineBatch.bodies.length; - if (body.kind === 'repository') { - // A hub is an identity mark, never a holding: a bright core, a soft - // halo and a thin ring at a fixed categorical size. - glowBatch.push(position, center, 0, 0, z, hue, 0.95, body.radius * 1.5); - glowBatch.push(position, center, 0, 0, z, hue, 0.26, body.radius * 7); - const ring = 40; - const ringRadius = body.radius * 2.1; - for (let step = 0; step < ring; step += 1) { - const a0 = (step / ring) * Math.PI * 2; - const a1 = ((step + 1) / ring) * Math.PI * 2; - lineBatch.push(position, center, Math.cos(a0) * ringRadius, Math.sin(a0) * ringRadius, z, hue, 0.5); - lineBatch.push(position, center, Math.cos(a1) * ringRadius, Math.sin(a1) * ringRadius, z, hue, 0.5); - } - } else { - const geometry = buildNeuralBody({ id: body.id, radius: body.radius, depth: body.depth }); - for (let particle = 0; particle < geometry.count; particle += 1) { - dustBatch.push( - position, - center, - geometry.positions[particle * 3]!, - geometry.positions[particle * 3 + 1]!, - z, - hue, - geometry.alphas[particle]!, - geometry.sizes[particle]!, - ); - } - for (let anchor = 0; anchor < geometry.glowCount; anchor += 1) { - glowBatch.push( - position, - center, - geometry.glows[anchor * 4]!, - geometry.glows[anchor * 4 + 1]!, - z, - hue, - geometry.glows[anchor * 4 + 3]!, - geometry.glows[anchor * 4 + 2]!, - ); - } - // Two flare sprites, dark at rest: a tight one over the crown and a - // wide, faint one that reads in peripheral vision. - glowBatch.push(position, center, 0, body.radius * 0.18, z, hue, 0.9, body.radius * 1.6, 1); - glowBatch.push(position, center, 0, body.radius * 0.18, z, hue, 0.35, body.radius * 3.6, 1); - for (let segment = 0; segment < geometry.filamentSegments; segment += 1) { - lineBatch.push(position, center, geometry.filaments[segment * 4]!, geometry.filaments[segment * 4 + 1]!, z, hue, 0.2); - lineBatch.push(position, center, geometry.filaments[segment * 4 + 2]!, geometry.filaments[segment * 4 + 3]!, z, hue, 0.2); - } - } - slices.push({ - dust: [dustStart, dustBatch.bodies.length], - glow: [glowStart, glowBatch.bodies.length], - line: [lineStart, lineBatch.bodies.length], - }); - }); - - const dustGeometry = dustBatch.geometry(true); - const glowGeometry = glowBatch.geometry(true); - const lineGeometry = lineBatch.geometry(false); - const dust = new Points(dustGeometry, dustMaterial); - const glows = new Points(glowGeometry, dustMaterial); - const filaments = new LineSegments(lineGeometry, lineMaterial); - for (const object of [dust, glows, filaments]) object.frustumCulled = false; - - // Evidenced paths are sampled in world space and rebuilt when the spread - // changes (a resize), which is rare and cheap: a few curves of 24 segments. - const pathGeometry = new BufferGeometry(); - const pathFrom: number[] = []; - const pathTo: number[] = []; - for (const path of model.paths) { - const from = index.get(path.from); - const to = index.get(path.to); - if (from === undefined || to === undefined) continue; - for (let step = 0; step < 24; step += 1) { - pathFrom.push(from, from); - pathTo.push(to, to); - } - } - pathGeometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(pathFrom.length * 3), 3)); - pathGeometry.setAttribute('aFrom', new Float32BufferAttribute(pathFrom, 1)); - pathGeometry.setAttribute('aTo', new Float32BufferAttribute(pathTo, 1)); - const paths = new LineSegments(pathGeometry, pathMaterial); - paths.frustumCulled = false; - let pathPoints: Array> = []; - - const layoutPaths = (spread: number): void => { - pathPoints = model.paths.map((path) => samplePath(model, path, spread, 24)); - const attribute = pathGeometry.getAttribute('position'); - let vertex = 0; - for (const points of pathPoints) { - for (let step = 0; step < points.length - 1; step += 1) { - const a = points[step]!; - const b = points[step + 1]!; - attribute.setXYZ(vertex, a[0], a[1], 0); - attribute.setXYZ(vertex + 1, b[0], b[1], 0); - vertex += 2; - } - } - attribute.needsUpdate = true; - }; - - // One travelling light per path, parked cold until its relation conducts. - // Pulses are placed in world space directly (centre 0, spread-independent). - const pulseCount = Math.max(1, model.paths.length); - const pulseBatch = new Batch(); - for (const path of model.paths) { - pulseBatch.push(index.get(path.from) ?? 0, [0, 0], 0, 0, 0, unit(colors.alert), 0, 0.05); - } - if (model.paths.length === 0) pulseBatch.push(0, [0, 0], 0, 0, 0, unit(colors.alert), 0, 0.05); - const pulseGeometry = pulseBatch.geometry(true); - const pulses = new Points(pulseGeometry, dustMaterial); - pulses.frustumCulled = false; - - // The focus halo: one restrained ring the hover raises around one body. - const haloMaterial = new LineBasicMaterial({ transparent: true, opacity: 0, depthTest: false }); - const haloGeometry = new BufferGeometry(); - const haloPoints: number[] = []; - for (let step = 0; step < 64; step += 1) { - const angle = (step / 64) * Math.PI * 2; - haloPoints.push(Math.cos(angle), Math.sin(angle), 0); - } - haloGeometry.setAttribute('position', new Float32BufferAttribute(haloPoints, 3)); - const halo = new LineLoop(haloGeometry, haloMaterial); - halo.visible = false; - - scene.add(paths, glows, dust, filaments, pulses, halo); - - // ---- state -------------------------------------------------------------- - let viewport: Viewport = { width: 1, height: 1 }; - let spread = 1; - let worldExtent = spreadExtent(model.extent, spread); - let fit: CameraState = fitBounds(worldExtent, viewport, CAMERA_PAD_PX); - let cam: CameraState = fit; - let cameraTarget: CameraState | null = null; - let atFit = true; - let emphasis: ReadonlySet | null = null; - const focus = { t: 0, target: 0, shown: null as string | null }; - let focusNeighborhood: Set | null = null; - let alive = true; - let raf = 0; - let lastFrame = 0; - /** Under reduced motion heat still has to cool. Nothing travels; the static - * frame is simply recomposed once a second while anything is warm, the same - * coarse clock the signal panel uses to keep a printed age true. */ - let coolingTimer: ReturnType | null = null; - - const neighborhoodOf = (id: string): Set => { - const set = new Set([id]); - for (const path of model.pathsByBody.get(id) ?? []) { - set.add(path.from); - set.add(path.to); - } - return set; - }; - - const applyCamera = (): void => { - const halfW = (viewport.width * cam.scale) / 2; - const halfH = (viewport.height * cam.scale) / 2; - camera3.left = -halfW; - camera3.right = halfW; - camera3.top = halfH; - camera3.bottom = -halfH; - camera3.position.set(cam.cx, cam.cy, 10); - camera3.updateProjectionMatrix(); - uniforms.uPixelsPerUnit.value = pixelRatio / cam.scale; - uniforms.uSpread.value = spread; - }; - - const writeState = (): void => { - const shown = focus.shown; - bodies.forEach((body, position) => { - const channels = bodyState({ - heat: field.heatOf(body.id), - vitality: body.vitality, - inFocusNeighborhood: shown === null ? null : focusNeighborhood?.has(body.id) === true, - focusT: focus.t, - shown: body.id === shown, - outsideEmphasis: emphasis === null ? null : !emphasis.has(body.id), - }); - state.data[position * 4] = channelByte(channels.heat); - state.data[position * 4 + 1] = channelByte(channels.dim); - state.data[position * 4 + 2] = channelByte(channels.raise); - state.data[position * 4 + 3] = channelByte(channels.vitality); - }); - state.texture.needsUpdate = true; - }; - - const writeHalo = (): void => { - const shown = focus.shown; - const body = shown === null ? undefined : model.byId.get(shown); - if (!body || focus.t <= 0.01) { - halo.visible = false; - return; - } - halo.visible = true; - const radius = body.kind === 'repository' ? body.radius * 3 : body.radius * 1.12; - halo.position.set(body.x * spread, body.y - (body.kind === 'repository' ? 0 : body.radius * 0.05), 0); - halo.scale.set(radius, radius * 0.92, 1); - haloMaterial.opacity = 0.85 * focus.t; - const [hr, hg, hb] = unit(colors.hot); - // The shader uniforms are raw sRGB; say so here too, or three would treat - // the ring as linear and output-encode it lighter than the dust it rings. - haloMaterial.color.setRGB(hr, hg, hb, SRGBColorSpace); - }; - - const writePulses = (now: number): void => { - const reduced = isReduced(); - const phase = (now % HOP_PULSE_PERIOD_MS) / HOP_PULSE_PERIOD_MS; - const position = pulseGeometry.getAttribute('position'); - const alpha = pulseGeometry.getAttribute('aAlpha'); - const size = pulseGeometry.getAttribute('aSize'); - model.paths.forEach((path, at) => { - const heatFrom = field.heatOf(path.from); - const heatTo = field.heatOf(path.to); - const travel = Math.min(heatFrom, heatTo); - const points = pathPoints[at]; - if (reduced || travel <= 0.04 || !points || points.length < 2) { - alpha.setX(at, 0); - return; - } - const forward = heatFrom >= heatTo; - const spans = points.length - 1; - const walked = (forward ? phase : 1 - phase) * spans; - const span = Math.max(0, Math.min(spans - 1, Math.floor(walked))); - const local = walked - span; - const a = points[span]!; - const b = points[span + 1]!; - position.setXYZ(at, a[0] + (b[0] - a[0]) * local, a[1] + (b[1] - a[1]) * local, 0); - alpha.setX(at, 0.95 * Math.min(1, travel * 2)); - size.setX(at, 0.05 + 0.07 * travel); - }); - position.needsUpdate = true; - alpha.needsUpdate = true; - size.needsUpdate = true; - }; - - const compose = (now: number): void => { - if (!alive) return; - writeState(); - writeHalo(); - writePulses(now); - applyCamera(); - renderer.render(scene, camera3); - }; - - const currentView = (): SceneView => ({ camera: cam, viewport, spread, fit }); - const publish = (): void => onView(currentView()); - - const step = (now: number): void => { - raf = 0; - if (!alive) return; - const delta = lastFrame === 0 ? 16 : now - lastFrame; - lastFrame = now; - const warm = field.tick(now); - focus.t = approach(focus.t, focus.target, delta, 90); - const focusSettled = settled(focus.t, focus.target); - if (focusSettled) { - focus.t = focus.target; - if (focus.target === 0) { - focus.shown = null; - focusNeighborhood = null; - } - } - let cameraMoving = false; - if (cameraTarget) { - const next: CameraState = { - cx: approach(cam.cx, cameraTarget.cx, delta, 70), - cy: approach(cam.cy, cameraTarget.cy, delta, 70), - scale: approach(cam.scale, cameraTarget.scale, delta, 70), - }; - const tolerance = cameraTarget.scale * 0.5; - const done = - settled(next.cx, cameraTarget.cx, tolerance) - && settled(next.cy, cameraTarget.cy, tolerance) - && settled(next.scale, cameraTarget.scale, cameraTarget.scale * 0.002); - cam = done ? cameraTarget : next; - if (done) cameraTarget = null; - cameraMoving = !done; - publish(); - } - compose(now); - if (alive && wantsNextFrame({ warm, focusSettled, cameraMoving, reduced: isReduced() })) { - raf = requestAnimationFrame(step); - } else { - lastFrame = 0; - } - }; - - const settle = (): void => { - if (raf) { - cancelAnimationFrame(raf); - raf = 0; - } - if (coolingTimer !== null) { - clearTimeout(coolingTimer); - coolingTimer = null; - } - lastFrame = 0; - const warm = field.tick(performance.now()); - focus.t = focus.target; - if (focus.target === 0) { - focus.shown = null; - focusNeighborhood = null; - } - if (cameraTarget) { - cam = cameraTarget; - cameraTarget = null; - publish(); - } - compose(performance.now()); - if (warm && alive && isReduced()) coolingTimer = setTimeout(settle, 1000); - }; - - const wake = (): void => { - if (!alive) return; - if (isReduced()) { - settle(); - return; - } - if (!raf) { - lastFrame = 0; - raf = requestAnimationFrame(step); - } - }; - - const moveCamera = (next: CameraState, animate: boolean): void => { - const bounded = clampZoom(next, fit); - if (cameraEquals(bounded, cam) && cameraTarget === null) return; - if (animate && !isReduced()) { - cameraTarget = bounded; - wake(); - return; - } - cameraTarget = null; - cam = bounded; - publish(); - wake(); - }; - - const emphasisCamera = (ids: ReadonlySet): CameraState | null => { - const bounds = bodiesBounds(model, ids, spread); - if (!bounds) return null; - return clampZoom(fitBounds(bounds, viewport, CAMERA_PAD_PX * 2), fit); - }; - - const unsubscribe = field.subscribe(wake); - - const retint = (): void => { - const dustColor = dustGeometry.getAttribute('aColor'); - const glowColor = glowGeometry.getAttribute('aColor'); - const lineColor = lineGeometry.getAttribute('aColor'); - slices.forEach((slice, position) => { - const [cr, cg, cb] = hueOf(bodies[position]!); - for (let at = slice.dust[0]; at < slice.dust[1]; at += 1) dustColor.setXYZ(at, cr, cg, cb); - for (let at = slice.glow[0]; at < slice.glow[1]; at += 1) glowColor.setXYZ(at, cr, cg, cb); - for (let at = slice.line[0]; at < slice.line[1]; at += 1) lineColor.setXYZ(at, cr, cg, cb); - }); - dustColor.needsUpdate = true; - glowColor.needsUpdate = true; - lineColor.needsUpdate = true; - const [ar, ag, ab] = unit(colors.alert); - const pulseColor = pulseGeometry.getAttribute('aColor'); - for (let at = 0; at < pulseCount; at += 1) pulseColor.setXYZ(at, ar, ag, ab); - pulseColor.needsUpdate = true; - uniforms.uHot.value = unit(colors.hot); - uniforms.uAlert.value = unit(colors.alert); - uniforms.uDim.value = unit(colors.dim); - uniforms.uEdge.value = unit(colors.edge); - }; - - layoutPaths(spread); - - return { - canvas, - resize: (next) => { - if (!alive) return; - const width = Math.max(1, Math.floor(next.width)); - const height = Math.max(1, Math.floor(next.height)); - viewport = { width, height }; - renderer.setSize(width, height, false); - const nextSpread = columnSpread(model.extent, viewport); - if (nextSpread !== spread) { - spread = nextSpread; - worldExtent = spreadExtent(model.extent, spread); - layoutPaths(spread); - } - const nextFit = fitBounds(worldExtent, viewport, CAMERA_PAD_PX); - // A resize while the reader was looking at a fitted view (the whole - // field, or a focused repository) keeps that view fitted; a camera the - // reader zoomed or panned holds its world centre. - fit = nextFit; - if (atFit) { - cam = emphasis === null ? nextFit : emphasisCamera(emphasis) ?? nextFit; - cameraTarget = null; - } else { - cam = clampZoom(cam, fit); - } - publish(); - compose(performance.now()); - }, - focus: (id) => { - if (!alive) return; - const body = id === null ? null : model.byId.get(id) ?? null; - if (body === null) { - focus.target = 0; - } else { - focus.shown = body.id; - focusNeighborhood = neighborhoodOf(body.id); - focus.target = 1; - } - wake(); - }, - emphasize: (ids) => { - if (!alive) return; - emphasis = ids; - // Either view starts fitted: the whole field, or the focused members. - atFit = true; - if (ids === null) { - moveCamera(fit, true); - return; - } - const target = emphasisCamera(ids); - if (target) moveCamera(target, true); - else wake(); - }, - fit: () => { - atFit = true; - moveCamera(emphasis === null ? fit : emphasisCamera(emphasis) ?? fit, true); - }, - zoomIn: () => { - atFit = false; - moveCamera({ ...(cameraTarget ?? cam), scale: (cameraTarget ?? cam).scale / 1.5 }, true); - }, - zoomOut: () => { - atFit = false; - moveCamera({ ...(cameraTarget ?? cam), scale: (cameraTarget ?? cam).scale * 1.5 }, true); - }, - zoomAt: (factor, anchor) => { - atFit = false; - moveCamera(zoomAbout(cameraTarget ?? cam, viewport, factor, anchor), false); - }, - panBy: (dx, dy) => { - atFit = false; - moveCamera(panBy(cameraTarget ?? cam, dx, dy), false); - }, - view: currentView, - pick: (px, py) => { - const { x, y } = unproject(cam, viewport, px, py); - return pickBody(model, x, y, spread); - }, - wake, - settle, - retheme: () => { - if (!alive) return; - colors = palette(container); - retint(); - compose(performance.now()); - }, - dispose: () => { - if (!alive) return; - alive = false; - unsubscribe(); - if (raf) cancelAnimationFrame(raf); - raf = 0; - if (coolingTimer !== null) clearTimeout(coolingTimer); - coolingTimer = null; - for (const geometry of [dustGeometry, glowGeometry, lineGeometry, pathGeometry, pulseGeometry, haloGeometry]) { - geometry.dispose(); - } - for (const material of [dustMaterial, lineMaterial, pathMaterial, haloMaterial]) material.dispose(); - state.texture.dispose(); - renderer.dispose(); - // `dispose` frees GPU objects but keeps the context alive on a canvas - // nobody will draw to again; browsers evict the oldest live contexts by - // firing `webglcontextlost` on them, which would read as a real loss on - // whichever field is current. Release it deliberately instead. - renderer.forceContextLoss(); - canvas.remove(); - }, - }; -} diff --git a/dashboard/src/viz/scene/registrySceneModel.test.ts b/dashboard/src/viz/scene/registrySceneModel.test.ts deleted file mode 100644 index 2674ede398..0000000000 --- a/dashboard/src/viz/scene/registrySceneModel.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { ProjectRegistryEntry, ProjectRepoGroup } from '../../contracts/generated.ts'; -import { composeRegistryField } from '../../workspaces/brain/field.ts'; -import { - CROWN_SCALE, - HUB_RADIUS, - bodiesBounds, - buildRegistryScene, - columnSpread, - depthFor, - pickBody, - samplePath, - spreadX, -} from './registrySceneModel.ts'; - -const NOW = 1_700_000_000; -const DAY = 86_400; - -function entry(id: string, ageDays: number, artifacts: number, kind = 'primary'): ProjectRegistryEntry { - return { - project_id: id, - label: id, - project_root: `/${id}`, - canonical_root: `/${id}`, - kind, - store_count: 1, - artifact_count: artifacts, - alias_count: 0, - branches: [], - default_branch: null, - last_seen_at: NOW - ageDays * DAY, - }; -} - -function group(label: string, gitCommonDir: string | null, projects: ProjectRegistryEntry[]): ProjectRepoGroup { - return { label, git_common_dir: gitCommonDir, branches: [], project_count: projects.length, projects }; -} - -const GROUPS: ProjectRepoGroup[] = [ - group('shared', '/shared/.git', [entry('main', 0.2, 300), entry('wt', 3, 40, 'worktree')]), - group('lone', '/lone/.git', [entry('lone', 20, 12)]), - group('old', '/old/.git', [entry('old', 400, 3)]), -]; - -describe('registry scene model', () => { - const field = composeRegistryField(GROUPS, NOW); - const scene = buildRegistryScene(field); - - it('keeps every measured coordinate and adds only the field’s own relations', () => { - for (const node of field.nodes) { - const body = scene.byId.get(node.id); - expect(body).toBeDefined(); - expect(body!.x).toBe(node.x); - expect(body!.y).toBe(node.y); - } - expect(scene.paths.map((path) => [path.from, path.to, path.relation, path.grade])).toEqual([ - ['main', 'repo:/shared/.git', 'checkout', 'exact'], - ['wt', 'repo:/shared/.git', 'checkout', 'exact'], - ]); - // Lone checkouts have no hub and no path: nothing to conduct into. - expect(scene.pathsByBody.has('lone')).toBe(false); - expect(scene.pathsByBody.get('repo:/shared/.git')).toHaveLength(2); - }); - - it('gives a hub a fixed categorical size, never a holding-sized crown', () => { - const hub = scene.byId.get('repo:/shared/.git')!; - expect(hub.kind).toBe('repository'); - expect(hub.radius).toBe(HUB_RADIUS); - const heavy = scene.byId.get('main')!; - const light = scene.byId.get('old')!; - expect(heavy.radius).toBeGreaterThan(light.radius); - expect(light.radius).toBeGreaterThan(hub.radius); - expect(heavy.radius).toBeCloseTo(0.24 * CROWN_SCALE, 6); - }); - - it('restates recency as depth so dormant bodies recede', () => { - expect(depthFor(1)).toBe(0); - expect(depthFor(0.3)).toBe(1); - expect(depthFor(0)).toBe(2); - expect(scene.byId.get('main')!.depth).toBe(0); - expect(scene.byId.get('old')!.depth).toBe(2); - expect(scene.byId.get('repo:/shared/.git')!.depth).toBe(0); - }); - - it('grows the frame to hold the tallest crown and the deepest tail', () => { - expect(scene.extent.x).toEqual(field.extent.x); - expect(scene.extent.y[1]).toBeGreaterThanOrEqual(field.extent.y[1]); - expect(scene.extent.y[0]).toBeLessThanOrEqual(field.extent.y[0]); - const heavy = scene.byId.get('main')!; - expect(scene.extent.y[1]).toBeGreaterThanOrEqual(heavy.y + heavy.radius * 0.9); - }); - - it('prints one tick per recency column at its centre line', () => { - expect(scene.columns.map((column) => column.x)).toEqual([0, 1, 2, 3, 4]); - expect(scene.columnDividers).toEqual([0.5, 1.5, 2.5, 3.5]); - expect(scene.columns.map((column) => column.count)).toEqual(field.columns.map((column) => column.count)); - }); - - it('picks the smallest crown under a point and nothing over empty field', () => { - const light = scene.byId.get('old')!; - expect(pickBody(scene, light.x, light.y)?.id).toBe('old'); - expect(pickBody(scene, light.x + 5, light.y + 5)).toBeNull(); - const hub = scene.byId.get('repo:/shared/.git')!; - expect(pickBody(scene, hub.x, hub.y)?.id).toBe('repo:/shared/.git'); - }); - - it('spreads the categorical axis to the aperture without touching a body', () => { - expect(columnSpread(scene.extent, { width: 400, height: 400 })).toBe(1); - const wide = columnSpread(scene.extent, { width: 2000, height: 400 }); - expect(wide).toBeGreaterThan(1); - expect(wide).toBeLessThanOrEqual(2.4); - const body = scene.byId.get('lone')!; - expect(spreadX(body.x, wide)).toBeCloseTo(body.x * wide, 12); - // Picking answers in the spread world. - expect(pickBody(scene, spreadX(body.x, wide), body.y, wide)?.id).toBe('lone'); - }); - - it('samples an evidenced path from below the crown to the hub and sags it', () => { - const path = scene.paths[0]!; - const points = samplePath(scene, path, 1, 8); - const project = scene.byId.get(path.from)!; - const hub = scene.byId.get(path.to)!; - expect(points).toHaveLength(9); - expect(points[0]![0]).toBeCloseTo(project.x, 9); - expect(points[0]![1]).toBeLessThan(project.y); - expect(points[8]).toEqual([hub.x, hub.y]); - const straightMid = (points[0]![1] + points[8]![1]) / 2; - expect(points[4]![1]).toBeLessThan(straightMid); - expect(samplePath(scene, { ...path, from: 'absent' }, 1)).toEqual([]); - }); - - it('bounds a repository neighbourhood for the camera', () => { - const bounds = bodiesBounds(scene, ['main', 'wt', 'repo:/shared/.git'])!; - for (const id of ['main', 'wt', 'repo:/shared/.git']) { - const body = scene.byId.get(id)!; - expect(body.x).toBeGreaterThanOrEqual(bounds.x[0]); - expect(body.x).toBeLessThanOrEqual(bounds.x[1]); - expect(body.y).toBeGreaterThanOrEqual(bounds.y[0]); - expect(body.y).toBeLessThanOrEqual(bounds.y[1]); - } - expect(bodiesBounds(scene, ['absent'])).toBeNull(); - }); -}); diff --git a/dashboard/src/viz/scene/registrySceneModel.ts b/dashboard/src/viz/scene/registrySceneModel.ts deleted file mode 100644 index 662587a359..0000000000 --- a/dashboard/src/viz/scene/registrySceneModel.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * The registry field as a renderer-neutral scene: bodies, hubs, evidenced - * paths, axis ticks. A pure transform of `composeRegistryField`'s output. It - * adds no relation and moves no coordinate — the field already measured every - * position — it only names what a scene runtime has to draw and in what order. - * - * Every body carries the field's own mass radius so the three consumers of - * that number (the anti-overlap pass, the crown geometry, the pick test) - * cannot disagree about how big a project is. - */ -import { - MASS_AXIS_HEIGHT, - RECENCY_COLUMNS, - bodyRadius, - type FieldNode, - type RegistryField, -} from '../../workspaces/brain/field.ts'; -import type { FieldExtent } from '../graph/types.ts'; - -export type SceneBodyKind = 'project' | 'repository'; - -export interface SceneBody { - readonly id: string; - readonly label: string; - readonly kind: SceneBodyKind; - /** The registry's own `kind` word (`primary`, `worktree`, …); hue source. */ - readonly hueKey: string; - readonly x: number; - readonly y: number; - /** Crown radius in field units. A hub's is a fixed categorical size. */ - readonly radius: number; - /** Indexed mass for a project; checkout count for a hub. */ - readonly mass: number; - /** Recency 0..1; resting luminance. */ - readonly vitality: number; - /** Depth layer 0..2 — recency again, so dormant bodies recede. */ - readonly depth: number; -} - -export interface ScenePath { - readonly id: string; - /** The project body; the path leaves from below its crown. */ - readonly from: string; - /** The repository hub. */ - readonly to: string; - /** The one relation this registry knows: same git directory. */ - readonly relation: 'checkout'; - readonly grade: 'exact'; -} - -export interface SceneColumn { - readonly id: string; - readonly label: string; - readonly bound: string; - readonly count: number; - /** Column centre line, world x. */ - readonly x: number; -} - -export interface RegistrySceneModel { - readonly bodies: readonly SceneBody[]; - readonly paths: readonly ScenePath[]; - readonly columns: readonly SceneColumn[]; - /** Vertical hairlines between columns, world x. */ - readonly columnDividers: readonly number[]; - readonly massAxis: { readonly low: number; readonly high: number }; - readonly extent: FieldExtent; - readonly byId: ReadonlyMap; - readonly pathsByBody: ReadonlyMap; -} - -/** Fixed categorical size for a repository hub: it is an identity, not a - * holding, so it must never read as a small project. */ -export const HUB_RADIUS = 0.055; - -/** - * The drawn crown is larger than the field's clearance radius. The clearance - * pass keeps body CENTRES apart; the luminous bodies themselves are additive - * and may overlap into one field, which is what makes a crowded column read - * as a dense band with structure in it rather than a row of separate discs. - */ -export const CROWN_SCALE = 1.6; - -/** Depth is recency restated: live in front, dormant behind. */ -export function depthFor(vitality: number): number { - if (vitality >= 0.55) return 0; - if (vitality >= 0.22) return 1; - return 2; -} - -/** - * How far apart the recency columns are drawn, relative to the field's own - * unit spacing, so the categorical axis fills a wide aperture instead of - * leaving a band in its middle. Columns are ordered categories: their spacing - * carries no measurement, so stretching it is not a lie about any project. - * Body geometry is never stretched — only where its centre sits. Bounded so a - * narrow viewport compresses at most to the field's own spacing. - */ -export function columnSpread(extent: FieldExtent, viewport: { width: number; height: number }): number { - const spanX = Math.max(1e-6, extent.x[1] - extent.x[0]); - const spanY = Math.max(1e-6, extent.y[1] - extent.y[0]); - const aspect = viewport.height > 0 ? viewport.width / viewport.height : 1; - return Math.max(1, Math.min(2.4, (aspect * spanY) / spanX)); -} - -/** The world x a body's centre is drawn at under a spread. */ -export function spreadX(x: number, spread: number): number { - return x * spread; -} - -export function spreadExtent(extent: FieldExtent, spread: number): FieldExtent { - return { x: [extent.x[0] * spread, extent.x[1] * spread], y: extent.y }; -} - -/** - * The sampled curve of one evidenced path under a spread: it leaves from below - * the project's crown — where the filaments hang — and sags toward the hub, so - * the relation reads as connective tissue rather than a ruled chord. - */ -export function samplePath( - model: RegistrySceneModel, - path: ScenePath, - spread: number, - segments = 24, -): Array { - const project = model.byId.get(path.from); - const hub = model.byId.get(path.to); - if (!project || !hub) return []; - const from: readonly [number, number] = [spreadX(project.x, spread), project.y - project.radius * 1.35]; - const to: readonly [number, number] = [spreadX(hub.x, spread), hub.y]; - const midX = (from[0] + to[0]) / 2; - const midY = (from[1] + to[1]) / 2; - const sag = Math.min(0.35, Math.hypot(to[0] - from[0], to[1] - from[1]) * 0.22); - const control: readonly [number, number] = [midX, midY - sag]; - const points: Array = []; - for (let step = 0; step <= segments; step += 1) { - const t = step / segments; - const u = 1 - t; - points.push([ - u * u * from[0] + 2 * u * t * control[0] + t * t * to[0], - u * u * from[1] + 2 * u * t * control[1] + t * t * to[1], - ]); - } - return points; -} - -function toBody(node: FieldNode, massCeiling: number): SceneBody { - const isHub = node.kind === 'repository'; - return { - id: node.id, - label: node.label, - kind: isHub ? 'repository' : 'project', - hueKey: node.kind, - x: node.x, - y: node.y, - radius: isHub ? HUB_RADIUS : bodyRadius(node.degree, massCeiling) * CROWN_SCALE, - mass: node.degree, - vitality: node.vitality, - depth: isHub ? 0 : depthFor(node.vitality), - }; -} - -export function buildRegistryScene(field: RegistryField): RegistrySceneModel { - const bodies = field.nodes.map((node) => toBody(node, field.massCeiling)); - const byId = new Map(bodies.map((body) => [body.id, body] as const)); - - const paths: ScenePath[] = []; - const pathsByBody = new Map(); - for (const edge of field.edges) { - const hub = byId.get(edge.source); - const project = byId.get(edge.target); - if (!hub || !project || hub.kind !== 'repository' || project.kind !== 'project') continue; - const path: ScenePath = { - id: `${edge.source}->${edge.target}`, - from: project.id, - to: hub.id, - relation: 'checkout', - grade: 'exact', - }; - paths.push(path); - for (const id of [project.id, hub.id]) { - const list = pathsByBody.get(id); - if (list) list.push(path); - else pathsByBody.set(id, [path]); - } - } - - const columns: SceneColumn[] = field.columns.map((column, index) => ({ - ...column, - x: index, - })); - const columnDividers = RECENCY_COLUMNS.slice(1).map((_, index) => index + 0.5); - - // The field's own frame clears the clearance radius; the drawn body is a - // larger crown with filaments hanging below it, so the frame grows to hold - // the tallest crown and the deepest tail actually present. Horizontal - // margins are column edges, not data, and stay as the field set them. - let top = field.extent.y[1]; - let bottom = field.extent.y[0]; - for (const body of bodies) { - if (body.kind !== 'project') continue; - top = Math.max(top, body.y + body.radius * 0.95); - bottom = Math.min(bottom, body.y - body.radius * 1.7); - } - - return { - bodies, - paths, - columns, - columnDividers, - massAxis: { low: 0, high: MASS_AXIS_HEIGHT }, - extent: { x: field.extent.x, y: [bottom, top] }, - byId, - pathsByBody, - }; -} - -/** - * The body under a world point: the smallest crown containing it, so a light - * project sitting over a heavy one is still reachable. Hubs use their own - * fixed radius. Returns null over empty field. - */ -export function pickBody( - model: RegistrySceneModel, - x: number, - y: number, - spread = 1, -): SceneBody | null { - let best: SceneBody | null = null; - for (const body of model.bodies) { - // The crown sits a little above the origin and the filaments hang below; - // the hit disc is centred between so either half of the body picks. - const dx = x - spreadX(body.x, spread); - const dy = y - (body.y - body.radius * 0.2); - const reach = body.kind === 'repository' ? body.radius * 2.2 : body.radius * 1.05; - if (dx * dx + dy * dy > reach * reach) continue; - if (best === null || body.radius < best.radius) best = body; - } - return best; -} - -/** World bounds of a set of bodies with their crowns and hanging filaments. */ -export function bodiesBounds( - model: RegistrySceneModel, - ids: Iterable, - spread = 1, -): { x: [number, number]; y: [number, number] } | null { - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - for (const id of ids) { - const body = model.byId.get(id); - if (!body) continue; - const reach = body.kind === 'repository' ? body.radius * 3 : body.radius * 1.9; - const cx = spreadX(body.x, spread); - minX = Math.min(minX, cx - reach); - maxX = Math.max(maxX, cx + reach); - minY = Math.min(minY, body.y - reach); - maxY = Math.max(maxY, body.y + body.radius * 1.2); - } - if (!Number.isFinite(minX)) return null; - return { x: [minX, maxX], y: [minY, maxY] }; -} diff --git a/dashboard/src/viz/scene/sceneCamera.test.ts b/dashboard/src/viz/scene/sceneCamera.test.ts deleted file mode 100644 index ec9b9f47e3..0000000000 --- a/dashboard/src/viz/scene/sceneCamera.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - clampZoom, - fitBounds, - panBy, - project, - unproject, - visibleBounds, - zoomAbout, - zoomLevel, -} from './sceneCamera.ts'; - -const BOUNDS = { x: [-0.5, 4.5] as const, y: [-0.3, 3.2] as const }; -const VIEWPORT = { width: 800, height: 400 }; - -describe('scene camera', () => { - it('contain-fits the bounds so the whole axis is visible with padding', () => { - const camera = fitBounds(BOUNDS, VIEWPORT, 20); - const shown = visibleBounds(camera, VIEWPORT); - expect(shown.x[0]).toBeLessThanOrEqual(BOUNDS.x[0]); - expect(shown.x[1]).toBeGreaterThanOrEqual(BOUNDS.x[1]); - expect(shown.y[0]).toBeLessThanOrEqual(BOUNDS.y[0]); - expect(shown.y[1]).toBeGreaterThanOrEqual(BOUNDS.y[1]); - // The limiting axis (height here) exactly fills the padded viewport. - const padded = (BOUNDS.y[1] - BOUNDS.y[0]) / camera.scale; - expect(padded).toBeCloseTo(VIEWPORT.height - 40, 6); - expect(zoomLevel(camera, camera)).toBe(1); - }); - - it('projects y-up world to y-down screen and back', () => { - const camera = fitBounds(BOUNDS, VIEWPORT); - const low = project(camera, VIEWPORT, 2, BOUNDS.y[0]); - const high = project(camera, VIEWPORT, 2, BOUNDS.y[1]); - expect(high.py).toBeLessThan(low.py); - const back = unproject(camera, VIEWPORT, low.px, low.py); - expect(back.x).toBeCloseTo(2, 9); - expect(back.y).toBeCloseTo(BOUNDS.y[0], 9); - }); - - it('keeps the world point under the pointer fixed while zooming', () => { - const camera = fitBounds(BOUNDS, VIEWPORT); - const anchor = { px: 600, py: 100 }; - const before = unproject(camera, VIEWPORT, anchor.px, anchor.py); - const zoomed = zoomAbout(camera, VIEWPORT, 2, anchor); - const after = unproject(zoomed, VIEWPORT, anchor.px, anchor.py); - expect(after.x).toBeCloseTo(before.x, 9); - expect(after.y).toBeCloseTo(before.y, 9); - expect(zoomLevel(zoomed, camera)).toBeCloseTo(2, 9); - }); - - it('pans in screen pixels and clamps zoom to a window around the fit', () => { - const fit = fitBounds(BOUNDS, VIEWPORT); - const panned = panBy(fit, 100, 0); - expect(project(panned, VIEWPORT, fit.cx, fit.cy).px).toBeCloseTo(VIEWPORT.width / 2 + 100, 9); - expect(clampZoom({ ...fit, scale: fit.scale / 1000 }, fit).scale).toBeCloseTo(fit.scale / 12, 12); - expect(clampZoom({ ...fit, scale: fit.scale * 1000 }, fit).scale).toBeCloseTo(fit.scale / 0.6, 12); - expect(clampZoom(fit, fit)).toBe(fit); - }); - - it('never divides by a zero viewport', () => { - const camera = fitBounds(BOUNDS, { width: 0, height: 0 }); - expect(Number.isFinite(camera.scale)).toBe(true); - expect(camera.scale).toBeGreaterThan(0); - }); -}); diff --git a/dashboard/src/viz/scene/sceneCamera.ts b/dashboard/src/viz/scene/sceneCamera.ts deleted file mode 100644 index cea897197e..0000000000 --- a/dashboard/src/viz/scene/sceneCamera.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Orthographic 2.5D camera arithmetic for a measured field, kept pure so the - * same numbers drive the scene runtime, the DOM label overlay, the SVG axis - * overlay and the minimap — and so a fit, a zoom and a projection can be - * asserted without a renderer. - * - * World is y-up (heavier bodies sit higher). Screen is y-down CSS pixels. - */ - -export interface Viewport { - readonly width: number; - readonly height: number; -} - -export interface WorldBounds { - readonly x: readonly [number, number]; - readonly y: readonly [number, number]; -} - -export interface CameraState { - /** World coordinate at the viewport centre. */ - readonly cx: number; - readonly cy: number; - /** World units per CSS pixel. Smaller is closer. */ - readonly scale: number; -} - -/** Contain-fit `bounds` inside `viewport` with `padPx` of clearance on every - * side. A degenerate viewport yields a unit camera rather than NaN. */ -export function fitBounds(bounds: WorldBounds, viewport: Viewport, padPx = 0): CameraState { - const width = Math.max(1, viewport.width - 2 * padPx); - const height = Math.max(1, viewport.height - 2 * padPx); - const spanX = Math.max(1e-6, bounds.x[1] - bounds.x[0]); - const spanY = Math.max(1e-6, bounds.y[1] - bounds.y[0]); - return { - cx: (bounds.x[0] + bounds.x[1]) / 2, - cy: (bounds.y[0] + bounds.y[1]) / 2, - scale: Math.max(spanX / width, spanY / height), - }; -} - -export function project( - camera: CameraState, - viewport: Viewport, - x: number, - y: number, -): { px: number; py: number } { - return { - px: viewport.width / 2 + (x - camera.cx) / camera.scale, - py: viewport.height / 2 - (y - camera.cy) / camera.scale, - }; -} - -export function unproject( - camera: CameraState, - viewport: Viewport, - px: number, - py: number, -): { x: number; y: number } { - return { - x: camera.cx + (px - viewport.width / 2) * camera.scale, - y: camera.cy - (py - viewport.height / 2) * camera.scale, - }; -} - -/** Zoom by `factor` (>1 closes in) keeping the world point under `anchor` - * fixed on screen, so the zoom is pointer-centred rather than centre-centred. */ -export function zoomAbout( - camera: CameraState, - viewport: Viewport, - factor: number, - anchor: { px: number; py: number }, -): CameraState { - const before = unproject(camera, viewport, anchor.px, anchor.py); - const scale = camera.scale / factor; - const after = unproject({ ...camera, scale }, viewport, anchor.px, anchor.py); - return { cx: camera.cx + (before.x - after.x), cy: camera.cy + (before.y - after.y), scale }; -} - -export function panBy(camera: CameraState, dxPx: number, dyPx: number): CameraState { - return { ...camera, cx: camera.cx - dxPx * camera.scale, cy: camera.cy + dyPx * camera.scale }; -} - -/** Bound the zoom to a window around the fit: no closer than `maxZoom`× and - * no further than `minZoom`× the fit scale, so a wheel cannot lose the field. */ -export function clampZoom( - camera: CameraState, - fit: CameraState, - minZoom = 0.6, - maxZoom = 12, -): CameraState { - const scale = Math.min(fit.scale / minZoom, Math.max(fit.scale / maxZoom, camera.scale)); - return scale === camera.scale ? camera : { ...camera, scale }; -} - -/** Zoom factor relative to the fit, for the readout: 1 is Fit. */ -export function zoomLevel(camera: CameraState, fit: CameraState): number { - return fit.scale / camera.scale; -} - -/** The world rectangle the viewport currently shows. */ -export function visibleBounds(camera: CameraState, viewport: Viewport): WorldBounds { - const halfW = (viewport.width * camera.scale) / 2; - const halfH = (viewport.height * camera.scale) / 2; - return { x: [camera.cx - halfW, camera.cx + halfW], y: [camera.cy - halfH, camera.cy + halfH] }; -} - -export function cameraEquals(a: CameraState, b: CameraState, epsilon = 1e-9): boolean { - return ( - Math.abs(a.cx - b.cx) <= epsilon - && Math.abs(a.cy - b.cy) <= epsilon - && Math.abs(a.scale - b.scale) <= epsilon - ); -} diff --git a/dashboard/src/viz/scene/sceneState.test.ts b/dashboard/src/viz/scene/sceneState.test.ts deleted file mode 100644 index 0465d0bbdc..0000000000 --- a/dashboard/src/viz/scene/sceneState.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { EMPHASIS_DIM, FOCUS_DIM, bodyState, channelByte, wantsNextFrame } from './sceneState.ts'; - -const REST = { - heat: 0, - vitality: 0.8, - inFocusNeighborhood: null, - focusT: 0, - shown: false, - outsideEmphasis: null, -} as const; - -describe('scene body state channels', () => { - it('lets inspection touch dim and raise but never heat', () => { - const hovered = bodyState({ ...REST, inFocusNeighborhood: true, focusT: 1, shown: true }); - const unrelated = bodyState({ ...REST, inFocusNeighborhood: false, focusT: 1 }); - expect(hovered.heat).toBe(0); - expect(unrelated.heat).toBe(0); - expect(hovered.raise).toBe(1); - expect(hovered.dim).toBe(0); - expect(unrelated.raise).toBe(0); - expect(unrelated.dim).toBeCloseTo(FOCUS_DIM, 9); - // Only enough to establish focus: the unrelated body stays legible. - expect(unrelated.dim).toBeLessThan(0.7); - }); - - it('carries admitted heat through untouched by focus or emphasis', () => { - const warm = bodyState({ ...REST, heat: 0.6, inFocusNeighborhood: false, focusT: 1, outsideEmphasis: true }); - expect(warm.heat).toBeCloseTo(0.6, 9); - expect(bodyState({ ...REST, heat: 3 }).heat).toBe(1); - expect(bodyState({ ...REST, heat: -1 }).heat).toBe(0); - }); - - it('recedes bodies outside a focused repository and keeps the stronger dim', () => { - expect(bodyState({ ...REST, outsideEmphasis: true }).dim).toBeCloseTo(EMPHASIS_DIM, 9); - expect(bodyState({ ...REST, outsideEmphasis: false }).dim).toBe(0); - const both = bodyState({ ...REST, outsideEmphasis: true, inFocusNeighborhood: false, focusT: 1 }); - expect(both.dim).toBeCloseTo(Math.max(EMPHASIS_DIM, FOCUS_DIM), 9); - }); - - it('eases the isolation with the focus strength', () => { - expect(bodyState({ ...REST, inFocusNeighborhood: false, focusT: 0.5 }).dim).toBeCloseTo(FOCUS_DIM / 2, 9); - expect(bodyState({ ...REST, shown: true, focusT: 0.25 }).raise).toBe(0.25); - }); - - it('quantises channels to the byte the state texture holds', () => { - expect(channelByte(0)).toBe(0); - expect(channelByte(1)).toBe(255); - expect(channelByte(0.5)).toBe(128); - expect(channelByte(2)).toBe(255); - }); -}); - -describe('frame policy', () => { - it('runs while heat, focus or camera is unsettled and stops otherwise', () => { - expect(wantsNextFrame({ warm: true, focusSettled: true, cameraMoving: false, reduced: false })).toBe(true); - expect(wantsNextFrame({ warm: false, focusSettled: false, cameraMoving: false, reduced: false })).toBe(true); - expect(wantsNextFrame({ warm: false, focusSettled: true, cameraMoving: true, reduced: false })).toBe(true); - expect(wantsNextFrame({ warm: false, focusSettled: true, cameraMoving: false, reduced: false })).toBe(false); - }); - - it('never asks for a frame under reduced motion, however unsettled the scene is', () => { - expect(wantsNextFrame({ warm: true, focusSettled: false, cameraMoving: true, reduced: true })).toBe(false); - }); -}); diff --git a/dashboard/src/viz/scene/sceneState.ts b/dashboard/src/viz/scene/sceneState.ts deleted file mode 100644 index fb24dd7875..0000000000 --- a/dashboard/src/viz/scene/sceneState.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * The per-body state channels the scene runtime samples every frame, and the - * decision of whether another frame is owed — kept pure so the two product - * rules the renderer must never break can be asserted without a GPU: - * inspection never writes the heat channel, and reduced motion never asks for - * an animation frame. - */ - -export interface BodyStateInput { - /** Admitted heat from the activation field, 0..1. The ONLY heat source. */ - readonly heat: number; - /** Recency, 0..1. */ - readonly vitality: number; - /** Whether this body is the inspected one or joined to it by a drawn path. - * `null` when nothing is inspected. */ - readonly inFocusNeighborhood: boolean | null; - /** Eased inspection strength, 0..1. */ - readonly focusT: number; - /** Whether this body is the inspected one. */ - readonly shown: boolean; - /** Whether a repository emphasis is active and this body is outside it. - * `null` when no emphasis is active. */ - readonly outsideEmphasis: boolean | null; -} - -export interface BodyState { - readonly heat: number; - readonly dim: number; - readonly raise: number; - readonly vitality: number; -} - -/** Unrelated bodies dim only enough to establish focus; they stay legible. */ -export const FOCUS_DIM = 0.62; -/** Bodies outside a focused repository recede to context. */ -export const EMPHASIS_DIM = 0.78; - -export function bodyState(input: BodyStateInput): BodyState { - const focusDim = input.inFocusNeighborhood === false ? input.focusT * FOCUS_DIM : 0; - const emphasisDim = input.outsideEmphasis === true ? EMPHASIS_DIM : 0; - return { - heat: clamp(input.heat), - dim: clamp(Math.max(focusDim, emphasisDim)), - raise: input.shown ? clamp(input.focusT) : 0, - vitality: clamp(input.vitality), - }; -} - -/** Quantise a channel to the byte the state texture carries. */ -export function channelByte(value: number): number { - return Math.round(clamp(value) * 255); -} - -export interface FramePolicyInput { - readonly warm: boolean; - readonly focusSettled: boolean; - readonly cameraMoving: boolean; - readonly reduced: boolean; -} - -/** Whether the loop asks for another animation frame. Reduced motion never - * does: it composes static frames on demand instead. */ -export function wantsNextFrame(input: FramePolicyInput): boolean { - if (input.reduced) return false; - return input.warm || !input.focusSettled || input.cameraMoving; -} - -function clamp(value: number): number { - return Math.max(0, Math.min(1, value)); -} diff --git a/dashboard/src/workspaces/brain/BrainInteraction.dom.test.tsx b/dashboard/src/workspaces/brain/BrainInteraction.dom.test.tsx index 20d81e95a0..81b979162a 100644 --- a/dashboard/src/workspaces/brain/BrainInteraction.dom.test.tsx +++ b/dashboard/src/workspaces/brain/BrainInteraction.dom.test.tsx @@ -1,36 +1,61 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { BrainPage } from './BrainPage.tsx'; import { useScope } from '../../data/scope/store.ts'; import { fixtureEnvelope } from '../../test/fixtureEnvelope.ts'; -import type { ActivationField } from '../../viz/graph/activation.ts'; import type { LiveActivityPulse } from '../../data/sse/connect.ts'; const stream = vi.hoisted(() => ({ pulses: [] as LiveActivityPulse[], revision: 0 })); -const graph = vi.hoisted(() => ({ activation: null as ActivationField | null })); vi.mock('../../data/sse/useEvents.tsx', () => ({ useEventStreamState: () => ({ state: 'live', lastEventAt: null }), useLiveActivity: () => stream, })); -vi.mock('./RegistryScene.tsx', () => ({ - RegistryScene: (props: { activation: ActivationField; onInspect: (id: string | null) => void }) => { - graph.activation = props.activation; - return ; - }, -})); -const project = (id: string) => ({ project_id: id, label: id, project_root: `/${id}`, canonical_root: `/${id}`, kind: 'primary', store_count: 1, artifact_count: 2, alias_count: 0, branches: [], default_branch: null, last_seen_at: 1700000000 }); +const project = (id: string) => ({ + project_id: id, + label: id, + project_root: `/${id}`, + canonical_root: `/${id}`, + kind: 'primary', + store_count: 1, + artifact_count: 2, + alias_count: 0, + branches: [], + default_branch: null, + last_seen_at: 1700000000, +}); const projects = [project('p1'), project('p2')]; const groups = [{ label: 'repo', git_common_dir: '/repo/.git', branches: [], project_count: 2, projects }]; function mount() { - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(fixtureEnvelope({ - status: 'ok', error: null, limit: 100, truncated: false, active_project_id: 'p1', active_project_root: '/p1', - summary: { project_count: 2, repo_count: 1, truncated: false }, project_tree: groups, projects: [], - }))))); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify( + fixtureEnvelope({ + status: 'ok', + error: null, + limit: 100, + truncated: false, + active_project_id: 'p1', + active_project_root: '/p1', + summary: { project_count: 2, repo_count: 1, truncated: false }, + project_tree: groups, + projects: [], + }), + ), + ), + ), + ); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); - const content = ; + const content = ( + + + + ); return { ...render(content), client }; } @@ -42,37 +67,39 @@ afterEach(() => { }); describe('Brain exact interaction identity', () => { - it('shares pointer and keyboard inspection without scope or activity changes', async () => { + it('shares pointer and keyboard inspection without scope changes', async () => { mount(); - fireEvent.mouseEnter(await screen.findByRole('button', { name: 'Canvas project' })); + fireEvent.pointerEnter(await screen.findByRole('button', { name: /^p1/ })); expect(within(screen.getByRole('region', { name: 'Inspected project' })).getByText('/p1')).toBeTruthy(); - expect(graph.activation?.warm).toBe(false); expect(useScope.getState().scope.kind).toBe('all'); fireEvent.focus(screen.getByRole('button', { name: /^p2/ })); expect(within(screen.getByRole('region', { name: 'Inspected project' })).getByText('/p2')).toBeTruthy(); fireEvent.keyDown(screen.getByRole('button', { name: /^p2/ }), { key: 'Escape' }); expect(screen.queryByRole('region', { name: 'Inspected project' })).toBeNull(); - expect(graph.activation?.warm).toBe(false); }); it('does not attribute an unscoped admitted event to the active project', async () => { const view = mount(); - await screen.findByRole('button', { name: 'Canvas project' }); - stream.pulses = [{ eventId: 'run:heartbeat:1', observationTime: '1700000000000001', projectId: null, family: 'heartbeat', streamId: 'heartbeat', at: Date.now() }]; + await screen.findByRole('button', { name: /^p1/ }); + stream.pulses = [ + { + eventId: 'run:heartbeat:1', + observationTime: '1700000000000001', + projectId: null, + family: 'heartbeat', + streamId: 'heartbeat', + at: Date.now(), + }, + ]; stream.revision = 1; - view.rerender(); - await waitFor(() => expect(graph.activation?.warm).toBe(false)); - stream.pulses = [...stream.pulses, { eventId: 'run:activity:1', observationTime: '1700000000000002', projectId: 'p1', family: 'hook_activity', streamId: 'activity', at: Date.now() }]; - stream.revision = 2; - view.rerender(); - await waitFor(() => expect(graph.activation?.heatOf('p1')).toBeGreaterThan(0)); - expect(graph.activation?.heatOf('repo:/repo/.git')).toBeGreaterThan(0); - expect(graph.activation?.heatOf('p2')).toBe(0); + view.rerender( + + + , + ); fireEvent.click(screen.getByText(/Inspect admitted events/)); - fireEvent.click(screen.getByRole('button', { name: 'hook_activity · run:activity:1' })); - expect(within(screen.getByRole('region', { name: 'Inspected project' })).getByText('/p1')).toBeTruthy(); - expect(useScope.getState().scope.kind).toBe('all'); fireEvent.click(screen.getByRole('button', { name: 'heartbeat · run:heartbeat:1' })); expect(screen.queryByRole('region', { name: 'Inspected project' })).toBeNull(); + expect(useScope.getState().scope.kind).toBe('all'); }); }); diff --git a/dashboard/src/workspaces/brain/BrainPage.tsx b/dashboard/src/workspaces/brain/BrainPage.tsx index d50e4ebe69..cb0be608bd 100644 --- a/dashboard/src/workspaces/brain/BrainPage.tsx +++ b/dashboard/src/workspaces/brain/BrainPage.tsx @@ -1,8 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useRef, useState, type ReactNode } from 'react'; import { GitBranch, FolderGit2 } from 'lucide-react'; -import { useActivationField } from '../../viz/graph/useActivationField.ts'; -import { buildAdjacency, neighborsOf } from '../../viz/graph/adjacency.ts'; -import { buildRegistryScene, type SceneBody } from '../../viz/scene/registrySceneModel.ts'; import { useEventStreamState, useLiveActivity } from '../../data/sse/useEvents.tsx'; import { CenteredState, ReadSection, envelopeReadState } from '../../ui/ReadSection.tsx'; import { Legend } from '../../ui/instrument.tsx'; @@ -24,7 +21,6 @@ import { } from './field.ts'; import { ScopedBrain } from './ScopedBrain.tsx'; import { ProjectInspector } from './ProjectInspector.tsx'; -import { RegistryScene } from './RegistryScene.tsx'; import { type ProjectRegistryEntry, type ProjectRepoGroup, @@ -34,9 +30,7 @@ import { * is selected. * * Unscoped, the question is "what does this daemon look after?" and the answer - * is the registry, composed as a measured field (see `field.ts`) rather than a - * force layout — several dozen unrelated repositories have no shape to - * discover, so position is spent on measurement instead. + * is the project list, with recency and mass stated as text (see `field.ts`). * * Scoped, the question becomes "what does TraceDecay know about THIS project?", * which is a different surface entirely (see `ScopedBrain.tsx`). */ @@ -143,58 +137,39 @@ export function BrainPage() { {summary.truncated ? ' · truncated' : ''} - {/* The brain is the surface, not a banner above a list: the canvas - * takes every pixel the viewport can spare, readouts sit on it as - * instrument HUD, and the registry becomes a dense side rail that - * remains the canvas's accessible equivalent. */} + {/* The registry is the page. Readouts and the inspector sit in a + * rail beside it so no row is hidden behind chrome. */}
- {/* Below `lg` this is a vertical stack, and the column has to be - * allowed its natural height. Pinned to a share of the viewport - * it both squeezed the field and overflowed, and what would not - * fit painted straight through the registry rail beneath it. - * The shell's `main` is the scroll container, so giving the - * stack its real height simply makes the page scroll. From `lg` - * the two panes split the viewport and each owns its overflow - * again. */} -
- {viewedRepository ?
- - {/* Keep row coordinates stable between pointer-down and click: - * inspection must not insert content above its own trigger. */} -
- {inspectedProject && inspectedGroup ? setInspectedId(null)} onRepository={() => setRepositoryView(inspectedGroup.git_common_dir)} /> :
-

Project inspection

-

Hover or focus a project to inspect its exact registry evidence.

-

Click or Enter selects project scope. Escape dismisses inspection.

-
} +
+ {(viewedRepository ? [viewedRepository] : matchingGroups).map((group, index) => ( + + ))}
- - {query ?

{matchingGroups.reduce((count, group) => count + group.projects.length, 0)} matching projects · canvas retains the full registry

: null} - {/* The counts that are the same on every row, said once. Every - * project in a real registry holds exactly one store and three - * to five artifacts, so "1 ST · 4 ART" printed forty-four times - * was one fact with forty-three echoes. */} - {holdings?.uniformLine ? ( -

- {holdings.uniformLine} -

- ) : null} - {matchingGroups.map((group, index) => ( - - ))} - + +
); @@ -203,22 +178,21 @@ export function BrainPage() { ); } -/** The registry side rail is a scroll container from `lg` and an ordinary - * block below it, so its tab stop is measured from the rendered box (see - * `useScrollTabStop`) instead of hard-coded — a literal `tabIndex={0}` gave - * every keyboard user on a narrow screen a dead stop in front of the cards. */ -function RegistryRail({ children }: { children: ReactNode }) { - const railRef = useRef(null); - const railTabStop = useScrollTabStop(railRef); +/** The registry pane is a scroll container from `lg` and an ordinary block + * below it, so its tab stop is measured from the rendered box (see + * `useScrollTabStop`) instead of hard-coded. */ +function RegistryPane({ children }: { children: ReactNode }) { + const paneRef = useRef(null); + const tabStop = useScrollTabStop(paneRef); return ( - + ); } @@ -235,147 +209,34 @@ function toEnvelopeResult( return undefined; } -/** The all-projects field. Position, size and brightness are all measurements - * of the registry — recency across, indexed mass up — so the composition can be - * read rather than merely looked at. The activation field fires on real SSE - * beats and conducts exactly one hop along a drawn relation, which exists only - * where several checkouts share a git directory. A repository view is a camera - * movement over the same coordinates, never a different field. */ +/** Registry measurements beside the project list. Recency, mass, and + * shared-checkout counts stay as text; the list is the registry. */ function RegistryFieldView({ groups, repository, - inspectedId, onInspect, }: { groups: ProjectRepoGroup[]; repository?: ProjectRepoGroup; - inspectedId: string | null; onInspect: (id: string | null) => void; }) { - const selectProject = useScope((s) => s.selectProject); - const activation = useActivationField(4200); const { state: sseState, lastEventAt } = useEventStreamState(); - const { pulses, revision } = useLiveActivity(); - // null until the first pass adopts the connection's current revision: the - // pulses already in the ring at mount are history, not activity to replay. - const drawnRevision = useRef(null); - - // `groups` is rebuilt (sorted into a fresh array) on every render of this - // page, and the page re-renders on every live pulse. Memoising on its - // identity would therefore hand GraphCanvas a new node/edge array per event - // and force a full renderer teardown plus layout each time. Key the memo on - // what the topology and the measurements actually ARE instead, so the canvas - // is rebuilt only when the registry really changed. - const groupsRef = useRef(groups); - groupsRef.current = groups; - const fieldSignature = groups - .map( - (group) => - `${group.git_common_dir ?? group.label}|${group.projects - .map( - (project) => - `${project.project_id}:${project.kind}:${indexedMass(project)}:${bucketStamp(project.last_seen_at)}`, - ) - .join(',')}`, - ) - .join(';'); - - const field: RegistryField = useMemo( - () => composeRegistryField(groupsRef.current), - [fieldSignature], + const { pulses } = useLiveActivity(); + const field = composeRegistryField(groups); + const projectIds = new Set( + groups.flatMap((group) => group.projects.map((project) => project.project_id)), ); - const sceneModel = useMemo(() => buildRegistryScene(field), [field]); - // Repository zoom is camera emphasis over stable coordinates: the members - // and their hub stay where the field measured them and everything else - // recedes to context. Keyed on the group's identity and the field (which - // changes only when the registry does) so a live pulse's re-render never - // hands the scene a fresh set, while a checkout added mid-view still joins. - const repositoryKey = repository?.git_common_dir ?? null; - const repositoryRef = useRef(repository); - repositoryRef.current = repository; - const emphasis = useMemo(() => { - if (repositoryKey === null) return null; - const ids = new Set(repositoryRef.current?.projects.map((project) => project.project_id)); - ids.add(`repo:${repositoryKey}`); - return ids as ReadonlySet; - }, [field, repositoryKey]); - - // Propagation reads the drawn relation list, so activation can only ever - // travel where the viewer can see a path to travel along. On this field most - // projects have no drawn relation at all, which is correct: a beat in one - // repository has nothing to conduct into. - const adjacency = useMemo(() => buildAdjacency(field.edges), [field]); - const drawnIds = useMemo(() => new Set(field.nodes.map((node) => node.id)), [field]); - - // The brain fires on real identities: each accepted event lights the neuron - // named by its own exact scope, at an intensity that reflects what actually - // happened. Only unseen pulses fire (the ring is a decay window, not a log). - // An unscoped event does not establish which project was touched. - useEffect(() => { - if (sseState !== 'live' || revision === drawnRevision.current) return; - if (drawnRevision.current === null) { - drawnRevision.current = revision; - return; - } - const unseen = Math.min(revision - drawnRevision.current, pulses.length); - drawnRevision.current = revision; - for (const pulse of pulses.slice(pulses.length - unseen)) { - const projectId = pulse.projectId; - // A scope naming something this field does not draw fires nothing: heat - // on an id with no body is heat nobody can see, and it would keep the - // render loop awake resolving an invisible decay. - if (!projectId || !drawnIds.has(projectId)) continue; - const energy = strikeIntensity(pulse.family); - activation.strike([projectId], energy); - // One synaptic hop along the field's own edges — which exist only where - // several checkouts share a git directory. It lands at a third the - // energy, so the conducting edge lights from the end where the event - // actually happened. - const hop = neighborsOf(adjacency, projectId); - if (hop.length > 0) activation.strike(hop, energy / 3); - } - }, [pulses, revision, sseState, adjacency, drawnIds, activation]); - - // Stable across renders; the current registry is read through the ref at - // click time. The scene only ever hands over project bodies, and the - // `repo:` guard keeps that true even if a caller changes. - const handleSelect = useCallback( - (id: string) => { - if (id.startsWith('repo:')) return; - const project = groupsRef.current - .flatMap((group) => group.projects) - .find((candidate) => candidate.project_id === id); - if (project) selectProject(project.project_id, project.label); - }, - [selectProject], + const totals = groups.flatMap((group) => group.projects).reduce( + (acc, project) => ({ + stores: acc.stores + project.store_count, + artifacts: acc.artifacts + project.artifact_count, + }), + { stores: 0, artifacts: 0 }, ); - /** Secondary label lines are the registry's own counts, never a score. */ - const bodyDetail = useCallback((body: SceneBody): readonly string[] => { - const project = groupsRef.current - .flatMap((group) => group.projects) - .find((candidate) => candidate.project_id === body.id); - if (!project) return []; - return [ - `stores ${project.store_count.toLocaleString()}`, - `artifacts ${project.artifact_count.toLocaleString()}`, - `mass ${indexedMass(project).toLocaleString()}`, - ]; - }, []); - - const totals = groups - .flatMap((g) => g.projects) - .reduce( - (acc, p) => ({ - stores: acc.stores + p.store_count, - artifacts: acc.artifacts + p.artifact_count, - }), - { stores: 0, artifacts: 0 }, - ); return ( <> - {/* Readouts reserve space so no measured body is hidden behind chrome. */} -
+
- onInspect(id !== null && drawnIds.has(id) ? id : null)} /> + onInspect(id !== null && projectIds.has(id) ? id : null)} + />
- { if (id !== null) onInspect(id); }} - onSelect={handleSelect} - emphasis={emphasis} - detail={bodyDetail} - // The field has a fixed aspect (five columns across a mass axis) and - // the camera fits it whole, so a canvas far taller than it is wide - // shrinks the whole composition into a band with dead space above and - // below. On a phone the canvas is therefore sized in viewport WIDTHS, - // which keeps its shape near the field's own; from `md` up there is - // enough width that a generous height is the right trade again. - canvasClassName="min-h-[64vw] max-h-[84vw] md:max-h-none md:min-h-[55vh] lg:min-h-0" - ariaLabel={repository ? `${repository.label} repository: ${repository.projects.length} registered projects in camera focus. Exact repository relationships; original measured positions retained. Project registry is the accessible equivalent.` : fieldDescription(field)} - fallbackDescription="the Project registry beside this field remains available as a text alternative" - caption={repository ?

{repository.git_common_dir} · exact registry relation · project mass and recency retain the overview measurements · other projects recede to context

: } - /> + {repository ? ( +

+ {repository.git_common_dir} · {repository.projects.length} registered{' '} + {repository.projects.length === 1 ? 'project' : 'projects'} +

+ ) : ( + + )} ); } -/** The field's reading, stated. The recency columns and their counts are - * printed on the field itself (the scene's axis overlay), so the caption - * carries what the picture cannot: the horizon brightness is relative to, the - * shape of the mass axis, and whether any relation exists to be drawn. */ +/** Recency columns, mass shape, and whether any checkout relation exists. */ function FieldAxis({ field }: { field: RegistryField }) { return (
- {/* Short enough to survive a 320px rail: the full sentence is the - * paragraph below, and a legend that truncates to "INDEXED M…" states - * nothing. */} - recency across · mass up + recency · mass

{field.columns.map((column) => `${column.label} ${column.count}`).join(' · ')}

- Recency glow spans now to {formatHorizon(field.vitalityHorizonDays)}, the - age nine in ten projects here are younger than.{' '} + Recency runs from now to {formatHorizon(field.vitalityHorizonDays)}, the age nine in ten + projects here are younger than.{' '} {field.mass.total > 0 && field.mass.lowerHalfCount > field.mass.total / 2 - ? `Mass is lopsided: ${field.mass.lowerHalfCount} of ${field.mass.total} projects hold ${field.mass.floor}–${field.mass.median} indexed units; the heaviest at ${field.mass.ceiling} sets the top.` + ? `Mass is lopsided: ${field.mass.lowerHalfCount} of ${field.mass.total} projects hold ${field.mass.floor}-${field.mass.median} indexed units; the heaviest at ${field.mass.ceiling} sets the top.` : ''}{' '} {field.sharedRepoCount > 0 ? `${field.sharedRepoCount} shared ${field.sharedRepoCount === 1 ? 'repository is' : 'repositories are'} wired to their checkouts; all other projects stand alone.` - : 'No repository has multiple checkouts, so the wire returned no relation to draw.'} + : 'No repository has multiple checkouts, so there is no relation to list.'}

); } -/** The vitality horizon in the shortest form that keeps it readable. */ function formatHorizon(days: number): string { if (days < 2) return `${Math.round(days * 24)} h`; if (days < 60) return `${days < 10 ? days.toFixed(1) : Math.round(days)} d`; return `${Math.round(days / 30)} mo`; } -function fieldDescription(field: RegistryField): string { - const occupied = field.columns - .filter((column) => column.count > 0) - .map((column) => `${column.count} ${column.label}`) - .join(', '); - return `Registry field: projects placed by when they were last seen (${occupied || 'none'}) and by indexed mass, which runs from ${field.mass.floor} to ${field.mass.ceiling} across ${field.mass.total} projects. Brightness is recency, full now and out at ${formatHorizon(field.vitalityHorizonDays)}, the age nine in ten of them are younger than. The project registry list alongside is the accessible equivalent.`; -} - -/** Which recency column a timestamp lands in, as a memo key. Keying the field - * memo on the raw timestamp would recompose the layout on any clock tick; the - * layout only actually changes when a project crosses a column boundary. */ -function bucketStamp(lastSeenAt: number): number { - return Math.floor((Date.now() / 1000 - lastSeenAt) / 3600); -} - -/** Corner-bracketed instrument readout floating on the canvas: the counts that - * used to occupy four tall tiles, rendered as one hairline strip so the brain - * keeps the space. Pointer-transparent so it never steals a graph drag. */ +/** Counts that used to occupy four tall tiles, as one strip. */ export function InstrumentReadout({ items, }: { @@ -498,20 +327,6 @@ export function InstrumentReadout({ ); } -/** Firing intensity by event family: structural change reads brightest, a - * heartbeat is only a breath. Unknown families fire at a middling default - * rather than going dark — a new event family is still real activity. */ -function strikeIntensity(family: string): number { - if (family === 'heartbeat') return 0.22; - if (family === 'project_registry_changed') return 0.95; - if (family === 'storage_telemetry_invalidated') return 0.6; - if (family.startsWith('code_index')) return 0.8; - if (family === 'hook_activity') return 0.85; - if (family === 'tool_call_activity') return 0.7; - if (family === 'session_ingest_activity') return 0.65; - return 0.5; -} - function RepoGroupCard({ group, holdings, @@ -529,7 +344,7 @@ function RepoGroupCard({ {/* Count and noun from the one array this header heads. `project_count` * is set from `projects.len()` in `project_registry.rs`, so preferring * it while pluralising from the array could only ever disagree by - * printing "3 project" over one row — a contract drift rendered as a + * printing "3 project" over one row, a contract drift rendered as a * typo. */} {group.projects.length} {group.projects.length === 1 ? 'project' : 'projects'} @@ -614,7 +429,7 @@ function ProjectRow({ ) : null} {/* The row carries the channel that actually varies across the - * registry — graph scopes span 0 to 242 here — plus any other channel + * registry, graph scopes span 0 to 242 here, plus any other channel * that departs from what the rail stated above it. A project holding * five artifacts where everything else holds four IS a reading, and * must not be swallowed by the summary. */} diff --git a/dashboard/src/workspaces/brain/RegistryScene.dom.test.tsx b/dashboard/src/workspaces/brain/RegistryScene.dom.test.tsx deleted file mode 100644 index 6757f8d14e..0000000000 --- a/dashboard/src/workspaces/brain/RegistryScene.dom.test.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ProjectRepoGroup } from '../../contracts/generated.ts'; -import { ActivationField } from '../../viz/graph/activation.ts'; -import type { RegistryRuntime, SceneView } from '../../viz/scene/registryRuntime.ts'; -import { buildRegistryScene, type SceneBody } from '../../viz/scene/registrySceneModel.ts'; -import { setMotionPreference } from '../../viz/trace/reducedMotion.ts'; -import { composeRegistryField } from './field.ts'; -import { RegistryScene } from './RegistryScene.tsx'; - -/** - * The React half of the hybrid, with the Three.js half replaced by a recording - * double. Every assertion here is about the CONTRACT between the two: what the - * DOM host hands the runtime for a hover, a click, a keyboard focus and a - * motion preference — and what it refuses to do (create activity, select a - * hub, draw without a context). - */ - -const runtime = vi.hoisted(() => ({ - instance: null as (RegistryRuntime & { calls: string[] }) | null, - created: 0, - webgl: true, - picked: null as SceneBody | null, - context: null as { onLost: () => void; onRestored: () => void } | null, -})); - -vi.mock('../../viz/graph/renderer.ts', () => ({ - hasWebGl: () => runtime.webgl, - watchWebGlContext: (_canvases: unknown, handlers: { onLost: () => void; onRestored: () => void }) => { - runtime.context = handlers; - return () => {}; - }, -})); - -vi.mock('../../viz/scene/registryRuntime.ts', () => ({ - createRegistryRuntime: (options: { container: HTMLElement; onView: (view: SceneView) => void }) => { - const calls: string[] = []; - // ~105 CSS px per column (room for both tick lines) with the whole mass - // axis inside the 640×320 box, so every body's name can print. - const camera = { cx: 2, cy: 1.6, scale: 0.0095 }; - const view: SceneView = { camera, viewport: { width: 640, height: 320 }, spread: 1, fit: camera }; - const canvas = document.createElement('canvas'); - options.container.appendChild(canvas); - const instance: RegistryRuntime & { calls: string[] } = { - calls, - canvas, - resize: () => { - calls.push('resize'); - options.onView(view); - }, - focus: (id) => void calls.push(`focus:${id}`), - emphasize: (ids) => void calls.push(`emphasize:${ids ? [...ids].sort().join(',') : null}`), - fit: () => void calls.push('fit'), - zoomIn: () => void calls.push('zoomIn'), - zoomOut: () => void calls.push('zoomOut'), - zoomAt: () => void calls.push('zoomAt'), - panBy: () => void calls.push('panBy'), - view: () => view, - pick: () => runtime.picked, - wake: () => void calls.push('wake'), - settle: () => void calls.push('settle'), - retheme: () => void calls.push('retheme'), - dispose: () => void calls.push('dispose'), - }; - runtime.instance = instance; - runtime.created += 1; - return instance; - }, -})); - -const NOW = 1_700_000_000; -const entry = (id: string, ageDays: number, artifacts: number) => ({ - project_id: id, - label: id, - project_root: `/${id}`, - canonical_root: `/${id}`, - kind: 'primary', - store_count: 1, - artifact_count: artifacts, - alias_count: 0, - branches: [], - default_branch: null, - last_seen_at: NOW - ageDays * 86_400, -}); -const GROUPS: ProjectRepoGroup[] = [ - { label: 'shared', git_common_dir: '/shared/.git', branches: [], project_count: 2, projects: [entry('main', 0.1, 300), entry('wt', 2, 20)] }, - { label: 'lone', git_common_dir: '/lone/.git', branches: [], project_count: 1, projects: [entry('lone', 30, 8)] }, -]; -const MODEL = buildRegistryScene(composeRegistryField(GROUPS, NOW)); - -function mount(overrides: Partial[0]> = {}) { - const activation = new ActivationField({ halfLifeMs: 4200 }); - const onInspect = vi.fn(); - const onSelect = vi.fn(); - const view = render( - caption

} - detail={() => ['stores 1']} - {...overrides} - />, - ); - return { ...view, activation, onInspect, onSelect }; -} - -describe('RegistryScene host contract', () => { - beforeEach(() => { - runtime.instance = null; - runtime.created = 0; - runtime.webgl = true; - runtime.picked = null; - runtime.context = null; - Object.defineProperties(HTMLElement.prototype, { - clientWidth: { configurable: true, get: () => 640 }, - clientHeight: { configurable: true, get: () => 320 }, - }); - Object.defineProperty(window, 'matchMedia', { - configurable: true, - value: vi.fn().mockReturnValue({ matches: false, addEventListener: () => {}, removeEventListener: () => {} }), - }); - HTMLElement.prototype.setPointerCapture = () => {}; - HTMLElement.prototype.releasePointerCapture = () => {}; - }); - - afterEach(() => { - localStorage.removeItem('td.motion-preference'); - }); - - it('states a missing WebGL context as a typed absence and names the fallback', () => { - runtime.webgl = false; - mount(); - const absence = screen.getByRole('status'); - expect(absence.getAttribute('data-state')).toBe('unavailable'); - expect(absence.textContent).toMatch(/no WebGL context/); - expect(absence.textContent).toMatch(/registry list remains available/); - expect(runtime.instance).toBeNull(); - expect(screen.queryByRole('img')).toBeNull(); - }); - - it('builds the runtime once the container has a box and prints the axis and names from the same camera', () => { - mount(); - expect(runtime.instance).not.toBeNull(); - expect(runtime.instance!.calls).toContain('resize'); - expect(screen.getByRole('img', { name: 'Registry field: test' })).toBeTruthy(); - // Column ticks and the hub identity are DOM text, never canvas-only. - expect(screen.getByText('< 24H')).toBeTruthy(); - expect(screen.getByText(/today · 1/i)).toBeTruthy(); - expect(screen.getByText('repo:shared')).toBeTruthy(); - expect(screen.getByText('hub · massless')).toBeTruthy(); - // Controls and the minimap are siblings of the image, never children of a - // `role="img"` (whose children are presentational to assistive tech). - const image = screen.getByRole('img', { name: 'Registry field: test' }); - const controls = screen.getByRole('group', { name: 'Registry field camera controls' }); - expect(image.contains(controls)).toBe(false); - expect(screen.getByLabelText('Registry field zoom').textContent).toBe('100%'); - }); - - it('states a lost context as a typed absence and rebuilds when the browser restores it', () => { - mount(); - expect(runtime.created).toBe(1); - expect(runtime.context).not.toBeNull(); - act(() => runtime.context!.onLost()); - const absence = screen.getByRole('status'); - expect(absence.getAttribute('data-state')).toBe('unavailable'); - expect(absence.textContent).toMatch(/lost its WebGL context/); - expect(absence.textContent).toMatch(/returns if the browser restores/); - expect(runtime.instance!.calls).toContain('dispose'); - act(() => runtime.context!.onRestored()); - expect(screen.queryByText(/lost its WebGL context/)).toBeNull(); - expect(screen.getByRole('img', { name: 'Registry field: test' })).toBeTruthy(); - expect(runtime.created).toBe(2); - }); - - it('turns pointer movement into inspection only: no heat, no selection', () => { - const { activation, onInspect, onSelect } = mount(); - const field = screen.getByRole('img', { name: 'Registry field: test' }); - runtime.picked = MODEL.byId.get('main')!; - fireEvent.pointerMove(field, { clientX: 100, clientY: 100, buttons: 0 }); - expect(onInspect).toHaveBeenLastCalledWith('main'); - expect(runtime.instance!.calls).toContain('focus:main'); - expect(activation.warm).toBe(false); - expect(onSelect).not.toHaveBeenCalled(); - // Moving within the same body is not a new inspection. - fireEvent.pointerMove(field, { clientX: 102, clientY: 101, buttons: 0 }); - expect(onInspect).toHaveBeenCalledTimes(1); - runtime.picked = null; - fireEvent.pointerMove(field, { clientX: 300, clientY: 300, buttons: 0 }); - expect(onInspect).toHaveBeenLastCalledWith(null); - expect(runtime.instance!.calls).toContain('focus:null'); - expect(activation.warm).toBe(false); - }); - - it('selects a project body on a primary click and never a repository hub or another button', () => { - const { activation, onSelect } = mount(); - const field = screen.getByRole('img', { name: 'Registry field: test' }); - runtime.picked = MODEL.byId.get('repo:/shared/.git')!; - fireEvent.pointerDown(field, { button: 0, clientX: 10, clientY: 10, pointerId: 1 }); - fireEvent.pointerUp(field, { button: 0, clientX: 10, clientY: 10, pointerId: 1 }); - expect(onSelect).not.toHaveBeenCalled(); - runtime.picked = MODEL.byId.get('wt')!; - // A middle or secondary button is not a selection gesture. - fireEvent.pointerDown(field, { button: 1, clientX: 10, clientY: 10, pointerId: 1 }); - fireEvent.pointerUp(field, { button: 1, clientX: 10, clientY: 10, pointerId: 1 }); - fireEvent.pointerDown(field, { button: 2, clientX: 10, clientY: 10, pointerId: 1 }); - fireEvent.pointerUp(field, { button: 2, clientX: 10, clientY: 10, pointerId: 1 }); - expect(onSelect).not.toHaveBeenCalled(); - fireEvent.pointerDown(field, { button: 0, clientX: 10, clientY: 10, pointerId: 1 }); - fireEvent.pointerUp(field, { button: 0, clientX: 10, clientY: 10, pointerId: 1 }); - expect(onSelect).toHaveBeenCalledWith('wt'); - expect(activation.warm).toBe(false); - }); - - it('un-dims the names when the pointer leaves, even while the inspector retains the project', () => { - const { rerender, activation, onInspect, onSelect } = mount(); - const field = screen.getByRole('img', { name: 'Registry field: test' }); - runtime.picked = MODEL.byId.get('lone')!; - fireEvent.pointerMove(field, { clientX: 100, clientY: 100, buttons: 0 }); - // The page retains the inspection (it swallows the null), as BrainPage does. - rerender( - caption

} - detail={() => []} - />, - ); - const labelOf = (id: string) => screen.getByText(id).parentElement!; - expect(labelOf('main').className).toMatch(/opacity-40/); - fireEvent.pointerLeave(field); - expect(runtime.instance!.calls).toContain('focus:null'); - expect(labelOf('main').className).not.toMatch(/opacity-40/); - }); - - it('treats a drag as a pan, not a click', () => { - const { onSelect } = mount(); - const field = screen.getByRole('img', { name: 'Registry field: test' }); - runtime.picked = MODEL.byId.get('wt')!; - fireEvent.pointerDown(field, { button: 0, clientX: 10, clientY: 10, pointerId: 1 }); - fireEvent.pointerMove(field, { clientX: 40, clientY: 30, buttons: 1 }); - fireEvent.pointerUp(field, { button: 0, clientX: 40, clientY: 30, pointerId: 1 }); - expect(runtime.instance!.calls).toContain('panBy'); - expect(onSelect).not.toHaveBeenCalled(); - }); - - it('routes keyboard inspection, repository emphasis and camera controls to the runtime', () => { - const { rerender, activation, onInspect, onSelect } = mount(); - rerender( - caption

} - detail={() => []} - />, - ); - expect(runtime.instance!.calls).toContain('focus:lone'); - expect(runtime.instance!.calls).toContain('emphasize:main,repo:/shared/.git,wt'); - // Emphasis forces every member's name to print and the minimap to appear. - expect(screen.getByText('main')).toBeTruthy(); - expect(screen.getByText('wt')).toBeTruthy(); - expect(screen.getByRole('img', { name: /minimap: 3 highlighted bodies/ })).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Zoom in registry field' })); - fireEvent.click(screen.getByRole('button', { name: 'Zoom out registry field' })); - fireEvent.click(screen.getByRole('button', { name: 'Fit' })); - expect(runtime.instance!.calls.slice(-3)).toEqual(['zoomIn', 'zoomOut', 'fit']); - expect(activation.warm).toBe(false); - }); - - it('settles the scene the moment motion is turned off', () => { - const { rerender, activation, onInspect, onSelect } = mount(); - expect(runtime.instance!.calls).not.toContain('settle'); - setMotionPreference('reduced'); - rerender( - caption

} - detail={() => []} - />, - ); - expect(runtime.instance!.calls).toContain('settle'); - }); - - it('disposes the runtime with the component', () => { - const { unmount } = mount(); - unmount(); - expect(runtime.instance!.calls.at(-1)).toBe('dispose'); - }); -}); diff --git a/dashboard/src/workspaces/brain/RegistryScene.tsx b/dashboard/src/workspaces/brain/RegistryScene.tsx deleted file mode 100644 index 8fa593157e..0000000000 --- a/dashboard/src/workspaces/brain/RegistryScene.tsx +++ /dev/null @@ -1,595 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import type { ActivationField } from '../../viz/graph/activation.ts'; -import { GraphUnavailable } from '../../viz/graph/GraphCanvas.tsx'; -import { hasWebGl, watchWebGlContext } from '../../viz/graph/renderer.ts'; -import { labelBudget, selectLabels, type LabelCandidate } from '../../viz/scene/labelPriority.ts'; -import { - createRegistryRuntime, - type RegistryRuntime, - type SceneView, -} from '../../viz/scene/registryRuntime.ts'; -import { - spreadExtent, - spreadX, - type RegistrySceneModel, - type SceneBody, -} from '../../viz/scene/registrySceneModel.ts'; -import { - fitBounds, - project, - visibleBounds, - zoomLevel, - type Viewport, -} from '../../viz/scene/sceneCamera.ts'; -import { useReducedMotion } from '../../viz/trace/reducedMotion.ts'; -import { cn } from '../../ui/cn'; - -/** - * The registry field's React host: the DOM half of the hybrid. - * - * Owns the container box, the WebGL availability and context-loss states, the - * pointer wiring, the crisp overlays (axis, labels, minimap, camera readout) - * and the camera controls. The luminous field itself is drawn by - * `createRegistryRuntime`, which this component builds when the container - * has a box and tears down when it loses one. Nothing here creates activity: - * pointer focus and keyboard focus both arrive at the runtime as inspection, - * and only the caller's `ActivationField` can warm a body. - */ -export function RegistryScene({ - model, - activation, - inspectedId, - onInspect, - onSelect, - emphasis, - ariaLabel, - fallbackDescription, - caption, - detail, - canvasClassName, -}: { - model: RegistrySceneModel; - activation: ActivationField; - inspectedId: string | null; - /** Pointer inspection leaving the field. `null` when the pointer leaves. */ - onInspect: (id: string | null) => void; - /** A project body was clicked. Hubs never arrive here. */ - onSelect: (id: string) => void; - /** Repository zoom: bodies to focus the camera on; others recede. */ - emphasis: ReadonlySet | null; - ariaLabel: string; - fallbackDescription: string; - caption: ReactNode; - /** Secondary label lines for a body, printed when the zoom earns them. */ - detail: (body: SceneBody) => readonly string[]; - canvasClassName?: string; -}) { - const containerRef = useRef(null); - const runtimeRef = useRef(null); - const contextWatchRef = useRef<(() => void) | null>(null); - const resizeObserverRef = useRef(null); - const webglRef = useRef(null); - if (webglRef.current === null) webglRef.current = hasWebGl(); - const [box, setBox] = useState({ width: 0, height: 0 }); - const hasBox = box.width > 0 && box.height > 0; - const [contextLostFor, setContextLostFor] = useState(null); - const [generation, setGeneration] = useState(0); - const [view, setView] = useState(null); - /** What the scene is actually isolating right now: the pointer's body while - * it hovers, else the retained keyboard/inspector focus. Labels dim from - * this, never from the retained inspection alone, so leaving the field - * un-dims the names exactly when it un-dims the bodies. */ - const [sceneFocus, setSceneFocus] = useState(null); - const { reduced } = useReducedMotion(); - const reducedRef = useRef(reduced); - reducedRef.current = reduced; - const onInspectRef = useRef(onInspect); - onInspectRef.current = onInspect; - const onSelectRef = useRef(onSelect); - onSelectRef.current = onSelect; - const emphasisRef = useRef(emphasis); - emphasisRef.current = emphasis; - const inspectedRef = useRef(inspectedId); - inspectedRef.current = inspectedId; - - const attachContainer = useCallback((node: HTMLDivElement | null) => { - containerRef.current = node; - resizeObserverRef.current?.disconnect(); - resizeObserverRef.current = null; - if (!node) { - setBox({ width: 0, height: 0 }); - return; - } - const measure = (): void => { - const width = node.clientWidth; - const height = node.clientHeight; - setBox((previous) => - previous.width === width && previous.height === height ? previous : { width, height }, - ); - }; - measure(); - if (typeof ResizeObserver !== 'function') return; - const observer = new ResizeObserver(measure); - observer.observe(node); - resizeObserverRef.current = observer; - }, []); - - useEffect(() => () => contextWatchRef.current?.(), []); - - // The runtime's lifetime: one per model while the container has a box. - useEffect(() => { - const container = containerRef.current; - if (!container || !hasBox || !webglRef.current || model.bodies.length === 0) return; - let runtime: RegistryRuntime; - try { - runtime = createRegistryRuntime({ - container, - model, - field: activation, - isReduced: () => reducedRef.current, - onView: setView, - }); - } catch { - // A renderer that cannot construct is the same absence as no context. - webglRef.current = false; - setGeneration((value) => value + 1); - return; - } - runtimeRef.current = runtime; - runtime.resize(box); - if (emphasisRef.current) runtime.emphasize(emphasisRef.current); - if (inspectedRef.current) runtime.focus(inspectedRef.current); - // Disposal releases the GL context on purpose, which fires the same - // `webglcontextlost` a GPU eviction would; the latch tells them apart. - let cancelled = false; - contextWatchRef.current?.(); - contextWatchRef.current = watchWebGlContext([runtime.canvas], { - onLost: () => { - if (cancelled) return; - cancelled = true; - runtime.dispose(); - if (runtimeRef.current === runtime) runtimeRef.current = null; - setContextLostFor(model); - }, - onRestored: () => { - setContextLostFor(null); - setGeneration((value) => value + 1); - }, - }); - return () => { - cancelled = true; - runtime.dispose(); - if (runtimeRef.current === runtime) runtimeRef.current = null; - }; - // The measured box is applied through `resize` below; only its presence - // decides whether a runtime may exist. - }, [model, activation, hasBox, generation]); - - useEffect(() => { - if (hasBox) runtimeRef.current?.resize(box); - }, [hasBox, box]); - - useEffect(() => { - runtimeRef.current?.focus(inspectedId); - setSceneFocus(inspectedId); - }, [inspectedId]); - - useEffect(() => { - runtimeRef.current?.emphasize(emphasis); - }, [emphasis]); - - useEffect(() => { - if (reduced) runtimeRef.current?.settle(); - }, [reduced]); - - useEffect(() => { - const observer = new MutationObserver(() => runtimeRef.current?.retheme()); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-contrast'] }); - return () => observer.disconnect(); - }, []); - - // Wheel zoom must cancel the page scroll, which a React `onWheel` (passive) - // cannot, so it is attached natively for the container's lifetime. - useEffect(() => { - const container = containerRef.current; - if (!container || !hasBox) return; - const onWheel = (event: WheelEvent): void => { - const runtime = runtimeRef.current; - if (!runtime) return; - event.preventDefault(); - const bounds = container.getBoundingClientRect(); - const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12; - runtime.zoomAt(factor, { px: event.clientX - bounds.left, py: event.clientY - bounds.top }); - }; - container.addEventListener('wheel', onWheel, { passive: false }); - return () => container.removeEventListener('wheel', onWheel); - }, [hasBox]); - - const drag = useRef<{ x: number; y: number; moved: boolean; button: number } | null>(null); - const hoveredRef = useRef(null); - - const pickAt = (event: { clientX: number; clientY: number }): SceneBody | null => { - const container = containerRef.current; - const runtime = runtimeRef.current; - if (!container || !runtime) return null; - const bounds = container.getBoundingClientRect(); - return runtime.pick(event.clientX - bounds.left, event.clientY - bounds.top); - }; - - const labels = useMemo(() => { - if (!view) return []; - const { camera, viewport, spread, fit } = view; - const zoom = zoomLevel(camera, fit); - const detailed = zoom >= 1.5; - const byMass = [...model.bodies] - .filter((body) => body.kind === 'project') - .sort((a, b) => b.mass - a.mass) - .slice(0, 6) - .map((body) => body.id); - // Hover isolates the drawn neighbourhood: the focused body and anything a - // path joins it to keep full ink; the rest recedes with the scene. - const neighborhood = sceneFocus === null - ? null - : new Set([sceneFocus, ...(model.pathsByBody.get(sceneFocus) ?? []).flatMap((path) => [path.from, path.to])]); - // A narrow aperture has no room for secondary lines at all. - const roomy = viewport.width >= 480; - const candidates: Array = model.bodies.map((body) => { - const anchor = project(camera, viewport, spreadX(body.x, spread), body.y); - const crownPx = body.radius / camera.scale; - const lines = - body.kind === 'repository' - ? ['hub · massless'] - : roomy && (detailed || byMass.includes(body.id)) - ? detail(body) - : []; - const longest = Math.max(body.label.length + (body.kind === 'repository' ? 5 : 0), ...lines.map((line) => line.length)); - const width = longest * 6.6 + 8; - const height = 14 + lines.length * 12; - const offset = crownPx * (body.kind === 'repository' ? 2.6 : 1.05) + 6; - // Beside the crown first, then the other side, then under the tail, - // then above the crown: the first placement that prints whole wins. - const placements = [ - { px: anchor.px + offset, py: anchor.py - 8 }, - { px: anchor.px - offset - width, py: anchor.py - 8 }, - { px: anchor.px - width / 2, py: anchor.py + crownPx * (body.kind === 'repository' ? 2.6 : 1.9) + 4 }, - { px: anchor.px - width / 2, py: anchor.py - crownPx * (body.kind === 'repository' ? 2.6 : 1.1) - height - 4 }, - ]; - return { - id: body.id, - priority: body.kind === 'repository' ? Number.MAX_SAFE_INTEGER : body.mass, - placements, - width, - height, - forced: body.id === inspectedId || body.id === sceneFocus || emphasis?.has(body.id) === true, - body, - lines, - dimmed: (neighborhood !== null && !neighborhood.has(body.id)) || (emphasis !== null && !emphasis.has(body.id)), - }; - }); - // In a repository view only the members and an inspected body are named; - // everything else has receded to context and keeps its name in the rail. - const named = emphasis === null - ? candidates - : candidates.filter((candidate) => emphasis.has(candidate.id) || candidate.id === inspectedId || candidate.id === sceneFocus); - const chosen = selectLabels(named, viewport, labelBudget(zoom, model.bodies.length, viewport)); - return named.flatMap((candidate) => { - const placed = chosen.get(candidate.id); - return placed ? [{ ...candidate, px: placed.px, py: placed.py, side: placed.placement }] : []; - }); - }, [view, model, detail, inspectedId, sceneFocus, emphasis]); - - if (model.bodies.length === 0) { - return

no registered project to draw

; - } - if (!webglRef.current) { - return ( - - this browser has no WebGL context, so the {model.bodies.length.toLocaleString()}-body - registry field cannot draw — {fallbackDescription} - - ); - } - if (contextLostFor === model) { - return ( - - the registry field lost its WebGL context, so it is no longer being drawn —{' '} - {fallbackDescription}, and the field returns if the browser restores the context - - ); - } - - const zoom = view ? zoomLevel(view.camera, view.fit) : 1; - return ( -
- {/* The drawn field is one image to assistive tech; its controls and - * minimap are siblings, not children, so they stay in the tree. */} -
-
{ - drag.current = { x: event.clientX, y: event.clientY, moved: false, button: event.button }; - event.currentTarget.setPointerCapture(event.pointerId); - }} - onPointerMove={(event) => { - const runtime = runtimeRef.current; - if (!runtime) return; - if (drag.current && drag.current.button === 0 && event.buttons === 1) { - const dx = event.clientX - drag.current.x; - const dy = event.clientY - drag.current.y; - if (Math.abs(dx) + Math.abs(dy) > 2) drag.current.moved = true; - if (drag.current.moved) { - runtime.panBy(dx, dy); - drag.current.x = event.clientX; - drag.current.y = event.clientY; - } - return; - } - const hit = pickAt(event); - const id = hit?.id ?? null; - if (id === hoveredRef.current) return; - hoveredRef.current = id; - runtime.focus(id); - setSceneFocus(id); - onInspectRef.current(id); - }} - onPointerUp={(event) => { - const press = drag.current; - drag.current = null; - event.currentTarget.releasePointerCapture(event.pointerId); - // Only a primary-button press that did not pan is a click. - if (!press || press.button !== 0 || press.moved) return; - const hit = pickAt(event); - // Selection scopes only through a project body; a repository hub is - // an identity that does not narrow scope. - if (hit && hit.kind === 'project') onSelectRef.current(hit.id); - }} - onPointerCancel={() => { - drag.current = null; - }} - onPointerLeave={() => { - drag.current = null; - if (hoveredRef.current === null) return; - hoveredRef.current = null; - runtimeRef.current?.focus(null); - setSceneFocus(null); - onInspectRef.current(null); - }} - > - {view ? : null} - {view ? ( -
- {labels.map((label) => ( -
= 2 && 'items-center text-center', - )} - style={{ left: label.px, top: label.py, width: label.width }} - > - - {label.body.kind === 'repository' ? `repo:${label.body.label}` : label.body.label} - - {label.lines.map((line) => ( - - {line} - - ))} -
- ))} -
- ) : null} -
-
- - - {Math.round(zoom * 100)}% - - - -
- {view && (emphasis !== null || Math.abs(zoom - 1) > 0.02) ? ( - - ) : null} -
-
- -
{caption}
-
-
- ); -} - -/** Column dividers, recency ticks and the mass axis, drawn crisp in screen - * space from the same camera the scene uses. */ -function AxisOverlay({ model, view }: { model: RegistrySceneModel; view: SceneView }) { - const { camera, viewport, spread } = view; - const top = project(camera, viewport, 0, model.massAxis.high); - const bottom = project(camera, viewport, 0, model.massAxis.low); - const axisX = project(camera, viewport, spreadX(model.extent.x[0], spread) + 0.06, 0).px; - // Tick text degrades with the room a column has: both lines, the bound - // alone, or dividers only. The caption below the field always prints every - // column and its count in the DOM flow. - const columnPx = spread / camera.scale; - const tickLines = columnPx >= 104 ? 2 : columnPx >= 58 ? 1 : 0; - return ( - - {model.columnDividers.map((x) => { - const { px } = project(camera, viewport, spreadX(x, spread), 0); - return ( - - ); - })} - {tickLines > 0 - ? model.columns.map((column) => { - const { px } = project(camera, viewport, spreadX(column.x, spread), 0); - return ( - - - {column.bound.toUpperCase()} - - {tickLines > 1 ? ( - - {column.label} · {column.count} - - ) : null} - - ); - }) - : null} - - - - - - - - high mass - - - low mass - - - ); -} - -function Minimap({ - model, - view, - emphasis, -}: { - model: RegistrySceneModel; - view: SceneView; - emphasis: ReadonlySet | null; -}) { - const width = 150; - const height = 84; - const mini: Viewport = { width, height }; - const fit = fitBounds(spreadExtent(model.extent, view.spread), mini, 4); - const shown = visibleBounds(view.camera, view.viewport); - const a = project(fit, mini, shown.x[0], shown.y[1]); - const b = project(fit, mini, shown.x[1], shown.y[0]); - return ( - - {model.bodies.map((body) => { - const { px, py } = project(fit, mini, spreadX(body.x, view.spread), body.y); - const highlighted = emphasis?.has(body.id) === true; - return ( - - ); - })} - - - ); -} - -function SceneKey() { - const items = [ - ['body', 'one project'], - ['size', 'indexed mass'], - ['hue', 'project kind'], - ['brightness · depth', 'recency; dormant recedes'], - ['path', 'shared git directory (exact)'], - ['amber bloom', 'admitted activity'], - ['cyan ring', 'inspection only'], - ] as const; - return ( -
- {items.map(([label, value]) => ( - - {label} - · - {value} - - ))} -
- ); -}